Merge origin/main into pr-ssh-stack-main

This commit is contained in:
Lawrence Chen 2026-03-13 06:25:08 -07:00
commit 965965c879
12 changed files with 1077 additions and 83 deletions

View file

@ -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
@ -102,7 +101,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
@ -110,7 +109,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
@ -130,14 +151,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
@ -150,6 +174,7 @@ jobs:
go-version-file: daemon/remote/go.mod
- 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: |
@ -162,6 +187,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' \
@ -171,6 +197,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"
@ -183,15 +210,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}')"
@ -203,7 +231,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\`"
@ -211,7 +239,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 }}"
@ -285,7 +313,7 @@ jobs:
done
- 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 }}
@ -309,7 +337,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: |
@ -330,7 +358,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 }}
@ -395,7 +423,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
@ -410,7 +438,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: |
@ -419,6 +447,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: Attest remote daemon nightly assets
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true'
@ -440,16 +471,20 @@ jobs:
path: |
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
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]"
@ -458,7 +493,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
@ -471,18 +506,23 @@ 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
<<<<<<< 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

@ -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 = "<group>"; };
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = "<group>"; };
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = "<group>"; };
A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; };
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; };
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
@ -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 */,
);

View file

@ -1368,6 +1368,7 @@ struct ContentView: View {
@State private var workspaceHandoffGeneration: UInt64 = 0
@State private var workspaceHandoffFallbackTask: Task<Void, Never>?
@State private var didApplyUITestSidebarSelection = false
@State private var workspaceHandoffReadyCheckTask: Task<Void, Never>?
@State private var titlebarThemeGeneration: UInt64 = 0
@State private var sidebarDraggedTabId: UUID?
@State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
@ -2919,6 +2920,8 @@ struct ContentView: View {
retiringWorkspaceId = nil
workspaceHandoffFallbackTask?.cancel()
workspaceHandoffFallbackTask = nil
workspaceHandoffReadyCheckTask?.cancel()
workspaceHandoffReadyCheckTask = nil
return
}
@ -2926,6 +2929,7 @@ struct ContentView: View {
let generation = workspaceHandoffGeneration
retiringWorkspaceId = oldSelectedId
workspaceHandoffFallbackTask?.cancel()
workspaceHandoffReadyCheckTask?.cancel()
#if DEBUG
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
@ -2941,6 +2945,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)
@ -2960,9 +2994,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
@ -7259,6 +7304,9 @@ struct VerticalTabsSidebar: View {
}
var body: some View {
let workspaceCount = tabManager.tabs.count
let canCloseWorkspace = workspaceCount > 1
VStack(spacing: 0) {
GeometryReader { proxy in
ScrollView {
@ -7282,7 +7330,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,
@ -9555,7 +9608,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 &&
@ -9574,7 +9629,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
@ -9681,12 +9738,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? {
@ -10444,7 +10497,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) {

View file

@ -8,6 +8,7 @@ import Darwin
import Sentry
import Bonsplit
import IOSurface
import UniformTypeIdentifiers
#if os(macOS)
func cmuxShouldUseTransparentBackgroundWindow() -> Bool {
@ -75,6 +76,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 +101,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 +146,184 @@ 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 attributed = attributedString(
from: pasteboard,
type: type,
documentType: documentType
)
/// 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 }
return types.contains(.tiff) || types.contains(.png)
let sanitized = attributed?.string
.split(separator: objectReplacementCharacter, omittingEmptySubsequences: false)
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let sanitized, !sanitized.isEmpty else { return nil }
return sanitized
}
/// 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 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 }
// 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 }
return try? NSAttributedString(
data: data,
options: [
.documentType: documentType,
.characterEncoding: String.Encoding.utf8.rawValue
],
documentAttributes: 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 }
private static func rtfdAttachmentImageRepresentation(
in pasteboard: NSPasteboard
) -> (data: Data, fileExtension: String)? {
guard let attributed = attributedString(
from: pasteboard,
type: .rtfd,
documentType: .rtfd
) else { return nil }
guard pngData.count <= maxClipboardImageSize else {
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 imageRepresentation = imageAttachmentRepresentation(
data: data,
preferredFilename: fileWrapper.preferredFilename
) {
result = imageRepresentation
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) {
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 {
let withoutComments = html.replacingOccurrences(
of: "<!--[\\s\\S]*?-->",
with: " ",
options: .regularExpression
)
let withoutTags = withoutComments.replacingOccurrences(
of: "<[^>]+>",
with: " ",
options: .regularExpression
)
let normalized = withoutTags
.replacingOccurrences(of: "&nbsp;", with: " ")
.replacingOccurrences(of: "&#160;", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
return normalized.isEmpty
}
/// When the clipboard contains only image data (or rich text that resolves to
/// 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,
assumeNoText: Bool = false
) -> String? {
if !assumeNoText && stringContents(from: pasteboard) != nil { return nil }
let imageData: Data
let fileExtension: String
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),
let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil }
imageData = pngData
fileExtension = "png"
}
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 +332,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 +348,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 +1055,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
}

View file

@ -664,6 +664,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: [
@ -740,6 +767,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
@ -916,10 +946,22 @@ class TabManager: ObservableObject {
} else {
tabs.append(newWorkspace)
}
if overrideWorkingDirectory != nil,
let workingDirectory,
let panelId = newWorkspace.focusedTerminalPanel?.id {
scheduleInitialWorkspaceGitMetadataRefresh(
workspaceId: newWorkspace.id,
panelId: panelId,
directory: workingDirectory
)
}
if eagerLoadTerminal {
newWorkspace.focusedTerminalPanel?.surface.requestBackgroundSurfaceStartIfNeeded()
}
if select {
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("create", to: newWorkspace.id)
#endif
selectedTabId = newWorkspace.id
NotificationCenter.default.post(
name: .ghosttyDidFocusTab,
@ -1495,6 +1537,9 @@ class TabManager: ObservableObject {
}
func selectWorkspace(_ workspace: Workspace) {
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id)
#endif
selectedTabId = workspace.id
}
@ -2077,6 +2122,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,
@ -2144,13 +2192,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
@ -2162,13 +2204,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
@ -2241,6 +2277,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))
@ -2253,6 +2323,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
}

View file

@ -3740,7 +3740,7 @@ class TerminalController {
"close_left", "close_right", "close_others",
"new_terminal_right", "new_browser_right",
"reload", "duplicate",
"pin", "unpin", "mark_unread"
"pin", "unpin", "mark_read", "mark_unread"
]
var result: V2CallResult = .err(code: "invalid_params", message: "Unknown tab action", data: [
@ -3854,6 +3854,10 @@ class TerminalController {
workspace.setPanelPinned(panelId: surfaceId, pinned: false)
finish(["pinned": false])
case "mark_read":
workspace.markPanelRead(surfaceId)
finish()
case "mark_unread", "mark_as_unread":
workspace.markPanelUnread(surfaceId)
finish()
@ -4037,7 +4041,7 @@ class TerminalController {
"ref": v2Ref(kind: .surface, uuid: panel.id),
"index": index,
"type": panel.panelType.rawValue,
"title": panel.displayTitle,
"title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle,
"focused": panel.id == focusedSurfaceId,
"pane_id": v2OrNull(paneUUID?.uuidString),
"pane_ref": v2Ref(kind: .pane, uuid: paneUUID),
@ -5325,7 +5329,7 @@ class TerminalController {
if sourcePaneUUID == targetPaneUUID {
return .err(code: "invalid_params", message: "pane_id and target_pane_id must be different", data: nil)
}
let focus = v2Bool(params, "focus") ?? true
let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true)
var result: V2CallResult = .err(code: "internal_error", message: "Failed to swap panes", data: nil)
v2MainSync {
@ -5408,7 +5412,7 @@ class TerminalController {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
let focus = v2Bool(params, "focus") ?? true
let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true)
var result: V2CallResult = .err(code: "internal_error", message: "Failed to break pane", data: nil)
v2MainSync {
@ -5449,7 +5453,7 @@ class TerminalController {
return
}
let destinationWorkspace = tabManager.addWorkspace()
let destinationWorkspace = tabManager.addWorkspace(select: focus)
guard let destinationPane = destinationWorkspace.bonsplitController.focusedPaneId
?? destinationWorkspace.bonsplitController.allPaneIds.first else {
if let sourcePaneForRollback {
@ -5476,10 +5480,6 @@ class TerminalController {
result = .err(code: "internal_error", message: "Failed to attach surface to new workspace", data: nil)
return
}
if !focus {
tabManager.selectWorkspace(sourceWorkspace)
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"window_id": v2OrNull(windowId?.uuidString),

View file

@ -1,6 +1,7 @@
import XCTest
import AppKit
import SwiftUI
import UniformTypeIdentifiers
import WebKit
import SwiftUI
import ObjectiveC.runtime
@ -872,6 +873,163 @@ 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("<p>Hello <strong>world</strong></p>", 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("<meta charset='utf-8'><img src=\"https://example.com/keyboard.png\">", 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("<p>Hello <img src=\"https://example.com/keyboard.png\"></p>", 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))
}
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))
}
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 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()
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
final class AppDelegateWindowContextRoutingTests: XCTestCase {
private func makeMainWindow(id: UUID) -> NSWindow {

View file

@ -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..<config.workspaceCount {
let workspace = timed("workspace-\(label(for: workspaceIndex))-create", collectInto: &creationSamples) {
manager.addWorkspace(
select: true,
eagerLoadTerminal: false,
autoWelcomeIfNeeded: false
)
}
settleWorkspaceSelection(manager)
timed("workspace-\(label(for: workspaceIndex))-populate", collectInto: &populationSamples) {
populate(workspace: workspace, tabsPerWorkspace: config.tabsPerWorkspace)
}
settleWorkspaceSelection(manager)
}
XCTAssertEqual(manager.tabs.count, config.workspaceCount)
XCTAssertTrue(manager.tabs.allSatisfy { $0.panels.count == config.tabsPerWorkspace })
for pass in 0..<config.switchPasses {
for switchIndex in 0..<manager.tabs.count {
timed("pass-\(label(for: pass))-next-\(label(for: switchIndex))", collectInto: &switchSamples) {
timed("pass-\(label(for: pass))-next-dispatch-\(label(for: switchIndex))", collectInto: &switchDispatchSamples) {
manager.selectNextTab()
}
timed("pass-\(label(for: pass))-next-drain1-\(label(for: switchIndex))", collectInto: &switchFirstDrainSamples) {
drainMainQueue()
}
timed("pass-\(label(for: pass))-next-unfocus-\(label(for: switchIndex))", collectInto: &switchUnfocusSamples) {
manager.completePendingWorkspaceUnfocus(reason: "workspace_stress_profile")
}
timed("pass-\(label(for: pass))-next-drain2-\(label(for: switchIndex))", collectInto: &switchSecondDrainSamples) {
drainMainQueue()
}
}
}
for switchIndex in 0..<manager.tabs.count {
timed("pass-\(label(for: pass))-prev-\(label(for: switchIndex))", collectInto: &switchSamples) {
timed("pass-\(label(for: pass))-prev-dispatch-\(label(for: switchIndex))", collectInto: &switchDispatchSamples) {
manager.selectPreviousTab()
}
timed("pass-\(label(for: pass))-prev-drain1-\(label(for: switchIndex))", collectInto: &switchFirstDrainSamples) {
drainMainQueue()
}
timed("pass-\(label(for: pass))-prev-unfocus-\(label(for: switchIndex))", collectInto: &switchUnfocusSamples) {
manager.completePendingWorkspaceUnfocus(reason: "workspace_stress_profile")
}
timed("pass-\(label(for: pass))-prev-drain2-\(label(for: switchIndex))", collectInto: &switchSecondDrainSamples) {
drainMainQueue()
}
}
}
}
XCTAssertNotNil(manager.selectedWorkspace)
let creationSummary = TimingSummary(samples: creationSamples)
let populationSummary = TimingSummary(samples: populationSamples)
let switchSummary = TimingSummary(samples: switchSamples)
let switchDispatchSummary = TimingSummary(samples: switchDispatchSamples)
let switchFirstDrainSummary = TimingSummary(samples: switchFirstDrainSamples)
let switchUnfocusSummary = TimingSummary(samples: switchUnfocusSamples)
let switchSecondDrainSummary = TimingSummary(samples: switchSecondDrainSamples)
let report = [
"Workspace stress config workspaces=\(config.workspaceCount) tabsPerWorkspace=\(config.tabsPerWorkspace) switchPasses=\(config.switchPasses)",
reportLine(title: "create", summary: creationSummary, slowest: slowest(creationSamples)),
reportLine(title: "populate", summary: populationSummary, slowest: slowest(populationSamples)),
reportLine(title: "switch", summary: switchSummary, slowest: slowest(switchSamples)),
reportLine(title: "switch.dispatch", summary: switchDispatchSummary, slowest: slowest(switchDispatchSamples)),
reportLine(title: "switch.drain1", summary: switchFirstDrainSummary, slowest: slowest(switchFirstDrainSamples)),
reportLine(title: "switch.unfocus", summary: switchUnfocusSummary, slowest: slowest(switchUnfocusSamples)),
reportLine(title: "switch.drain2", summary: switchSecondDrainSummary, slowest: slowest(switchSecondDrainSamples))
].joined(separator: "\n")
print(report)
let attachment = XCTAttachment(string: report)
attachment.name = "workspace-stress-profile"
attachment.lifetime = .keepAlways
add(attachment)
if let createP95BudgetMs = config.createP95BudgetMs {
XCTAssertLessThanOrEqual(
creationSummary.p95Ms,
createP95BudgetMs,
"Workspace creation p95 exceeded budget"
)
}
if let switchP95BudgetMs = config.switchP95BudgetMs {
XCTAssertLessThanOrEqual(
switchSummary.p95Ms,
switchP95BudgetMs,
"Workspace switch p95 exceeded budget"
)
}
}
private func populate(workspace: Workspace, tabsPerWorkspace: Int) {
guard tabsPerWorkspace > 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<T>(
_ 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)
}
}

View file

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

View file

@ -6,3 +6,4 @@ a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933
c47010b80cd9ae6d1ab744c120f011a465521ea3 d6904870a3c920b2787b1c4b950cfdef232606bb9876964f5e8497081d5cb5df
0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de
312c7b23a7c8dc0704431940d76ba5dc32a46afb ae73cb18a9d6efec42126a1d99e0e9d12022403d7dc301dfa21ed9f7c89c9e30
404a3f175ba6baafabc46cac807194883e040980 bcbd2954f4746fe5bcb4bfca6efeddd3ea355fda2836371f4c7150271c58acbd

View file

@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Regression: pane.swap and pane.break should not steal visible focus."""
from __future__ import annotations
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _focused_pane_id(client: cmux, workspace_id: str) -> str:
payload = client._call("pane.list", {"workspace_id": workspace_id}) or {}
for row in payload.get("panes") or []:
if bool(row.get("focused")):
return str(row.get("id") or "")
return ""
def main() -> int:
created_workspaces: list[str] = []
try:
with cmux(SOCKET_PATH) as client:
workspace_id = client.new_workspace()
created_workspaces.append(workspace_id)
client.select_workspace(workspace_id)
time.sleep(0.2)
_ = client.new_split("right")
time.sleep(0.2)
panes_payload = client._call("pane.list", {"workspace_id": workspace_id}) or {}
panes = panes_payload.get("panes") or []
_must(len(panes) == 2, f"expected two panes after split: {panes_payload}")
focused_row = next((row for row in panes if bool(row.get("focused"))), None)
_must(focused_row is not None, f"expected focused pane after split: {panes_payload}")
focused_pane_id = str(focused_row.get("id") or "")
other_row = next((row for row in panes if str(row.get("id") or "") != focused_pane_id), None)
_must(other_row is not None, f"expected non-focused pane after split: {panes_payload}")
other_pane_id = str(other_row.get("id") or "")
client.focus_pane(other_pane_id)
time.sleep(0.2)
_must(
_focused_pane_id(client, workspace_id) == other_pane_id,
"expected explicit pane focus before pane.swap regression check",
)
client._call("pane.swap", {"pane_id": other_pane_id, "target_pane_id": focused_pane_id})
time.sleep(0.2)
_must(
_focused_pane_id(client, workspace_id) == other_pane_id,
"pane.swap should preserve the currently focused pane when invoked over the socket",
)
_must(
client.current_workspace() == workspace_id,
"pane.swap should not change the selected workspace",
)
broken_payload = client._call("pane.break", {"pane_id": other_pane_id}) or {}
broken_workspace_id = str(broken_payload.get("workspace_id") or "")
_must(bool(broken_workspace_id), f"pane.break returned no workspace_id: {broken_payload}")
created_workspaces.append(broken_workspace_id)
time.sleep(0.2)
_must(
client.current_workspace() == workspace_id,
"pane.break should preserve the selected workspace when invoked over the socket",
)
finally:
with cmux(SOCKET_PATH) as cleanup_client:
for workspace_id in reversed(created_workspaces):
try:
cleanup_client.close_workspace(workspace_id)
except Exception:
pass
print("PASS: pane.swap and pane.break preserve visible focus for socket callers")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Regression: surface.list and list-panels should return custom tab titles."""
from __future__ import annotations
import glob
import json
import os
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_cli_json(cli: str, args: list[str]) -> dict:
proc = subprocess.run(
[cli, "--socket", SOCKET_PATH, "--json", *args],
capture_output=True,
text=True,
check=False,
)
if proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"CLI failed ({' '.join(args)}): {merged}")
try:
return json.loads(proc.stdout or "{}")
except Exception as exc: # noqa: BLE001
raise cmuxError(f"Invalid JSON output: {proc.stdout!r} ({exc})")
def main() -> int:
cli = _find_cli_binary()
workspace_id = ""
try:
with cmux(SOCKET_PATH) as client:
workspace_id = client.new_workspace()
client.select_workspace(workspace_id)
time.sleep(0.2)
current_payload = client._call("surface.current", {"workspace_id": workspace_id}) or {}
surface_id = str(current_payload.get("surface_id") or "")
_must(bool(surface_id), f"surface.current returned no surface_id: {current_payload}")
title = f"renamed-surface-{int(time.time() * 1000)}"
renamed = client._call(
"surface.action",
{"surface_id": surface_id, "action": "rename", "title": title},
) or {}
_must(str(renamed.get("title") or "") == title, f"surface.action rename failed: {renamed}")
listed = client._call("surface.list", {"workspace_id": workspace_id}) or {}
row = next((item for item in listed.get("surfaces") or [] if str(item.get("id") or "") == surface_id), None)
_must(row is not None, f"surface.list missing renamed surface: {listed}")
_must(str(row.get("title") or "") == title, f"surface.list should return custom title {title!r}: {row}")
cli_listed = _run_cli_json(cli, ["list-panels", "--workspace", workspace_id])
cli_row = next((item for item in cli_listed.get("surfaces") or [] if str(item.get("title") or "") == title), None)
_must(cli_row is not None, f"list-panels missing renamed surface: {cli_listed}")
_must(str(cli_row.get("title") or "") == title, f"list-panels should return custom title {title!r}: {cli_row}")
finally:
if workspace_id:
with cmux(SOCKET_PATH) as cleanup_client:
try:
cleanup_client.close_workspace(workspace_id)
except Exception:
pass
print("PASS: surface.list and list-panels return custom surface titles")
return 0
if __name__ == "__main__":
raise SystemExit(main())