From f95a32ea522a7ae6c783aaa7991d64398672421e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 04:44:16 -0700 Subject: [PATCH 01/11] Add workspace stress profiling and reduce switch churn (#1218) * Add workspace stress profiling and reduce switch churn * Address workspace stress review feedback --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/ContentView.swift | 71 ++++- Sources/TabManager.swift | 92 ++++++- cmuxTests/WorkspaceStressProfileTests.swift | 282 ++++++++++++++++++++ 4 files changed, 426 insertions(+), 23 deletions(-) create mode 100644 cmuxTests/WorkspaceStressProfileTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index a37d6096..86445cf5 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -93,6 +93,7 @@ F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; }; + FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */; }; A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; }; A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; }; DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; @@ -238,6 +239,7 @@ F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = ""; }; + FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = ""; }; A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; }; A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; }; DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; @@ -472,6 +474,7 @@ F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */, + FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */, A5008380 /* BrowserFindJavaScriptTests.swift */, A5008382 /* CommandPaletteSearchEngineTests.swift */, ); @@ -711,6 +714,7 @@ F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */, + FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */, ); diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 19e2fa2e..be92eb3b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1331,6 +1331,7 @@ struct ContentView: View { @State private var workspaceHandoffGeneration: UInt64 = 0 @State private var workspaceHandoffFallbackTask: Task? @State private var didApplyUITestSidebarSelection = false + @State private var workspaceHandoffReadyCheckTask: Task? @State private var titlebarThemeGeneration: UInt64 = 0 @State private var sidebarDraggedTabId: UUID? @State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) @@ -2881,6 +2882,8 @@ struct ContentView: View { retiringWorkspaceId = nil workspaceHandoffFallbackTask?.cancel() workspaceHandoffFallbackTask = nil + workspaceHandoffReadyCheckTask?.cancel() + workspaceHandoffReadyCheckTask = nil return } @@ -2888,6 +2891,7 @@ struct ContentView: View { let generation = workspaceHandoffGeneration retiringWorkspaceId = oldSelectedId workspaceHandoffFallbackTask?.cancel() + workspaceHandoffReadyCheckTask?.cancel() #if DEBUG if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { @@ -2903,6 +2907,36 @@ struct ContentView: View { } #endif + workspaceHandoffReadyCheckTask = Task { [generation, newSelectedId] in + for delay in [0, 20_000_000, 40_000_000, 60_000_000] { + if delay > 0 { + do { + try await Task.sleep(nanoseconds: UInt64(delay)) + } catch { + return + } + } + let completed = await MainActor.run { () -> Bool in + guard workspaceHandoffGeneration == generation else { return false } + guard retiringWorkspaceId != nil else { return false } + guard canCompleteWorkspaceHandoffImmediately(for: newSelectedId) else { return false } +#if DEBUG + if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { + let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 + dlog( + "ws.handoff.fastReady id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newSelectedId))" + ) + } else { + dlog("ws.handoff.fastReady id=none selected=\(debugShortWorkspaceId(newSelectedId))") + } +#endif + completeWorkspaceHandoff(reason: "ready") + return true + } + if completed { return } + } + } + workspaceHandoffFallbackTask = Task { [generation] in do { try await Task.sleep(nanoseconds: 150_000_000) @@ -2922,9 +2956,20 @@ struct ContentView: View { completeWorkspaceHandoff(reason: reason) } + private func canCompleteWorkspaceHandoffImmediately(for workspaceId: UUID) -> Bool { + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return true } + if let focusedPanelId = workspace.focusedPanelId, + workspace.browserPanel(for: focusedPanelId) != nil { + return true + } + return workspace.hasLoadedTerminalSurface() + } + private func completeWorkspaceHandoff(reason: String) { workspaceHandoffFallbackTask?.cancel() workspaceHandoffFallbackTask = nil + workspaceHandoffReadyCheckTask?.cancel() + workspaceHandoffReadyCheckTask = nil let retiring = retiringWorkspaceId // Hide portal-hosted views for the retiring workspace BEFORE clearing @@ -7221,6 +7266,9 @@ struct VerticalTabsSidebar: View { } var body: some View { + let workspaceCount = tabManager.tabs.count + let canCloseWorkspace = workspaceCount > 1 + VStack(spacing: 0) { GeometryReader { proxy in ScrollView { @@ -7237,7 +7285,12 @@ struct VerticalTabsSidebar: View { tab: tab, index: index, isActive: tabManager.selectedTabId == tab.id, - tabCount: tabManager.tabs.count, + workspaceShortcutDigit: WorkspaceShortcutMapper.commandDigitForWorkspace( + at: index, + workspaceCount: workspaceCount + ), + canCloseWorkspace: canCloseWorkspace, + accessibilityWorkspaceCount: workspaceCount, unreadCount: notificationStore.unreadCount(forTabId: tab.id), latestNotificationText: { guard showsSidebarNotificationMessage, @@ -9506,7 +9559,9 @@ private struct TabItemView: View, Equatable { lhs.tab === rhs.tab && lhs.index == rhs.index && lhs.isActive == rhs.isActive && - lhs.tabCount == rhs.tabCount && + lhs.workspaceShortcutDigit == rhs.workspaceShortcutDigit && + lhs.canCloseWorkspace == rhs.canCloseWorkspace && + lhs.accessibilityWorkspaceCount == rhs.accessibilityWorkspaceCount && lhs.unreadCount == rhs.unreadCount && lhs.latestNotificationText == rhs.latestNotificationText && lhs.rowSpacing == rhs.rowSpacing && @@ -9522,7 +9577,9 @@ private struct TabItemView: View, Equatable { @ObservedObject var tab: Tab let index: Int let isActive: Bool - let tabCount: Int + let workspaceShortcutDigit: Int? + let canCloseWorkspace: Bool + let accessibilityWorkspaceCount: Int let unreadCount: Int let latestNotificationText: String? let rowSpacing: CGFloat @@ -9625,12 +9682,8 @@ private struct TabItemView: View, Equatable { usesInvertedActiveForeground ? 1.0 : 0.9 } - private var workspaceShortcutDigit: Int? { - WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabCount) - } - private var showCloseButton: Bool { - isHovering && tabCount > 1 && !(showsModifierShortcutHints || alwaysShowShortcutHints) + isHovering && canCloseWorkspace && !(showsModifierShortcutHints || alwaysShowShortcutHints) } private var workspaceShortcutLabel: String? { @@ -10267,7 +10320,7 @@ private struct TabItemView: View, Equatable { } private var accessibilityTitle: String { - String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(tabCount)") + String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(accessibilityWorkspaceCount)") } private func moveBy(_ delta: Int) { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index bd4dad61..4a888b14 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -655,6 +655,33 @@ class TabManager: ObservableObject { private static var nextPortOrdinal: Int = 0 private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0] @Published var selectedTabId: UUID? { + willSet { +#if DEBUG + guard newValue != selectedTabId else { + debugPendingWorkspaceSwitchTrigger = nil + debugPendingWorkspaceSwitchTarget = nil + debugPreparedWorkspaceSwitchTarget = nil + return + } + + if debugPreparedWorkspaceSwitchTarget == newValue { + debugPreparedWorkspaceSwitchTarget = nil + debugPendingWorkspaceSwitchTrigger = nil + debugPendingWorkspaceSwitchTarget = nil + } else { + let trigger = (debugPendingWorkspaceSwitchTarget == newValue + ? debugPendingWorkspaceSwitchTrigger + : nil) ?? "direct" + debugPendingWorkspaceSwitchTrigger = nil + debugPendingWorkspaceSwitchTarget = nil + debugBeginWorkspaceSwitch( + trigger: trigger, + from: selectedTabId, + to: newValue + ) + } +#endif + } didSet { guard selectedTabId != oldValue else { return } sentryBreadcrumb("workspace.switch", data: [ @@ -731,6 +758,9 @@ class TabManager: ObservableObject { private var debugWorkspaceSwitchCounter: UInt64 = 0 private var debugWorkspaceSwitchId: UInt64 = 0 private var debugWorkspaceSwitchStartTime: CFTimeInterval = 0 + private var debugPendingWorkspaceSwitchTrigger: String? + private var debugPendingWorkspaceSwitchTarget: UUID? + private var debugPreparedWorkspaceSwitchTarget: UUID? #endif #if DEBUG @@ -930,6 +960,9 @@ class TabManager: ObservableObject { newWorkspace.requestBackgroundTerminalSurfaceStartIfNeeded() } if select { +#if DEBUG + debugPrimeWorkspaceSwitchTrigger("create", to: newWorkspace.id) +#endif selectedTabId = newWorkspace.id NotificationCenter.default.post( name: .ghosttyDidFocusTab, @@ -1507,6 +1540,9 @@ class TabManager: ObservableObject { } func selectWorkspace(_ workspace: Workspace) { +#if DEBUG + debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id) +#endif selectedTabId = workspace.id } @@ -2093,6 +2129,9 @@ class TabManager: ObservableObject { // Keep selected-surface intent stable across selectedTabId didSet async restore. lastFocusedPanelByTab[tabId] = surfaceId } +#if DEBUG + debugPrimeWorkspaceSwitchTrigger("focus", to: tabId) +#endif selectedTabId = tabId NotificationCenter.default.post( name: .ghosttyDidFocusTab, @@ -2160,13 +2199,7 @@ class TabManager: ObservableObject { let nextIndex = (currentIndex + 1) % tabs.count #if DEBUG let nextId = tabs[nextIndex].id - debugWorkspaceSwitchCounter &+= 1 - debugWorkspaceSwitchId = debugWorkspaceSwitchCounter - debugWorkspaceSwitchStartTime = CACurrentMediaTime() - dlog( - "ws.switch.begin id=\(debugWorkspaceSwitchId) dir=next from=\(Self.debugShortWorkspaceId(currentId)) " + - "to=\(Self.debugShortWorkspaceId(nextId)) hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)" - ) + debugPrepareWorkspaceSwitch("next", from: currentId, to: nextId) #endif activateWorkspaceCycleHotWindow() selectedTabId = tabs[nextIndex].id @@ -2178,13 +2211,7 @@ class TabManager: ObservableObject { let prevIndex = (currentIndex - 1 + tabs.count) % tabs.count #if DEBUG let prevId = tabs[prevIndex].id - debugWorkspaceSwitchCounter &+= 1 - debugWorkspaceSwitchId = debugWorkspaceSwitchCounter - debugWorkspaceSwitchStartTime = CACurrentMediaTime() - dlog( - "ws.switch.begin id=\(debugWorkspaceSwitchId) dir=prev from=\(Self.debugShortWorkspaceId(currentId)) " + - "to=\(Self.debugShortWorkspaceId(prevId)) hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)" - ) + debugPrepareWorkspaceSwitch("prev", from: currentId, to: prevId) #endif activateWorkspaceCycleHotWindow() selectedTabId = tabs[prevIndex].id @@ -2257,6 +2284,40 @@ class TabManager: ObservableObject { return (debugWorkspaceSwitchId, debugWorkspaceSwitchStartTime) } + private func debugPrimeWorkspaceSwitchTrigger(_ trigger: String, to target: UUID?) { + guard selectedTabId != target else { + debugPendingWorkspaceSwitchTrigger = nil + debugPendingWorkspaceSwitchTarget = nil + return + } + debugPendingWorkspaceSwitchTrigger = trigger + debugPendingWorkspaceSwitchTarget = target + } + + private func debugPrepareWorkspaceSwitch(_ trigger: String, from: UUID?, to: UUID?) { + guard from != to else { + debugPendingWorkspaceSwitchTrigger = nil + debugPendingWorkspaceSwitchTarget = nil + debugPreparedWorkspaceSwitchTarget = nil + return + } + debugPendingWorkspaceSwitchTrigger = nil + debugPendingWorkspaceSwitchTarget = nil + debugBeginWorkspaceSwitch(trigger: trigger, from: from, to: to) + debugPreparedWorkspaceSwitchTarget = to + } + + private func debugBeginWorkspaceSwitch(trigger: String, from: UUID?, to: UUID?) { + debugWorkspaceSwitchCounter &+= 1 + debugWorkspaceSwitchId = debugWorkspaceSwitchCounter + debugWorkspaceSwitchStartTime = CACurrentMediaTime() + dlog( + "ws.switch.begin id=\(debugWorkspaceSwitchId) trigger=\(trigger) " + + "from=\(Self.debugShortWorkspaceId(from)) to=\(Self.debugShortWorkspaceId(to)) " + + "hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)" + ) + } + private static func debugShortWorkspaceId(_ id: UUID?) -> String { guard let id else { return "nil" } return String(id.uuidString.prefix(5)) @@ -2269,6 +2330,9 @@ class TabManager: ObservableObject { func selectTab(at index: Int) { guard index >= 0 && index < tabs.count else { return } +#if DEBUG + debugPrimeWorkspaceSwitchTrigger("select_index", to: tabs[index].id) +#endif selectedTabId = tabs[index].id } diff --git a/cmuxTests/WorkspaceStressProfileTests.swift b/cmuxTests/WorkspaceStressProfileTests.swift new file mode 100644 index 00000000..bebb48d9 --- /dev/null +++ b/cmuxTests/WorkspaceStressProfileTests.swift @@ -0,0 +1,282 @@ +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class WorkspaceStressProfileTests: XCTestCase { + private struct StressConfig { + let workspaceCount: Int + let tabsPerWorkspace: Int + let switchPasses: Int + let createP95BudgetMs: Double? + let switchP95BudgetMs: Double? + + static func current(environment: [String: String] = ProcessInfo.processInfo.environment) -> StressConfig { + StressConfig( + workspaceCount: parseInt(environment["CMUX_WORKSPACE_STRESS_WORKSPACES"], default: 48, minimum: 2), + tabsPerWorkspace: parseInt(environment["CMUX_WORKSPACE_STRESS_TABS_PER_WORKSPACE"], default: 10, minimum: 1), + switchPasses: parseInt(environment["CMUX_WORKSPACE_STRESS_SWITCH_PASSES"], default: 6, minimum: 1), + createP95BudgetMs: parseDouble(environment["CMUX_WORKSPACE_STRESS_CREATE_P95_BUDGET_MS"]), + switchP95BudgetMs: parseDouble(environment["CMUX_WORKSPACE_STRESS_SWITCH_P95_BUDGET_MS"]) + ) + } + + private static func parseInt(_ value: String?, default defaultValue: Int, minimum: Int) -> Int { + guard let value, let parsed = Int(value) else { return defaultValue } + return max(minimum, parsed) + } + + private static func parseDouble(_ value: String?) -> Double? { + guard let value, let parsed = Double(value) else { return nil } + return parsed + } + } + + private struct TimedSample { + let label: String + let elapsedMs: Double + } + + private struct TimingSummary { + let count: Int + let averageMs: Double + let medianMs: Double + let p95Ms: Double + let maxMs: Double + let totalMs: Double + + init(samples: [TimedSample]) { + let sorted = samples.map(\.elapsedMs).sorted() + count = sorted.count + totalMs = sorted.reduce(0, +) + averageMs = count > 0 ? totalMs / Double(count) : 0 + medianMs = Self.percentile(0.50, in: sorted) + p95Ms = Self.percentile(0.95, in: sorted) + maxMs = sorted.last ?? 0 + } + + private static func percentile(_ percentile: Double, in sortedValues: [Double]) -> Double { + guard !sortedValues.isEmpty else { return 0 } + let clamped = min(max(percentile, 0), 1) + let index = Int((Double(sortedValues.count - 1) * clamped).rounded(.up)) + return sortedValues[min(sortedValues.count - 1, max(0, index))] + } + } + + func testWorkspaceCreationAndSwitchingStressProfile() { + let config = StressConfig.current() + let welcomeWasShown = UserDefaults.standard.object(forKey: WelcomeSettings.shownKey) + UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey) + defer { + if let welcomeWasShown { + UserDefaults.standard.set(welcomeWasShown, forKey: WelcomeSettings.shownKey) + } else { + UserDefaults.standard.removeObject(forKey: WelcomeSettings.shownKey) + } + } + + var creationSamples: [TimedSample] = [] + var populationSamples: [TimedSample] = [] + var switchSamples: [TimedSample] = [] + var switchDispatchSamples: [TimedSample] = [] + var switchFirstDrainSamples: [TimedSample] = [] + var switchUnfocusSamples: [TimedSample] = [] + var switchSecondDrainSamples: [TimedSample] = [] + + let manager = timed("workspace-000-create", collectInto: &creationSamples) { + TabManager() + } + + guard let bootstrapWorkspace = manager.selectedWorkspace else { + XCTFail("Expected bootstrap workspace") + return + } + + timed("workspace-000-populate", collectInto: &populationSamples) { + populate(workspace: bootstrapWorkspace, tabsPerWorkspace: config.tabsPerWorkspace) + } + settleWorkspaceSelection(manager) + + for workspaceIndex in 1.. 0 else { return } + while workspace.panels.count < tabsPerWorkspace { + let created = workspace.newTerminalSurfaceInFocusedPane(focus: false) + guard created != nil else { + XCTFail("Expected terminal tab creation to succeed") + return + } + } + } + + private func settleWorkspaceSelection(_ manager: TabManager) { + drainMainQueue() + manager.completePendingWorkspaceUnfocus(reason: "workspace_stress_profile") + drainMainQueue() + } + + private func drainMainQueue() { + let deadline = Date(timeIntervalSinceNow: 1.0) + var drained = false + DispatchQueue.main.async { + drained = true + } + while !drained { + if Date() >= deadline { + XCTFail("Timed out draining main queue") + return + } + let sliceDeadline = min(deadline, Date(timeIntervalSinceNow: 0.001)) + _ = RunLoop.main.run(mode: .default, before: sliceDeadline) + } + } + + @discardableResult + private func timed( + _ label: String, + collectInto samples: inout [TimedSample], + operation: () -> T + ) -> T { + let startedAt = ProcessInfo.processInfo.systemUptime + let value = operation() + let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 + samples.append(TimedSample(label: label, elapsedMs: elapsedMs)) + return value + } + + private func slowest(_ samples: [TimedSample], count: Int = 5) -> String { + samples + .sorted { lhs, rhs in + if lhs.elapsedMs == rhs.elapsedMs { + return lhs.label < rhs.label + } + return lhs.elapsedMs > rhs.elapsedMs + } + .prefix(count) + .map { "\($0.label)=\(formatMs($0.elapsedMs))" } + .joined(separator: ", ") + } + + private func reportLine(title: String, summary: TimingSummary, slowest: String) -> String { + [ + "\(title):", + "count=\(summary.count)", + "avg=\(formatMs(summary.averageMs))", + "median=\(formatMs(summary.medianMs))", + "p95=\(formatMs(summary.p95Ms))", + "max=\(formatMs(summary.maxMs))", + "total=\(formatMs(summary.totalMs))", + "slowest=[\(slowest)]" + ].joined(separator: " ") + } + + private func formatMs(_ value: Double) -> String { + String(format: "%.2fms", value) + } + + private func label(for index: Int) -> String { + String(format: "%03d", index) + } +} From e94daa0bcff023bd470d4b1adf511d69de770cfe Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 04:46:13 -0700 Subject: [PATCH 02/11] Fix terminal Cmd+V clipboard payload handling (#1305) * Add clipboard payload regression tests * Fix terminal clipboard payload handling --- Sources/GhosttyTerminalView.swift | 146 ++++++++++++++---- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 55 +++++++ 2 files changed, 169 insertions(+), 32 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 65e50eaa..2bb71abf 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -75,6 +75,7 @@ private enum GhosttyPasteboardHelper { ) private static let utf8PlainTextType = NSPasteboard.PasteboardType("public.utf8-plain-text") private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t" + private static let objectReplacementCharacter = Character(UnicodeScalar(0xFFFC)!) static func pasteboard(for location: ghostty_clipboard_e) -> NSPasteboard? { switch location { @@ -99,13 +100,35 @@ private enum GhosttyPasteboardHelper { return value } - return pasteboard.string(forType: utf8PlainTextType) + if let value = pasteboard.string(forType: utf8PlainTextType) { + return value + } + + if hasImageData(in: pasteboard), + let html = pasteboard.string(forType: .html), + htmlHasNoVisibleText(html) { + return nil + } + + if let htmlText = attributedStringContents(from: pasteboard, type: .html, documentType: .html) { + return htmlText + } + + if let rtfText = attributedStringContents(from: pasteboard, type: .rtf, documentType: .rtf) { + return rtfText + } + + return attributedStringContents(from: pasteboard, type: .rtfd, documentType: .rtfd) } static func hasString(for location: ghostty_clipboard_e) -> Bool { guard let pasteboard = pasteboard(for: location) else { return false } - if let text = stringContents(from: pasteboard), !text.isEmpty { return true } - return clipboardHasImageOnly() + let types = pasteboard.types ?? [] + if types.contains(.fileURL) || types.contains(.string) || types.contains(utf8PlainTextType) + || types.contains(.html) || types.contains(.rtf) || types.contains(.rtfd) { + return true + } + return hasImageData(in: pasteboard) } static func writeString(_ string: String, to location: ghostty_clipboard_e) { @@ -122,40 +145,85 @@ private enum GhosttyPasteboardHelper { return result } - private static let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB + private static func attributedStringContents( + from pasteboard: NSPasteboard, + type: NSPasteboard.PasteboardType, + documentType: NSAttributedString.DocumentType + ) -> String? { + let data = + pasteboard.data(forType: type) + ?? pasteboard.string(forType: type)?.data(using: .utf8) + guard let data else { return nil } - /// Quick check: does the clipboard have image data and no text? - static func clipboardHasImageOnly() -> Bool { - let pb = NSPasteboard.general - let types = pb.types ?? [] - let hasText = types.contains(.string) || types.contains(.html) - || types.contains(.rtf) || types.contains(.rtfd) - if hasText { return false } + let attributed = try? NSAttributedString( + data: data, + options: [ + .documentType: documentType, + .characterEncoding: String.Encoding.utf8.rawValue + ], + documentAttributes: nil + ) + + let sanitized = attributed?.string + .split(separator: objectReplacementCharacter, omittingEmptySubsequences: false) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard let sanitized, !sanitized.isEmpty else { return nil } + return sanitized + } + + private static func hasImageData(in pasteboard: NSPasteboard) -> Bool { + let types = pasteboard.types ?? [] return types.contains(.tiff) || types.contains(.png) } - /// When the clipboard contains only image data (no text/HTML), saves it as - /// a temporary PNG file and returns the shell-escaped file path. Returns nil - /// if the clipboard contains text or no image. - static func saveClipboardImageIfNeeded() -> String? { - let pb = NSPasteboard.general - let types = pb.types ?? [] + private static func htmlHasNoVisibleText(_ html: String) -> Bool { + let withoutComments = html.replacingOccurrences( + of: "", + with: " ", + options: .regularExpression + ) + let withoutTags = withoutComments.replacingOccurrences( + of: "<[^>]+>", + with: " ", + options: .regularExpression + ) + let normalized = withoutTags + .replacingOccurrences(of: " ", with: " ") + .replacingOccurrences(of: " ", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + return normalized.isEmpty + } - // If pasteboard has text/HTML, this is a normal copy. - let hasText = types.contains(.string) || types.contains(.html) - || types.contains(.rtf) || types.contains(.rtfd) - if hasText { return nil } + /// When the clipboard contains only image data (or rich text that resolves to + /// an attachment-only image), saves it as a temporary PNG file and returns the + /// shell-escaped file path. Returns nil if the clipboard contains text or no image. + static func saveClipboardImageIfNeeded( + from pasteboard: NSPasteboard = .general, + assumeNoText: Bool = false + ) -> String? { + if !assumeNoText && stringContents(from: pasteboard) != nil { return nil } - // Check for image types (TIFF from screenshots, PNG from some tools). - guard types.contains(.tiff) || types.contains(.png) else { return nil } - guard let image = NSImage(pasteboard: pb), - let tiffData = image.tiffRepresentation, - let bitmap = NSBitmapImageRep(data: tiffData), - let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil } + let imageData: Data + let fileExtension: String + if let pngData = pasteboard.data(forType: .png) { + imageData = pngData + fileExtension = "png" + } else { + guard hasImageData(in: pasteboard), + let image = NSImage(pasteboard: pasteboard), + let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData), + let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil } + imageData = pngData + fileExtension = "png" + } - guard pngData.count <= maxClipboardImageSize else { + let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB + guard imageData.count <= maxClipboardImageSize else { #if DEBUG - dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(pngData.count)") + dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(imageData.count)") #endif return nil } @@ -164,11 +232,11 @@ private enum GhosttyPasteboardHelper { formatter.dateFormat = "yyyy-MM-dd-HHmmss" formatter.locale = Locale(identifier: "en_US_POSIX") let timestamp = formatter.string(from: Date()) - let filename = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).png" + let filename = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).\(fileExtension)" let path = (NSTemporaryDirectory() as NSString).appendingPathComponent(filename) do { - try pngData.write(to: URL(fileURLWithPath: path)) + try imageData.write(to: URL(fileURLWithPath: path)) } catch { #if DEBUG dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)") @@ -180,6 +248,16 @@ private enum GhosttyPasteboardHelper { } } +#if DEBUG +func cmuxPasteboardStringContentsForTesting(_ pasteboard: NSPasteboard) -> String? { + GhosttyPasteboardHelper.stringContents(from: pasteboard) +} + +func cmuxPasteboardImagePathForTesting(_ pasteboard: NSPasteboard) -> String? { + GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: pasteboard) +} +#endif + enum TerminalOpenURLTarget: Equatable { case embeddedBrowser(URL) case external(URL) @@ -877,7 +955,11 @@ class GhosttyApp { // When clipboard has only image data (e.g. screenshot), save as temp // PNG and paste the file path so CLI tools can receive images. - if value.isEmpty, let imagePath = GhosttyPasteboardHelper.saveClipboardImageIfNeeded() { + if value.isEmpty, + let imagePath = pasteboard.flatMap({ + GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: $0, assumeNoText: true) + }) + { value = imagePath } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 357e4b67..a1e8d179 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -872,6 +872,61 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } +@MainActor +final class GhosttyPasteboardHelperTests: XCTestCase { + func testHTMLOnlyPasteboardExtractsPlainText() { + let pasteboard = NSPasteboard(name: .init("cmux-test-html-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.setString("

Hello world

", forType: .html) + + XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello world") + XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) + } + + func testImageHTMLClipboardFallsBackToImagePath() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.setString("", forType: .html) + + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.red.setFill() + NSRect(x: 0, y: 0, width: 1, height: 1).fill() + image.unlockFocus() + let tiffData = try XCTUnwrap(image.tiffRepresentation) + let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData)) + let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + pasteboard.setData(pngData, forType: .png) + + XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard)) + + let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard)) + defer { try? FileManager.default.removeItem(atPath: imagePath) } + + XCTAssertTrue(imagePath.hasSuffix(".png")) + XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) + } + + func testImageHTMLClipboardWithVisibleTextPrefersText() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-text-\(UUID().uuidString)")) + pasteboard.clearContents() + pasteboard.setString("

Hello

", forType: .html) + + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.blue.setFill() + NSRect(x: 0, y: 0, width: 1, height: 1).fill() + image.unlockFocus() + let tiffData = try XCTUnwrap(image.tiffRepresentation) + let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData)) + let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + pasteboard.setData(pngData, forType: .png) + + XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello") + XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) + } +} + @MainActor final class AppDelegateWindowContextRoutingTests: XCTestCase { private func makeMainWindow(id: UUID) -> NSWindow { From e6309e7841f333d4b0305116606da129ca385995 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 04:56:19 -0700 Subject: [PATCH 03/11] Harden the nightly workflow (#1356) --- .github/workflows/nightly.yml | 68 ++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 41b0b04f..adcadcad 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -13,10 +13,9 @@ on: concurrency: group: nightly-build-${{ github.ref_name }} - # Queue main pushes instead of hard-canceling older runs. The decide job - # already coalesces to the current main HEAD, and we re-check HEAD before - # publishing so stale queued runs exit cleanly instead of showing up red. - cancel-in-progress: false + # Only the newest nightly matters. Cancel older runs so a fresh main push + # does not sit behind an outdated build that would be discarded anyway. + cancel-in-progress: true permissions: contents: write @@ -100,7 +99,7 @@ jobs: build-sign-notarize-nightly: needs: decide if: needs.decide.outputs.should_build == 'true' - runs-on: macos-15 + runs-on: depot-macos-latest steps: - name: Checkout build ref uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -108,7 +107,29 @@ jobs: ref: ${{ needs.decide.outputs.head_sha }} submodules: recursive + - name: Check whether build commit is still current main HEAD before build + if: needs.decide.outputs.should_publish == 'true' + id: current_head_prebuild + run: | + set -euo pipefail + CURRENT_MAIN_SHA="$(git ls-remote origin refs/heads/main | awk '{print $1}')" + BUILD_SHA="${{ needs.decide.outputs.head_sha }}" + if [ "$CURRENT_MAIN_SHA" = "$BUILD_SHA" ]; then + STILL_CURRENT=true + else + STILL_CURRENT=false + fi + echo "still_current=${STILL_CURRENT}" >> "$GITHUB_OUTPUT" + { + echo "### Pre-build publish guard" + echo + echo "- build sha: \`$BUILD_SHA\`" + echo "- current main sha: \`$CURRENT_MAIN_SHA\`" + echo "- continue build/sign/publish: \`$STILL_CURRENT\`" + } >> "$GITHUB_STEP_SUMMARY" + - name: Select Xcode + if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' run: | set -euo pipefail if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then @@ -128,14 +149,17 @@ jobs: xcrun --sdk macosx --show-sdk-path - name: Install build deps + if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' run: | npm install --global "create-dmg@${CREATE_DMG_VERSION}" - name: Download pre-built GhosttyKit.xcframework + if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' run: | ./scripts/download-prebuilt-ghosttykit.sh - name: Cache Swift packages + if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 with: path: .spm-cache @@ -143,6 +167,7 @@ jobs: restore-keys: spm- - name: Derive Sparkle public key from private key + if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} run: | @@ -155,6 +180,7 @@ jobs: echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV" - name: Build universal nightly app (Release) + if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' run: | xcodebuild -scheme cmux -configuration Release -derivedDataPath build-universal \ -destination 'generic/platform=macOS' \ @@ -164,6 +190,7 @@ jobs: CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build - name: Verify nightly binary architectures + if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' run: | set -euo pipefail APP_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/MacOS/cmux" @@ -176,15 +203,16 @@ jobs: [[ "$CLI_ARCHS" == *arm64* && "$CLI_ARCHS" == *x86_64* ]] - name: Run CLI version memory guard regression + if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true' run: | set -euo pipefail CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux" [ -x "$CLI_BINARY" ] || { echo "cmux CLI binary not found at $CLI_BINARY" >&2; exit 1; } CMUX_CLI_BIN="$CLI_BINARY" python3 tests/test_cli_version_memory_guard.py - - name: Check whether build commit is still current main HEAD - if: needs.decide.outputs.should_publish == 'true' - id: current_head + - name: Check whether build commit is still current main HEAD after build + if: needs.decide.outputs.should_publish == 'true' && steps.current_head_prebuild.outputs.still_current == 'true' + id: current_head_postbuild run: | set -euo pipefail CURRENT_MAIN_SHA="$(git ls-remote origin refs/heads/main | awk '{print $1}')" @@ -196,7 +224,7 @@ jobs: fi echo "still_current=${STILL_CURRENT}" >> "$GITHUB_OUTPUT" { - echo "### Publish guard" + echo "### Post-build publish guard" echo echo "- build sha: \`$BUILD_SHA\`" echo "- current main sha: \`$CURRENT_MAIN_SHA\`" @@ -204,7 +232,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Inject nightly identities and metadata - if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' + if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true') run: | set -euo pipefail SHORT_SHA="${{ needs.decide.outputs.short_sha }}" @@ -259,7 +287,7 @@ jobs: echo "Commit SHA: ${SHORT_SHA}" - name: Import signing cert - if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' + if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true') env: APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} @@ -283,7 +311,7 @@ jobs: security list-keychains -d user -s build.keychain - name: Codesign apps - if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' + if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true') env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} run: | @@ -304,7 +332,7 @@ jobs: done - name: Notarize apps and dmgs - if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' + if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true') env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} @@ -369,7 +397,7 @@ jobs: "$NIGHTLY_DMG_IMMUTABLE" - name: Upload dSYMs to Sentry - if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' + if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true') env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: manaflow @@ -384,7 +412,7 @@ jobs: build-universal/Build/Products/Release/ - name: Generate Sparkle appcasts (nightly) - if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' + if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true') env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} run: | @@ -393,6 +421,9 @@ jobs: exit 1 fi ./scripts/sparkle_generate_appcast.sh "$NIGHTLY_DMG_IMMUTABLE" nightly appcast.xml + # Keep the legacy universal feed alive long enough for older nightly + # installs to migrate onto the unified nightly appcast. + cp appcast.xml appcast-universal.xml - name: Upload branch nightly artifacts if: needs.decide.outputs.should_publish != 'true' @@ -402,10 +433,11 @@ jobs: path: | cmux-nightly-macos*.dmg appcast.xml + appcast-universal.xml if-no-files-found: error - name: Move nightly tag to built commit - if: needs.decide.outputs.should_publish == 'true' && steps.current_head.outputs.still_current == 'true' + if: needs.decide.outputs.should_publish == 'true' && steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true' run: | set -euo pipefail git config user.name "github-actions[bot]" @@ -414,7 +446,7 @@ jobs: git push origin refs/tags/nightly --force - name: Publish nightly release assets - if: needs.decide.outputs.should_publish == 'true' && steps.current_head.outputs.still_current == 'true' + if: needs.decide.outputs.should_publish == 'true' && steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true' uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 with: tag_name: nightly @@ -427,12 +459,14 @@ jobs: **cmux NIGHTLY** is published as a universal app: - bundle ID `com.cmuxterm.app.nightly` - feed `appcast.xml` + - compatibility feed `appcast-universal.xml` for older universal nightlies [Download cmux-nightly-macos.dmg](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) files: | cmux-nightly-macos-${{ github.run_id }}*.dmg cmux-nightly-macos.dmg appcast.xml + appcast-universal.xml overwrite_files: true - name: Cleanup keychain From 2c8808fc577363ef97941199613e5b4093e52e1c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 05:04:21 -0700 Subject: [PATCH 04/11] test: cover jpeg clipboard image paste fallback --- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index a1e8d179..1543ff56 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1,6 +1,7 @@ import XCTest import AppKit import SwiftUI +import UniformTypeIdentifiers import WebKit import SwiftUI import ObjectiveC.runtime @@ -925,6 +926,36 @@ final class GhosttyPasteboardHelperTests: XCTestCase { XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello") XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) } + + func testJPEGClipboardFallsBackToImagePath() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-jpeg-\(UUID().uuidString)")) + pasteboard.clearContents() + + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.green.setFill() + NSRect(x: 0, y: 0, width: 1, height: 1).fill() + image.unlockFocus() + + let tiffData = try XCTUnwrap(image.tiffRepresentation) + let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData)) + let jpegData = try XCTUnwrap( + bitmap.representation( + using: .jpeg, + properties: [.compressionFactor: 1.0] + ) + ) + pasteboard.setData( + jpegData, + forType: NSPasteboard.PasteboardType(UTType.jpeg.identifier) + ) + + let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard)) + defer { try? FileManager.default.removeItem(atPath: imagePath) } + + XCTAssertTrue(imagePath.hasSuffix(".jpeg")) + XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) + } } @MainActor From adcd0be0f7d0046d997b1da481ffd750deb397d4 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 05:05:56 -0700 Subject: [PATCH 05/11] fix: support direct image clipboard UTIs --- Sources/GhosttyTerminalView.swift | 37 +++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2bb71abf..d05dbd75 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -8,6 +8,7 @@ import Darwin import Sentry import Bonsplit import IOSurface +import UniformTypeIdentifiers #if os(macOS) func cmuxShouldUseTransparentBackgroundWindow() -> Bool { @@ -175,7 +176,35 @@ private enum GhosttyPasteboardHelper { private static func hasImageData(in pasteboard: NSPasteboard) -> Bool { let types = pasteboard.types ?? [] - return types.contains(.tiff) || types.contains(.png) + if types.contains(.tiff) || types.contains(.png) { + return true + } + + return types.contains { type in + guard let utType = UTType(type.rawValue) else { return false } + return utType.conforms(to: .image) + } + } + + private static func directImageRepresentation( + in pasteboard: NSPasteboard + ) -> (data: Data, fileExtension: String)? { + if let pngData = pasteboard.data(forType: .png) { + return (pngData, "png") + } + + for type in pasteboard.types ?? [] { + guard type != .png, + type != .tiff, + let utType = UTType(type.rawValue), + utType.conforms(to: .image), + let imageData = pasteboard.data(forType: type), + let fileExtension = utType.preferredFilenameExtension, + !fileExtension.isEmpty else { continue } + return (imageData, fileExtension) + } + + return nil } private static func htmlHasNoVisibleText(_ html: String) -> Bool { @@ -207,9 +236,9 @@ private enum GhosttyPasteboardHelper { let imageData: Data let fileExtension: String - if let pngData = pasteboard.data(forType: .png) { - imageData = pngData - fileExtension = "png" + if let directImage = directImageRepresentation(in: pasteboard) { + imageData = directImage.data + fileExtension = directImage.fileExtension } else { guard hasImageData(in: pasteboard), let image = NSImage(pasteboard: pasteboard), From b7de2253641cfda95c9ed95dfa256caffaebac00 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 05:08:49 -0700 Subject: [PATCH 06/11] test: cover attachment-only rtfd image paste fallback --- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 1543ff56..5ce3ed6d 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -956,6 +956,59 @@ final class GhosttyPasteboardHelperTests: XCTestCase { XCTAssertTrue(imagePath.hasSuffix(".jpeg")) XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) } + + func testAttachmentOnlyRTFDClipboardFallsBackToImagePath() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-attachment-\(UUID().uuidString)")) + pasteboard.clearContents() + + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.orange.setFill() + NSRect(x: 0, y: 0, width: 1, height: 1).fill() + image.unlockFocus() + + let attachment = NSTextAttachment() + attachment.image = image + let attributed = NSAttributedString(attachment: attachment) + let data = try attributed.data( + from: NSRange(location: 0, length: attributed.length), + documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd] + ) + pasteboard.setData(data, forType: .rtfd) + + XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard)) + + let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard)) + defer { try? FileManager.default.removeItem(atPath: imagePath) } + + XCTAssertTrue(imagePath.hasSuffix(".tiff")) + XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) + } + + func testRTFDClipboardWithVisibleTextPrefersText() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-text-\(UUID().uuidString)")) + pasteboard.clearContents() + + let image = NSImage(size: NSSize(width: 1, height: 1)) + image.lockFocus() + NSColor.purple.setFill() + NSRect(x: 0, y: 0, width: 1, height: 1).fill() + image.unlockFocus() + + let attachment = NSTextAttachment() + attachment.image = image + + let attributed = NSMutableAttributedString(string: "Hello ") + attributed.append(NSAttributedString(attachment: attachment)) + let data = try attributed.data( + from: NSRange(location: 0, length: attributed.length), + documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd] + ) + pasteboard.setData(data, forType: .rtfd) + + XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello") + XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) + } } @MainActor From 4a3b9e7c9b6c6faf211fd686b93061a6d61b906e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 05:09:55 -0700 Subject: [PATCH 07/11] fix: support attachment-only rtfd image paste fallback --- Sources/GhosttyTerminalView.swift | 82 ++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 13 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index d05dbd75..ce232301 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -151,18 +151,10 @@ private enum GhosttyPasteboardHelper { type: NSPasteboard.PasteboardType, documentType: NSAttributedString.DocumentType ) -> String? { - let data = - pasteboard.data(forType: type) - ?? pasteboard.string(forType: type)?.data(using: .utf8) - guard let data else { return nil } - - let attributed = try? NSAttributedString( - data: data, - options: [ - .documentType: documentType, - .characterEncoding: String.Encoding.utf8.rawValue - ], - documentAttributes: nil + let attributed = attributedString( + from: pasteboard, + type: type, + documentType: documentType ) let sanitized = attributed?.string @@ -174,6 +166,67 @@ private enum GhosttyPasteboardHelper { return sanitized } + private static func attributedString( + from pasteboard: NSPasteboard, + type: NSPasteboard.PasteboardType, + documentType: NSAttributedString.DocumentType + ) -> NSAttributedString? { + let data = + pasteboard.data(forType: type) + ?? pasteboard.string(forType: type)?.data(using: .utf8) + guard let data else { return nil } + + return try? NSAttributedString( + data: data, + options: [ + .documentType: documentType, + .characterEncoding: String.Encoding.utf8.rawValue + ], + documentAttributes: nil + ) + } + + private static func rtfdAttachmentImageRepresentation( + in pasteboard: NSPasteboard + ) -> (data: Data, fileExtension: String)? { + guard let attributed = attributedString( + from: pasteboard, + type: .rtfd, + documentType: .rtfd + ) else { return nil } + + var result: (data: Data, fileExtension: String)? + attributed.enumerateAttribute( + .attachment, + in: NSRange(location: 0, length: attributed.length) + ) { value, _, stop in + guard let attachment = value as? NSTextAttachment else { return } + + if let fileWrapper = attachment.fileWrapper, + let data = fileWrapper.regularFileContents { + let fileExtension = + (fileWrapper.preferredFilename as NSString?)?.pathExtension ?? "" + if !fileExtension.isEmpty { + result = (data, fileExtension) + stop.pointee = true + return + } + } + + guard let image = attachment.image( + forBounds: .zero, + textContainer: nil, + characterIndex: 0 + ), + let tiffData = image.tiffRepresentation else { return } + + result = (tiffData, "tiff") + stop.pointee = true + } + + return result + } + private static func hasImageData(in pasteboard: NSPasteboard) -> Bool { let types = pasteboard.types ?? [] if types.contains(.tiff) || types.contains(.png) { @@ -226,7 +279,7 @@ private enum GhosttyPasteboardHelper { } /// When the clipboard contains only image data (or rich text that resolves to - /// an attachment-only image), saves it as a temporary PNG file and returns the + /// an attachment-only image), saves it as a temporary image file and returns the /// shell-escaped file path. Returns nil if the clipboard contains text or no image. static func saveClipboardImageIfNeeded( from pasteboard: NSPasteboard = .general, @@ -239,6 +292,9 @@ private enum GhosttyPasteboardHelper { if let directImage = directImageRepresentation(in: pasteboard) { imageData = directImage.data fileExtension = directImage.fileExtension + } else if let rtfdAttachment = rtfdAttachmentImageRepresentation(in: pasteboard) { + imageData = rtfdAttachment.data + fileExtension = rtfdAttachment.fileExtension } else { guard hasImageData(in: pasteboard), let image = NSImage(pasteboard: pasteboard), From 09ea29a63a9044dbf0ecf9e1a480870d3114e74a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 05:19:51 -0700 Subject: [PATCH 08/11] fix: restrict rtfd attachment fallback to images --- Sources/GhosttyTerminalView.swift | 51 ++++++++++++------- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 19 +++++++ 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index ce232301..b420a07e 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -203,30 +203,45 @@ private enum GhosttyPasteboardHelper { guard let attachment = value as? NSTextAttachment else { return } if let fileWrapper = attachment.fileWrapper, - let data = fileWrapper.regularFileContents { - let fileExtension = - (fileWrapper.preferredFilename as NSString?)?.pathExtension ?? "" - if !fileExtension.isEmpty { - result = (data, fileExtension) - stop.pointee = true - return - } + let data = fileWrapper.regularFileContents, + let imageRepresentation = imageAttachmentRepresentation( + data: data, + preferredFilename: fileWrapper.preferredFilename + ) { + result = imageRepresentation + stop.pointee = true } - - guard let image = attachment.image( - forBounds: .zero, - textContainer: nil, - characterIndex: 0 - ), - let tiffData = image.tiffRepresentation else { return } - - result = (tiffData, "tiff") - stop.pointee = true } return result } + private static func imageAttachmentRepresentation( + data: Data, + preferredFilename: String? + ) -> (data: Data, fileExtension: String)? { + let pathExtension = + (preferredFilename as NSString?)?.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines) + ?? "" + if let type = !pathExtension.isEmpty ? UTType(filenameExtension: pathExtension) : nil, + type.conforms(to: .image), + let fileExtension = type.preferredFilenameExtension ?? nonEmpty(pathExtension) { + return (data, fileExtension) + } + + guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil), + let typeIdentifier = CGImageSourceGetType(imageSource) as String?, + let type = UTType(typeIdentifier), + type.conforms(to: .image), + let fileExtension = type.preferredFilenameExtension else { return nil } + return (data, fileExtension) + } + + private static func nonEmpty(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + private static func hasImageData(in pasteboard: NSPasteboard) -> Bool { let types = pasteboard.types ?? [] if types.contains(.tiff) || types.contains(.png) { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 5ce3ed6d..501256ad 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -985,6 +985,25 @@ final class GhosttyPasteboardHelperTests: XCTestCase { XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath)) } + func testAttachmentOnlyRTFDNonImageClipboardDoesNotFallBackToImagePath() throws { + let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-non-image-\(UUID().uuidString)")) + pasteboard.clearContents() + + let wrapper = FileWrapper(regularFileWithContents: Data("hello".utf8)) + wrapper.preferredFilename = "note.txt" + + let attachment = NSTextAttachment(fileWrapper: wrapper) + let attributed = NSAttributedString(attachment: attachment) + let data = try attributed.data( + from: NSRange(location: 0, length: attributed.length), + documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd] + ) + pasteboard.setData(data, forType: .rtfd) + + XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard)) + XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard)) + } + func testRTFDClipboardWithVisibleTextPrefersText() throws { let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-text-\(UUID().uuidString)")) pasteboard.clearContents() From 099f0a931ae0538051d9adba98cef8bc6986f5d1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 05:37:17 -0700 Subject: [PATCH 09/11] Restore Ghostty Pure prompt redraw fix --- ghostty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostty b/ghostty index a50579bd..51cad051 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit a50579bd5ddec81c6244b9b349d4bf781f667cec +Subproject commit 51cad05140ea20ba7cbe8cd5b6d0d180fc022db3 From fd790346f4473f99c2e000426a8bc720f94f634e Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 05:49:34 -0700 Subject: [PATCH 10/11] Point Ghostty submodule at published Pure redraw fix --- docs/ghostty-fork.md | 4 +++- ghostty | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index d85ca46b..c57c12e6 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -77,14 +77,16 @@ touch the same stale-frame mitigation path and tend to conflict in the same file - Commits: - `0cf559581` (zsh: fix Pure-style multiline prompt redraws) - `312c7b23a` (zsh: avoid extra Pure continuation markers) + - `404a3f175` (Fix Pure prompt redraw markers) - Files: - `src/shell-integration/zsh/ghostty-integration` - Summary: - Handles multiline prompts that use `\n%{\r%}` to return to column 0 before the visible prompt line. - Keeps redraw-safe prompt-start markers for async themes. - Avoids inserting an explicit continuation marker after Pure's hidden carriage return, because Ghostty already tracks the newline as prompt continuation and the extra marker duplicates the preprompt row. + - Restores that prompt-marker behavior on top of the current Ghostty `main` base after the older redraw fix drifted out during later submodule updates. -The fork branch HEAD is now the section 6 zsh redraw commit. +The fork branch HEAD is now the section 6 zsh redraw follow-up commit. ## Upstreamed fork changes diff --git a/ghostty b/ghostty index 51cad051..404a3f17 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 51cad05140ea20ba7cbe8cd5b6d0d180fc022db3 +Subproject commit 404a3f175ba6baafabc46cac807194883e040980 From dfcb6335bb6e05e7784be7e5e7e2e7989fa270cf Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 13 Mar 2026 05:56:58 -0700 Subject: [PATCH 11/11] Pin GhosttyKit checksum for Pure redraw fix --- scripts/ghosttykit-checksums.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index c7c101ac..582f8999 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -5,3 +5,4 @@ a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d 0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de 312c7b23a7c8dc0704431940d76ba5dc32a46afb ae73cb18a9d6efec42126a1d99e0e9d12022403d7dc301dfa21ed9f7c89c9e30 +404a3f175ba6baafabc46cac807194883e040980 bcbd2954f4746fe5bcb4bfca6efeddd3ea355fda2836371f4c7150271c58acbd