Merge remote-tracking branch 'origin/main' into pr-ssh-stack-main

# Conflicts:
#	.github/workflows/ci.yml
#	CLI/cmux.swift
#	Sources/GhosttyTerminalView.swift
#	Sources/SocketControlSettings.swift
#	Sources/TabManager.swift
#	Sources/TerminalController.swift
#	Sources/Workspace.swift
#	ghostty
#	scripts/reload.sh
This commit is contained in:
Lawrence Chen 2026-03-13 17:56:38 -07:00
commit 2eae782739
59 changed files with 3285 additions and 468 deletions

View file

@ -31,6 +31,9 @@ jobs:
- name: Validate release asset guard
run: node scripts/release_asset_guard.test.js
- name: Validate current GhosttyKit checksum pin
run: ./tests/test_ci_ghosttykit_checksum_present.sh
remote-daemon-tests:
runs-on: ubuntu-latest
steps:

View file

@ -510,16 +510,13 @@ jobs:
cmux-nightly-macos-${{ github.run_id }}*.dmg
cmux-nightly-macos.dmg
appcast.xml
<<<<<<< HEAD
remote-daemon-assets/cmuxd-remote-darwin-arm64
remote-daemon-assets/cmuxd-remote-darwin-amd64
remote-daemon-assets/cmuxd-remote-linux-arm64
remote-daemon-assets/cmuxd-remote-linux-amd64
remote-daemon-assets/cmuxd-remote-checksums.txt
remote-daemon-assets/cmuxd-remote-manifest.json
=======
appcast-universal.xml
>>>>>>> origin/main
overwrite_files: true
- name: Cleanup keychain

View file

@ -123,12 +123,12 @@ private final class CLISocketSentryTelemetry {
context["socket_errno_description"] = String(cString: strerror(code))
}
let tmpSockets = Self.discoverTmpCmuxSockets(limit: 10)
let tmpSockets = Self.discoverSockets(in: "/tmp", limit: 10)
if !tmpSockets.isEmpty {
context["tmp_cmux_sockets"] = tmpSockets
}
let taggedSockets = tmpSockets.filter { $0 != "/tmp/cmux.sock" }
if socketPath == "/tmp/cmux.sock",
let taggedSockets = tmpSockets.filter { $0 != CLISocketPathResolver.legacyDefaultSocketPath }
if CLISocketPathResolver.isImplicitDefaultPath(socketPath),
(envSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true),
!taggedSockets.isEmpty {
context["possible_root_cause"] = "CMUX_SOCKET_PATH/CMUX_SOCKET missing while tagged sockets exist"
@ -152,14 +152,16 @@ private final class CLISocketSentryTelemetry {
}
}
private static func discoverTmpCmuxSockets(limit: Int) -> [String] {
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: "/tmp") else {
private static func discoverSockets(in directory: String, limit: Int) -> [String] {
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: directory) else {
return []
}
var sockets: [String] = []
for name in entries.sorted() {
guard name.hasPrefix("cmux"), name.hasSuffix(".sock") else { continue }
let fullPath = "/tmp/\(name)"
let fullPath = URL(fileURLWithPath: directory)
.appendingPathComponent(name, isDirectory: false)
.path
var st = stat()
guard lstat(fullPath, &st) == 0 else { continue }
guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue }
@ -547,10 +549,24 @@ private enum CLISocketPathSource {
}
private enum CLISocketPathResolver {
static let defaultSocketPath = "/tmp/cmux.sock"
private static let appSupportDirectoryName = "cmux"
private static let stableSocketFileName = "cmux.sock"
private static let lastSocketPathFileName = "last-socket-path"
static let legacyDefaultSocketPath = "/tmp/cmux.sock"
private static let fallbackSocketPath = "/tmp/cmux-debug.sock"
private static let stagingSocketPath = "/tmp/cmux-staging.sock"
private static let lastSocketPathFile = "/tmp/cmux-last-socket-path"
private static let legacyLastSocketPathFile = "/tmp/cmux-last-socket-path"
static var defaultSocketPath: String {
let stablePath: String? = stableSocketDirectoryURL()?
.appendingPathComponent(stableSocketFileName, isDirectory: false)
.path
return stablePath ?? legacyDefaultSocketPath
}
static func isImplicitDefaultPath(_ path: String) -> Bool {
path == defaultSocketPath || path == legacyDefaultSocketPath
}
static func resolve(
requestedPath: String,
@ -586,6 +602,8 @@ private enum CLISocketPathResolver {
}
candidates.append(requestedPath)
candidates.append(defaultSocketPath)
candidates.append(legacyDefaultSocketPath)
candidates.append(fallbackSocketPath)
candidates.append(stagingSocketPath)
candidates.append(contentsOf: discoverTaggedSockets(limit: 12))
@ -596,33 +614,46 @@ private enum CLISocketPathResolver {
}
private static func readLastSocketPath() -> String? {
guard let data = try? String(contentsOfFile: lastSocketPathFile, encoding: .utf8) else {
return nil
let primaryCandidate: String? = stableSocketDirectoryURL()?
.appendingPathComponent(lastSocketPathFileName, isDirectory: false)
.path
let candidates = [primaryCandidate, legacyLastSocketPathFile].compactMap { $0 }
for candidate in candidates {
guard let data = try? String(contentsOfFile: candidate, encoding: .utf8) else {
continue
}
if let value = normalized(data) {
return value
}
}
return normalized(data)
return nil
}
private static func discoverTaggedSockets(limit: Int) -> [String] {
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: "/tmp") else {
return []
}
var discovered: [(path: String, mtime: TimeInterval)] = []
discovered.reserveCapacity(min(limit, entries.count))
for name in entries where name.hasPrefix("cmux") && name.hasSuffix(".sock") {
let path = "/tmp/\(name)"
var st = stat()
guard lstat(path, &st) == 0 else { continue }
guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue }
if path == defaultSocketPath || path == fallbackSocketPath || path == stagingSocketPath {
for directory in socketDiscoveryDirectories() {
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: directory) else {
continue
}
let modified = TimeInterval(st.st_mtimespec.tv_sec) + TimeInterval(st.st_mtimespec.tv_nsec) / 1_000_000_000
discovered.append((path: path, mtime: modified))
discovered.reserveCapacity(min(limit, discovered.count + entries.count))
for name in entries where name.hasPrefix("cmux") && name.hasSuffix(".sock") {
let path = URL(fileURLWithPath: directory)
.appendingPathComponent(name, isDirectory: false)
.path
var st = stat()
guard lstat(path, &st) == 0 else { continue }
guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue }
if path == defaultSocketPath || path == legacyDefaultSocketPath || path == fallbackSocketPath || path == stagingSocketPath {
continue
}
let modified = TimeInterval(st.st_mtimespec.tv_sec) + TimeInterval(st.st_mtimespec.tv_nsec) / 1_000_000_000
discovered.append((path: path, mtime: modified))
}
}
discovered.sort { $0.mtime > $1.mtime }
return discovered.prefix(limit).map(\.path)
return dedupe(discovered.prefix(limit).map(\.path))
}
private static func isSocketFile(_ path: String) -> Bool {
@ -669,6 +700,21 @@ private enum CLISocketPathResolver {
return trimmed.isEmpty ? nil : trimmed
}
private static func stableSocketDirectoryURL() -> URL? {
guard let appSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
return nil
}
return appSupportDirectory.appendingPathComponent(appSupportDirectoryName, isDirectory: true)
}
private static func socketDiscoveryDirectories() -> [String] {
let appSupportSocketDirectory: String = stableSocketDirectoryURL()?.path ?? ""
return dedupe([
"/tmp",
appSupportSocketDirectory,
])
}
private static func dedupe(_ paths: [String]) -> [String] {
var seen: Set<String> = []
var ordered: [String] = []
@ -946,7 +992,7 @@ struct CMUXCLI {
var socketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath
var socketPathSource: CLISocketPathSource
if let envSocketPath {
socketPathSource = envSocketPath == CLISocketPathResolver.defaultSocketPath ? .implicitDefault : .environment
socketPathSource = CLISocketPathResolver.isImplicitDefaultPath(envSocketPath) ? .implicitDefault : .environment
} else {
socketPathSource = .implicitDefault
}
@ -1079,6 +1125,14 @@ struct CMUXCLI {
return
}
if command == "themes" {
try runThemes(
commandArgs: commandArgs,
jsonOutput: jsonOutput
)
return
}
if command == "claude-teams" {
try runClaudeTeams(
commandArgs: commandArgs,
@ -5283,6 +5337,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...]
@ -6381,6 +6464,735 @@ 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
/// carriage returns must also be escaped since the socket protocol uses
/// newline as the message terminator.
private func socketQuote(_ s: String) -> String {
let escaped = s
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
return "\"\(escaped)\""
}
private func parseOption(_ args: [String], name: String) -> (String?, [String]) {
var remaining: [String] = []
var value: String?
@ -7647,7 +8459,7 @@ struct CMUXCLI {
let requestedSocketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath
let source: CLISocketPathSource
if let envSocketPath {
source = envSocketPath == CLISocketPathResolver.defaultSocketPath ? .implicitDefault : .environment
source = CLISocketPathResolver.isImplicitDefaultPath(envSocketPath) ? .implicitDefault : .environment
} else {
source = .implicitDefault
}
@ -9450,6 +10262,7 @@ struct CMUXCLI {
welcome
shortcuts
feedback [--email <email> --body <text> [--image <path> ...]]
themes [list|set|clear]
claude-teams [claude-args...]
ping
version
@ -9566,7 +10379,7 @@ struct CMUXCLI {
CMUX_TAB_ID Optional alias used by `tab-action`/`rename-tab` as default --tab.
CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface.
CMUX_SOCKET_PATH Override the Unix socket path. Without this, the CLI defaults
to /tmp/cmux.sock and auto-discovers tagged/debug sockets.
to ~/Library/Application Support/cmux/cmux.sock and auto-discovers tagged/debug sockets.
"""
}
}

View file

@ -213,6 +213,8 @@ Browser developer-tool shortcuts follow Safari defaults and are customizable in
cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the stable version. Built automatically from the latest `main` commit and auto-updates via its own Sparkle feed.
Report nightly bugs on [GitHub Issues](https://github.com/manaflow-ai/cmux/issues) or in [#nightly-bugs on Discord](https://discord.gg/xsgFEVrWCZ).
## Session restore (current behavior)
On relaunch, cmux currently restores app layout and metadata only:

View file

@ -93,15 +93,27 @@
</array>
</dict>
</array>
<key>UTImportedTypeDeclarations</key>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.splittabbar.tabtransfer</string>
<key>UTTypeDescription</key>
<string>Bonsplit Tab Transfer</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.cmux.sidebar-tab-reorder</string>
<key>UTTypeDescription</key>
<string>cmux Sidebar Tab Reorder</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
</dict>
</array>
<key>NSAppTransportSecurity</key>

View file

@ -845,13 +845,13 @@
"en": {
"stringUnit": {
"state": "translated",
"value": "Welcome"
"value": "Welcome to cmux!"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ようこそ"
"value": "cmuxへようこそ"
}
}
}
@ -873,6 +873,23 @@
}
}
},
"sidebar.help.discord": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Discord"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Discord"
}
}
}
},
"sidebar.help.githubIssues": {
"extractionState": "manual",
"localizations": {
@ -35131,6 +35148,23 @@
}
}
},
"menu.openInVSCodeDesktop": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Open Current Directory in VS Code"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "現在のディレクトリを VS Code で開く"
}
}
}
},
"menu.openInWarp": {
"extractionState": "manual",
"localizations": {
@ -40346,6 +40380,119 @@
}
}
},
"settings.app.appIcon.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Dock and app switcher"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Dockとアプリスイッチャー"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "程序坞和应用切换器"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "Dock 和 App 切換器"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "Dock 및 앱 전환기"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Dock und App-Umschalter"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Dock y selector de apps"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Dock et sélecteur d'apps"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Dock e selettore app"
}
},
"da": {
"stringUnit": {
"state": "translated",
"value": "Dock og appskifter"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Dock i przełącznik aplikacji"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Dock и переключатель приложений"
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "Dock i prebacivač aplikacija"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "شريط Dock ومبدّل التطبيقات"
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "Dock og appbytter"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Dock e alternador de apps"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "Dock และตัวสลับแอป"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Dock ve uygulama değiştirici"
}
}
}
},
"settings.app.dockBadge": {
"extractionState": "manual",
"localizations": {

View file

@ -51,6 +51,7 @@ _CMUX_PR_FORCE="${_CMUX_PR_FORCE:-0}"
_CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}"
_CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
_CMUX_SHELL_ACTIVITY_LAST="${_CMUX_SHELL_ACTIVITY_LAST:-}"
_CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}"
_CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}"
@ -103,6 +104,19 @@ _cmux_report_tty_once() {
} >/dev/null 2>&1 & disown
}
_cmux_report_shell_activity_state() {
local state="$1"
[[ -n "$state" ]] || return 0
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
[[ "$_CMUX_SHELL_ACTIVITY_LAST" == "$state" ]] && return 0
_CMUX_SHELL_ACTIVITY_LAST="$state"
{
_cmux_send "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 & disown
}
_cmux_ports_kick() {
# Lightweight: just tell the app to run a batched scan for this panel.
# The app coalesces kicks across all panels and runs a single ps+lsof.
@ -291,10 +305,33 @@ _cmux_bash_cleanup() {
_cmux_stop_pr_poll_loop
}
_cmux_preexec_command() {
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
if [[ -z "$_CMUX_TTY_NAME" ]]; then
local t
t="$(tty 2>/dev/null || true)"
t="${t##*/}"
[[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
fi
_cmux_report_shell_activity_state running
_cmux_report_tty_once
_cmux_ports_kick
_cmux_stop_pr_poll_loop
}
_cmux_bash_preexec_hook() {
_cmux_preexec_command
}
_cmux_prompt_command() {
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
_cmux_report_shell_activity_state prompt
local now=$SECONDS
local pwd="$PWD"
@ -439,6 +476,17 @@ _cmux_install_prompt_command() {
;;
esac
fi
if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )); then
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3) )); then
builtin readonly _CMUX_BASH_PS0='${ _cmux_bash_preexec_hook; }'
else
builtin readonly _CMUX_BASH_PS0='$(_cmux_bash_preexec_hook >/dev/null)'
fi
if [[ "$PS0" != *"${_CMUX_BASH_PS0}"* ]]; then
PS0=$PS0"${_CMUX_BASH_PS0}"
fi
fi
}
# Ensure Resources/bin is at the front of PATH, and remove the app's

View file

@ -55,6 +55,7 @@ typeset -g _CMUX_ASYNC_JOB_TIMEOUT=20
typeset -g _CMUX_PORTS_LAST_RUN=0
typeset -g _CMUX_CMD_START=0
typeset -g _CMUX_SHELL_ACTIVITY_LAST=""
typeset -g _CMUX_TTY_NAME=""
typeset -g _CMUX_TTY_REPORTED=0
typeset -g _CMUX_GHOSTTY_SEMANTIC_PATCHED=0
@ -204,6 +205,19 @@ _cmux_report_tty_once() {
} >/dev/null 2>&1 &!
}
_cmux_report_shell_activity_state() {
local state="$1"
[[ -n "$state" ]] || return 0
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
[[ "$_CMUX_SHELL_ACTIVITY_LAST" == "$state" ]] && return 0
_CMUX_SHELL_ACTIVITY_LAST="$state"
{
_cmux_send "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 &!
}
_cmux_ports_kick() {
# Lightweight: just tell the app to run a batched scan for this panel.
# The app coalesces kicks across all panels and runs a single ps+lsof.
@ -455,6 +469,7 @@ _cmux_preexec() {
fi
_CMUX_CMD_START=$EPOCHSECONDS
_cmux_report_shell_activity_state running
# Heuristic: commands that may change git branch/dirty state without changing $PWD.
local cmd="${1## }"
@ -478,6 +493,7 @@ _cmux_precmd() {
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
_cmux_report_shell_activity_state prompt
# Handle cases where Ghostty integration initializes after this file.
_cmux_patch_ghostty_semantic_redraw

View file

@ -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 = {
@ -392,6 +396,7 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
case terminal
case tower
case vscode
case vscodeInline
case warp
case windsurf
case xcode
@ -442,6 +447,8 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
case .tower:
return String(localized: "menu.openInTower", defaultValue: "Open Current Directory in Tower")
case .vscode:
return String(localized: "menu.openInVSCodeDesktop", defaultValue: "Open Current Directory in VS Code")
case .vscodeInline:
return String(localized: "menu.openInVSCode", defaultValue: "Open Current Directory in VS Code (Inline)")
case .warp:
return String(localized: "menu.openInWarp", defaultValue: "Open Current Directory in Warp")
@ -474,6 +481,8 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
case .tower:
return common + ["tower", "git", "client"]
case .vscode:
return common + ["vs", "code", "visual", "studio", "desktop", "app"]
case .vscodeInline:
return common + ["vs", "code", "visual", "studio", "inline", "browser", "serve-web"]
case .warp:
return common + ["warp", "terminal", "shell"]
@ -488,7 +497,7 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
func isAvailable(in environment: DetectionEnvironment = .live) -> Bool {
guard let applicationPath = applicationPath(in: environment) else { return false }
guard self == .vscode else { return true }
guard self == .vscodeInline else { return true }
return VSCodeCLILaunchConfigurationBuilder.launchConfiguration(
vscodeApplicationURL: URL(fileURLWithPath: applicationPath, isDirectory: true),
isExecutableAtPath: environment.isExecutableFileAtPath
@ -553,6 +562,11 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
"/Applications/Visual Studio Code.app",
"/Applications/Code.app",
]
case .vscodeInline:
return [
"/Applications/Visual Studio Code.app",
"/Applications/Code.app",
]
case .warp:
return ["/Applications/Warp.app"]
case .windsurf:
@ -2119,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.
@ -2924,13 +2946,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private func restartSocketListenerIfEnabled(source: String) {
guard let tabManager,
let config = socketListenerConfigurationIfEnabled() else { return }
let restartPath = TerminalController.shared.activeSocketPath(preferredPath: config.path)
sentryBreadcrumb("socket.listener.restart", category: "socket", data: [
"mode": config.mode.rawValue,
"path": config.path,
"path": restartPath,
"source": source
])
TerminalController.shared.stop()
TerminalController.shared.start(tabManager: tabManager, socketPath: config.path, accessMode: config.mode)
TerminalController.shared.start(tabManager: tabManager, socketPath: restartPath, accessMode: config.mode)
}
private func startSocketListenerHealthMonitorIfNeeded() {
@ -2958,8 +2981,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private func restartSocketListenerIfNeededForHealthCheck(source: String) {
guard !socketListenerHealthCheckInFlight,
let config = socketListenerConfigurationIfEnabled() else { return }
let expectedSocketPath = config.path
let terminalController = TerminalController.shared
let expectedSocketPath = terminalController.activeSocketPath(preferredPath: config.path)
socketListenerHealthCheckInFlight = true
Thread.detachNewThread { [weak self, expectedSocketPath, source, terminalController] in
let health = terminalController.socketListenerHealth(expectedSocketPath: expectedSocketPath)
@ -2980,8 +3003,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
source: String,
expectedSocketPath: String
) {
guard let config = socketListenerConfigurationIfEnabled(),
config.path == expectedSocketPath else { return }
guard let config = socketListenerConfigurationIfEnabled() else { return }
let currentExpectedSocketPath = TerminalController.shared.activeSocketPath(preferredPath: config.path)
guard currentExpectedSocketPath == expectedSocketPath else { return }
guard !health.isHealthy else {
lastSocketListenerUnhealthyCaptureAt = .distantPast
return
@ -2989,7 +3013,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let failureSignals = health.failureSignals
var data: [String: Any] = [
"source": source,
"path": config.path,
"path": currentExpectedSocketPath,
"isRunning": health.isRunning ? 1 : 0,
"acceptLoopAlive": health.acceptLoopAlive ? 1 : 0,
"socketPathMatches": health.socketPathMatches ? 1 : 0,
@ -4578,6 +4602,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier })
}
private func resolvedWindow(for context: MainWindowContext) -> NSWindow? {
guard let window = context.window ?? windowForMainWindowId(context.windowId) else {
return nil
}
context.window = window
return window
}
private func mainWindowId(from window: NSWindow) -> UUID? {
guard let raw = window.identifier?.rawValue else { return nil }
let prefix = "cmux.main."
@ -4655,6 +4687,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return removed
}
private func discardOrphanedMainWindowContext(_ context: MainWindowContext) {
let contextKeys = mainWindowContexts.compactMap { key, value in
value === context ? key : nil
}
for key in contextKeys {
mainWindowContexts.removeValue(forKey: key)
}
commandPaletteVisibilityByWindowId.removeValue(forKey: context.windowId)
commandPalettePendingOpenByWindowId.removeValue(forKey: context.windowId)
commandPaletteRecentRequestAtByWindowId.removeValue(forKey: context.windowId)
commandPaletteEscapeSuppressionByWindowId.remove(context.windowId)
commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: context.windowId)
commandPaletteSelectionByWindowId.removeValue(forKey: context.windowId)
commandPaletteSnapshotByWindowId.removeValue(forKey: context.windowId)
if tabManager === context.tabManager {
if let nextContext = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) {
tabManager = nextContext.tabManager
sidebarState = nextContext.sidebarState
sidebarSelectionState = nextContext.sidebarSelectionState
TerminalController.shared.setActiveTabManager(nextContext.tabManager)
} else {
tabManager = nil
sidebarState = nil
sidebarSelectionState = nil
TerminalController.shared.setActiveTabManager(nil)
}
}
if let store = notificationStore {
for tab in context.tabManager.tabs {
store.clearNotifications(forTabId: tab.id)
}
}
}
private func mainWindowId(for window: NSWindow) -> UUID? {
if let context = mainWindowContexts[ObjectIdentifier(window)] {
return context.windowId
@ -5080,11 +5149,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
#endif
return nil
}
if let window = context.window ?? windowForMainWindowId(context.windowId) {
setActiveMainWindow(window)
if shouldBringToFront {
bringToFront(window)
}
guard let window = resolvedWindow(for: context) else {
#if DEBUG
logWorkspaceCreationRouting(
phase: "no_context",
source: debugSource,
reason: "context_window_missing",
event: event,
chosenContext: context,
workingDirectory: workingDirectory
)
#endif
discardOrphanedMainWindowContext(context)
return nil
}
setActiveMainWindow(window)
if shouldBringToFront {
bringToFront(window)
}
let workspace: Workspace
@ -5173,7 +5254,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
}
let fallback = mainWindowContexts.values.first
let fallback = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil })
#if DEBUG
logWorkspaceCreationRouting(
phase: "choose",
@ -9901,7 +9982,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
context.sidebarSelectionState.selection = .tabs
bringToFront(window)
context.tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId)
guard context.tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else {
#if DEBUG
recordMultiWindowNotificationOpenFailureIfNeeded(
tabId: tabId,
surfaceId: surfaceId,
notificationId: notificationId,
reason: "focus_failed"
)
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" {
writeJumpUnreadTestData(["jumpUnreadOpenResult": "0"])
}
#endif
return false
}
#if DEBUG
// UI test support: Jump-to-unread asserts that the correct workspace/panel is focused.
@ -9966,7 +10060,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
sidebarSelectionState?.selection = .tabs
bringToFront(window)
tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId)
guard tabManager.focusTabFromNotification(tabId, surfaceId: surfaceId) else {
#if DEBUG
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" {
writeJumpUnreadTestData([
"jumpUnreadFallbackFail": "focus_failed",
"jumpUnreadOpenResult": "0",
])
}
#endif
return false
}
#if DEBUG
recordJumpUnreadFocusFromModelIfNeeded(
@ -10812,6 +10916,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() {
@ -11422,4 +11534,5 @@ private extension NSWindow {
}
return hitWebView === webView
}
}

View file

@ -4754,7 +4754,7 @@ struct ContentView: View {
keywords: ["vscode", "inline", "serve-web", "stop", "server"],
when: { context in
context.bool(CommandPaletteContextKeys.panelIsTerminal)
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscode))
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscodeInline))
}
)
)
@ -4766,7 +4766,7 @@ struct ContentView: View {
keywords: ["vscode", "inline", "serve-web", "restart", "server"],
when: { context in
context.bool(CommandPaletteContextKeys.panelIsTerminal)
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscode))
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscodeInline))
}
)
)
@ -6227,7 +6227,7 @@ struct ContentView: View {
case .finder:
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directoryURL.path)
return true
case .vscode:
case .vscodeInline:
return openFocusedDirectoryInInlineVSCode(directoryURL)
default:
guard let applicationURL = target.applicationURL() else { return false }
@ -6238,7 +6238,7 @@ struct ContentView: View {
}
private func openFocusedDirectoryInInlineVSCode(_ directoryURL: URL) -> Bool {
guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscode.applicationURL(),
guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscodeInline.applicationURL(),
let workspace = tabManager.selectedWorkspace,
let sourcePanelId = workspace.focusedPanelId else {
return false
@ -6274,7 +6274,7 @@ struct ContentView: View {
}
private func restartInlineVSCodeServeWeb() -> Bool {
guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscode.applicationURL() else {
guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscodeInline.applicationURL() else {
return false
}
VSCodeServeWebController.shared.restart(vscodeApplicationURL: vscodeApplicationURL) { serveWebURL in
@ -8486,6 +8486,7 @@ private enum SidebarHelpMenuAction {
case changelog
case github
case githubIssues
case discord
case checkForUpdates
case sendFeedback
case welcome
@ -8986,6 +8987,7 @@ private struct SidebarHelpMenuButton: View {
private let changelogURL = URL(string: "https://cmux.dev/docs/changelog")
private let githubURL = URL(string: "https://github.com/manaflow-ai/cmux")
private let githubIssuesURL = URL(string: "https://github.com/manaflow-ai/cmux/issues")
private let discordURL = URL(string: "https://discord.gg/xsgFEVrWCZ")
private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help")
private let buttonSize: CGFloat = 22
private let iconSize: CGFloat = 11
@ -9030,7 +9032,7 @@ private struct SidebarHelpMenuButton: View {
private var helpPopover: some View {
VStack(alignment: .leading, spacing: 2) {
helpOptionButton(
title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome"),
title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome to cmux!"),
action: .welcome,
accessibilityIdentifier: "SidebarHelpMenuOptionWelcome",
isExternalLink: false
@ -9081,6 +9083,14 @@ private struct SidebarHelpMenuButton: View {
isExternalLink: true
)
}
if discordURL != nil {
helpOptionButton(
title: String(localized: "sidebar.help.discord", defaultValue: "Discord"),
action: .discord,
accessibilityIdentifier: "SidebarHelpMenuOptionDiscord",
isExternalLink: true
)
}
helpOptionButton(
title: String(localized: "command.checkForUpdates.title", defaultValue: "Check for Updates"),
action: .checkForUpdates,
@ -9171,6 +9181,9 @@ private struct SidebarHelpMenuButton: View {
case .githubIssues:
guard let githubIssuesURL else { return }
NSWorkspace.shared.open(githubIssuesURL)
case .discord:
guard let discordURL else { return }
NSWorkspace.shared.open(discordURL)
case .checkForUpdates:
Task { @MainActor in
AppDelegate.shared?.checkForUpdates(nil)

View file

@ -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) {

View file

@ -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
}
@ -2517,6 +2513,8 @@ final class TerminalSurface: Identifiable, ObservableObject {
private let workingDirectory: String?
private let initialCommand: String?
private let initialEnvironmentOverrides: [String: String]
var requestedWorkingDirectory: String? { workingDirectory }
private var additionalEnvironment: [String: String]
let hostedView: GhosttySurfaceScrollView
private let surfaceView: GhosttyNSView
private var lastPixelWidth: UInt32 = 0
@ -2528,6 +2526,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
private let maxPendingTextBytes = 1_048_576
private var backgroundSurfaceStartQueued = false
private var surfaceCallbackContext: Unmanaged<GhosttySurfaceCallbackContext>?
#if DEBUG
private var needsConfirmCloseOverrideForTesting: Bool?
#endif
private enum PortalLifecycleState: String {
case live
case closing
@ -2595,10 +2596,8 @@ final class TerminalSurface: Identifiable, ObservableObject {
self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines)
self.initialCommand = (trimmedCommand?.isEmpty == false) ? trimmedCommand : nil
self.initialEnvironmentOverrides = Self.mergedNormalizedEnvironment(
base: additionalEnvironment,
overrides: initialEnvironmentOverrides
)
self.initialEnvironmentOverrides = Self.mergedNormalizedEnvironment(base: [:], overrides: initialEnvironmentOverrides)
self.additionalEnvironment = Self.mergedNormalizedEnvironment(base: [:], overrides: additionalEnvironment)
// Match Ghostty's own SurfaceView: ensure a non-zero initial frame so the backing layer
// has non-zero bounds and the renderer can initialize without presenting a blank/stretched
// intermediate frame on the first real resize.
@ -3061,6 +3060,34 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
env["ZDOTDIR"] = integrationDir
} else if shellName == "bash" {
if GhosttyApp.shared.shellIntegrationMode() != "none" {
env["CMUX_LOAD_GHOSTTY_BASH_INTEGRATION"] = "1"
}
// macOS ships /bin/bash 3.2, where Ghostty's automatic bash
// integration is unsupported and HOME-based wrapper startup is
// not reliable. Bootstrap cmux bash integration on the first
// interactive prompt instead.
env["PROMPT_COMMAND"] = """
unset PROMPT_COMMAND; \
if [[ "${CMUX_LOAD_GHOSTTY_BASH_INTEGRATION:-0}" == "1" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then \
_cmux_ghostty_bash="$GHOSTTY_RESOURCES_DIR/shell-integration/bash/ghostty.bash"; \
[[ -r "$_cmux_ghostty_bash" ]] && source "$_cmux_ghostty_bash"; \
fi; \
if [[ "${CMUX_SHELL_INTEGRATION:-1}" != "0" && -n "${CMUX_SHELL_INTEGRATION_DIR:-}" ]]; then \
_cmux_bash_integration="$CMUX_SHELL_INTEGRATION_DIR/cmux-bash-integration.bash"; \
[[ -r "$_cmux_bash_integration" ]] && source "$_cmux_bash_integration"; \
fi; \
unset _cmux_ghostty_bash _cmux_bash_integration; \
if declare -F _cmux_prompt_command >/dev/null 2>&1; then _cmux_prompt_command; fi
"""
}
}
let startupEnvironment = additionalEnvironment
if !startupEnvironment.isEmpty {
for (key, value) in startupEnvironment where !key.isEmpty && !value.isEmpty && !key.hasPrefix("CMUX_") {
env[key] = value
}
}
@ -3140,6 +3167,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
@ -3320,6 +3351,11 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
func needsConfirmClose() -> Bool {
#if DEBUG
if let needsConfirmCloseOverrideForTesting {
return needsConfirmCloseOverrideForTesting
}
#endif
guard let surface = surface else { return false }
return ghostty_surface_needs_confirm_quit(surface)
}
@ -3438,6 +3474,11 @@ final class TerminalSurface: Identifiable, ObservableObject {
}
#if DEBUG
@MainActor
func setNeedsConfirmCloseOverrideForTesting(_ value: Bool?) {
needsConfirmCloseOverrideForTesting = value
}
/// Test-only helper to deterministically simulate a released runtime surface.
@MainActor
func releaseSurfaceForTesting() {

View file

@ -63,6 +63,10 @@ final class TerminalPanel: Panel, ObservableObject {
surface.hostedView
}
var requestedWorkingDirectory: String? {
surface.requestedWorkingDirectory
}
init(workspaceId: UUID, surface: TerminalSurface) {
self.id = surface.id
self.workspaceId = workspaceId
@ -95,34 +99,13 @@ final class TerminalPanel: Panel, ObservableObject {
configTemplate: configTemplate,
workingDirectory: workingDirectory,
initialCommand: initialCommand,
initialEnvironmentOverrides: Self.mergedNormalizedEnvironment(
base: additionalEnvironment,
overrides: initialEnvironmentOverrides
)
initialEnvironmentOverrides: initialEnvironmentOverrides,
additionalEnvironment: additionalEnvironment
)
surface.portOrdinal = portOrdinal
self.init(workspaceId: workspaceId, surface: surface)
}
private static func mergedNormalizedEnvironment(
base: [String: String],
overrides: [String: String]
) -> [String: String] {
var merged: [String: String] = [:]
merged.reserveCapacity(base.count + overrides.count)
for (rawKey, value) in base {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
merged[key] = value
}
for (rawKey, value) in overrides {
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { continue }
merged[key] = value
}
return merged
}
func updateTitle(_ newTitle: String) {
let trimmed = newTitle.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty && title != trimmed {
@ -211,6 +194,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()

View file

@ -1,3 +1,4 @@
import Darwin
import Foundation
#if canImport(Security)
import Security
@ -292,6 +293,26 @@ struct SocketControlSettings {
static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD"
static let launchTagEnvKey = "CMUX_TAG"
static let baseDebugBundleIdentifier = "com.cmuxterm.app.debug"
private static let socketDirectoryName = "cmux"
private static let stableSocketFileName = "cmux.sock"
private static let lastSocketPathFileName = "last-socket-path"
static let legacyStableDefaultSocketPath = "/tmp/cmux.sock"
static let legacyLastSocketPathFile = "/tmp/cmux-last-socket-path"
static var stableDefaultSocketPath: String {
stableSocketFileURL()?.path ?? legacyStableDefaultSocketPath
}
static var lastSocketPathFile: String {
lastSocketPathFileURL()?.path ?? legacyLastSocketPathFile
}
enum StableDefaultSocketPathEntry: Equatable {
case missing
case socket(ownerUserID: uid_t)
case other(ownerUserID: uid_t)
case inaccessible(errnoCode: Int32)
}
private static func normalizeMode(_ raw: String) -> String {
raw
@ -402,9 +423,16 @@ struct SocketControlSettings {
static func socketPath(
environment: [String: String] = ProcessInfo.processInfo.environment,
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
isDebugBuild: Bool = SocketControlSettings.isDebugBuild
isDebugBuild: Bool = SocketControlSettings.isDebugBuild,
currentUserID: uid_t = getuid(),
probeStableDefaultPathEntry: (String) -> StableDefaultSocketPathEntry = inspectStableDefaultSocketPathEntry
) -> String {
let fallback = defaultSocketPath(bundleIdentifier: bundleIdentifier, isDebugBuild: isDebugBuild)
let fallback = defaultSocketPath(
bundleIdentifier: bundleIdentifier,
isDebugBuild: isDebugBuild,
currentUserID: currentUserID,
probeStableDefaultPathEntry: probeStableDefaultPathEntry
)
if let taggedDebugPath = taggedDebugSocketPath(
bundleIdentifier: bundleIdentifier,
@ -433,7 +461,12 @@ struct SocketControlSettings {
return fallback
}
static func defaultSocketPath(bundleIdentifier: String?, isDebugBuild: Bool) -> String {
static func defaultSocketPath(
bundleIdentifier: String?,
isDebugBuild: Bool,
currentUserID: uid_t = getuid(),
probeStableDefaultPathEntry: (String) -> StableDefaultSocketPathEntry = inspectStableDefaultSocketPathEntry
) -> String {
if let taggedDebugPath = taggedDebugSocketPath(bundleIdentifier: bundleIdentifier, environment: [:]) {
return taggedDebugPath
}
@ -446,7 +479,38 @@ struct SocketControlSettings {
if isStagingBundleIdentifier(bundleIdentifier) {
return "/tmp/cmux-staging.sock"
}
return "/tmp/cmux.sock"
return resolvedStableDefaultSocketPath(
currentUserID: currentUserID,
probeStableDefaultPathEntry: probeStableDefaultPathEntry
)
}
static func userScopedStableSocketPath(currentUserID: uid_t = getuid()) -> String {
stableSocketDirectoryURL()?
.appendingPathComponent("cmux-\(currentUserID).sock", isDirectory: false)
.path ?? "/tmp/cmux-\(currentUserID).sock"
}
static func resolvedStableDefaultSocketPath(
currentUserID: uid_t = getuid(),
probeStableDefaultPathEntry: (String) -> StableDefaultSocketPathEntry = inspectStableDefaultSocketPathEntry
) -> String {
switch probeStableDefaultPathEntry(stableDefaultSocketPath) {
case .missing:
return stableDefaultSocketPath
case .socket(let ownerUserID) where ownerUserID == currentUserID:
return stableDefaultSocketPath
case .socket, .other, .inaccessible:
return userScopedStableSocketPath(currentUserID: currentUserID)
}
}
static func recordLastSocketPath(_ path: String, filePath: String = lastSocketPathFile) {
let payload = Data((path + "\n").utf8)
writeSocketPathMarker(payload, to: filePath)
if filePath != legacyLastSocketPathFile {
writeSocketPathMarker(payload, to: legacyLastSocketPathFile)
}
}
static func shouldHonorSocketPathOverride(
@ -506,6 +570,51 @@ struct SocketControlSettings {
|| bundleIdentifier.hasPrefix("com.cmuxterm.app.staging.")
}
static func stableSocketDirectoryURL(fileManager: FileManager = .default) -> URL? {
guard let appSupportDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
return nil
}
return appSupportDirectory.appendingPathComponent(socketDirectoryName, isDirectory: true)
}
static func stableSocketFileURL(fileManager: FileManager = .default) -> URL? {
stableSocketDirectoryURL(fileManager: fileManager)?
.appendingPathComponent(stableSocketFileName, isDirectory: false)
}
static func lastSocketPathFileURL(fileManager: FileManager = .default) -> URL? {
stableSocketDirectoryURL(fileManager: fileManager)?
.appendingPathComponent(lastSocketPathFileName, isDirectory: false)
}
private static func writeSocketPathMarker(_ payload: Data, to filePath: String) {
let fileURL = URL(fileURLWithPath: filePath)
let parentURL = fileURL.deletingLastPathComponent()
try? FileManager.default.createDirectory(
at: parentURL,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
try? payload.write(to: fileURL, options: .atomic)
}
private static func inspectStableDefaultSocketPathEntry(_ path: String) -> StableDefaultSocketPathEntry {
var st = stat()
guard lstat(path, &st) == 0 else {
let errnoCode = errno
if errnoCode == ENOENT {
return .missing
}
return .inaccessible(errnoCode: errnoCode)
}
let fileType = st.st_mode & mode_t(S_IFMT)
if fileType == mode_t(S_IFSOCK) {
return .socket(ownerUserID: st.st_uid)
}
return .other(ownerUserID: st.st_uid)
}
static func isTruthy(_ raw: String?) -> Bool {
guard let raw else { return false }
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {

View file

@ -60,18 +60,6 @@ enum WorkspaceAutoReorderSettings {
}
}
enum LastSurfaceCloseShortcutSettings {
static let key = "closeWorkspaceOnLastSurfaceShortcut"
static let defaultValue = false
static func closesWorkspace(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: key) == nil {
return defaultValue
}
return defaults.bool(forKey: key)
}
}
enum SidebarBranchLayoutSettings {
static let key = "sidebarBranchVerticalLayout"
static let defaultVerticalLayout = true
@ -763,6 +751,15 @@ class TabManager: ObservableObject {
private var pendingWorkspaceUnfocusTarget: (tabId: UUID, panelId: UUID)?
private var sidebarSelectedWorkspaceIds: Set<UUID> = []
var confirmCloseHandler: ((String, String, Bool) -> Bool)?
private struct WorkspaceCreationSnapshot {
let tabs: [Workspace]
let selectedTabId: UUID?
var selectedWorkspace: Workspace? {
guard let selectedTabId else { return nil }
return tabs.first(where: { $0.id == selectedTabId })
}
}
#if DEBUG
private var debugWorkspaceSwitchCounter: UInt64 = 0
private var debugWorkspaceSwitchId: UInt64 = 0
@ -925,13 +922,18 @@ class TabManager: ObservableObject {
placementOverride: NewWorkspacePlacement? = nil,
autoWelcomeIfNeeded: Bool = true
) -> Workspace {
sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1])
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
let inheritedConfig = inheritedTerminalConfigForNewWorkspace()
// Snapshot current published state once so workspace creation doesn't repeatedly
// bounce through Combine-backed accessors while we're preparing the new workspace.
let snapshot = workspaceCreationSnapshot()
let nextTabCount = snapshot.tabs.count + 1
sentryBreadcrumb("workspace.create", data: ["tabCount": nextTabCount])
let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory)
let workingDirectory = explicitWorkingDirectory ?? preferredWorkingDirectoryForNewTab(snapshot: snapshot)
let inheritedConfig = inheritedTerminalConfigForNewWorkspace(snapshot: snapshot)
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
let newWorkspace = Workspace(
title: "Terminal \(tabs.count + 1)",
title: "Terminal \(nextTabCount)",
workingDirectory: workingDirectory,
portOrdinal: ordinal,
configTemplate: inheritedConfig,
@ -940,19 +942,20 @@ class TabManager: ObservableObject {
)
newWorkspace.owningTabManager = self
wireClosedBrowserTracking(for: newWorkspace)
let insertIndex = newTabInsertIndex(placementOverride: placementOverride)
if insertIndex >= 0 && insertIndex <= tabs.count {
tabs.insert(newWorkspace, at: insertIndex)
let insertIndex = newTabInsertIndex(snapshot: snapshot, placementOverride: placementOverride)
var updatedTabs = snapshot.tabs
if insertIndex >= 0 && insertIndex <= updatedTabs.count {
updatedTabs.insert(newWorkspace, at: insertIndex)
} else {
tabs.append(newWorkspace)
updatedTabs.append(newWorkspace)
}
if overrideWorkingDirectory != nil,
let workingDirectory,
let panelId = newWorkspace.focusedTerminalPanel?.id {
tabs = updatedTabs
if let explicitWorkingDirectory,
let terminalPanel = newWorkspace.focusedTerminalPanel {
scheduleInitialWorkspaceGitMetadataRefresh(
workspaceId: newWorkspace.id,
panelId: panelId,
directory: workingDirectory
panelId: terminalPanel.id,
directory: explicitWorkingDirectory
)
}
if eagerLoadTerminal {
@ -972,8 +975,8 @@ class TabManager: ObservableObject {
#if DEBUG
UITestRecorder.incrementInt("addTabInvocations")
UITestRecorder.record([
"tabCount": String(tabs.count),
"selectedTabId": select ? newWorkspace.id.uuidString : (selectedTabId?.uuidString ?? "")
"tabCount": String(updatedTabs.count),
"selectedTabId": select ? newWorkspace.id.uuidString : (snapshot.selectedTabId?.uuidString ?? "")
])
#endif
if autoWelcomeIfNeeded && select && !UserDefaults.standard.bool(forKey: WelcomeSettings.shownKey) {
@ -1201,7 +1204,20 @@ class TabManager: ObservableObject {
}
func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? {
guard let workspace = selectedWorkspace else { return nil }
terminalPanelForWorkspaceConfigInheritanceSource(snapshot: workspaceCreationSnapshot())
}
private func workspaceCreationSnapshot() -> WorkspaceCreationSnapshot {
WorkspaceCreationSnapshot(
tabs: tabs,
selectedTabId: selectedTabId
)
}
private func terminalPanelForWorkspaceConfigInheritanceSource(
snapshot: WorkspaceCreationSnapshot
) -> TerminalPanel? {
guard let workspace = snapshot.selectedWorkspace else { return nil }
if let focusedTerminal = workspace.focusedTerminalPanel {
return focusedTerminal
}
@ -1216,13 +1232,19 @@ class TabManager: ObservableObject {
}
private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? {
if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource()?.surface.surface {
inheritedTerminalConfigForNewWorkspace(snapshot: workspaceCreationSnapshot())
}
private func inheritedTerminalConfigForNewWorkspace(
snapshot: WorkspaceCreationSnapshot
) -> ghostty_surface_config_s? {
if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource(snapshot: snapshot)?.surface.surface {
return cmuxInheritedSurfaceConfig(
sourceSurface: sourceSurface,
context: GHOSTTY_SURFACE_CONTEXT_TAB
)
}
if let fallbackFontPoints = selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() {
if let fallbackFontPoints = snapshot.selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() {
var config = ghostty_surface_config_new()
config.font_size = fallbackFontPoints
return config
@ -1238,24 +1260,36 @@ class TabManager: ObservableObject {
}
private func newTabInsertIndex(placementOverride: NewWorkspacePlacement? = nil) -> Int {
newTabInsertIndex(snapshot: workspaceCreationSnapshot(), placementOverride: placementOverride)
}
private func newTabInsertIndex(
snapshot: WorkspaceCreationSnapshot,
placementOverride: NewWorkspacePlacement? = nil
) -> Int {
let placement = placementOverride ?? WorkspacePlacementSettings.current()
let pinnedCount = tabs.filter { $0.isPinned }.count
let selectedIndex = selectedTabId.flatMap { tabId in
tabs.firstIndex(where: { $0.id == tabId })
let pinnedCount = snapshot.tabs.filter { $0.isPinned }.count
let selectedIndex = snapshot.selectedTabId.flatMap { tabId in
snapshot.tabs.firstIndex(where: { $0.id == tabId })
}
let selectedIsPinned = selectedIndex.map { tabs[$0].isPinned } ?? false
let selectedIsPinned = selectedIndex.map { snapshot.tabs[$0].isPinned } ?? false
return WorkspacePlacementSettings.insertionIndex(
placement: placement,
selectedIndex: selectedIndex,
selectedIsPinned: selectedIsPinned,
pinnedCount: pinnedCount,
totalCount: tabs.count
totalCount: snapshot.tabs.count
)
}
private func preferredWorkingDirectoryForNewTab() -> String? {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }) else {
preferredWorkingDirectoryForNewTab(snapshot: workspaceCreationSnapshot())
}
private func preferredWorkingDirectoryForNewTab(
snapshot: WorkspaceCreationSnapshot
) -> String? {
guard let tab = snapshot.selectedWorkspace else {
return nil
}
let focusedDirectory = tab.focusedPanelId
@ -1369,6 +1403,15 @@ class TabManager: ObservableObject {
tab.updatePanelDirectory(panelId: surfaceId, directory: normalized)
}
func updateSurfaceShellActivity(
tabId: UUID,
surfaceId: UUID,
state: Workspace.PanelShellActivityState
) {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
tab.updatePanelShellActivityState(panelId: surfaceId, state: state)
}
private func normalizeDirectory(_ directory: String) -> String {
let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return directory }
@ -1550,6 +1593,7 @@ class TabManager: ObservableObject {
if let confirmCloseHandler {
return confirmCloseHandler(title, message, acceptCmdD)
}
_ = acceptCmdD
let alert = NSAlert()
alert.messageText = title
@ -1558,15 +1602,18 @@ class TabManager: ObservableObject {
alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close"))
alert.addButton(withTitle: String(localized: "dialog.closeTab.cancel", defaultValue: "Cancel"))
// macOS convention: Cmd+D = confirm destructive close (e.g. "Don't Save").
// We only opt into this for the "close last workspace => close window" path to avoid
// conflicting with app-level Cmd+D (split right) during normal usage.
if acceptCmdD, let closeButton = alert.buttons.first {
closeButton.keyEquivalent = "d"
closeButton.keyEquivalentModifierMask = [.command]
// Keep Return/Enter behavior by explicitly setting the default button cell.
if let closeButton = alert.buttons.first {
closeButton.keyEquivalent = "\r"
closeButton.keyEquivalentModifierMask = []
alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell
alert.window.initialFirstResponder = closeButton
}
if let cancelButton = alert.buttons.dropFirst().first {
cancelButton.keyEquivalent = "\u{1b}"
}
if NSApp.activationPolicy() == .regular {
NSApp.activate(ignoringOtherApps: true)
}
return alert.runModal() == .alertFirstButtonReturn
@ -1692,7 +1739,11 @@ class TabManager: ObservableObject {
}
if tabs.count <= 1 {
// Last workspace in this window: close the window (Cmd+Shift+W behavior).
AppDelegate.shared?.closeMainWindowContainingTabId(workspace.id)
if let window {
window.performClose(nil)
} else {
AppDelegate.shared?.closeMainWindowContainingTabId(workspace.id)
}
} else {
closeWorkspace(workspace)
}
@ -1722,14 +1773,16 @@ class TabManager: ObservableObject {
dlog(
"surface.close.shortcut.begin tab=\(tab.id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) kind=\(panelKind) " +
"panelCount=\(tab.panels.count) bonsplitTabs=\(bonsplitTabCount) " +
"closeWorkspaceOnLastSurfaceSetting=\(LastSurfaceCloseShortcutSettings.closesWorkspace() ? 1 : 0)"
"panelCount=\(tab.panels.count) bonsplitTabs=\(bonsplitTabCount)"
)
#endif
// Route Cmd+W through Bonsplit/Workspace close handling so it matches the tab close
// button, including shared confirmation, setting-controlled last-surface behavior, and
// replacement-panel flow.
// button, including shared confirmation, last-surface workspace/window-close behavior,
// and the usual replacement-panel flow when the close does not collapse the workspace.
if let surfaceId = tab.surfaceIdFromPanelId(panelId) {
tab.markExplicitClose(surfaceId: surfaceId)
}
let closed = tab.closePanel(panelId)
#if DEBUG
dlog(
@ -1752,7 +1805,7 @@ class TabManager: ObservableObject {
guard tab.panels[surfaceId] != nil else { return }
if let terminalPanel = tab.terminalPanel(for: surfaceId),
terminalPanel.needsConfirmClose() {
tab.panelNeedsConfirmClose(panelId: surfaceId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
guard confirmClose(
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."),
@ -2154,14 +2207,33 @@ class TabManager: ObservableObject {
}
}
func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) {
@discardableResult
func focusTabFromNotification(_ tabId: UUID, surfaceId: UUID? = nil) -> Bool {
let wasSelected = selectedTabId == tabId
let desiredPanelId = surfaceId ?? tabs.first(where: { $0.id == tabId })?.focusedPanelId
guard let tab = tabs.first(where: { $0.id == tabId }) else {
#if DEBUG
dlog("notification.focus.fail tab=\(tabId.uuidString.prefix(5)) reason=missingTab")
#endif
return false
}
if let surfaceId, tab.panels[surfaceId] == nil {
#if DEBUG
dlog(
"notification.focus.fail tab=\(tabId.uuidString.prefix(5)) " +
"panel=\(surfaceId.uuidString.prefix(5)) reason=missingPanel"
)
#endif
return false
}
let desiredPanelId = surfaceId ?? tab.focusedPanelId
#if DEBUG
if let desiredPanelId {
AppDelegate.shared?.armJumpUnreadFocusRecord(tabId: tabId, surfaceId: desiredPanelId)
}
#endif
// Jump-to-unread should reveal the destination pane instead of keeping an old split-zoom
// state active around it.
tab.clearSplitZoom()
suppressFocusFlash = true
focusTab(tabId, surfaceId: desiredPanelId, suppressFlash: true)
if wasSelected {
@ -2179,6 +2251,7 @@ class TabManager: ObservableObject {
tab.triggerNotificationFocusFlash(panelId: targetPanelId, requiresSplit: false, shouldFocus: true)
notificationStore.markRead(forTabId: tabId, surfaceId: targetPanelId)
}
return true
}
func focusSurface(tabId: UUID, surfaceId: UUID) {

View file

@ -36,7 +36,7 @@ class TerminalController {
static let shared = TerminalController()
private nonisolated(unsafe) var socketPath = "/tmp/cmux.sock"
private nonisolated(unsafe) var socketPath = SocketControlSettings.stableDefaultSocketPath
private nonisolated(unsafe) var serverSocket: Int32 = -1
private nonisolated(unsafe) var isRunning = false
private nonisolated(unsafe) var acceptLoopAlive = false
@ -73,6 +73,13 @@ class TerminalController {
let acceptLoopAlive: Bool
let activeGeneration: UInt64
let pendingRearmGeneration: UInt64?
let listenerStartInProgress: Bool
}
private enum SocketBindAttemptResult {
case success(path: String)
case pathTooLong(path: String)
case failure(path: String, stage: String, errnoCode: Int32)
}
private static let focusIntentV1Commands: Set<String> = [
@ -174,11 +181,20 @@ class TerminalController {
isRunning: isRunning,
acceptLoopAlive: acceptLoopAlive,
activeGeneration: activeAcceptLoopGeneration,
pendingRearmGeneration: pendingAcceptLoopRearmGeneration
pendingRearmGeneration: pendingAcceptLoopRearmGeneration,
listenerStartInProgress: listenerStartInProgress
)
}
}
nonisolated func activeSocketPath(preferredPath: String) -> String {
let snapshot = listenerStateSnapshot()
if snapshot.isRunning || snapshot.acceptLoopAlive || snapshot.listenerStartInProgress || snapshot.serverSocket >= 0 {
return snapshot.socketPath
}
return preferredPath
}
private nonisolated func shouldContinueAcceptLoop(generation: UInt64) -> Bool {
withListenerState {
isRunning && generation == activeAcceptLoopGeneration
@ -333,6 +349,52 @@ class TerminalController {
return currentSorted != nextSorted
}
private struct SocketSurfaceKey: Hashable {
let workspaceId: UUID
let panelId: UUID
}
private final class SocketFastPathState: @unchecked Sendable {
private let queue = DispatchQueue(label: "com.cmux.socket-fast-path")
private var lastReportedDirectories: [SocketSurfaceKey: String] = [:]
private var lastReportedShellStates: [SocketSurfaceKey: Workspace.PanelShellActivityState] = [:]
private let maxTrackedDirectories = 4096
private let maxTrackedShellStates = 4096
func shouldPublishDirectory(workspaceId: UUID, panelId: UUID, directory: String) -> Bool {
let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId)
return queue.sync {
if lastReportedDirectories[key] == directory {
return false
}
if lastReportedDirectories.count >= maxTrackedDirectories {
lastReportedDirectories.removeAll(keepingCapacity: true)
}
lastReportedDirectories[key] = directory
return true
}
}
func shouldPublishShellActivity(
workspaceId: UUID,
panelId: UUID,
state: Workspace.PanelShellActivityState
) -> Bool {
let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId)
return queue.sync {
if lastReportedShellStates[key] == state {
return false
}
if lastReportedShellStates.count >= maxTrackedShellStates {
lastReportedShellStates.removeAll(keepingCapacity: true)
}
lastReportedShellStates[key] = state
return true
}
}
}
private static let socketFastPathState = SocketFastPathState()
nonisolated static func explicitSocketScope(
options: [String: String]
) -> (workspaceId: UUID, panelId: UUID)? {
@ -386,6 +448,21 @@ class TerminalController {
return directory.path.hasPrefix(temporary.path + "/")
}
nonisolated static func parseReportedShellActivityState(
_ rawState: String
) -> Workspace.PanelShellActivityState? {
switch rawState.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "prompt", "idle":
return .promptIdle
case "running", "busy", "command":
return .commandRunning
case "unknown", "clear":
return .unknown
default:
return nil
}
}
/// Update which window's TabManager receives socket commands.
/// This is used when the user switches between multiple terminal windows.
func setActiveTabManager(_ tabManager: TabManager?) {
@ -639,6 +716,60 @@ class TerminalController {
return (false, connectErrno)
}
private nonisolated static func bindListenerSocket(_ socket: Int32, path: String) -> SocketBindAttemptResult {
if let errnoCode = ensureSocketParentDirectoryExists(path: path) {
return .failure(path: path, stage: "create_directory", errnoCode: errnoCode)
}
if unlink(path) != 0, errno != ENOENT {
return .failure(path: path, stage: "unlink", errnoCode: errno)
}
guard let bindResult = bindUnixSocket(socket, path: path) else {
return .pathTooLong(path: path)
}
guard bindResult >= 0 else {
return .failure(path: path, stage: "bind", errnoCode: errno)
}
return .success(path: path)
}
private nonisolated static func ensureSocketParentDirectoryExists(path: String) -> Int32? {
let parentURL = URL(fileURLWithPath: path).deletingLastPathComponent()
do {
try FileManager.default.createDirectory(
at: parentURL,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
return nil
} catch let error as NSError {
if error.domain == NSPOSIXErrorDomain {
return Int32(error.code)
}
return EIO
}
}
nonisolated static func fallbackSocketPathAfterBindFailure(
requestedPath: String,
stage: String,
errnoCode: Int32,
currentUserID: uid_t = getuid()
) -> String? {
guard requestedPath == SocketControlSettings.stableDefaultSocketPath else {
return nil
}
switch stage {
case "unlink" where errnoCode == EACCES || errnoCode == EPERM:
return SocketControlSettings.userScopedStableSocketPath(currentUserID: currentUserID)
case "bind" where errnoCode == EACCES || errnoCode == EPERM || errnoCode == EADDRINUSE:
return SocketControlSettings.userScopedStableSocketPath(currentUserID: currentUserID)
default:
return nil
}
}
func start(tabManager: TabManager, socketPath: String, accessMode: SocketControlMode) {
self.tabManager = tabManager
self.accessMode = accessMode
@ -657,8 +788,9 @@ class TerminalController {
stop()
}
var activeSocketPath = socketPath
withListenerState {
self.socketPath = socketPath
self.socketPath = activeSocketPath
listenerStartInProgress = true
}
var listenerActivated = false
@ -670,9 +802,6 @@ class TerminalController {
}
}
// Remove existing socket file
unlink(socketPath)
// Create socket
let newServerSocket = socket(AF_UNIX, SOCK_STREAM, 0)
guard newServerSocket >= 0 else {
@ -686,29 +815,58 @@ class TerminalController {
return
}
// Bind to path
guard let bindResult = Self.bindUnixSocket(newServerSocket, path: socketPath) else {
var bindAttempt = Self.bindListenerSocket(newServerSocket, path: activeSocketPath)
if case .failure(let failedPath, let failedStage, let failedErrnoCode) = bindAttempt,
let fallbackPath = Self.fallbackSocketPathAfterBindFailure(
requestedPath: failedPath,
stage: failedStage,
errnoCode: failedErrnoCode
),
fallbackPath != failedPath {
sentryBreadcrumb(
"socket.listener.path.fallback",
category: "socket",
data: [
"requestedPath": failedPath,
"fallbackPath": fallbackPath,
"stage": failedStage,
"errno": Int(failedErrnoCode)
]
)
activeSocketPath = fallbackPath
withListenerState {
self.socketPath = activeSocketPath
}
bindAttempt = Self.bindListenerSocket(newServerSocket, path: activeSocketPath)
}
switch bindAttempt {
case .success(let boundPath):
activeSocketPath = boundPath
withListenerState {
self.socketPath = activeSocketPath
}
case .pathTooLong(let failedPath):
close(newServerSocket)
reportSocketListenerFailure(
message: "socket.listener.start.failed",
stage: "bind_path_too_long",
errnoCode: ENAMETOOLONG,
extra: [
"pathLength": socketPath.utf8.count,
"path": failedPath,
"pathLength": failedPath.utf8.count,
"maxPathLength": Self.unixSocketPathMaxLength
]
)
return
}
guard bindResult >= 0 else {
let errnoCode = errno
case .failure(let failedPath, let failedStage, let failedErrnoCode):
print("TerminalController: Failed to bind socket")
close(newServerSocket)
reportSocketListenerFailure(
message: "socket.listener.start.failed",
stage: "bind",
errnoCode: errnoCode
stage: failedStage,
errnoCode: failedErrnoCode,
extra: ["path": failedPath]
)
return
}
@ -728,6 +886,8 @@ class TerminalController {
return
}
SocketControlSettings.recordLastSocketPath(activeSocketPath)
let generation = withListenerState {
isRunning = true
pendingAcceptLoopRearmGeneration = nil
@ -740,12 +900,12 @@ class TerminalController {
}
listenerActivated = true
let listenerSocket = newServerSocket
print("TerminalController: Listening on \(socketPath)")
print("TerminalController: Listening on \(activeSocketPath)")
sentryBreadcrumb(
"socket.listener.listening",
category: "socket",
data: [
"path": socketPath,
"path": activeSocketPath,
"mode": accessMode.rawValue,
"generation": generation,
"backlog": Self.socketListenBacklog
@ -1469,6 +1629,9 @@ class TerminalController {
case "ports_kick":
return portsKick(args)
case "report_shell_state":
return reportShellState(args)
case "report_pwd":
return reportPwd(args)
@ -1632,6 +1795,9 @@ class TerminalController {
case "close_surface":
return closeSurface(args)
case "reload_config":
return reloadConfig(args)
case "refresh_surfaces":
return refreshSurfaces()
@ -4892,21 +5058,133 @@ class TerminalController {
return "OK \(base64)"
}
func readTerminalTextForSessionSnapshot(
private struct PasteboardItemSnapshot {
let representations: [(type: NSPasteboard.PasteboardType, data: Data)]
}
private func snapshotPasteboardItems(_ pasteboard: NSPasteboard) -> [PasteboardItemSnapshot] {
guard let items = pasteboard.pasteboardItems else { return [] }
return items.map { item in
let representations = item.types.compactMap { type -> (type: NSPasteboard.PasteboardType, data: Data)? in
guard let data = item.data(forType: type) else { return nil }
return (type: type, data: data)
}
return PasteboardItemSnapshot(representations: representations)
}
}
private func restorePasteboardItems(
_ snapshots: [PasteboardItemSnapshot],
to pasteboard: NSPasteboard
) {
_ = pasteboard.clearContents()
guard !snapshots.isEmpty else { return }
let restoredItems = snapshots.compactMap { snapshot -> NSPasteboardItem? in
guard !snapshot.representations.isEmpty else { return nil }
let item = NSPasteboardItem()
for representation in snapshot.representations {
item.setData(representation.data, forType: representation.type)
}
return item
}
guard !restoredItems.isEmpty else { return }
_ = pasteboard.writeObjects(restoredItems)
}
private func readGeneralPasteboardString(_ pasteboard: NSPasteboard) -> String? {
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL],
let firstURL = urls.first,
firstURL.isFileURL {
return firstURL.path
}
if let value = pasteboard.string(forType: .string) {
return value
}
return pasteboard.string(forType: NSPasteboard.PasteboardType("public.utf8-plain-text"))
}
private func readTerminalTextFromVTExportForSnapshot(
terminalPanel: TerminalPanel,
lineLimit: Int?
) -> String? {
let pasteboard = NSPasteboard.general
let snapshot = snapshotPasteboardItems(pasteboard)
defer {
restorePasteboardItems(snapshot, to: pasteboard)
}
let initialChangeCount = pasteboard.changeCount
guard terminalPanel.performBindingAction("write_screen_file:copy,vt") else {
return nil
}
guard pasteboard.changeCount != initialChangeCount else {
return nil
}
guard let exportedPath = Self.normalizedExportedScreenPath(readGeneralPasteboardString(pasteboard)) else {
return nil
}
let fileURL = URL(fileURLWithPath: exportedPath)
defer {
if Self.shouldRemoveExportedScreenFile(fileURL: fileURL) {
try? FileManager.default.removeItem(at: fileURL)
if Self.shouldRemoveExportedScreenDirectory(fileURL: fileURL) {
try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent())
}
}
}
guard let data = try? Data(contentsOf: fileURL),
var output = String(data: data, encoding: .utf8) else {
return nil
}
if let lineLimit {
output = tailTerminalLines(output, maxLines: lineLimit)
}
return output
}
func readTerminalTextForSnapshot(
terminalPanel: TerminalPanel,
includeScrollback: Bool = false,
lineLimit: Int? = nil
) -> String? {
if includeScrollback,
let vtOutput = readTerminalTextFromVTExportForSnapshot(
terminalPanel: terminalPanel,
lineLimit: lineLimit
) {
return vtOutput
}
let response = readTerminalTextBase64(
terminalPanel: terminalPanel,
includeScrollback: includeScrollback,
lineLimit: lineLimit
)
guard response.hasPrefix("OK ") else { return nil }
let payload = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
guard !payload.isEmpty else { return "" }
guard let data = Data(base64Encoded: payload) else { return nil }
return String(decoding: data, as: UTF8.self)
let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
if base64.isEmpty {
return ""
}
guard let data = Data(base64Encoded: base64),
let decoded = String(data: data, encoding: .utf8) else {
return nil
}
return decoded
}
func readTerminalTextForSessionSnapshot(
terminalPanel: TerminalPanel,
includeScrollback: Bool = false,
lineLimit: Int? = nil
) -> String? {
readTerminalTextForSnapshot(
terminalPanel: terminalPanel,
includeScrollback: includeScrollback,
lineLimit: lineLimit
)
}
private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult {
@ -9966,6 +10244,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
@ -10006,6 +10285,7 @@ class TerminalController {
report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports
report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning
ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel
report_shell_state <prompt|running> [--tab=X] [--panel=Y] - Report whether the shell is idle at a prompt or running a command
report_pwd <path> [--tab=X] [--panel=Y] - Report current working directory
clear_ports [--tab=X] [--panel=Y] - Clear listening ports
sidebar_state [--tab=X] - Dump sidebar metadata
@ -11348,7 +11628,9 @@ class TerminalController {
result = "ERROR: Surface not found"
return
}
tabManager.focusTabFromNotification(tab.id, surfaceId: surfaceId)
if !tabManager.focusTabFromNotification(tab.id, surfaceId: surfaceId) {
result = "ERROR: Focus failed"
}
}
return result
}
@ -13761,6 +14043,72 @@ class TerminalController {
return result
}
private func reportShellState(_ args: String) -> String {
let parsed = parseOptions(args)
guard let rawState = parsed.positional.first, !rawState.isEmpty else {
return "ERROR: Missing shell state — usage: report_shell_state <prompt|running> [--tab=X] [--panel=Y]"
}
guard let state = Self.parseReportedShellActivityState(rawState) else {
return "ERROR: Invalid shell state '\(rawState)' — expected prompt or running"
}
if let scope = Self.explicitSocketScope(options: parsed.options) {
guard Self.socketFastPathState.shouldPublishShellActivity(
workspaceId: scope.workspaceId,
panelId: scope.panelId,
state: state
) else {
return "OK"
}
DispatchQueue.main.async {
guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId) else { return }
tabManager.updateSurfaceShellActivity(tabId: scope.workspaceId, surfaceId: scope.panelId, state: state)
}
return "OK"
}
guard let tabManager else { return "ERROR: TabManager not available" }
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: report_shell_state <prompt|running> [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
tabManager.updateSurfaceShellActivity(tabId: tab.id, surfaceId: surfaceId, state: state)
}
return result
}
private func clearPorts(_ args: String) -> String {
let parsed = parseOptions(args)
var result = "OK"
@ -13959,6 +14307,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" }

View file

@ -356,8 +356,9 @@ extension Workspace {
switch panel.panelType {
case .terminal:
guard let terminalPanel = panel as? TerminalPanel else { return nil }
let capturedScrollback = includeScrollback
? TerminalController.shared.readTerminalTextForSessionSnapshot(
let shouldPersistScrollback = terminalPanel.shouldPersistScrollbackForSessionSnapshot()
let capturedScrollback = includeScrollback && shouldPersistScrollback
? TerminalController.shared.readTerminalTextForSnapshot(
terminalPanel: terminalPanel,
includeScrollback: true,
lineLimit: SessionPersistencePolicy.maxScrollbackLinesPerTerminal
@ -366,7 +367,8 @@ extension Workspace {
let resolvedScrollback = terminalSnapshotScrollback(
panelId: panelId,
capturedScrollback: capturedScrollback,
includeScrollback: includeScrollback
includeScrollback: includeScrollback,
allowFallbackScrollback: shouldPersistScrollback
)
terminalSnapshot = SessionTerminalPanelSnapshot(
workingDirectory: panelDirectories[panelId],
@ -413,24 +415,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
@ -4612,6 +4618,7 @@ final class Workspace: Identifiable, ObservableObject {
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private var panelShellActivityStates: [UUID: PanelShellActivityState] = [:]
private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:]
private static func isProxyOnlyRemoteError(_ detail: String) -> Bool {
@ -4637,6 +4644,26 @@ final class Workspace: Identifiable, ObservableObject {
static let markdown = "markdown"
}
enum PanelShellActivityState: String {
case unknown
case promptIdle
case commandRunning
}
nonisolated static func resolveCloseConfirmation(
shellActivityState: PanelShellActivityState?,
fallbackNeedsConfirmClose: Bool
) -> Bool {
switch shellActivityState ?? .unknown {
case .promptIdle:
return false
case .commandRunning:
return true
case .unknown:
return fallbackNeedsConfirmClose
}
}
// MARK: - Initialization
private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips {
@ -4800,6 +4827,9 @@ final class Workspace: Identifiable, ObservableObject {
bonsplitController.onExternalTabDrop = { [weak self] request in
self?.handleExternalTabDrop(request) ?? false
}
bonsplitController.onTabCloseRequest = { [weak self] tabId, _ in
self?.markExplicitClose(surfaceId: tabId)
}
// Set ourselves as delegate
bonsplitController.delegate = self
@ -4851,6 +4881,10 @@ final class Workspace: Identifiable, ObservableObject {
/// Prevents repeated close gestures (e.g., middle-click spam) from stacking dialogs.
private var pendingCloseConfirmTabIds: Set<TabID> = []
/// Tab IDs whose next close attempt came from an explicit user close gesture
/// (Cmd+W or the tab-strip X button), rather than an internal close/move flow.
private var explicitUserCloseTabIds: Set<TabID> = []
/// Deterministic tab selection to apply after a tab closes.
/// Keyed by the closing tab ID, value is the tab ID we want to select next.
private var postCloseSelectTabId: [TabID: TabID] = [:]
@ -4917,6 +4951,10 @@ final class Workspace: Identifiable, ObservableObject {
surfaceIdToPanelId[surfaceId]
}
func markExplicitClose(surfaceId: TabID) {
explicitUserCloseTabIds.insert(surfaceId)
}
func surfaceIdFromPanelId(_ panelId: UUID) -> TabID? {
surfaceIdToPanelId.first { $0.value == panelId }?.key
}
@ -5286,6 +5324,26 @@ final class Workspace: Identifiable, ObservableObject {
}
}
func updatePanelShellActivityState(panelId: UUID, state: PanelShellActivityState) {
guard panels[panelId] != nil else { return }
let previousState = panelShellActivityStates[panelId] ?? .unknown
guard previousState != state else { return }
panelShellActivityStates[panelId] = state
#if DEBUG
dlog(
"surface.shellState workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) from=\(previousState.rawValue) to=\(state.rawValue)"
)
#endif
}
func panelNeedsConfirmClose(panelId: UUID, fallbackNeedsConfirmClose: Bool) -> Bool {
Self.resolveCloseConfirmation(
shellActivityState: panelShellActivityStates[panelId],
fallbackNeedsConfirmClose: fallbackNeedsConfirmClose
)
}
func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) {
let state = SidebarGitBranchState(branch: branch, isDirty: isDirty)
let existing = panelGitBranches[panelId]
@ -5427,6 +5485,7 @@ final class Workspace: Identifiable, ObservableObject {
manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) }
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) }
panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) }
panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) }
recomputeListeningPorts()
}
@ -6038,11 +6097,35 @@ final class Workspace: Identifiable, ObservableObject {
let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId)
let remoteTerminalStartupCommand = remoteTerminalStartupCommand()
// Inherit working directory: prefer the source panel's reported cwd,
// then its requested startup cwd if shell integration has not reported
// back yet, and finally fall back to the workspace's current directory.
let splitWorkingDirectory: String? = {
if let panelDirectory = panelDirectories[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines),
!panelDirectory.isEmpty {
return panelDirectory
}
if let requestedWorkingDirectory = terminalPanel(for: panelId)?
.requestedWorkingDirectory?
.trimmingCharacters(in: .whitespacesAndNewlines),
!requestedWorkingDirectory.isEmpty {
return requestedWorkingDirectory
}
let workspaceDirectory = currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
return workspaceDirectory.isEmpty ? nil : workspaceDirectory
}()
#if DEBUG
dlog(
"split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") requestedDir=\(terminalPanel(for: panelId)?.requestedWorkingDirectory ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")"
)
#endif
// Create the new terminal panel.
let newPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: inheritedConfig,
workingDirectory: splitWorkingDirectory,
portOrdinal: portOrdinal,
initialCommand: remoteTerminalStartupCommand
)
@ -7412,9 +7495,9 @@ final class Workspace: Identifiable, ObservableObject {
/// Check if any panel needs close confirmation
func needsConfirmClose() -> Bool {
for panel in panels.values {
for (panelId, panel) in panels {
if let terminalPanel = panel as? TerminalPanel,
terminalPanel.needsConfirmClose() {
panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
return true
}
}
@ -8056,8 +8139,7 @@ extension Workspace: BonsplitDelegate {
@MainActor
private func shouldCloseWorkspaceOnLastSurface(for tabId: TabID) -> Bool {
let manager = owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: id) ?? AppDelegate.shared?.tabManager
guard LastSurfaceCloseShortcutSettings.closesWorkspace(),
panels.count <= 1,
guard panels.count <= 1,
panelIdFromSurfaceId(tabId) != nil,
let manager,
manager.tabs.contains(where: { $0.id == id }) else {
@ -8069,11 +8151,21 @@ extension Workspace: BonsplitDelegate {
@MainActor
private func confirmClosePanel(for tabId: TabID) async -> Bool {
let alert = NSAlert()
alert.messageText = "Close tab?"
alert.informativeText = "This will close the current tab."
alert.messageText = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?")
alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.")
alert.alertStyle = .warning
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: "Cancel")
alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close"))
alert.addButton(withTitle: String(localized: "dialog.closeTab.cancel", defaultValue: "Cancel"))
if let closeButton = alert.buttons.first {
closeButton.keyEquivalent = "\r"
closeButton.keyEquivalentModifierMask = []
alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell
alert.window.initialFirstResponder = closeButton
}
if let cancelButton = alert.buttons.dropFirst().first {
cancelButton.keyEquivalent = "\u{1b}"
}
// Prefer a sheet if we can find a window, otherwise fall back to modal.
if let window = NSApp.keyWindow ?? NSApp.mainWindow {
@ -8468,6 +8560,8 @@ extension Workspace: BonsplitDelegate {
}
}
let explicitUserClose = explicitUserCloseTabIds.remove(tab.id) != nil
if forceCloseTabIds.contains(tab.id) {
stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane)
recordPostCloseSelection()
@ -8481,7 +8575,7 @@ extension Workspace: BonsplitDelegate {
return false
}
if shouldCloseWorkspaceOnLastSurface(for: tab.id) {
if explicitUserClose && shouldCloseWorkspaceOnLastSurface(for: tab.id) {
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
owningTabManager?.closeWorkspaceWithConfirmation(self)
return false
@ -8498,7 +8592,7 @@ extension Workspace: BonsplitDelegate {
// If confirmation is required, Bonsplit will call into this delegate and we must return false.
// Show an app-level confirmation, then re-attempt the close with forceCloseTabIds to bypass
// this gating on the second pass.
if terminalPanel.needsConfirmClose() {
if panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
if pendingCloseConfirmTabIds.contains(tab.id) {
return false
@ -8591,6 +8685,7 @@ extension Workspace: BonsplitDelegate {
manualUnreadPanelIds.remove(panelId)
manualUnreadMarkedAt.removeValue(forKey: panelId)
panelSubscriptions.removeValue(forKey: panelId)
panelShellActivityStates.removeValue(forKey: panelId)
surfaceTTYNames.removeValue(forKey: panelId)
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
@ -8738,6 +8833,7 @@ extension Workspace: BonsplitDelegate {
pinnedPanelIds.remove(panelId)
manualUnreadPanelIds.remove(panelId)
panelSubscriptions.removeValue(forKey: panelId)
panelShellActivityStates.removeValue(forKey: panelId)
surfaceTTYNames.removeValue(forKey: panelId)
surfaceListeningPorts.removeValue(forKey: panelId)
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
@ -8770,7 +8866,7 @@ extension Workspace: BonsplitDelegate {
if forceCloseTabIds.contains(tab.id) { continue }
if let panelId = panelIdFromSurfaceId(tab.id),
let terminalPanel = terminalPanel(for: panelId),
terminalPanel.needsConfirmClose() {
panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
pendingPaneClosePanelIds.removeValue(forKey: pane.id)
return false
}

View file

@ -452,10 +452,9 @@ struct cmuxApp: App {
Divider()
// Terminal semantics:
// Cmd+W closes the focused tab/surface (with confirmation if needed) and keeps
// the workspace open by default. Cmd+Shift+W is the explicit workspace-close
// action, unless the user opts into closing the workspace when its last surface
// is closed.
// Cmd+W closes the focused tab/surface (with confirmation if needed). When that
// was the last surface in the workspace, cmux removes the workspace and closes
// the window if it was also the last workspace.
Button(String(localized: "menu.file.closeTab", defaultValue: "Close Tab")) {
closePanelOrWindow()
}
@ -3077,8 +3076,6 @@ struct SettingsView: View {
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey)
private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
@AppStorage(LastSurfaceCloseShortcutSettings.key)
private var closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
@AppStorage(SidebarWorkspaceDetailSettings.hideAllDetailsKey)
private var sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails
@ -3125,19 +3122,6 @@ struct SettingsView: View {
NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement
}
private var closeWorkspaceOnLastSurfaceShortcutSubtitle: String {
if closeWorkspaceOnLastSurfaceShortcut {
return String(
localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOn",
defaultValue: "Closing the last surface also closes its workspace."
)
}
return String(
localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOff",
defaultValue: "Closing the last surface keeps the workspace open. Use Cmd+Shift+W to close a workspace explicitly."
)
}
private var selectedSidebarActiveTabIndicatorStyle: SidebarActiveTabIndicatorStyle {
SidebarActiveTabIndicatorSettings.resolvedStyle(rawValue: sidebarActiveTabIndicatorStyle)
}
@ -3443,14 +3427,6 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 14) {
SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App"))
SettingsCard {
SettingsPickerRow(String(localized: "settings.app.theme", defaultValue: "Theme"), controlWidth: pickerColumnWidth, selection: $appearanceMode) {
ForEach(AppearanceMode.visibleCases) { mode in
Text(mode.displayName).tag(mode.rawValue)
}
}
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.app.language", defaultValue: "Language"),
subtitle: appLanguage != LanguageSettings.languageAtLaunch.rawValue
@ -3482,6 +3458,15 @@ struct SettingsView: View {
SettingsCardDivider()
ThemePickerRow(
selectedMode: appearanceMode,
onSelect: { mode in
appearanceMode = mode.rawValue
}
)
SettingsCardDivider()
AppIconPickerRow(
selectedMode: appIconMode,
onSelect: { mode in
@ -3505,17 +3490,6 @@ struct SettingsView: View {
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.app.closeWorkspaceOnLastSurfaceShortcut", defaultValue: "Closing Last Surface Closes Workspace"),
subtitle: closeWorkspaceOnLastSurfaceShortcutSubtitle
) {
Toggle("", isOn: $closeWorkspaceOnLastSurfaceShortcut)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.app.reorderOnNotification", defaultValue: "Reorder on Notification"),
subtitle: String(localized: "settings.app.reorderOnNotification.subtitle", defaultValue: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions.")
@ -4496,7 +4470,6 @@ struct SettingsView: View {
ShortcutHintDebugSettings.resetVisibilityDefaults()
alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
closeWorkspaceOnLastSurfaceShortcut = LastSurfaceCloseShortcutSettings.defaultValue
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
sidebarHideAllDetails = SidebarWorkspaceDetailSettings.defaultHideAllDetails
sidebarShowNotificationMessage = SidebarWorkspaceDetailSettings.defaultShowNotificationMessage
@ -4769,6 +4742,193 @@ private struct SettingsCardNote: View {
}
}
private struct ThemeWindowThumbnail: View {
let isDark: Bool
var body: some View {
GeometryReader { geo in
let width = geo.size.width
let height = geo.size.height
ZStack {
// Wallpaper background
if isDark {
LinearGradient(
colors: [Color(red: 0.1, green: 0.1, blue: 0.3), Color(red: 0.05, green: 0.05, blue: 0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Path { path in
path.move(to: CGPoint(x: 0, y: height * 0.5))
path.addQuadCurve(to: CGPoint(x: width, y: height), control: CGPoint(x: width * 0.5, y: height * 0.2))
path.addLine(to: CGPoint(x: width, y: 0))
path.addLine(to: CGPoint(x: 0, y: 0))
}
.fill(LinearGradient(colors: [Color(red: 0.2, green: 0.2, blue: 0.6).opacity(0.5), .clear], startPoint: .topLeading, endPoint: .bottomTrailing))
} else {
LinearGradient(
colors: [Color(red: 0.6, green: 0.8, blue: 0.95), Color(red: 0.2, green: 0.4, blue: 0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Path { path in
path.move(to: CGPoint(x: 0, y: height * 0.5))
path.addQuadCurve(to: CGPoint(x: width, y: height), control: CGPoint(x: width * 0.5, y: height * 0.2))
path.addLine(to: CGPoint(x: width, y: 0))
path.addLine(to: CGPoint(x: 0, y: 0))
}
.fill(LinearGradient(colors: [Color(red: 0.8, green: 0.9, blue: 1.0).opacity(0.6), .clear], startPoint: .topLeading, endPoint: .bottomTrailing))
}
// Menu bar
VStack(spacing: 0) {
HStack {
Image(systemName: "applelogo")
.font(.system(size: max(height * 0.08, 6)))
.foregroundColor(isDark ? .white : .black)
.opacity(0.8)
Spacer()
}
.padding(.horizontal, max(width * 0.04, 4))
.frame(height: max(height * 0.12, 8))
.background(.ultraThinMaterial)
Spacer()
}
// Back window
VStack(spacing: 0) {
Rectangle()
.fill(isDark ? Color(white: 0.2) : Color(white: 0.9))
.frame(height: max(height * 0.15, 8))
ZStack(alignment: .top) {
Rectangle()
.fill(isDark ? Color(white: 0.15) : Color(white: 0.98))
RoundedRectangle(cornerRadius: max(width * 0.02, 2), style: .continuous)
.fill(Color.accentColor)
.frame(height: max(height * 0.12, 6))
.padding(max(width * 0.04, 4))
}
}
.clipShape(RoundedRectangle(cornerRadius: max(width * 0.04, 4), style: .continuous))
.frame(width: width * 0.65, height: height * 0.45)
.shadow(color: .black.opacity(isDark ? 0.4 : 0.15), radius: 4, x: 0, y: 2)
.offset(x: -width * 0.08, y: -height * 0.1)
// Front window with traffic lights
VStack(spacing: 0) {
ZStack {
Rectangle()
.fill(isDark ? Color(white: 0.18) : Color(white: 0.92))
HStack(spacing: max(width * 0.025, 2)) {
Circle().fill(Color(red: 1.0, green: 0.37, blue: 0.34)).frame(width: max(width * 0.04, 3))
Circle().fill(Color(red: 1.0, green: 0.74, blue: 0.18)).frame(width: max(width * 0.04, 3))
Circle().fill(Color(red: 0.15, green: 0.79, blue: 0.25)).frame(width: max(width * 0.04, 3))
Spacer()
}
.padding(.horizontal, max(width * 0.04, 4))
}
.frame(height: max(height * 0.18, 10))
Rectangle()
.fill(isDark ? Color(white: 0.1) : .white)
}
.clipShape(RoundedRectangle(cornerRadius: max(width * 0.05, 5), style: .continuous))
.shadow(color: .black.opacity(isDark ? 0.5 : 0.2), radius: 6, x: 0, y: 3)
.frame(width: width * 0.75, height: height * 0.55)
.offset(x: width * 0.12, y: height * 0.2)
}
}
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
)
}
}
private struct ThemePickerRow: View {
let selectedMode: String
let onSelect: (AppearanceMode) -> Void
private let thumbWidth: CGFloat = 76
private let thumbHeight: CGFloat = 50
var body: some View {
HStack(alignment: .top, spacing: 12) {
Text(String(localized: "settings.app.theme", defaultValue: "Theme"))
.font(.system(size: 13, weight: .medium))
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 8) {
ForEach(AppearanceMode.visibleCases) { mode in
let isSelected = selectedMode == mode.rawValue
Button {
onSelect(mode)
} label: {
VStack(spacing: 4) {
Group {
if mode == .system {
ZStack {
ThemeWindowThumbnail(isDark: false)
.mask(
GeometryReader { geo in
Rectangle()
.frame(width: geo.size.width / 2, height: geo.size.height)
.position(x: geo.size.width / 4, y: geo.size.height / 2)
}
)
ThemeWindowThumbnail(isDark: true)
.mask(
GeometryReader { geo in
Rectangle()
.frame(width: geo.size.width / 2, height: geo.size.height)
.position(x: geo.size.width * 0.75, y: geo.size.height / 2)
}
)
GeometryReader { geo in
Rectangle()
.fill(Color.primary.opacity(0.15))
.frame(width: 1, height: geo.size.height)
.position(x: geo.size.width / 2, y: geo.size.height / 2)
}
}
} else {
ThemeWindowThumbnail(isDark: mode == .dark)
}
}
.frame(width: thumbWidth, height: thumbHeight)
Text(mode.displayName)
.font(.system(size: 10))
.fontWeight(isSelected ? .semibold : .regular)
.foregroundColor(isSelected ? .primary : .secondary)
}
.padding(.vertical, 8)
.padding(.horizontal, 10)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(isSelected
? Color.accentColor.opacity(0.12)
: Color.clear)
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
)
}
.buttonStyle(.plain)
.focusable(false)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
.layoutPriority(1)
}
.padding(.horizontal, 14)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct AppIconPickerRow: View {
let selectedMode: String
let onSelect: (AppIconMode) -> Void
@ -4777,20 +4937,25 @@ private struct AppIconPickerRow: View {
private let autoIconSize: CGFloat = 36
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(String(localized: "settings.app.appIcon", defaultValue: "App Icon"))
.font(.system(size: 13, weight: .medium))
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(String(localized: "settings.app.appIcon", defaultValue: "App Icon"))
.font(.system(size: 13, weight: .medium))
Text(String(localized: "settings.app.appIcon.subtitle", defaultValue: "Dock and app switcher"))
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 12) {
HStack(spacing: 8) {
ForEach(AppIconMode.allCases) { mode in
let isSelected = selectedMode == mode.rawValue
Button {
onSelect(mode)
} label: {
VStack(spacing: 6) {
VStack(spacing: 4) {
Group {
if mode == .automatic {
// Show both icons overlapping
ZStack {
Image("AppIconLight")
.resizable()
@ -4816,25 +4981,29 @@ private struct AppIconPickerRow: View {
}
Text(mode.displayName)
.font(.system(size: 11))
.font(.system(size: 10))
.foregroundColor(isSelected ? .primary : .secondary)
}
.padding(.vertical, 8)
.padding(.horizontal, 12)
.padding(.horizontal, 10)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(isSelected
? Color.accentColor.opacity(0.12)
: Color.clear)
)
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
)
}
.buttonStyle(.plain)
.focusable(false)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
.layoutPriority(1)
}
.padding(.horizontal, 14)
.padding(.vertical, 9)

View file

@ -235,6 +235,113 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Menu-driven add workspace should still route to key window context when object-key lookup misses")
}
func testAddWorkspaceInPreferredMainWindowPrunesOrphanedContextWithoutLiveWindow() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let orphanWindowId = UUID()
let orphanManager = TabManager()
let orphanSidebarState = SidebarState()
let orphanSidebarSelectionState = SidebarSelectionState()
autoreleasepool {
var orphanWindow: NSWindow? = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
orphanWindow?.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(orphanWindowId.uuidString)")
appDelegate.registerMainWindow(
orphanWindow!,
windowId: orphanWindowId,
tabManager: orphanManager,
sidebarState: orphanSidebarState,
sidebarSelectionState: orphanSidebarSelectionState
)
orphanWindow = nil
}
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
XCTAssertNil(appDelegate.mainWindow(for: orphanWindowId), "Test precondition: orphaned context should not have a live window")
let orphanCount = orphanManager.tabs.count
XCTAssertNil(
appDelegate.addWorkspaceInPreferredMainWindow(),
"Workspace creation should refuse orphaned contexts with no live window"
)
XCTAssertEqual(orphanManager.tabs.count, orphanCount, "Orphaned manager must not receive a new workspace")
XCTAssertNil(appDelegate.tabManagerFor(windowId: orphanWindowId), "Orphaned context should be pruned after failed resolution")
}
func testCustomCmdTNewWorkspacePrunesOrphanedContextWithoutLiveWindow() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let existingWindowIds = mainWindowIds()
let orphanWindowId = UUID()
let orphanManager = TabManager()
let orphanSidebarState = SidebarState()
let orphanSidebarSelectionState = SidebarSelectionState()
autoreleasepool {
var orphanWindow: NSWindow? = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
orphanWindow?.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(orphanWindowId.uuidString)")
appDelegate.registerMainWindow(
orphanWindow!,
windowId: orphanWindowId,
tabManager: orphanManager,
sidebarState: orphanSidebarState,
sidebarSelectionState: orphanSidebarSelectionState
)
orphanWindow = nil
}
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
XCTAssertNil(appDelegate.mainWindow(for: orphanWindowId), "Test precondition: orphaned context should not have a live window")
let orphanCount = orphanManager.tabs.count
let remappedCmdT = StoredShortcut(key: "t", command: true, shift: false, option: false, control: false)
withTemporaryShortcut(action: .newTab, shortcut: remappedCmdT) {
guard let event = makeKeyDownEvent(
key: "t",
modifiers: [.command],
keyCode: 17, // kVK_ANSI_T
windowNumber: 0
) else {
XCTFail("Failed to construct remapped Cmd+T event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
}
XCTAssertEqual(orphanManager.tabs.count, orphanCount, "Orphaned manager must not receive a new workspace from remapped Cmd+T")
XCTAssertNil(appDelegate.tabManagerFor(windowId: orphanWindowId), "Remapped Cmd+T should prune the orphaned context after failed resolution")
let createdWindowIds = mainWindowIds().subtracting(existingWindowIds)
for windowId in createdWindowIds {
closeWindow(withId: windowId)
}
}
func testCmdDigitRoutesToEventWindowWhenActiveManagerIsStale() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
@ -422,6 +529,48 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertNil(self.window(withId: windowId), "Confirming Cmd+Ctrl+W should close the window")
}
func testCmdWClosesWindowWhenClosingLastSurfaceInLastWorkspace() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let targetWindow = window(withId: windowId),
let manager = appDelegate.tabManagerFor(windowId: windowId) else {
XCTFail("Expected test window and manager")
return
}
XCTAssertEqual(manager.tabs.count, 1)
XCTAssertEqual(manager.tabs[0].panels.count, 1)
guard let event = makeKeyDownEvent(
key: "w",
modifiers: [.command],
keyCode: 13,
windowNumber: targetWindow.windowNumber
) else {
XCTFail("Failed to construct Cmd+W event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
XCTAssertNil(
self.window(withId: windowId),
"Cmd+W on the last surface in the last workspace should close the window"
)
}
func testCmdPhysicalIWithDvorakCharactersDoesNotTriggerShowNotifications() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
@ -2336,6 +2485,16 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier })
}
private func mainWindowIds() -> Set<UUID> {
Set(NSApp.windows.compactMap { window in
guard let raw = window.identifier?.rawValue,
raw.hasPrefix("cmux.main.") else {
return nil
}
return UUID(uuidString: String(raw.dropFirst("cmux.main.".count)))
})
}
private func closeWindow(withId windowId: UUID) {
guard let window = window(withId: windowId) else { return }
window.performClose(nil)

View file

@ -4802,43 +4802,6 @@ final class WorkspaceAutoReorderSettingsTests: XCTestCase {
}
}
final class LastSurfaceCloseShortcutSettingsTests: XCTestCase {
func testDefaultKeepsWorkspaceOpen() {
let suiteName = "LastSurfaceCloseShortcutSettingsTests.Default.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
XCTAssertFalse(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults))
}
func testStoredTrueClosesWorkspace() {
let suiteName = "LastSurfaceCloseShortcutSettingsTests.Enabled.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(true, forKey: LastSurfaceCloseShortcutSettings.key)
XCTAssertTrue(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults))
}
func testStoredFalseKeepsWorkspaceOpen() {
let suiteName = "LastSurfaceCloseShortcutSettingsTests.Disabled.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(false, forKey: LastSurfaceCloseShortcutSettings.key)
XCTAssertFalse(LastSurfaceCloseShortcutSettings.closesWorkspace(defaults: defaults))
}
}
final class SidebarBranchLayoutSettingsTests: XCTestCase {
func testDefaultUsesVerticalLayout() {
let suiteName = "SidebarBranchLayoutSettingsTests.Default.\(UUID().uuidString)"
@ -5325,6 +5288,54 @@ final class WorkspaceTeardownTests: XCTestCase {
}
}
@MainActor
final class WorkspaceSplitWorkingDirectoryTests: XCTestCase {
func testNewTerminalSplitFallsBackToRequestedWorkingDirectoryWhenReportedDirectoryIsStale() {
let workspace = Workspace()
guard let sourcePaneId = workspace.bonsplitController.focusedPaneId else {
XCTFail("Expected focused pane in new workspace")
return
}
let staleCurrentDirectory = workspace.currentDirectory
let requestedDirectory = "/tmp/cmux-requested-split-cwd-\(UUID().uuidString)"
guard let sourcePanel = workspace.newTerminalSurface(
inPane: sourcePaneId,
focus: false,
workingDirectory: requestedDirectory
) else {
XCTFail("Expected source terminal panel to be created")
return
}
XCTAssertEqual(sourcePanel.requestedWorkingDirectory, requestedDirectory)
XCTAssertNil(
workspace.panelDirectories[sourcePanel.id],
"Expected requested cwd to exist before shell integration reports a live cwd"
)
XCTAssertEqual(
workspace.currentDirectory,
staleCurrentDirectory,
"Expected focused workspace cwd to remain stale before panel directory updates"
)
guard let splitPanel = workspace.newTerminalSplit(
from: sourcePanel.id,
orientation: .horizontal,
focus: false
) else {
XCTFail("Expected split terminal panel to be created")
return
}
XCTAssertEqual(
splitPanel.requestedWorkingDirectory,
requestedDirectory,
"Expected split to inherit the source terminal's requested cwd when no reported cwd exists yet"
)
}
}
@MainActor
final class TabManagerWorkspaceOwnershipTests: XCTestCase {
func testCloseWorkspaceIgnoresWorkspaceNotOwnedByManager() {
@ -5456,31 +5467,112 @@ final class TabManagerCloseWorkspacesWithConfirmationTests: XCTestCase {
@MainActor
final class TabManagerCloseCurrentPanelTests: XCTestCase {
func testCloseCurrentPanelKeepsWorkspaceOpenWhenItOwnsTheLastSurface() {
func testRuntimeCloseSkipsConfirmationWhenShellReportsPromptIdle() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let initialPanelId = workspace.focusedPanelId else {
XCTFail("Expected selected workspace and focused panel")
let panelId = workspace.focusedPanelId,
let terminalPanel = workspace.terminalPanel(for: panelId) else {
XCTFail("Expected selected workspace and focused terminal panel")
return
}
let initialWorkspaceId = workspace.id
XCTAssertEqual(manager.tabs.count, 1)
XCTAssertEqual(workspace.panels.count, 1)
terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(true)
workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle)
var promptCount = 0
manager.confirmCloseHandler = { _, _, _ in
promptCount += 1
return false
}
manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId)
drainMainQueue()
drainMainQueue()
XCTAssertEqual(promptCount, 0, "Runtime closes should honor prompt-idle shell state")
XCTAssertNil(workspace.panels[panelId], "Expected the original panel to close")
XCTAssertEqual(workspace.panels.count, 1, "Expected a replacement surface after closing the last panel")
}
func testRuntimeClosePromptsWhenShellReportsRunningCommand() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let panelId = workspace.focusedPanelId,
let terminalPanel = workspace.terminalPanel(for: panelId) else {
XCTFail("Expected selected workspace and focused terminal panel")
return
}
terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(false)
workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning)
var promptCount = 0
manager.confirmCloseHandler = { _, _, _ in
promptCount += 1
return false
}
manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId)
XCTAssertEqual(promptCount, 1, "Running commands should still require confirmation")
XCTAssertNotNil(workspace.panels[panelId], "Prompt rejection should keep the original panel open")
}
func testCloseCurrentPanelClosesWorkspaceWhenItOwnsTheLastSurface() {
let manager = TabManager()
let firstWorkspace = manager.tabs[0]
let secondWorkspace = manager.addWorkspace()
manager.selectWorkspace(secondWorkspace)
guard let secondPanelId = secondWorkspace.focusedPanelId else {
XCTFail("Expected focused panel in selected workspace")
return
}
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
XCTAssertEqual(secondWorkspace.panels.count, 1)
manager.closeCurrentPanelWithConfirmation()
drainMainQueue()
drainMainQueue()
XCTAssertEqual(manager.tabs.count, 1, "Closing the last surface should not remove the workspace")
XCTAssertEqual(manager.selectedTabId, initialWorkspaceId)
XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId)
XCTAssertNil(workspace.panels[initialPanelId], "Expected the original surface to be closed")
XCTAssertEqual(workspace.panels.count, 1, "Expected the workspace to stay alive with a replacement surface")
XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId)
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
XCTAssertNil(secondWorkspace.panels[secondPanelId])
XCTAssertTrue(secondWorkspace.panels.isEmpty)
}
func testClosePanelButtonKeepsWorkspaceOpenWhenItOwnsTheLastSurface() {
func testClosePanelButtonClosesWorkspaceWhenItOwnsTheLastSurface() {
let manager = TabManager()
let firstWorkspace = manager.tabs[0]
let secondWorkspace = manager.addWorkspace()
manager.selectWorkspace(secondWorkspace)
guard let secondPanelId = secondWorkspace.focusedPanelId else {
XCTFail("Expected focused panel in selected workspace")
return
}
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
XCTAssertEqual(secondWorkspace.panels.count, 1)
guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else {
XCTFail("Expected bonsplit surface ID for focused panel")
return
}
secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId)
XCTAssertFalse(secondWorkspace.closePanel(secondPanelId))
drainMainQueue()
drainMainQueue()
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
XCTAssertNil(secondWorkspace.panels[secondPanelId])
XCTAssertTrue(secondWorkspace.panels.isEmpty)
}
func testGenericClosePanelKeepsWorkspaceOpenWithoutExplicitCloseMarker() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let initialPanelId = workspace.focusedPanelId else {
@ -5496,85 +5588,15 @@ final class TabManagerCloseCurrentPanelTests: XCTestCase {
drainMainQueue()
drainMainQueue()
XCTAssertEqual(manager.tabs.count, 1, "Closing the last surface should not remove the workspace")
XCTAssertEqual(manager.tabs.count, 1)
XCTAssertEqual(manager.selectedTabId, initialWorkspaceId)
XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId)
XCTAssertNil(workspace.panels[initialPanelId], "Expected the original surface to be closed")
XCTAssertEqual(workspace.panels.count, 1, "Expected the workspace to stay alive with a replacement surface")
XCTAssertNil(workspace.panels[initialPanelId])
XCTAssertEqual(workspace.panels.count, 1)
XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId)
}
func testCloseCurrentPanelClosesWorkspaceWhenLastSurfaceShortcutSettingEnabled() {
let defaults = UserDefaults.standard
let originalSetting = defaults.object(forKey: LastSurfaceCloseShortcutSettings.key)
defaults.set(true, forKey: LastSurfaceCloseShortcutSettings.key)
defer {
if let originalSetting {
defaults.set(originalSetting, forKey: LastSurfaceCloseShortcutSettings.key)
} else {
defaults.removeObject(forKey: LastSurfaceCloseShortcutSettings.key)
}
}
let manager = TabManager()
let firstWorkspace = manager.tabs[0]
let secondWorkspace = manager.addWorkspace()
manager.selectWorkspace(secondWorkspace)
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
XCTAssertEqual(secondWorkspace.panels.count, 1)
manager.closeCurrentPanelWithConfirmation()
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
}
func testClosePanelButtonClosesWorkspaceWhenLastSurfaceShortcutSettingEnabled() {
let defaults = UserDefaults.standard
let originalSetting = defaults.object(forKey: LastSurfaceCloseShortcutSettings.key)
defaults.set(true, forKey: LastSurfaceCloseShortcutSettings.key)
defer {
if let originalSetting {
defaults.set(originalSetting, forKey: LastSurfaceCloseShortcutSettings.key)
} else {
defaults.removeObject(forKey: LastSurfaceCloseShortcutSettings.key)
}
}
let manager = TabManager()
let firstWorkspace = manager.tabs[0]
let secondWorkspace = manager.addWorkspace()
manager.selectWorkspace(secondWorkspace)
guard let secondPanelId = secondWorkspace.focusedPanelId else {
XCTFail("Expected focused panel in selected workspace")
return
}
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
XCTAssertEqual(secondWorkspace.panels.count, 1)
XCTAssertFalse(secondWorkspace.closePanel(secondPanelId))
drainMainQueue()
drainMainQueue()
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
}
func testCloseCurrentPanelWithLegacySettingIgnoresStaleSurfaceId() {
let defaults = UserDefaults.standard
let originalSetting = defaults.object(forKey: LastSurfaceCloseShortcutSettings.key)
defaults.set(true, forKey: LastSurfaceCloseShortcutSettings.key)
defer {
if let originalSetting {
defaults.set(originalSetting, forKey: LastSurfaceCloseShortcutSettings.key)
} else {
defaults.removeObject(forKey: LastSurfaceCloseShortcutSettings.key)
}
}
func testCloseCurrentPanelIgnoresStaleSurfaceId() {
let manager = TabManager()
let firstWorkspace = manager.tabs[0]
let secondWorkspace = manager.addWorkspace()
@ -5626,6 +5648,43 @@ final class TabManagerCloseCurrentPanelTests: XCTestCase {
}
}
@MainActor
final class TabManagerNotificationFocusTests: XCTestCase {
func testFocusTabFromNotificationClearsSplitZoomBeforeFocusingTargetPanel() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let leftPanelId = workspace.focusedPanelId,
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
XCTFail("Expected split setup to succeed")
return
}
workspace.focusPanel(leftPanelId)
XCTAssertTrue(workspace.toggleSplitZoom(panelId: leftPanelId), "Expected split zoom to enable")
XCTAssertTrue(workspace.bonsplitController.isSplitZoomed, "Expected workspace to start zoomed")
XCTAssertTrue(manager.focusTabFromNotification(workspace.id, surfaceId: rightPanel.id))
drainMainQueue()
drainMainQueue()
XCTAssertFalse(
workspace.bonsplitController.isSplitZoomed,
"Expected notification focus to exit split zoom so the target pane becomes visible"
)
XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected notification target panel to be focused")
}
func testFocusTabFromNotificationReturnsFalseForMissingPanel() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace else {
XCTFail("Expected selected workspace")
return
}
XCTAssertFalse(manager.focusTabFromNotification(workspace.id, surfaceId: UUID()))
}
}
@MainActor
final class TabManagerPendingUnfocusPolicyTests: XCTestCase {
func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() {
@ -11060,6 +11119,27 @@ final class InternalTabDragConfigurationTests: XCTestCase {
)
}
}
@MainActor
final class InternalTabDragBundleDeclarationTests: XCTestCase {
private func exportedTypeIdentifiers(bundle: Bundle) -> Set<String> {
let declarations = (bundle.object(forInfoDictionaryKey: "UTExportedTypeDeclarations") as? [[String: Any]]) ?? []
return Set(declarations.compactMap { $0["UTTypeIdentifier"] as? String })
}
func testAppBundleExportsInternalDragTypes() {
let exported = exportedTypeIdentifiers(bundle: Bundle(for: AppDelegate.self))
XCTAssertTrue(
exported.contains("com.splittabbar.tabtransfer"),
"Expected app bundle to export bonsplit tab-transfer type, got \(exported)"
)
XCTAssertTrue(
exported.contains("com.cmux.sidebar-tab-reorder"),
"Expected app bundle to export sidebar tab-reorder type, got \(exported)"
)
}
}
#endif
@MainActor
@ -14588,6 +14668,29 @@ final class GhosttyTerminalViewVisibilityPolicyTests: XCTestCase {
}
final class TerminalControllerSocketListenerHealthTests: XCTestCase {
func testStableSocketBindPermissionFailureFallsBackToUserScopedSocket() {
XCTAssertEqual(
TerminalController.fallbackSocketPathAfterBindFailure(
requestedPath: SocketControlSettings.stableDefaultSocketPath,
stage: "bind",
errnoCode: EACCES,
currentUserID: 501
),
SocketControlSettings.userScopedStableSocketPath(currentUserID: 501)
)
}
func testNonStableSocketBindFailureDoesNotFallback() {
XCTAssertNil(
TerminalController.fallbackSocketPathAfterBindFailure(
requestedPath: "/tmp/cmux-debug.sock",
stage: "bind",
errnoCode: EACCES,
currentUserID: 501
)
)
}
private func makeTempSocketPath() -> String {
"/tmp/cmux-socket-health-\(UUID().uuidString).sock"
}

View file

@ -1218,10 +1218,11 @@ final class SocketControlSettingsTests: XCTestCase {
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock",
],
bundleIdentifier: "com.cmuxterm.app",
isDebugBuild: false
isDebugBuild: false,
probeStableDefaultPathEntry: { _ in .missing }
)
XCTAssertEqual(path, "/tmp/cmux.sock")
XCTAssertEqual(path, SocketControlSettings.stableDefaultSocketPath)
}
func testNightlyReleaseUsesDedicatedDefaultAndIgnoresAmbientSocketOverride() {
@ -1230,7 +1231,8 @@ final class SocketControlSettingsTests: XCTestCase {
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock",
],
bundleIdentifier: "com.cmuxterm.app.nightly",
isDebugBuild: false
isDebugBuild: false,
probeStableDefaultPathEntry: { _ in .missing }
)
XCTAssertEqual(path, "/tmp/cmux-nightly.sock")
@ -1267,7 +1269,8 @@ final class SocketControlSettingsTests: XCTestCase {
"CMUX_ALLOW_SOCKET_OVERRIDE": "1",
],
bundleIdentifier: "com.cmuxterm.app",
isDebugBuild: false
isDebugBuild: false,
probeStableDefaultPathEntry: { _ in .missing }
)
XCTAssertEqual(path, "/tmp/cmux-debug-forced.sock")
@ -1275,23 +1278,61 @@ final class SocketControlSettingsTests: XCTestCase {
func testDefaultSocketPathByChannel() {
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app", isDebugBuild: false),
"/tmp/cmux.sock"
SocketControlSettings.defaultSocketPath(
bundleIdentifier: "com.cmuxterm.app",
isDebugBuild: false,
probeStableDefaultPathEntry: { _ in .missing }
),
SocketControlSettings.stableDefaultSocketPath
)
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.nightly", isDebugBuild: false),
SocketControlSettings.defaultSocketPath(
bundleIdentifier: "com.cmuxterm.app.nightly",
isDebugBuild: false,
probeStableDefaultPathEntry: { _ in .missing }
),
"/tmp/cmux-nightly.sock"
)
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.debug.tag", isDebugBuild: false),
SocketControlSettings.defaultSocketPath(
bundleIdentifier: "com.cmuxterm.app.debug.tag",
isDebugBuild: false,
probeStableDefaultPathEntry: { _ in .missing }
),
"/tmp/cmux-debug.sock"
)
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.staging.tag", isDebugBuild: false),
SocketControlSettings.defaultSocketPath(
bundleIdentifier: "com.cmuxterm.app.staging.tag",
isDebugBuild: false,
probeStableDefaultPathEntry: { _ in .missing }
),
"/tmp/cmux-staging.sock"
)
}
func testStableReleaseFallsBackToUserScopedSocketWhenStablePathOwnedByDifferentUser() {
let path = SocketControlSettings.defaultSocketPath(
bundleIdentifier: "com.cmuxterm.app",
isDebugBuild: false,
currentUserID: 501,
probeStableDefaultPathEntry: { _ in .socket(ownerUserID: 0) }
)
XCTAssertEqual(path, SocketControlSettings.userScopedStableSocketPath(currentUserID: 501))
}
func testStableReleaseFallsBackToUserScopedSocketWhenStablePathIsBlockedByNonSocketEntry() {
let path = SocketControlSettings.defaultSocketPath(
bundleIdentifier: "com.cmuxterm.app",
isDebugBuild: false,
currentUserID: 501,
probeStableDefaultPathEntry: { _ in .other(ownerUserID: 501) }
)
XCTAssertEqual(path, SocketControlSettings.userScopedStableSocketPath(currentUserID: 501))
}
func testUntaggedDebugBundleBlockedWithoutLaunchTag() {
XCTAssertTrue(
SocketControlSettings.shouldBlockUntaggedDebugLaunch(

View file

@ -728,6 +728,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",

View file

@ -982,6 +982,7 @@ final class MultiWindowNotificationsUITests: XCTestCase {
if includeGlobalFallback {
candidates.append(contentsOf: discoverTmpSocketCandidates(limit: 12))
candidates.append("/tmp/cmux-debug.sock")
candidates.append(stableSocketPath())
candidates.append("/tmp/cmux.sock")
}
@ -995,6 +996,13 @@ final class MultiWindowNotificationsUITests: XCTestCase {
return unique
}
private func stableSocketPath() -> String {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
.appendingPathComponent("cmux", isDirectory: true)
.appendingPathComponent("cmux.sock", isDirectory: false)
.path ?? "/tmp/cmux.sock"
}
private func socketMatchesRequiredWorkspace(_ candidatePath: String, workspaceId: String?) -> Bool {
guard let workspaceId, !workspaceId.isEmpty else { return true }
let originalPath = socketPath

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Before After
Before After

View file

@ -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.

@ -1 +1 @@
Subproject commit a50579bd5ddec81c6244b9b349d4bf781f667cec
Subproject commit bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42

View file

@ -7,3 +7,4 @@ c47010b80cd9ae6d1ab744c120f011a465521ea3 d6904870a3c920b2787b1c4b950cfdef232606b
0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de
312c7b23a7c8dc0704431940d76ba5dc32a46afb ae73cb18a9d6efec42126a1d99e0e9d12022403d7dc301dfa21ed9f7c89c9e30
404a3f175ba6baafabc46cac807194883e040980 bcbd2954f4746fe5bcb4bfca6efeddd3ea355fda2836371f4c7150271c58acbd
bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42 6b83b66768e8bba871a3753ae8ffbaabd03370b306c429cd86c9cdcc8db82589

View file

@ -11,6 +11,8 @@ DERIVED_SET=0
TAG=""
CMUX_DEBUG_LOG=""
CLI_PATH=""
LAST_SOCKET_PATH_DIR="$HOME/Library/Application Support/cmux"
LAST_SOCKET_PATH_FILE="${LAST_SOCKET_PATH_DIR}/last-socket-path"
write_dev_cli_shim() {
local target="$1"
@ -90,6 +92,13 @@ select_cmux_shim_target() {
return 1
}
write_last_socket_path() {
local socket_path="$1"
mkdir -p "$LAST_SOCKET_PATH_DIR"
echo "$socket_path" > "$LAST_SOCKET_PATH_FILE" || true
echo "$socket_path" > /tmp/cmux-last-socket-path || true
}
usage() {
cat <<'EOF'
Usage: ./scripts/reload.sh --tag <name> [options]
@ -349,7 +358,7 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then
CMUXD_SOCKET="${APP_SUPPORT_DIR}/cmuxd-dev-${TAG_SLUG}.sock"
CMUX_SOCKET="/tmp/cmux-debug-${TAG_SLUG}.sock"
CMUX_DEBUG_LOG="/tmp/cmux-debug-${TAG_SLUG}.log"
echo "$CMUX_SOCKET" > /tmp/cmux-last-socket-path || true
write_last_socket_path "$CMUX_SOCKET"
echo "$CMUX_DEBUG_LOG" > /tmp/cmux-last-debug-log-path || true
/usr/libexec/PlistBuddy -c "Add :LSEnvironment dict" "$INFO_PLIST" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXD_UNIX_PATH \"${CMUXD_SOCKET}\"" "$INFO_PLIST" 2>/dev/null \
@ -404,15 +413,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

View file

@ -9,6 +9,15 @@ NAME_SET=0
BUNDLE_SET=0
DERIVED_SET=0
TAG=""
LAST_SOCKET_PATH_DIR="$HOME/Library/Application Support/cmux"
LAST_SOCKET_PATH_FILE="${LAST_SOCKET_PATH_DIR}/last-socket-path"
write_last_socket_path() {
local socket_path="$1"
mkdir -p "$LAST_SOCKET_PATH_DIR"
echo "$socket_path" > "$LAST_SOCKET_PATH_FILE" || true
echo "$socket_path" > /tmp/cmux-last-socket-path || true
}
usage() {
cat <<'EOF'
@ -186,12 +195,12 @@ if [[ -f "$INFO_PLIST" ]]; then
|| /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string $BUNDLE_ID" "$INFO_PLIST"
# Inject staging socket paths via LSEnvironment so the Release binary
# (which defaults to /tmp/cmux.sock) uses isolated sockets instead.
# (which defaults to the per-user stable socket) uses isolated sockets instead.
STAGING_SLUG="${TAG_SLUG:-staging}"
APP_SUPPORT_DIR="$HOME/Library/Application Support/cmux"
CMUXD_SOCKET="${APP_SUPPORT_DIR}/cmuxd-${STAGING_SLUG}.sock"
CMUX_SOCKET="/tmp/cmux-${STAGING_SLUG}.sock"
echo "$CMUX_SOCKET" > /tmp/cmux-last-socket-path || true
write_last_socket_path "$CMUX_SOCKET"
/usr/libexec/PlistBuddy -c "Add :LSEnvironment dict" "$INFO_PLIST" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Set :LSEnvironment:CMUXD_UNIX_PATH \"${CMUXD_SOCKET}\"" "$INFO_PLIST" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :LSEnvironment:CMUXD_UNIX_PATH string \"${CMUXD_SOCKET}\"" "$INFO_PLIST"

View file

@ -45,7 +45,13 @@ class cmuxError(Exception):
pass
_LAST_SOCKET_PATH_FILE = "/tmp/cmux-last-socket-path"
_APP_SUPPORT_DIR = os.path.expanduser("~/Library/Application Support/cmux")
_STABLE_SOCKET_PATH = os.path.join(_APP_SUPPORT_DIR, "cmux.sock")
_LEGACY_STABLE_SOCKET_PATH = "/tmp/cmux.sock"
_LAST_SOCKET_PATH_FILES = [
os.path.join(_APP_SUPPORT_DIR, "last-socket-path"),
"/tmp/cmux-last-socket-path",
]
_DEFAULT_DEBUG_BUNDLE_ID = "com.cmuxterm.app.debug"
@ -83,13 +89,14 @@ def _default_bundle_id() -> str:
def _read_last_socket_path() -> Optional[str]:
try:
with open(_LAST_SOCKET_PATH_FILE, "r", encoding="utf-8") as f:
path = f.read().strip()
if path:
return path
except OSError:
pass
for marker_path in _LAST_SOCKET_PATH_FILES:
try:
with open(marker_path, "r", encoding="utf-8") as f:
path = f.read().strip()
if path:
return path
except OSError:
continue
return None
@ -134,8 +141,8 @@ def _default_socket_path() -> str:
if override:
if os.path.exists(override) and _can_connect(override):
return override
# Fall back to other heuristics if the override points at a stale socket file.
if not os.path.exists(override):
# Treat stable defaults as implicit so old env values still migrate cleanly.
if not os.path.exists(override) and override not in {_STABLE_SOCKET_PATH, _LEGACY_STABLE_SOCKET_PATH}:
return override
last_socket = _read_last_socket_path()
@ -144,13 +151,14 @@ def _default_socket_path() -> str:
return last_socket
# Prefer the non-tagged sockets when present.
candidates = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"]
candidates = ["/tmp/cmux-debug.sock", _STABLE_SOCKET_PATH, _LEGACY_STABLE_SOCKET_PATH]
for path in candidates:
if os.path.exists(path) and _can_connect(path):
return path
# Otherwise, fall back to the newest tagged debug socket if there is one.
# Otherwise, fall back to the newest discovered socket if there is one.
tagged = glob.glob("/tmp/cmux-debug-*.sock")
tagged.extend(glob.glob(os.path.join(_APP_SUPPORT_DIR, "cmux*.sock")))
tagged = [p for p in tagged if os.path.exists(p)]
if tagged:
tagged.sort(key=lambda p: os.path.getmtime(p), reverse=True)

View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Fails fast when the checked-in ghostty submodule SHA lacks a pinned
# GhosttyKit archive checksum. This prevents new ghostty bumps from merging
# without the checksum entry that nightly/release workflows require.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
CHECKSUMS_FILE="$ROOT_DIR/scripts/ghosttykit-checksums.txt"
if [ ! -f "$CHECKSUMS_FILE" ]; then
echo "FAIL: missing checksum file $CHECKSUMS_FILE"
exit 1
fi
GHOSTTY_SHA="$(
git -C "$ROOT_DIR" ls-tree HEAD ghostty \
| awk '$4 == "ghostty" { print $3; found = 1 } END { if (!found) exit 1 }'
)"
MATCH_COUNT="$(
awk -v sha="$GHOSTTY_SHA" '
$1 == sha {
count += 1
}
END {
print count + 0
}
' "$CHECKSUMS_FILE"
)"
if [ "$MATCH_COUNT" -eq 0 ]; then
echo "FAIL: scripts/ghosttykit-checksums.txt is missing an entry for ghostty $GHOSTTY_SHA"
exit 1
fi
if [ "$MATCH_COUNT" -ne 1 ]; then
echo "FAIL: scripts/ghosttykit-checksums.txt has $MATCH_COUNT entries for ghostty $GHOSTTY_SHA"
exit 1
fi
echo "PASS: scripts/ghosttykit-checksums.txt pins ghostty $GHOSTTY_SHA"

View file

@ -59,24 +59,29 @@ def _wait_for_focused_cwd(
client: cmux,
expected: str,
timeout: float = 12.0,
exclude_panel: str | None = None,
panel: str | None = None,
tab: str | None = None,
) -> dict[str, str]:
"""Wait for focused_cwd to match expected.
If exclude_panel is given, also require that focused_panel differs from
that value ensuring we're checking the *new* pane, not the original.
If panel is given, also require that focused_panel matches that panel.
If tab is given, also require that the selected tab matches that tab.
"""
def pred():
state = _parse_sidebar_state(client.sidebar_state())
cwd = state.get("focused_cwd", "")
if cwd != expected:
return None
if exclude_panel and state.get("focused_panel", "") == exclude_panel:
if panel and state.get("focused_panel", "") != panel:
return None
if tab and state.get("tab", "") != tab:
return None
return state
label = f"focused_cwd={expected!r}"
if exclude_panel:
label += f" (panel != {exclude_panel})"
if panel:
label += f" (panel == {panel})"
if tab:
label += f" (tab == {tab})"
return _wait_for(pred, timeout=timeout, interval=0.3, label=label)
@ -84,12 +89,25 @@ def _send_cd_and_wait(
client: cmux,
target: str,
timeout: float = 12.0,
surface: str | int | None = None,
) -> dict[str, str]:
"""cd to target and wait for sidebar focused_cwd to reflect it."""
client.send(f"cd {target}\n")
if surface is None:
client.send(f"cd {target}\n")
else:
client.send_surface(surface, f"cd {target}\n")
return _wait_for_focused_cwd(client, target, timeout=timeout)
def _focus_first_surface(client: cmux) -> str:
surfaces = client.list_surfaces()
if not surfaces:
raise AssertionError("Current tab has no surfaces")
surface_id = surfaces[0][1]
client.focus_surface(surface_id)
return surface_id
def main() -> int:
tag = os.environ.get("CMUX_TAG", "")
@ -119,17 +137,22 @@ def main() -> int:
print("=== Split CWD Inheritance Tests ===")
print(" [setup] creating isolated workspace tab...")
setup_tab = client.new_tab()
client.select_tab(setup_tab)
time.sleep(1.0)
setup_surface = _focus_first_surface(client)
time.sleep(0.5)
# --- Setup: cd to test_dir_a in workspace 1 ---
print(" [setup] cd to test_dir_a and wait for shell integration...")
_send_cd_and_wait(client, test_dir_a)
_send_cd_and_wait(client, test_dir_a, surface=setup_surface)
state = _parse_sidebar_state(client.sidebar_state())
check("setup: focused_cwd is test_dir_a", state.get("focused_cwd") == test_dir_a,
f"got {state.get('focused_cwd')!r}")
# --- Test 1: New split inherits test_dir_a ---
print(" [test1] creating right split from test_dir_a...")
# Record the original panel so we can verify focus moves to the NEW pane.
original_panel = state.get("focused_panel", "")
split_result = client.new_split("right")
if not split_result:
check("split created", False)
@ -138,15 +161,15 @@ def main() -> int:
return 1
check("split created", True)
# Wait for the NEW pane (different panel ID) to report test_dir_a.
# Socket split commands should not steal focus; focus the returned pane
# explicitly, then assert that pane inherited the source cwd.
new_panel = split_result.strip()
client.focus_surface_by_panel(new_panel)
time.sleep(4) # wait for new bash to start + run PROMPT_COMMAND
try:
state = _wait_for_focused_cwd(
client, test_dir_a, timeout=15.0, exclude_panel=original_panel,
client, test_dir_a, timeout=15.0, panel=new_panel,
)
new_panel = state.get("focused_panel", "")
check("test1: focus moved to new pane", new_panel != original_panel,
f"original={original_panel!r}, current={new_panel!r}")
check("test1: split inherited test_dir_a",
state.get("focused_cwd") == test_dir_a,
f"focused_cwd={state.get('focused_cwd')!r}")
@ -159,8 +182,6 @@ def main() -> int:
# First cd to test_dir_b so we have a different dir to inherit
print(" [test2] cd to test_dir_b, then creating new workspace tab...")
_send_cd_and_wait(client, test_dir_b)
state = _parse_sidebar_state(client.sidebar_state())
original_tab = state.get("tab", "")
tab_result = client.new_tab()
if not tab_result:
@ -170,23 +191,14 @@ def main() -> int:
return 1
check("new tab created", True)
# New workspace should be a different tab AND inherit test_dir_b
# Focus the returned workspace explicitly, then assert it inherited cwd.
new_tab = tab_result.strip()
client.select_tab(new_tab)
time.sleep(4)
try:
def _new_tab_with_cwd():
s = _parse_sidebar_state(client.sidebar_state())
tab_id = s.get("tab", "")
cwd = s.get("focused_cwd", "")
if tab_id != original_tab and cwd == test_dir_b:
return s
return None
state = _wait_for(
_new_tab_with_cwd, timeout=15.0, interval=0.3,
label=f"new tab with focused_cwd={test_dir_b!r}",
state = _wait_for_focused_cwd(
client, test_dir_b, timeout=15.0, tab=new_tab,
)
check("test2: focus moved to new tab", state.get("tab") != original_tab,
f"original={original_tab!r}, current={state.get('tab')!r}")
check("test2: new workspace inherited test_dir_b",
state.get("focused_cwd") == test_dir_b,
f"focused_cwd={state.get('focused_cwd')!r}")

View file

@ -19,6 +19,7 @@ Notes:
import base64
import errno
import glob
import json
import os
import select
@ -32,16 +33,53 @@ class cmuxError(Exception):
"""Exception raised for cmux errors."""
_APP_SUPPORT_DIR = os.path.expanduser("~/Library/Application Support/cmux")
_STABLE_SOCKET_PATH = os.path.join(_APP_SUPPORT_DIR, "cmux.sock")
_LEGACY_STABLE_SOCKET_PATH = "/tmp/cmux.sock"
_LAST_SOCKET_PATH_FILES = [
os.path.join(_APP_SUPPORT_DIR, "last-socket-path"),
"/tmp/cmux-last-socket-path",
]
def _read_last_socket_path() -> Optional[str]:
for marker_path in _LAST_SOCKET_PATH_FILES:
try:
with open(marker_path, "r", encoding="utf-8") as f:
path = f.read().strip()
if path:
return path
except OSError:
continue
return None
def _default_socket_path() -> str:
# Backwards/forward compatibility: some scripts export CMUX_SOCKET,
# while the client historically used CMUX_SOCKET_PATH.
override = os.environ.get("CMUX_SOCKET_PATH") or os.environ.get("CMUX_SOCKET")
if override:
return override
candidates = ["/tmp/cmux-debug.sock", "/tmp/cmux.sock"]
if os.path.exists(override):
return override
if override not in {_STABLE_SOCKET_PATH, _LEGACY_STABLE_SOCKET_PATH}:
return override
last_socket = _read_last_socket_path()
if last_socket and os.path.exists(last_socket):
return last_socket
candidates = ["/tmp/cmux-debug.sock", _STABLE_SOCKET_PATH, _LEGACY_STABLE_SOCKET_PATH]
for path in candidates:
if os.path.exists(path):
return path
discovered = glob.glob("/tmp/cmux-debug-*.sock")
discovered.extend(glob.glob(os.path.join(_APP_SUPPORT_DIR, "cmux*.sock")))
discovered = [path for path in discovered if os.path.exists(path)]
if discovered:
discovered.sort(key=os.path.getmtime, reverse=True)
return discovered[0]
return candidates[0]

View file

@ -227,7 +227,11 @@ def main():
print(f"\nFound cmux process: PID {pid}")
# Try to connect to the socket
socket_paths = ["/tmp/cmux.sock", "/tmp/cmux-debug.sock"]
socket_paths = [
os.path.expanduser("~/Library/Application Support/cmux/cmux.sock"),
"/tmp/cmux.sock",
"/tmp/cmux-debug.sock",
]
client = None
for socket_path in socket_paths:
if os.path.exists(socket_path):

View file

@ -31,7 +31,7 @@ def infer_app_name_for_osascript(socket_path: str) -> str:
Examples:
- /tmp/cmux-debug.sock -> "cmux DEV"
- /tmp/cmux-debug-foo.sock -> "cmux DEV foo"
- /tmp/cmux.sock -> "cmux"
- ~/Library/Application Support/cmux/cmux.sock -> "cmux"
- /tmp/cmux-foo.sock -> "cmux foo"
"""
base = Path(socket_path).name

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit fa452db181f361514087558a29204bda7e38218f
Subproject commit 73c1ef2df9a6c8a2837212ecce900794d0f21826

View file

@ -16,6 +16,7 @@ export async function SiteFooter() {
links: [
{ label: t("blog"), href: "/blog" },
{ label: t("community"), href: "/community" },
{ label: t("nightly"), href: "/nightly" },
],
},
{

View file

@ -0,0 +1,99 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { SiteHeader } from "../components/site-header";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "nightly" });
return {
title: t("metaTitle"),
description: t("metaDescription"),
};
}
const linkClass =
"underline underline-offset-2 decoration-border hover:decoration-foreground transition-colors";
export default function NightlyPage() {
const t = useTranslations("nightly");
return (
<div className="min-h-screen">
<SiteHeader section={t("title")} />
<main className="w-full max-w-2xl mx-auto px-6 py-10">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<img
src="/logo-nightly.png"
alt="cmux NIGHTLY icon"
width={48}
height={48}
className="rounded-xl"
/>
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{t("title")}
</h1>
</div>
</div>
{/* Description */}
<p
className="text-[15px] text-muted mb-8"
style={{ lineHeight: 1.5 }}
>
{t("description")}
</p>
{/* Download button */}
<a
href="https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg"
className="inline-flex items-center gap-2.5 rounded-full font-medium bg-foreground hover:opacity-85 transition-opacity px-5 py-2.5 text-[15px]"
style={{ color: "var(--background)", textDecoration: "none" }}
>
<svg
width={16}
height={19}
viewBox="0 0 814 1000"
fill="currentColor"
>
<path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57.8-155.5-127.4c-58.3-81.6-105.6-208.4-105.6-328.6 0-193 125.6-295.5 249.2-295.5 65.7 0 120.5 43.1 161.7 43.1 39.2 0 100.4-45.8 175.1-45.8 28.3 0 130.3 2.6 197.2 99.2zM554.1 159.4c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.9 32.4-57.2 83.6-57.2 135.4 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 137.6-71.2z" />
</svg>
{t("download")}
</a>
<p
className="text-[15px] text-muted mt-8"
style={{ lineHeight: 1.5 }}
>
{t.rich("warning", {
githubLink: (chunks) => (
<a
href="https://github.com/manaflow-ai/cmux/issues"
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
{chunks}
</a>
),
discordLink: (chunks) => (
<a
href="https://discord.gg/xsgFEVrWCZ"
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
{chunks}
</a>
),
})}
</p>
</main>
</div>
);
}

View file

@ -21,6 +21,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ path: "/docs/browser-automation", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.8 },
{ path: "/community", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.5 },
{ path: "/wall-of-love", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.5 },
{ path: "/nightly", lastModified: new Date(), changeFrequency: "weekly" as const, priority: 0.6 },
];
const entries: MetadataRoute.Sitemap = [];

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "تواصل معنا",
"nightly": "إصدار ليلي",
"copyright": "© {year} Manaflow",
"language": "اللغة"
},
@ -581,6 +582,15 @@
"connorelsea": "أستخدمه منذ أسبوع وهو رائع. علامة تبويب عمودية لكل مهمة قيد التنفيذ. بالداخل، Claude على جانب والمتصفح مع PR والموارد على الجانب الآخر، أتنقل بين المهام وأبقى منظماً. امزج ذلك مع المهارات لجعل Claude يراقب CI بشكل متكرر وما إلى ذلك. أشعر بالتنوير بصراحة",
"tonkotsuboy": "انتقلت من Warp إلى Ghostty في بداية السنة، لكن الآن انتقلت إلى cmux. علامات التبويب العمودية مريحة، وأقدر الإشعارات عندما تنتهي مهام Claude Code. هو مبني على Ghostty لذا الأداء السريع ينتقل معه. عرض الفرع والإكمالات التي أعددتها في Ghostty لا تزال تعمل أيضاً."
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "أحدث الإصدارات من الفرع الرئيسي",
"metaTitle": "cmux NIGHTLY — إصدارات ليلية",
"metaDescription": "حمّل cmux NIGHTLY، تطبيق مستقل يُبنى تلقائياً من أحدث commit على main. يعمل بجانب النسخة المستقرة مع تحديثات تلقائية خاصة به.",
"description": "يُبنى cmux NIGHTLY تلقائياً من أحدث commit على main. يمتلك معرّف حزمة خاص به، لذا يعمل بجانب النسخة المستقرة دون تعارض. استخدمه لاختبار الميزات الجديدة قبل إصدارها.",
"download": "تحميل NIGHTLY لنظام Mac",
"warning": "قد تحتوي الإصدارات الليلية على أخطاء أو ميزات غير مكتملة. إذا حدثت مشكلة، أبلغ عنها على <githubLink>GitHub</githubLink> أو في <discordLink>#nightly-bugs على Discord</discordLink> وارجع إلى النسخة المستقرة."
},
"languageSwitcher": {
"label": "اللغة"
}

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "Kontakt",
"nightly": "Nightly",
"copyright": "© {year} Manaflow",
"language": "Jezik"
},
@ -581,6 +582,15 @@
"connorelsea": "Koristim ovo sedmicu i fantastično je. Vertikalni tab za svaki zadatak u toku. Unutra, Claude na jednoj strani a preglednik sa PR-ovima i resursima na drugoj, prebacujem se između zadataka i ostajam organizovan. Pomiješajte to sa skillovima da Claude prati CI rekurzivno itd. osjećam se prosvijećenim iskreno",
"tonkotsuboy": "Prešao sam sa Warpa na Ghostty početkom godine, ali sad sam prešao na cmux. Vertikalni tabovi su praktični, i cijenim što dobijem notifikaciju kada Claude Code zadaci završe. Baziran je na Ghostty-ju tako da munjevite performanse ostaju. Prikaz grane i completioni koje sam podesio u Ghostty-ju i dalje rade."
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Najnovije verzije iz main grane",
"metaTitle": "cmux NIGHTLY — Nightly verzije",
"metaDescription": "Preuzmite cmux NIGHTLY, zasebnu aplikaciju koja se automatski kompajlira iz posljednjeg commita na main. Radi uporedo sa stabilnom verzijom s vlastitim automatskim ažuriranjima.",
"description": "cmux NIGHTLY se automatski kompajlira iz posljednjeg commita na main. Ima vlastiti bundle ID, pa radi uporedo sa stabilnom verzijom bez konflikata. Koristite ga za testiranje novih funkcija prije objavljivanja.",
"download": "Preuzmi NIGHTLY za Mac",
"warning": "Nightly verzije mogu sadržavati greške ili nepotpune funkcije. Ako nešto ne radi, prijavite to na <githubLink>GitHubu</githubLink> ili u <discordLink>#nightly-bugs na Discordu</discordLink> i prebacite se na stabilnu verziju."
},
"languageSwitcher": {
"label": "Jezik"
}

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "Kontakt",
"nightly": "Nightly",
"copyright": "© {year} Manaflow",
"language": "Sprog"
},
@ -581,6 +582,15 @@
"connorelsea": "Har brugt det i en uge og det er fantastisk. Vertikal fane for hver igangværende opgave. Indeni, Claude på den ene side og browser med PR og ressourcer på den anden, skift mellem opgaver og hold orden. Bland det med skills så Claude kan overvåge CI rekursivt, osv. føler mig oplyst ærlig talt",
"tonkotsuboy": "Jeg skiftede fra Warp til Ghostty i starten af året, men nu er jeg skiftet til cmux. De vertikale faner er praktiske, og jeg sætter pris på at blive notificeret når Claude Code-opgaver er færdige. Det er Ghostty-baseret så den lynhurtige ydeevne følger med. Branch-visning og completions jeg satte op i Ghostty virker stadig."
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Seneste builds fra main",
"metaTitle": "cmux NIGHTLY — Nightly Builds",
"metaDescription": "Download cmux NIGHTLY, en separat app bygget automatisk fra det seneste main-commit. Kører ved siden af den stabile version med egne automatiske opdateringer.",
"description": "cmux NIGHTLY bygges automatisk fra det seneste commit på main. Den har sit eget bundle-ID, så den kører ved siden af den stabile version uden konflikter. Brug den til at teste nye funktioner før de udkommer.",
"download": "Download NIGHTLY til Mac",
"warning": "Nightly builds kan indeholde fejl eller ufærdige funktioner. Hvis noget går galt, rapportér det på <githubLink>GitHub</githubLink> eller i <discordLink>#nightly-bugs på Discord</discordLink> og skift tilbage til den stabile version."
},
"languageSwitcher": {
"label": "Sprog"
}

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "Kontakt",
"nightly": "Nightly",
"copyright": "© {year} Manaflow",
"language": "Sprache"
},
@ -581,6 +582,15 @@
"connorelsea": "Nutze das seit einer Woche und es ist fantastisch. Ein vertikaler Tab pro WIP-Aufgabe. Darin Claude auf einer Seite und Browser mit PR und Ressourcen auf der anderen. Zwischen Aufgaben wechseln und organisiert bleiben. Dazu Skills, damit Claude CI rekursiv überwacht usw. Fühle mich ehrlich gesagt erleuchtet.",
"tonkotsuboy": "Anfang des Jahres bin ich von Warp zu Ghostty gewechselt, aber jetzt bin ich bei cmux. Die vertikalen Tabs sind praktisch, und ich schätze die Benachrichtigungen, wenn Claude-Code-Aufgaben fertig sind. Da es auf Ghostty basiert, bleibt die blitzschnelle Performance erhalten. Branch-Anzeige und Vervollständigungen, die ich in Ghostty eingerichtet hatte, funktionieren auch weiterhin."
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Aktuelle Builds vom main-Branch",
"metaTitle": "cmux NIGHTLY — Nightly Builds",
"metaDescription": "Laden Sie cmux NIGHTLY herunter, eine separate App, die automatisch aus dem neuesten main-Commit erstellt wird. Läuft neben der stabilen Version mit eigenen Auto-Updates.",
"description": "cmux NIGHTLY wird automatisch aus dem neuesten Commit auf main erstellt. Es hat eine eigene Bundle-ID und läuft daher ohne Konflikte neben der stabilen Version. Damit können Sie neue Funktionen testen, bevor sie veröffentlicht werden.",
"download": "NIGHTLY für Mac herunterladen",
"warning": "Nightly Builds können Fehler oder unfertige Funktionen enthalten. Falls Probleme auftreten, melden Sie diese auf <githubLink>GitHub</githubLink> oder in <discordLink>#nightly-bugs auf Discord</discordLink> und wechseln Sie zur stabilen Version."
},
"languageSwitcher": {
"label": "Sprache"
}

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "Contact",
"nightly": "Nightly",
"copyright": "© {year} Manaflow",
"language": "Language"
},
@ -583,6 +584,15 @@
"connorelsea": "Been using this for a week and it's fantastic. Vert tab for each WIP task. Inside, claudes on one side and browser with PR and resources on the other, switch between tasks and stay organized. Mix that with skills to have Claude watch CI recursively, etc. feeling enlightened tbh",
"tonkotsuboy": "I switched from Warp to Ghostty at the start of the year, but now I've switched to cmux. The vertical tabs are convenient, and I appreciate getting notified when Claude Code tasks finish. It's Ghostty-based so the blazing fast performance carries over. Branch display and completions I set up in Ghostty still work too."
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Bleeding-edge builds from main",
"metaTitle": "cmux NIGHTLY — Nightly Builds",
"metaDescription": "Download cmux NIGHTLY, a separate app built automatically from the latest main commit. Runs alongside the stable version with its own auto-updates.",
"description": "cmux NIGHTLY is built automatically from the latest commit on main. It has its own bundle ID, so it runs alongside the stable version without conflicts. Use it to test new features before they ship.",
"download": "Download NIGHTLY for Mac",
"warning": "Nightly builds may contain bugs or incomplete features. If something breaks, report it on <githubLink>GitHub</githubLink> or in <discordLink>#nightly-bugs on Discord</discordLink>, and switch back to the stable release."
},
"languageSwitcher": {
"label": "Language"
}

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "Contacto",
"nightly": "Nightly",
"copyright": "© {year} Manaflow",
"language": "Idioma"
},
@ -581,6 +582,15 @@
"connorelsea": "Lo llevo usando una semana y es fantástico. Una pestaña vertical por cada tarea WIP. Dentro, Claude a un lado y navegador con PR y recursos al otro. Cambiar entre tareas y mantener todo organizado. Combinado con skills para que Claude vigile CI recursivamente, etc. Sinceramente me siento iluminado.",
"tonkotsuboy": "A principios de año cambié de Warp a Ghostty, pero ahora me cambié a cmux. Las pestañas verticales son cómodas y agradezco las notificaciones cuando terminan las tareas de Claude Code. Al estar basado en Ghostty, el rendimiento ultrarrápido se mantiene. La visualización de ramas y las completaciones que configuré en Ghostty siguen funcionando."
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Compilaciones de última hora desde main",
"metaTitle": "cmux NIGHTLY — Compilaciones Nightly",
"metaDescription": "Descarga cmux NIGHTLY, una app independiente compilada automáticamente desde el último commit en main. Funciona junto a la versión estable con sus propias actualizaciones automáticas.",
"description": "cmux NIGHTLY se compila automáticamente desde el último commit en main. Tiene su propio bundle ID, así que funciona junto a la versión estable sin conflictos. Úsala para probar nuevas funciones antes de su lanzamiento.",
"download": "Descargar NIGHTLY para Mac",
"warning": "Las compilaciones nightly pueden contener errores o funciones incompletas. Si algo falla, repórtalo en <githubLink>GitHub</githubLink> o en <discordLink>#nightly-bugs en Discord</discordLink> y cambia a la versión estable."
},
"languageSwitcher": {
"label": "Idioma"
}

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "Contact",
"nightly": "Nightly",
"copyright": "© {year} Manaflow",
"language": "Langue"
},
@ -581,6 +582,15 @@
"connorelsea": "Je l'utilise depuis une semaine et c'est fantastique. Un onglet vertical par tache en cours. A l'interieur, Claude d'un cote et le navigateur avec la PR et les ressources de l'autre. Basculer entre les taches en restant organise. En combinant avec les skills pour que Claude surveille le CI recursivement, etc. Franchement, je me sens eclaire.",
"tonkotsuboy": "J'etais passe de Warp a Ghostty en debut d'annee, mais maintenant je suis passe a cmux. Les onglets verticaux sont pratiques, et j'apprecie les notifications quand les taches Claude Code sont terminees. Comme c'est base sur Ghostty, les performances ultra-rapides sont conservees. L'affichage des branches et les completions que j'avais configures dans Ghostty fonctionnent toujours."
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Builds de pointe depuis main",
"metaTitle": "cmux NIGHTLY — Builds Nightly",
"metaDescription": "Téléchargez cmux NIGHTLY, une app séparée compilée automatiquement depuis le dernier commit sur main. Fonctionne à côté de la version stable avec ses propres mises à jour automatiques.",
"description": "cmux NIGHTLY est compilé automatiquement depuis le dernier commit sur main. Il possède son propre bundle ID et fonctionne donc à côté de la version stable sans conflit. Utilisez-le pour tester les nouvelles fonctionnalités avant leur sortie.",
"download": "Télécharger NIGHTLY pour Mac",
"warning": "Les builds nightly peuvent contenir des bugs ou des fonctionnalités incomplètes. En cas de problème, signalez-le sur <githubLink>GitHub</githubLink> ou dans <discordLink>#nightly-bugs sur Discord</discordLink> et revenez à la version stable."
},
"languageSwitcher": {
"label": "Langue"
}

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "Contatti",
"nightly": "Nightly",
"copyright": "© {year} Manaflow",
"language": "Lingua"
},
@ -581,6 +582,15 @@
"connorelsea": "Lo uso da una settimana ed è fantastico. Un tab verticale per ogni task in corso. Dentro, Claude da un lato e il browser con PR e risorse dall'altro, passo tra i task e resto organizzato. Combinalo con le skill per far monitorare la CI a Claude ricorsivamente, ecc. mi sento illuminato onestamente",
"tonkotsuboy": "A inizio anno sono passato da Warp a Ghostty, ma ora sono passato a cmux. I tab verticali sono comodi e apprezzo le notifiche quando i task di Claude Code finiscono. È basato su Ghostty quindi le prestazioni fulminee restano. Anche la visualizzazione del branch e i completamenti che avevo impostato su Ghostty funzionano ancora."
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Build di ultima generazione dal branch main",
"metaTitle": "cmux NIGHTLY — Build Nightly",
"metaDescription": "Scarica cmux NIGHTLY, un'app separata compilata automaticamente dall'ultimo commit su main. Funziona accanto alla versione stabile con aggiornamenti automatici propri.",
"description": "cmux NIGHTLY viene compilata automaticamente dall'ultimo commit su main. Ha un proprio bundle ID, quindi funziona accanto alla versione stabile senza conflitti. Usala per testare le nuove funzionalità prima del rilascio.",
"download": "Scarica NIGHTLY per Mac",
"warning": "Le build nightly possono contenere bug o funzionalità incomplete. In caso di problemi, segnalali su <githubLink>GitHub</githubLink> o in <discordLink>#nightly-bugs su Discord</discordLink> e torna alla versione stabile."
},
"languageSwitcher": {
"label": "Lingua"
}

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "お問い合わせ",
"nightly": "ナイトリー",
"copyright": "© {year} Manaflow",
"language": "言語"
},
@ -581,6 +582,15 @@
"connorelsea": "1週間使ってるけど最高。WIPタスクごとに縦タブ。中にはClaudeを片側に、PRやリソースのブラウザをもう片側に。タスクを切り替えながら整理できる。スキルでClaudeにCIを再帰的に監視させたり。正直、悟りを開いた気分。",
"tonkotsuboy": "年初にWarpからGhosttyに乗り換えたけど、今はcmuxに乗り換えた💻 垂直タブが便利で、Claude Codeのタスクの終了が通知されるのがありがたい。Ghosttyベースだから爆速動作はそのまま。ghosttyでやったブランチ表示や補完もそのまま使える"
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "mainブランチからの最新ビルド",
"metaTitle": "cmux NIGHTLY — ナイトリービルド",
"metaDescription": "cmux NIGHTLYをダウンロード。mainの最新コミットから自動ビルドされる独立アプリ。安定版と並行して動作し、独自の自動アップデート機能付き。",
"description": "cmux NIGHTLYはmainの最新コミットから自動ビルドされます。独自のバンドルIDを持つため、安定版と競合せず並行して動作します。新機能をリリース前にテストできます。",
"download": "Mac版 NIGHTLYをダウンロード",
"warning": "ナイトリービルドにはバグや未完成の機能が含まれる場合があります。問題が発生した場合は<githubLink>GitHub</githubLink>または<discordLink>Discordの#nightly-bugs</discordLink>で報告し、安定版に切り替えてください。"
},
"languageSwitcher": {
"label": "言語"
}

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "ទំនាក់ទំនង",
"nightly": "Nightly",
"copyright": "© {year} Manaflow",
"language": "ភាសា"
},
@ -577,6 +578,15 @@
"connorelsea": "ប្រើមកមួយសប្តាហ៍ហើយ វាល្អខ្លាំង។ ផ្ទាំងបញ្ឈរសម្រាប់កិច្ចការនីមួយៗ។ ខាងក្នុង Claude នៅម្ខាង កម្មវិធីរុករកជាមួយ PR និងធនធាននៅម្ខាង ប្ដូររវាងកិច្ចការហើយរក្សាការរៀបចំ។ ផ្សំជាមួយ skills ឱ្យ Claude តាមដាន CI ដដែលៗ ។ រឹតតែស្រស់បំព្រង",
"tonkotsuboy": "ខ្ញុំប្ដូរពី Warp មក Ghostty ដើមឆ្នាំ ប៉ុន្តែឥឡូវខ្ញុំប្ដូរមក cmux។ ផ្ទាំងបញ្ឈរងាយស្រួល ហើយខ្ញុំពេញចិត្តដែលទទួលបានជូនដំណឹងពេល Claude Code បានបញ្ចប់។ វាផ្អែកលើ Ghostty ដូច្នេះល្បឿនលឿនប្រែកៗនៅតែមាន។ ការបង្ហាញ branch និង completion ដែលខ្ញុំបានកំណត់ក្នុង Ghostty នៅតែដំណើរការដែរ។"
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "កំណែចុងក្រោយពីសាខា main",
"metaTitle": "cmux NIGHTLY — កំណែ Nightly",
"metaDescription": "ទាញយក cmux NIGHTLY កម្មវិធីដាច់ដោយឡែកដែលត្រូវបានបង្កើតដោយស្វ័យប្រវត្តិពី commit ចុងក្រោយនៅលើ main។ ដំណើរការស្របជាមួយកំណែស្ថិរភាពជាមួយការអាប់ដេតស្វ័យប្រវត្តិផ្ទាល់ខ្លួន។",
"description": "cmux NIGHTLY ត្រូវបានបង្កើតដោយស្វ័យប្រវត្តិពី commit ចុងក្រោយនៅលើ main។ វាមាន bundle ID ផ្ទាល់ខ្លួន ដូច្នេះវាដំណើរការស្របជាមួយកំណែស្ថិរភាពដោយគ្មានជម្លោះ។ ប្រើវាដើម្បីសាកល្បងមុខងារថ្មីមុនពេលចេញផ្សាយ។",
"download": "ទាញយក NIGHTLY សម្រាប់ Mac",
"warning": "កំណែ nightly អាចមានកំហុស ឬមុខងារមិនទាន់ពេញលេញ។ ប្រសិនបើមានបញ្ហា សូមរាយការណ៍នៅលើ <githubLink>GitHub</githubLink> ឬក្នុង <discordLink>#nightly-bugs នៅលើ Discord</discordLink> ហើយប្តូរទៅកំណែស្ថិរភាពវិញ។"
},
"languageSwitcher": {
"label": "ភាសា"
},

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "문의",
"nightly": "나이틀리",
"copyright": "© {year} Manaflow",
"language": "언어"
},
@ -581,6 +582,15 @@
"connorelsea": "일주일째 쓰고 있는데 환상적이에요. WIP 작업마다 세로 탭 하나씩. 안에는 한쪽에 Claude, 다른 쪽에 PR과 리소스 브라우저. 작업 전환하면서 정리가 돼요. 스킬로 Claude에게 CI를 재귀적으로 감시시키는 것도 가능. 솔직히 깨달음을 얻은 기분.",
"tonkotsuboy": "연초에 Warp에서 Ghostty로 갈아탔는데, 이제는 cmux로 갈아탔어요. 세로 탭이 편하고, Claude Code 작업이 끝나면 알림이 와서 좋아요. Ghostty 기반이라 빠른 성능은 그대로. Ghostty에서 설정한 브랜치 표시랑 자동완성도 그대로 쓸 수 있어요."
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "main 브랜치의 최신 빌드",
"metaTitle": "cmux NIGHTLY — 나이틀리 빌드",
"metaDescription": "cmux NIGHTLY를 다운로드하세요. main의 최신 커밋에서 자동으로 빌드되는 독립 앱입니다. 안정 버전과 나란히 실행되며 독자적인 자동 업데이트를 제공합니다.",
"description": "cmux NIGHTLY는 main의 최신 커밋에서 자동으로 빌드됩니다. 자체 번들 ID를 가지고 있어 안정 버전과 충돌 없이 나란히 실행됩니다. 출시 전에 새로운 기능을 테스트할 수 있습니다.",
"download": "Mac용 NIGHTLY 다운로드",
"warning": "나이틀리 빌드에는 버그나 미완성 기능이 포함될 수 있습니다. 문제가 발생하면 <githubLink>GitHub</githubLink> 또는 <discordLink>Discord의 #nightly-bugs</discordLink>에서 보고하고 안정 버전으로 전환하세요."
},
"languageSwitcher": {
"label": "언어"
}

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "Kontakt",
"nightly": "Nightly",
"copyright": "© {year} Manaflow",
"language": "Språk"
},
@ -581,6 +582,15 @@
"connorelsea": "Har brukt dette i en uke og det er fantastisk. Vertikal fane for hver pågående oppgave. Inni har jeg Claude på den ene siden og nettleser med PR og ressurser på den andre, bytter mellom oppgaver og holder orden. Bland det med skills for å la Claude overvåke CI rekursivt, osv. Føler meg opplyst tbh",
"tonkotsuboy": "Jeg byttet fra Warp til Ghostty i begynnelsen av året, men nå har jeg byttet til cmux. De vertikale fanene er praktiske, og jeg setter pris på å bli varslet når Claude Code-oppgaver er ferdige. Det er Ghostty-basert, så den lynraske ytelsen følger med. Grenvisning og autofullføringer jeg satte opp i Ghostty fungerer fortsatt også."
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Nyeste bygg fra main",
"metaTitle": "cmux NIGHTLY — Nightly-bygg",
"metaDescription": "Last ned cmux NIGHTLY, en separat app som bygges automatisk fra siste main-commit. Kjører ved siden av den stabile versjonen med egne automatiske oppdateringer.",
"description": "cmux NIGHTLY bygges automatisk fra siste commit på main. Den har sin egen bundle-ID, så den kjører ved siden av den stabile versjonen uten konflikter. Bruk den til å teste nye funksjoner før de lanseres.",
"download": "Last ned NIGHTLY for Mac",
"warning": "Nightly-bygg kan inneholde feil eller uferdige funksjoner. Hvis noe går galt, rapporter det på <githubLink>GitHub</githubLink> eller i <discordLink>#nightly-bugs på Discord</discordLink> og bytt tilbake til den stabile versjonen."
},
"languageSwitcher": {
"label": "Språk"
}

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "Kontakt",
"nightly": "Nightly",
"copyright": "© {year} Manaflow",
"language": "Język"
},
@ -581,6 +582,15 @@
"connorelsea": "Używam tego od tygodnia i jest fantastyczne. Pionowa karta dla każdego zadania w toku. Wewnątrz, Claude po jednej stronie a przeglądarka z PR i zasobami po drugiej, przełączam się między zadaniami i utrzymuję porządek. Połącz to ze skillami żeby Claude monitorował CI rekursywnie itp. czuję się oświecony szczerze mówiąc",
"tonkotsuboy": "Na początku roku przeszedłem z Warpa na Ghostty, ale teraz przeszedłem na cmux. Pionowe karty są wygodne i doceniam powiadomienia gdy zadania Claude Code się kończą. Jest oparty na Ghostty więc błyskawiczna wydajność zostaje. Wyświetlanie brancha i uzupełniania które skonfigurowałem w Ghostty nadal działają."
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Najnowsze buildy z gałęzi main",
"metaTitle": "cmux NIGHTLY — Buildy Nightly",
"metaDescription": "Pobierz cmux NIGHTLY, osobną aplikację budowaną automatycznie z najnowszego commita na main. Działa obok wersji stabilnej z własnymi aktualizacjami automatycznymi.",
"description": "cmux NIGHTLY jest budowany automatycznie z najnowszego commita na main. Ma własne bundle ID, więc działa obok wersji stabilnej bez konfliktów. Używaj go, aby testować nowe funkcje przed ich wydaniem.",
"download": "Pobierz NIGHTLY na Maca",
"warning": "Buildy nightly mogą zawierać błędy lub niekompletne funkcje. W razie problemów zgłoś je na <githubLink>GitHubie</githubLink> lub w <discordLink>#nightly-bugs na Discordzie</discordLink> i przełącz się na wersję stabilną."
},
"languageSwitcher": {
"label": "Język"
}

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "Contato",
"nightly": "Nightly",
"copyright": "© {year} Manaflow",
"language": "Idioma"
},
@ -581,6 +582,15 @@
"connorelsea": "Usando há uma semana e é fantástico. Aba vertical para cada tarefa em andamento. Dentro, claudes de um lado e navegador com PR e recursos do outro, alterno entre tarefas e mantenho tudo organizado. Misture com skills para o Claude monitorar CI recursivamente, etc. me sinto iluminado pra ser honesto",
"tonkotsuboy": "Mudei do Warp para o Ghostty no início do ano, mas agora migrei para o cmux. As abas verticais são práticas e gosto de ser notificado quando tarefas do Claude Code terminam. É baseado no Ghostty, então a performance ultrarrápida se mantém. A exibição de branches e completions que configurei no Ghostty continuam funcionando também."
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Builds de ponta do branch main",
"metaTitle": "cmux NIGHTLY — Builds Nightly",
"metaDescription": "Baixe o cmux NIGHTLY, um app separado compilado automaticamente do commit mais recente no main. Funciona ao lado da versão estável com suas próprias atualizações automáticas.",
"description": "O cmux NIGHTLY é compilado automaticamente do commit mais recente no main. Ele tem seu próprio bundle ID, então funciona ao lado da versão estável sem conflitos. Use-o para testar novos recursos antes do lançamento.",
"download": "Baixar NIGHTLY para Mac",
"warning": "Builds nightly podem conter bugs ou recursos incompletos. Se algo quebrar, reporte no <githubLink>GitHub</githubLink> ou em <discordLink>#nightly-bugs no Discord</discordLink> e volte para a versão estável."
},
"languageSwitcher": {
"label": "Idioma"
}

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "Контакты",
"nightly": "Ночные сборки",
"copyright": "© {year} Manaflow",
"language": "Язык"
},
@ -581,6 +582,15 @@
"connorelsea": "Использую неделю и это фантастика. Вертикальная вкладка для каждой текущей задачи. Внутри Claude с одной стороны и браузер с PR и ресурсами с другой, переключаюсь между задачами и остаюсь организованным. Сочетай это со скиллами чтобы Claude рекурсивно следил за CI и т.д. чувствую себя просветлённым честно говоря",
"tonkotsuboy": "В начале года перешёл с Warp на Ghostty, а теперь перешёл на cmux. Вертикальные вкладки удобны, и ценю уведомления когда задачи Claude Code завершаются. Он на базе Ghostty, так что молниеносная скорость сохраняется. Отображение веток и автодополнения, которые я настроил в Ghostty, тоже работают."
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Актуальные сборки из ветки main",
"metaTitle": "cmux NIGHTLY — Ночные сборки",
"metaDescription": "Скачайте cmux NIGHTLY — отдельное приложение, автоматически собираемое из последнего коммита в main. Работает параллельно со стабильной версией с собственными автообновлениями.",
"description": "cmux NIGHTLY автоматически собирается из последнего коммита в main. У него собственный bundle ID, поэтому он работает параллельно со стабильной версией без конфликтов. Используйте его для тестирования новых функций до релиза.",
"download": "Скачать NIGHTLY для Mac",
"warning": "Ночные сборки могут содержать ошибки или незавершённые функции. Если что-то сломалось, сообщите на <githubLink>GitHub</githubLink> или в <discordLink>#nightly-bugs в Discord</discordLink> и переключитесь на стабильную версию."
},
"languageSwitcher": {
"label": "Язык"
}

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "ติดต่อ",
"nightly": "Nightly",
"copyright": "© {year} Manaflow",
"language": "ภาษา"
},
@ -577,6 +578,15 @@
"connorelsea": "ใช้มาสัปดาห์นึงแล้ว เยี่ยมมาก แท็บแนวตั้งสำหรับแต่ละงานที่ทำอยู่ ข้างในมี Claude อยู่ด้านนึงและเบราว์เซอร์กับ PR และทรัพยากรอยู่อีกด้าน สลับไปมาระหว่างงานได้อย่างเป็นระเบียบ ผสมกับ skills ให้ Claude คอยดู CI แบบ recursive ฯลฯ รู้สึกตาสว่างเลย",
"tonkotsuboy": "ผมเปลี่ยนจาก Warp มา Ghostty ตอนต้นปี แต่ตอนนี้เปลี่ยนมา cmux แล้ว แท็บแนวตั้งสะดวกดี และชอบที่แจ้งเตือนเมื่องาน Claude Code เสร็จ มันใช้ Ghostty เป็นฐานก็เลยเร็วเหมือนเดิม การแสดง branch และ completion ที่ตั้งไว้ใน Ghostty ก็ยังใช้ได้อยู่"
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "บิลด์ล่าสุดจาก main",
"metaTitle": "cmux NIGHTLY — บิลด์ Nightly",
"metaDescription": "ดาวน์โหลด cmux NIGHTLY แอปแยกที่สร้างอัตโนมัติจาก commit ล่าสุดบน main ทำงานควบคู่กับเวอร์ชันเสถียรพร้อมอัปเดตอัตโนมัติของตัวเอง",
"description": "cmux NIGHTLY สร้างอัตโนมัติจาก commit ล่าสุดบน main มี bundle ID เป็นของตัวเอง จึงทำงานควบคู่กับเวอร์ชันเสถียรได้โดยไม่ขัดแย้ง ใช้เพื่อทดสอบฟีเจอร์ใหม่ก่อนเปิดตัว",
"download": "ดาวน์โหลด NIGHTLY สำหรับ Mac",
"warning": "บิลด์ nightly อาจมีบั๊กหรือฟีเจอร์ที่ยังไม่สมบูรณ์ หากพบปัญหา รายงานบน <githubLink>GitHub</githubLink> หรือใน <discordLink>#nightly-bugs บน Discord</discordLink> แล้วสลับกลับไปใช้เวอร์ชันเสถียร"
},
"languageSwitcher": {
"label": "ภาษา"
},

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "İletişim",
"nightly": "Nightly",
"copyright": "© {year} Manaflow",
"language": "Dil"
},
@ -581,6 +582,15 @@
"connorelsea": "Bir haftadır kullanıyorum ve harika. Her devam eden görev için dikey sekme. İçinde bir tarafta Claude'lar, diğer tarafta PR ve kaynaklarla tarayıcı, görevler arasında geçiş yapıp düzenli kalıyorum. Bunu Claude'un CI'ı özyinelemeli izlemesi için skill'lerle birleştirin, vs. aydınlanmış hissediyorum açıkçası",
"tonkotsuboy": "Yılın başında Warp'tan Ghostty'ye geçtim ama şimdi cmux'a geçtim. Dikey sekmeler kullanışlı ve Claude Code görevleri bittiğinde bildirim almayı takdir ediyorum. Ghostty tabanlı olduğu için çok hızlı performans aynen devam ediyor. Ghostty'de ayarladığım dal gösterimi ve tamamlamalar da hâlâ çalışıyor."
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "main dalından güncel derlemeler",
"metaTitle": "cmux NIGHTLY — Nightly Derlemeler",
"metaDescription": "cmux NIGHTLY indirin. main deki en son commit ten otomatik olarak derlenen bağımsız bir uygulama. Kararlı sürümle yan yana çalışır ve kendi otomatik güncellemelerine sahiptir.",
"description": "cmux NIGHTLY, main deki en son commit ten otomatik olarak derlenir. Kendi bundle ID sine sahip olduğundan kararlı sürümle çakışmadan yan yana çalışır. Yeni özellikleri yayınlanmadan önce test etmek için kullanın.",
"download": "Mac için NIGHTLY indir",
"warning": "Nightly derlemeler hatalar veya tamamlanmamış özellikler içerebilir. Bir sorun oluşursa <githubLink>GitHub</githubLink> veya <discordLink>Discord daki #nightly-bugs</discordLink> kanalında bildirin ve kararlı sürüme geçin."
},
"languageSwitcher": {
"label": "Dil"
}

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "联系我们",
"nightly": "每夜构建",
"copyright": "© {year} Manaflow",
"language": "语言"
},
@ -581,6 +582,15 @@
"connorelsea": "用了一周,非常棒。每个进行中的任务一个垂直标签页。里面一边是 Claude另一边是浏览器看 PR 和资料,在任务之间切换保持有序。配合 skill 让 Claude 递归监控 CI 等等。感觉开悟了。",
"tonkotsuboy": "年初从 Warp 换到 Ghostty现在又换到了 cmux。垂直标签页很方便Claude Code 任务完成时收到通知很实用。基于 Ghostty 所以依然飞快。之前在 Ghostty 里设置的分支显示和补全也都能用。"
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "来自 main 分支的最新构建",
"metaTitle": "cmux NIGHTLY — 每夜构建",
"metaDescription": "下载 cmux NIGHTLY从最新 main 提交自动构建的独立应用。与稳定版并行运行,拥有独立的自动更新。",
"description": "cmux NIGHTLY 从 main 的最新提交自动构建。它拥有独立的 Bundle ID因此可以与稳定版并行运行互不冲突。用它来测试尚未发布的新功能。",
"download": "下载 Mac 版 NIGHTLY",
"warning": "每夜构建可能包含错误或不完整的功能。如果遇到问题,请在 <githubLink>GitHub</githubLink> 或 <discordLink>Discord 的 #nightly-bugs</discordLink> 上报告,并切换回稳定版。"
},
"languageSwitcher": {
"label": "语言"
}

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter",
"discord": "Discord",
"contact": "聯絡我們",
"nightly": "每夜建置",
"copyright": "© {year} Manaflow",
"language": "語言"
},
@ -581,6 +582,15 @@
"connorelsea": "用了一週,非常棒。每個進行中的任務一個垂直分頁。裡面一邊是 Claude另一邊是瀏覽器看 PR 和資料,在任務之間切換保持有序。搭配 skill 讓 Claude 遞迴監控 CI 等等。感覺開悟了。",
"tonkotsuboy": "年初從 Warp 換到 Ghostty現在又換到了 cmux。垂直分頁很方便Claude Code 任務完成時收到通知很實用。基於 Ghostty 所以依然飛快。之前在 Ghostty 裡設定的分支顯示和補全也都能用。"
},
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "來自 main 分支的最新建置",
"metaTitle": "cmux NIGHTLY — 每夜建置",
"metaDescription": "下載 cmux NIGHTLY從最新 main 提交自動建置的獨立應用。與穩定版並行運行,擁有獨立的自動更新。",
"description": "cmux NIGHTLY 從 main 的最新提交自動建置。它擁有獨立的 Bundle ID因此可以與穩定版並行運行互不衝突。用它來測試尚未發佈的新功能。",
"download": "下載 Mac 版 NIGHTLY",
"warning": "每夜建置可能包含錯誤或不完整的功能。如果遇到問題,請在 <githubLink>GitHub</githubLink> 或 <discordLink>Discord 的 #nightly-bugs</discordLink> 上回報,並切換回穩定版。"
},
"languageSwitcher": {
"label": "語言"
}

BIN
web/public/logo-nightly.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB