Merge remote-tracking branch 'origin/main' into feature/sidebar-pr-metadata

# Conflicts:
#	Sources/ContentView.swift
#	Sources/Workspace.swift
This commit is contained in:
Lawrence Chen 2026-02-24 20:49:29 -08:00
commit f28eb00b31
92 changed files with 22498 additions and 734 deletions

View file

@ -7,6 +7,15 @@ on:
pull_request: pull_request:
jobs: jobs:
workflow-guard-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Validate self-hosted runner guards
run: ./tests/test_ci_self_hosted_guard.sh
web-typecheck: web-typecheck:
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
@ -26,6 +35,8 @@ jobs:
run: bun tsc --noEmit run: bun tsc --noEmit
ui-tests: ui-tests:
# Never run self-hosted jobs for fork pull requests.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: self-hosted runs-on: self-hosted
concurrency: concurrency:
group: self-hosted-build group: self-hosted-build

View file

@ -294,6 +294,19 @@ jobs:
# by appcast URLs to prevent signature/asset mismatch races. # by appcast URLs to prevent signature/asset mismatch races.
cp "$DMG_RELEASE" "$NIGHTLY_DMG_IMMUTABLE" cp "$DMG_RELEASE" "$NIGHTLY_DMG_IMMUTABLE"
- name: Upload dSYMs to Sentry
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: manaflow
SENTRY_PROJECT: cmuxterm-macos
run: |
if [ -z "$SENTRY_AUTH_TOKEN" ]; then
echo "SENTRY_AUTH_TOKEN not set, skipping dSYM upload"
exit 0
fi
brew install getsentry/tools/sentry-cli || true
sentry-cli debug-files upload --include-sources build/Build/Products/Release/
- name: Generate Sparkle appcast (nightly) - name: Generate Sparkle appcast (nightly)
env: env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}

View file

@ -250,6 +250,20 @@ jobs:
xcrun stapler staple "$DMG_RELEASE" xcrun stapler staple "$DMG_RELEASE"
xcrun stapler validate "$DMG_RELEASE" xcrun stapler validate "$DMG_RELEASE"
- name: Upload dSYMs to Sentry
if: steps.guard_release_assets.outputs.skip_all != 'true'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: manaflow
SENTRY_PROJECT: cmuxterm-macos
run: |
if [ -z "$SENTRY_AUTH_TOKEN" ]; then
echo "SENTRY_AUTH_TOKEN not set, skipping dSYM upload"
exit 0
fi
brew install getsentry/tools/sentry-cli || true
sentry-cli debug-files upload --include-sources build/Build/Products/Release/
- name: Generate Sparkle appcast - name: Generate Sparkle appcast
if: steps.guard_release_assets.outputs.skip_all != 'true' if: steps.guard_release_assets.outputs.skip_all != 'true'
env: env:

View file

@ -2007,6 +2007,54 @@ struct CMUXCLI {
} }
} }
func displayBrowserValue(_ value: Any) -> String {
if value is NSNull {
return "null"
}
if let string = value as? String {
return string
}
if let bool = value as? Bool {
return bool ? "true" : "false"
}
if let number = value as? NSNumber {
return number.stringValue
}
if JSONSerialization.isValidJSONObject(value),
let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted]),
let text = String(data: data, encoding: .utf8) {
return text
}
return String(describing: value)
}
func displayBrowserLogItems(_ value: Any?) -> String? {
guard let items = value as? [Any], !items.isEmpty else {
return nil
}
let lines = items.map { item -> String in
guard let dict = item as? [String: Any] else {
return displayBrowserValue(item)
}
let text = (dict["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let levelRaw = (dict["level"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let level = levelRaw.isEmpty ? "log" : levelRaw
if text.isEmpty {
if let message = (dict["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
!message.isEmpty {
return "[error] \(message)"
}
return displayBrowserValue(dict)
}
return "[\(level)] \(text)"
}
return lines.joined(separator: "\n")
}
func nonFlagArgs(_ values: [String]) -> [String] { func nonFlagArgs(_ values: [String]) -> [String] {
values.filter { !$0.hasPrefix("-") } values.filter { !$0.hasPrefix("-") }
} }
@ -2174,7 +2222,13 @@ struct CMUXCLI {
throw CLIError(message: "browser eval requires a script") throw CLIError(message: "browser eval requires a script")
} }
let payload = try client.sendV2(method: "browser.eval", params: ["surface_id": sid, "script": trimmed]) let payload = try client.sendV2(method: "browser.eval", params: ["surface_id": sid, "script": trimmed])
output(payload, fallback: "OK") let fallback: String
if let value = payload["value"] {
fallback = displayBrowserValue(value)
} else {
fallback = "OK"
}
output(payload, fallback: fallback)
return return
} }
@ -2785,7 +2839,8 @@ struct CMUXCLI {
throw CLIError(message: "Unsupported browser console subcommand: \(consoleVerb)") throw CLIError(message: "Unsupported browser console subcommand: \(consoleVerb)")
} }
let payload = try client.sendV2(method: method, params: ["surface_id": sid]) let payload = try client.sendV2(method: method, params: ["surface_id": sid])
output(payload, fallback: "OK") let fallback = displayBrowserLogItems(payload["entries"]) ?? "OK"
output(payload, fallback: fallback)
return return
} }
@ -2799,7 +2854,8 @@ struct CMUXCLI {
throw CLIError(message: "Unsupported browser errors subcommand: \(errorsVerb)") throw CLIError(message: "Unsupported browser errors subcommand: \(errorsVerb)")
} }
let payload = try client.sendV2(method: "browser.errors.list", params: params) let payload = try client.sendV2(method: "browser.errors.list", params: params)
output(payload, fallback: "OK") let fallback = displayBrowserLogItems(payload["errors"]) ?? "OK"
output(payload, fallback: fallback)
return return
} }

View file

@ -22,6 +22,7 @@
A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; }; A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; };
A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; }; A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; };
A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; }; A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; };
A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; };
A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; }; A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; };
A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; }; A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; };
A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; };
@ -54,11 +55,13 @@
A5001208 /* UpdateTitlebarAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001218 /* UpdateTitlebarAccessory.swift */; }; A5001208 /* UpdateTitlebarAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001218 /* UpdateTitlebarAccessory.swift */; };
A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; }; A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; };
A5001240 /* WindowDecorationsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001241 /* WindowDecorationsController.swift */; }; A5001240 /* WindowDecorationsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001241 /* WindowDecorationsController.swift */; };
A5001610 /* SessionPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001611 /* SessionPersistence.swift */; };
A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; };
A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; };
B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; }; B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; };
B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; }; B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; };
C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE00001A1B2C3D4E5F719 /* claude */; }; C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */ = {isa = PBXBuildFile; fileRef = C1ADE00001A1B2C3D4E5F719 /* claude */; };
D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */ = {isa = PBXBuildFile; fileRef = D1BEF00001A1B2C3D4E5F719 /* open */; };
84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; }; 84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; };
A5002000 /* THIRD_PARTY_LICENSES.md in Resources */ = {isa = PBXBuildFile; fileRef = A5002001 /* THIRD_PARTY_LICENSES.md */; }; A5002000 /* THIRD_PARTY_LICENSES.md in Resources */ = {isa = PBXBuildFile; fileRef = A5002001 /* THIRD_PARTY_LICENSES.md */; };
B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; }; B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; };
@ -71,11 +74,14 @@
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; }; D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; };
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; }; D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; };
E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; }; E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; };
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; }; F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; };
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; }; F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; };
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; };
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; }; F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; };
/* End PBXBuildFile section */ F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; };
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; };
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
A5001020 /* Embed Frameworks */ = { A5001020 /* Embed Frameworks */ = {
@ -96,6 +102,7 @@
files = ( files = (
B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */, B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */,
C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */, C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */,
D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */,
); );
name = "Copy CLI"; name = "Copy CLI";
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -144,6 +151,7 @@
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; }; A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; };
A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = "<group>"; }; A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = "<group>"; };
A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; };
A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = "<group>"; };
A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = "<group>"; }; A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = "<group>"; };
A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = "<group>"; }; A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = "<group>"; };
A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = "<group>"; }; A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = "<group>"; };
@ -177,11 +185,13 @@
A5001222 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = "<group>"; }; A5001222 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = "<group>"; };
A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = "<group>"; }; A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = "<group>"; };
A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = "<group>"; }; A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = "<group>"; };
A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = "<group>"; };
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; }; C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; };
A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = "<group>"; }; B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = "<group>"; };
C1ADE00001A1B2C3D4E5F719 /* claude */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/claude"; sourceTree = SOURCE_ROOT; }; C1ADE00001A1B2C3D4E5F719 /* claude */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/claude"; sourceTree = SOURCE_ROOT; };
D1BEF00001A1B2C3D4E5F719 /* open */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/open"; sourceTree = SOURCE_ROOT; };
A5002001 /* THIRD_PARTY_LICENSES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = THIRD_PARTY_LICENSES.md; sourceTree = SOURCE_ROOT; }; A5002001 /* THIRD_PARTY_LICENSES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = THIRD_PARTY_LICENSES.md; sourceTree = SOURCE_ROOT; };
B9000001A1B2C3D4E5F60719 /* cmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux.swift; sourceTree = "<group>"; }; B9000001A1B2C3D4E5F60719 /* cmux.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmux.swift; sourceTree = "<group>"; };
B9000004A1B2C3D4E5F60719 /* cmux */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmux; sourceTree = BUILT_PRODUCTS_DIR; }; B9000004A1B2C3D4E5F60719 /* cmux */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = cmux; sourceTree = BUILT_PRODUCTS_DIR; };
@ -190,14 +200,17 @@
B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsUITests.swift; sourceTree = "<group>"; }; B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiWindowNotificationsUITests.swift; sourceTree = "<group>"; };
B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceConfirmDialogUITests.swift; sourceTree = "<group>"; }; B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceConfirmDialogUITests.swift; sourceTree = "<group>"; };
B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceCmdDUITests.swift; sourceTree = "<group>"; }; B9000022A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWorkspaceCmdDUITests.swift; sourceTree = "<group>"; };
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = "<group>"; }; D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = "<group>"; };
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; }; D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; };
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; }; E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; };
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; }; F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; };
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; }; F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; }; F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; }; F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = "<group>"; };
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; };
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
A5001030 /* Frameworks */ = { A5001030 /* Frameworks */ = {
@ -319,6 +332,7 @@
A5001019 /* TerminalController.swift */, A5001019 /* TerminalController.swift */,
A5001541 /* PortScanner.swift */, A5001541 /* PortScanner.swift */,
A5001225 /* SocketControlSettings.swift */, A5001225 /* SocketControlSettings.swift */,
A5001600 /* SentryHelper.swift */,
A5001090 /* AppDelegate.swift */, A5001090 /* AppDelegate.swift */,
A5001091 /* NotificationsPage.swift */, A5001091 /* NotificationsPage.swift */,
A5001092 /* TerminalNotificationStore.swift */, A5001092 /* TerminalNotificationStore.swift */,
@ -345,6 +359,7 @@
A5001219 /* WindowToolbarController.swift */, A5001219 /* WindowToolbarController.swift */,
A5001241 /* WindowDecorationsController.swift */, A5001241 /* WindowDecorationsController.swift */,
A5001222 /* WindowAccessor.swift */, A5001222 /* WindowAccessor.swift */,
A5001611 /* SessionPersistence.swift */,
); );
path = Sources; path = Sources;
sourceTree = "<group>"; sourceTree = "<group>";
@ -395,17 +410,20 @@
path = cmuxUITests; path = cmuxUITests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
F1000003A1B2C3D4E5F60718 /* cmuxTests */ = { F1000003A1B2C3D4E5F60718 /* cmuxTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */, F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */,
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */, F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */,
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */, F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */, F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
); F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */,
path = cmuxTests; F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */,
sourceTree = "<group>"; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
}; );
path = cmuxTests;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -548,6 +566,7 @@
A5001007 /* TerminalController.swift in Sources */, A5001007 /* TerminalController.swift in Sources */,
A5001540 /* PortScanner.swift in Sources */, A5001540 /* PortScanner.swift in Sources */,
A5001226 /* SocketControlSettings.swift in Sources */, A5001226 /* SocketControlSettings.swift in Sources */,
A5001601 /* SentryHelper.swift in Sources */,
A5001093 /* AppDelegate.swift in Sources */, A5001093 /* AppDelegate.swift in Sources */,
A5001094 /* NotificationsPage.swift in Sources */, A5001094 /* NotificationsPage.swift in Sources */,
A5001095 /* TerminalNotificationStore.swift in Sources */, A5001095 /* TerminalNotificationStore.swift in Sources */,
@ -574,6 +593,7 @@
A5001209 /* WindowToolbarController.swift in Sources */, A5001209 /* WindowToolbarController.swift in Sources */,
A5001240 /* WindowDecorationsController.swift in Sources */, A5001240 /* WindowDecorationsController.swift in Sources */,
A500120C /* WindowAccessor.swift in Sources */, A500120C /* WindowAccessor.swift in Sources */,
A5001610 /* SessionPersistence.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -594,18 +614,21 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
F1000005A1B2C3D4E5F60718 /* Sources */ = { F1000005A1B2C3D4E5F60718 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */, F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */,
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */, F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */,
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */, F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
); F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */,
runOnlyForDeploymentPostprocessing = 0; F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */,
}; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
B9000006A1B2C3D4E5F60719 /* Sources */ = { );
runOnlyForDeploymentPostprocessing = 0;
};
B9000006A1B2C3D4E5F60719 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (

View file

@ -194,6 +194,16 @@ Browser developer-tool shortcuts follow Safari defaults and are customizable in
cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the stable version. Built automatically from the latest `main` commit and auto-updates via its own Sparkle feed. cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the stable version. Built automatically from the latest `main` commit and auto-updates via its own Sparkle feed.
## Star History
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
</picture>
</a>
## Community ## Community
- [Discord](https://discord.gg/xsgFEVrWCZ) - [Discord](https://discord.gg/xsgFEVrWCZ)

283
Resources/bin/open Executable file
View file

@ -0,0 +1,283 @@
#!/usr/bin/env bash
# cmux open wrapper - routes HTTP(S) URLs to cmux's in-app browser
#
# When running inside a cmux terminal (CMUX_SOCKET_PATH is set), this wrapper
# intercepts `open https://...` invocations and opens them in cmux's built-in
# browser within the same workspace. All other arguments pass through to
# /usr/bin/open unchanged.
SYSTEM_OPEN_BIN="${CMUX_OPEN_WRAPPER_SYSTEM_OPEN:-/usr/bin/open}"
DEFAULTS_BIN="${CMUX_OPEN_WRAPPER_DEFAULTS:-/usr/bin/defaults}"
PYTHON3_BIN="${CMUX_OPEN_WRAPPER_PYTHON3:-}"
if [[ ! -x "$SYSTEM_OPEN_BIN" ]]; then
SYSTEM_OPEN_BIN="/usr/bin/open"
fi
if [[ ! -x "$DEFAULTS_BIN" ]]; then
DEFAULTS_BIN="/usr/bin/defaults"
fi
if [[ -n "$PYTHON3_BIN" ]]; then
if [[ ! -x "$PYTHON3_BIN" ]]; then
PYTHON3_BIN=""
fi
elif command -v python3 >/dev/null 2>&1; then
PYTHON3_BIN="$(command -v python3)"
fi
settings_domain="${CMUX_BUNDLE_ID:-}"
whitelist_raw=""
whitelist_patterns=()
system_open() {
exec "$SYSTEM_OPEN_BIN" "$@"
}
trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
to_lower_ascii() {
# Bash 3.2-compatible lowercase conversion.
LC_ALL=C printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
}
normalize_boolean() {
to_lower_ascii "$(trim "$1")"
}
is_false_setting() {
local normalized
normalized="$(normalize_boolean "$1")"
case "$normalized" in
0|false|no|off)
return 0
;;
esac
return 1
}
canonicalize_idn_host() {
local value="$1"
[[ -z "$PYTHON3_BIN" ]] && {
printf '%s' "$value"
return 0
}
local canonicalized
canonicalized="$("$PYTHON3_BIN" - "$value" <<'PY' 2>/dev/null || true
import sys
host = sys.argv[1].strip().rstrip(".")
if not host:
raise SystemExit(1)
labels = host.split(".")
if any(not label for label in labels):
raise SystemExit(1)
try:
canonical = ".".join(label.encode("idna").decode("ascii") for label in labels)
except Exception:
raise SystemExit(1)
sys.stdout.write(canonical.lower())
PY
)"
if [[ -n "$canonicalized" ]]; then
printf '%s' "$canonicalized"
return 0
fi
printf '%s' "$value"
}
is_http_url() {
local value="$1"
case "$value" in
[Hh][Tt][Tt][Pp]://*|[Hh][Tt][Tt][Pp][Ss]://*)
return 0
;;
esac
return 1
}
normalize_host() {
local value
value="$(trim "$1")"
value="$(to_lower_ascii "$value")"
[[ -z "$value" ]] && return 1
if [[ "$value" == *"://"* ]]; then
value="${value#*://}"
fi
value="${value%%/*}"
value="${value%%\?*}"
value="${value%%\#*}"
if [[ "$value" == *"@"* ]]; then
value="${value##*@}"
fi
if [[ "$value" == \[* ]]; then
value="${value#\[}"
value="${value%%\]*}"
elif [[ "$value" == *:* ]]; then
local colons="${value//[^:]}"
if [[ ${#colons} -eq 1 ]] && [[ "$value" =~ :[0-9]+$ ]]; then
value="${value%:*}"
fi
fi
while [[ "$value" == .* ]]; do
value="${value#.}"
done
while [[ "$value" == *. ]]; do
value="${value%.}"
done
[[ -z "$value" ]] && return 1
value="$(canonicalize_idn_host "$value")"
printf '%s' "$value"
}
normalize_whitelist_pattern() {
local value
value="$(trim "$1")"
value="$(to_lower_ascii "$value")"
[[ -z "$value" ]] && return 1
if [[ "$value" == \*.* ]]; then
local suffix
suffix="$(normalize_host "${value#*.}")" || return 1
printf '*.%s' "$suffix"
return 0
fi
normalize_host "$value"
}
host_matches_pattern() {
local host="$1"
local pattern="$2"
if [[ "$pattern" == \*.* ]]; then
local suffix="${pattern#*.}"
[[ "$host" == "$suffix" ]] && return 0
[[ "$host" == *".$suffix" ]] && return 0
return 1
fi
[[ "$host" == "$pattern" ]]
}
host_matches_whitelist() {
local url="$1"
if [[ ${#whitelist_patterns[@]} -eq 0 ]]; then
return 0
fi
local host
host="$(normalize_host "$url")" || return 1
for pattern in "${whitelist_patterns[@]}"; do
if host_matches_pattern "$host" "$pattern"; then
return 0
fi
done
return 1
}
load_whitelist_patterns() {
local raw="$1"
local line
while IFS= read -r line || [[ -n "$line" ]]; do
local normalized
normalized="$(normalize_whitelist_pattern "$line")" || continue
whitelist_patterns+=("$normalized")
done <<< "$raw"
}
# Pass through immediately if not in a cmux terminal.
if [[ -z "$CMUX_SOCKET_PATH" ]]; then
system_open "$@"
fi
# No arguments → pass through.
if [[ $# -eq 0 ]]; then
system_open "$@"
fi
# Scan for flags that indicate explicit user intent → pass through.
# Also collect non-flag arguments (potential URLs/files).
passthrough=false
urls=()
for arg in "$@"; do
case "$arg" in
-a|-b|-R|-e|-t|-f|-W|-g|-n|-h|-s|-j|-u|--env|--stdin|--stdout|--stderr)
passthrough=true
break
;;
-*)
# Unknown flag → be conservative, pass through
passthrough=true
break
;;
*)
if is_http_url "$arg"; then
urls+=("$arg")
else
# Non-URL, non-flag argument (file path, etc.) → pass through all
passthrough=true
break
fi
;;
esac
done
if [[ "$passthrough" == true ]] || [[ ${#urls[@]} -eq 0 ]]; then
system_open "$@"
fi
# Respect the same settings used for terminal link clicks.
if [[ -n "$settings_domain" ]]; then
open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserInterceptTerminalOpenCommandInCmuxBrowser 2>/dev/null || true)"
if [[ -z "$open_in_cmux" ]]; then
# Backward compatibility for installs that predate the dedicated open-wrapper toggle.
open_in_cmux="$("$DEFAULTS_BIN" read "$settings_domain" browserOpenTerminalLinksInCmuxBrowser 2>/dev/null || true)"
fi
if is_false_setting "$open_in_cmux"; then
system_open "$@"
fi
whitelist_raw="$("$DEFAULTS_BIN" read "$settings_domain" browserHostWhitelist 2>/dev/null || true)"
if [[ -n "$whitelist_raw" ]]; then
load_whitelist_patterns "$whitelist_raw"
fi
fi
# Find cmux CLI (same directory as this script).
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
CMUX_CLI="$SELF_DIR/cmux"
if [[ ! -x "$CMUX_CLI" ]]; then
system_open "$@"
fi
# Open each URL in cmux's in-app browser; track failures individually.
failed_urls=()
for url in "${urls[@]}"; do
if ! host_matches_whitelist "$url"; then
failed_urls+=("$url")
continue
fi
"$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url")
done
# Fall back to system open only for URLs that failed.
if [[ ${#failed_urls[@]} -gt 0 ]]; then
system_open "${failed_urls[@]}"
fi

View file

@ -23,6 +23,18 @@ _cmux_send() {
fi fi
} }
_cmux_restore_scrollback_once() {
local path="${CMUX_RESTORE_SCROLLBACK_FILE:-}"
[[ -n "$path" ]] || return 0
unset CMUX_RESTORE_SCROLLBACK_FILE
if [[ -r "$path" ]]; then
/bin/cat -- "$path" 2>/dev/null || true
/bin/rm -f -- "$path" >/dev/null 2>&1 || true
fi
}
_cmux_restore_scrollback_once
# Throttle heavy work to avoid prompt latency. # Throttle heavy work to avoid prompt latency.
_CMUX_PWD_LAST_PWD="${_CMUX_PWD_LAST_PWD:-}" _CMUX_PWD_LAST_PWD="${_CMUX_PWD_LAST_PWD:-}"
_CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}" _CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}"

View file

@ -24,6 +24,18 @@ _cmux_send() {
fi fi
} }
_cmux_restore_scrollback_once() {
local path="${CMUX_RESTORE_SCROLLBACK_FILE:-}"
[[ -n "$path" ]] || return 0
unset CMUX_RESTORE_SCROLLBACK_FILE
if [[ -r "$path" ]]; then
/bin/cat -- "$path" 2>/dev/null || true
/bin/rm -f -- "$path" >/dev/null 2>&1 || true
fi
}
_cmux_restore_scrollback_once
# Throttle heavy work to avoid prompt latency. # Throttle heavy work to avoid prompt latency.
typeset -g _CMUX_PWD_LAST_PWD="" typeset -g _CMUX_PWD_LAST_PWD=""
typeset -g _CMUX_GIT_LAST_PWD="" typeset -g _CMUX_GIT_LAST_PWD=""

File diff suppressed because it is too large Load diff

View file

@ -117,6 +117,9 @@ final class WindowBrowserHostView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? { override func hitTest(_ point: NSPoint) -> NSView? {
updateDividerCursor(at: point) updateDividerCursor(at: point)
if shouldPassThroughToTitlebar(at: point) {
return nil
}
if shouldPassThroughToSidebarResizer(at: point) { if shouldPassThroughToSidebarResizer(at: point) {
return nil return nil
} }
@ -127,6 +130,18 @@ final class WindowBrowserHostView: NSView {
return hitView === self ? nil : hitView return hitView === self ? nil : hitView
} }
private func shouldPassThroughToTitlebar(at point: NSPoint) -> Bool {
guard let window else { return false }
// Window-level portal hosts sit above SwiftUI content. Never intercept
// hits that land in native titlebar space or the custom titlebar strip
// we reserve directly under it for window drag/double-click behaviors.
let windowPoint = convert(point, to: nil)
let nativeTitlebarHeight = window.frame.height - window.contentLayoutRect.height
let customTitlebarBandHeight = max(28, min(72, nativeTitlebarHeight))
let interactionBandMinY = window.contentLayoutRect.maxY - customTitlebarBandHeight - 0.5
return windowPoint.y >= interactionBandMinY
}
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool { private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
// Browser portal host sits above SwiftUI content. Allow pointer/mouse events // Browser portal host sits above SwiftUI content. Allow pointer/mouse events
// to reach the SwiftUI sidebar divider resizer zone. // to reach the SwiftUI sidebar divider resizer zone.
@ -326,6 +341,8 @@ final class WindowBrowserPortal: NSObject {
private weak var installedContainerView: NSView? private weak var installedContainerView: NSView?
private weak var installedReferenceView: NSView? private weak var installedReferenceView: NSView?
private var hasDeferredFullSyncScheduled = false private var hasDeferredFullSyncScheduled = false
private var hasExternalGeometrySyncScheduled = false
private var geometryObservers: [NSObjectProtocol] = []
private struct Entry { private struct Entry {
weak var webView: WKWebView? weak var webView: WKWebView?
@ -345,9 +362,73 @@ final class WindowBrowserPortal: NSObject {
hostView.layer?.masksToBounds = true hostView.layer?.masksToBounds = true
hostView.translatesAutoresizingMaskIntoConstraints = true hostView.translatesAutoresizingMaskIntoConstraints = true
hostView.autoresizingMask = [] hostView.autoresizingMask = []
installGeometryObservers(for: window)
_ = ensureInstalled() _ = ensureInstalled()
} }
private func installGeometryObservers(for window: NSWindow) {
guard geometryObservers.isEmpty else { return }
let center = NotificationCenter.default
geometryObservers.append(center.addObserver(
forName: NSWindow.didResizeNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSWindow.didEndLiveResizeNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSSplitView.didResizeSubviewsNotification,
object: nil,
queue: .main
) { [weak self] notification in
MainActor.assumeIsolated {
guard let self,
let splitView = notification.object as? NSSplitView,
let window = self.window,
splitView.window === window else { return }
self.scheduleExternalGeometrySynchronize()
}
})
}
private func removeGeometryObservers() {
for observer in geometryObservers {
NotificationCenter.default.removeObserver(observer)
}
geometryObservers.removeAll()
}
private func scheduleExternalGeometrySynchronize() {
guard !hasExternalGeometrySyncScheduled else { return }
hasExternalGeometrySyncScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.hasExternalGeometrySyncScheduled = false
self.synchronizeAllEntriesFromExternalGeometryChange()
}
}
private func synchronizeAllEntriesFromExternalGeometryChange() {
guard ensureInstalled() else { return }
installedContainerView?.layoutSubtreeIfNeeded()
installedReferenceView?.layoutSubtreeIfNeeded()
hostView.superview?.layoutSubtreeIfNeeded()
hostView.layoutSubtreeIfNeeded()
synchronizeAllWebViews(excluding: nil, source: "externalGeometry")
}
@discardableResult @discardableResult
private func ensureInstalled() -> Bool { private func ensureInstalled() -> Bool {
guard let window else { return false } guard let window else { return false }
@ -419,13 +500,32 @@ final class WindowBrowserPortal: NSObject {
return false return false
} }
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool { private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= epsilon && abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
abs(lhs.origin.y - rhs.origin.y) <= epsilon && abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
abs(lhs.size.width - rhs.size.width) <= epsilon && abs(lhs.size.width - rhs.size.width) <= epsilon &&
abs(lhs.size.height - rhs.size.height) <= epsilon abs(lhs.size.height - rhs.size.height) <= epsilon
} }
private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect {
guard rect.origin.x.isFinite,
rect.origin.y.isFinite,
rect.size.width.isFinite,
rect.size.height.isFinite else {
return rect
}
let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0)
func snap(_ value: CGFloat) -> CGFloat {
(value * scale).rounded(.toNearestOrAwayFromZero) / scale
}
return NSRect(
x: snap(rect.origin.x),
y: snap(rect.origin.y),
width: max(0, snap(rect.size.width)),
height: max(0, snap(rect.size.height))
)
}
private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool { private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool {
frame.minX < bounds.minX - epsilon || frame.minX < bounds.minX - epsilon ||
frame.minY < bounds.minY - epsilon || frame.minY < bounds.minY - epsilon ||
@ -765,7 +865,8 @@ final class WindowBrowserPortal: NSObject {
_ = synchronizeHostFrameToReference() _ = synchronizeHostFrameToReference()
let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) let frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
let frameInHost = hostView.convert(frameInWindow, from: nil) let frameInHostRaw = hostView.convert(frameInWindow, from: nil)
let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView)
let hostBounds = hostView.bounds let hostBounds = hostView.bounds
let hasFiniteHostBounds = let hasFiniteHostBounds =
hostBounds.origin.x.isFinite && hostBounds.origin.x.isFinite &&
@ -838,6 +939,8 @@ final class WindowBrowserPortal: NSObject {
CATransaction.setDisableActions(true) CATransaction.setDisableActions(true)
containerView.frame = targetFrame containerView.frame = targetFrame
CATransaction.commit() CATransaction.commit()
webView.needsLayout = true
webView.layoutSubtreeIfNeeded()
} }
let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size) let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size)
@ -952,6 +1055,7 @@ final class WindowBrowserPortal: NSObject {
} }
func tearDown() { func tearDown() {
removeGeometryObservers()
for webViewId in Array(entriesByWebViewId.keys) { for webViewId in Array(entriesByWebViewId.keys) {
detachWebView(withId: webViewId) detachWebView(withId: webViewId)
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@ enum KeyboardShortcutSettings {
case toggleSidebar case toggleSidebar
case newTab case newTab
case newWindow case newWindow
case closeWindow
case showNotifications case showNotifications
case jumpToUnread case jumpToUnread
case triggerFlash case triggerFlash
@ -17,6 +18,7 @@ enum KeyboardShortcutSettings {
case prevSurface case prevSurface
case nextSidebarTab case nextSidebarTab
case prevSidebarTab case prevSidebarTab
case renameTab
case renameWorkspace case renameWorkspace
case closeWorkspace case closeWorkspace
case newSurface case newSurface
@ -43,6 +45,7 @@ enum KeyboardShortcutSettings {
case .toggleSidebar: return "Toggle Sidebar" case .toggleSidebar: return "Toggle Sidebar"
case .newTab: return "New Workspace" case .newTab: return "New Workspace"
case .newWindow: return "New Window" case .newWindow: return "New Window"
case .closeWindow: return "Close Window"
case .showNotifications: return "Show Notifications" case .showNotifications: return "Show Notifications"
case .jumpToUnread: return "Jump to Latest Unread" case .jumpToUnread: return "Jump to Latest Unread"
case .triggerFlash: return "Flash Focused Panel" case .triggerFlash: return "Flash Focused Panel"
@ -50,6 +53,7 @@ enum KeyboardShortcutSettings {
case .prevSurface: return "Previous Surface" case .prevSurface: return "Previous Surface"
case .nextSidebarTab: return "Next Workspace" case .nextSidebarTab: return "Next Workspace"
case .prevSidebarTab: return "Previous Workspace" case .prevSidebarTab: return "Previous Workspace"
case .renameTab: return "Rename Tab"
case .renameWorkspace: return "Rename Workspace" case .renameWorkspace: return "Rename Workspace"
case .closeWorkspace: return "Close Workspace" case .closeWorkspace: return "Close Workspace"
case .newSurface: return "New Surface" case .newSurface: return "New Surface"
@ -72,11 +76,13 @@ enum KeyboardShortcutSettings {
case .toggleSidebar: return "shortcut.toggleSidebar" case .toggleSidebar: return "shortcut.toggleSidebar"
case .newTab: return "shortcut.newTab" case .newTab: return "shortcut.newTab"
case .newWindow: return "shortcut.newWindow" case .newWindow: return "shortcut.newWindow"
case .closeWindow: return "shortcut.closeWindow"
case .showNotifications: return "shortcut.showNotifications" case .showNotifications: return "shortcut.showNotifications"
case .jumpToUnread: return "shortcut.jumpToUnread" case .jumpToUnread: return "shortcut.jumpToUnread"
case .triggerFlash: return "shortcut.triggerFlash" case .triggerFlash: return "shortcut.triggerFlash"
case .nextSidebarTab: return "shortcut.nextSidebarTab" case .nextSidebarTab: return "shortcut.nextSidebarTab"
case .prevSidebarTab: return "shortcut.prevSidebarTab" case .prevSidebarTab: return "shortcut.prevSidebarTab"
case .renameTab: return "shortcut.renameTab"
case .renameWorkspace: return "shortcut.renameWorkspace" case .renameWorkspace: return "shortcut.renameWorkspace"
case .closeWorkspace: return "shortcut.closeWorkspace" case .closeWorkspace: return "shortcut.closeWorkspace"
case .focusLeft: return "shortcut.focusLeft" case .focusLeft: return "shortcut.focusLeft"
@ -104,6 +110,8 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false) return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false)
case .newWindow: case .newWindow:
return StoredShortcut(key: "n", command: true, shift: true, option: false, control: false) return StoredShortcut(key: "n", command: true, shift: true, option: false, control: false)
case .closeWindow:
return StoredShortcut(key: "w", command: true, shift: false, option: false, control: true)
case .showNotifications: case .showNotifications:
return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false) return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false)
case .jumpToUnread: case .jumpToUnread:
@ -114,6 +122,8 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "]", command: true, shift: false, option: false, control: true) return StoredShortcut(key: "]", command: true, shift: false, option: false, control: true)
case .prevSidebarTab: case .prevSidebarTab:
return StoredShortcut(key: "[", command: true, shift: false, option: false, control: true) return StoredShortcut(key: "[", command: true, shift: false, option: false, control: true)
case .renameTab:
return StoredShortcut(key: "r", command: true, shift: false, option: false, control: false)
case .renameWorkspace: case .renameWorkspace:
return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false) return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false)
case .closeWorkspace: case .closeWorkspace:

View file

@ -182,11 +182,11 @@ private struct NotificationRow: View {
Button(action: onOpen) { Button(action: onOpen) {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
Circle() Circle()
.fill(notification.isRead ? Color.clear : Color.accentColor) .fill(notification.isRead ? Color.clear : cmuxAccentColor())
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
.overlay( .overlay(
Circle() Circle()
.stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) .stroke(cmuxAccentColor().opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
) )
.padding(.top, 6) .padding(.top, 6)

View file

@ -127,6 +127,9 @@ enum BrowserLinkOpenSettings {
static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser" static let openTerminalLinksInCmuxBrowserKey = "browserOpenTerminalLinksInCmuxBrowser"
static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true static let defaultOpenTerminalLinksInCmuxBrowser: Bool = true
static let interceptTerminalOpenCommandInCmuxBrowserKey = "browserInterceptTerminalOpenCommandInCmuxBrowser"
static let defaultInterceptTerminalOpenCommandInCmuxBrowser: Bool = true
static let browserHostWhitelistKey = "browserHostWhitelist" static let browserHostWhitelistKey = "browserHostWhitelist"
static let defaultBrowserHostWhitelist: String = "" static let defaultBrowserHostWhitelist: String = ""
@ -137,6 +140,23 @@ enum BrowserLinkOpenSettings {
return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey) return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey)
} }
static func interceptTerminalOpenCommandInCmuxBrowser(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: interceptTerminalOpenCommandInCmuxBrowserKey) != nil {
return defaults.bool(forKey: interceptTerminalOpenCommandInCmuxBrowserKey)
}
// Migrate existing behavior for users who only had the link-click toggle.
if defaults.object(forKey: openTerminalLinksInCmuxBrowserKey) != nil {
return defaults.bool(forKey: openTerminalLinksInCmuxBrowserKey)
}
return defaultInterceptTerminalOpenCommandInCmuxBrowser
}
static func initialInterceptTerminalOpenCommandInCmuxBrowserValue(defaults: UserDefaults = .standard) -> Bool {
interceptTerminalOpenCommandInCmuxBrowser(defaults: defaults)
}
static func hostWhitelist(defaults: UserDefaults = .standard) -> [String] { static func hostWhitelist(defaults: UserDefaults = .standard) -> [String] {
let raw = defaults.string(forKey: browserHostWhitelistKey) ?? defaultBrowserHostWhitelist let raw = defaults.string(forKey: browserHostWhitelistKey) ?? defaultBrowserHostWhitelist
return raw return raw
@ -360,6 +380,21 @@ func browserPreparedNavigationRequest(_ request: URLRequest) -> URLRequest {
return preparedRequest return preparedRequest
} }
private let browserEmbeddedNavigationSchemes: Set<String> = [
"about",
"applewebdata",
"blob",
"data",
"http",
"https",
"javascript",
]
func browserShouldOpenURLExternally(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), !scheme.isEmpty else { return false }
return !browserEmbeddedNavigationSchemes.contains(scheme)
}
enum BrowserUserAgentSettings { enum BrowserUserAgentSettings {
// Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens, // Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens,
// and some installs may have legacy Chrome UA overrides. Both can cause Google to serve // and some installs may have legacy Chrome UA overrides. Both can cause Google to serve
@ -1151,6 +1186,13 @@ final class BrowserPanel: Panel, ObservableObject {
/// Published can go forward state /// Published can go forward state
@Published private(set) var canGoForward: Bool = false @Published private(set) var canGoForward: Bool = false
private var nativeCanGoBack: Bool = false
private var nativeCanGoForward: Bool = false
private var usesRestoredSessionHistory: Bool = false
private var restoredBackHistoryStack: [URL] = []
private var restoredForwardHistoryStack: [URL] = []
private var restoredHistoryCurrentURL: URL?
/// Published estimated progress (0.0 - 1.0) /// Published estimated progress (0.0 - 1.0)
@Published private(set) var estimatedProgress: Double = 0.0 @Published private(set) var estimatedProgress: Double = 0.0
@ -1353,6 +1395,43 @@ final class BrowserPanel: Panel, ObservableObject {
focusFlashToken &+= 1 focusFlashToken &+= 1
} }
func sessionNavigationHistorySnapshot() -> (
backHistoryURLStrings: [String],
forwardHistoryURLStrings: [String]
) {
if usesRestoredSessionHistory {
let back = restoredBackHistoryStack.compactMap { Self.serializableSessionHistoryURLString($0) }
// `restoredForwardHistoryStack` stores nearest-forward entries at the end.
let forward = restoredForwardHistoryStack.reversed().compactMap { Self.serializableSessionHistoryURLString($0) }
return (back, forward)
}
let back = webView.backForwardList.backList.compactMap {
Self.serializableSessionHistoryURLString($0.url)
}
let forward = webView.backForwardList.forwardList.compactMap {
Self.serializableSessionHistoryURLString($0.url)
}
return (back, forward)
}
func restoreSessionNavigationHistory(
backHistoryURLStrings: [String],
forwardHistoryURLStrings: [String],
currentURLString: String?
) {
let restoredBack = Self.sanitizedSessionHistoryURLs(backHistoryURLStrings)
let restoredForward = Self.sanitizedSessionHistoryURLs(forwardHistoryURLStrings)
guard !restoredBack.isEmpty || !restoredForward.isEmpty else { return }
usesRestoredSessionHistory = true
restoredBackHistoryStack = restoredBack
// Store nearest-forward entries at the end to make stack pop operations trivial.
restoredForwardHistoryStack = Array(restoredForward.reversed())
restoredHistoryCurrentURL = Self.sanitizedSessionHistoryURL(currentURLString)
refreshNavigationAvailability()
}
private func setupObservers() { private func setupObservers() {
// URL changes // URL changes
let urlObserver = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in let urlObserver = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in
@ -1386,7 +1465,9 @@ final class BrowserPanel: Panel, ObservableObject {
// Can go back // Can go back
let backObserver = webView.observe(\.canGoBack, options: [.new]) { [weak self] webView, _ in let backObserver = webView.observe(\.canGoBack, options: [.new]) { [weak self] webView, _ in
Task { @MainActor in Task { @MainActor in
self?.canGoBack = webView.canGoBack guard let self else { return }
self.nativeCanGoBack = webView.canGoBack
self.refreshNavigationAvailability()
} }
} }
webViewObservers.append(backObserver) webViewObservers.append(backObserver)
@ -1394,7 +1475,9 @@ final class BrowserPanel: Panel, ObservableObject {
// Can go forward // Can go forward
let forwardObserver = webView.observe(\.canGoForward, options: [.new]) { [weak self] webView, _ in let forwardObserver = webView.observe(\.canGoForward, options: [.new]) { [weak self] webView, _ in
Task { @MainActor in Task { @MainActor in
self?.canGoForward = webView.canGoForward guard let self else { return }
self.nativeCanGoForward = webView.canGoForward
self.refreshNavigationAvailability()
} }
} }
webViewObservers.append(forwardObserver) webViewObservers.append(forwardObserver)
@ -1612,6 +1695,9 @@ final class BrowserPanel: Panel, ObservableObject {
faviconTask?.cancel() faviconTask?.cancel()
faviconTask = nil faviconTask = nil
lastFaviconURLString = nil lastFaviconURLString = nil
// Clear the previous page's favicon so it never persists across navigations.
// The loading spinner covers this gap; didFinish will fetch the new favicon.
faviconPNGData = nil
loadingGeneration &+= 1 loadingGeneration &+= 1
loadingEndWorkItem?.cancel() loadingEndWorkItem?.cancel()
loadingEndWorkItem = nil loadingEndWorkItem = nil
@ -1657,13 +1743,28 @@ final class BrowserPanel: Panel, ObservableObject {
navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation) navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation)
} }
private func navigateWithoutInsecureHTTPPrompt(to url: URL, recordTypedNavigation: Bool) { private func navigateWithoutInsecureHTTPPrompt(
to url: URL,
recordTypedNavigation: Bool,
preserveRestoredSessionHistory: Bool = false
) {
let request = URLRequest(url: url) let request = URLRequest(url: url)
navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation) navigateWithoutInsecureHTTPPrompt(
request: request,
recordTypedNavigation: recordTypedNavigation,
preserveRestoredSessionHistory: preserveRestoredSessionHistory
)
} }
private func navigateWithoutInsecureHTTPPrompt(request: URLRequest, recordTypedNavigation: Bool) { private func navigateWithoutInsecureHTTPPrompt(
request: URLRequest,
recordTypedNavigation: Bool,
preserveRestoredSessionHistory: Bool = false
) {
guard let url = request.url else { return } guard let url = request.url else { return }
if !preserveRestoredSessionHistory {
abandonRestoredSessionHistoryIfNeeded()
}
// Some installs can end up with a legacy Chrome UA override; keep this pinned. // Some installs can end up with a legacy Chrome UA override; keep this pinned.
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
shouldRenderWebView = true shouldRenderWebView = true
@ -1808,26 +1909,90 @@ extension BrowserPanel {
/// Go back in history /// Go back in history
func goBack() { func goBack() {
guard canGoBack else { return } guard canGoBack else { return }
if usesRestoredSessionHistory {
guard let targetURL = restoredBackHistoryStack.popLast() else {
refreshNavigationAvailability()
return
}
if let current = resolvedCurrentSessionHistoryURL() {
restoredForwardHistoryStack.append(current)
}
restoredHistoryCurrentURL = targetURL
refreshNavigationAvailability()
navigateWithoutInsecureHTTPPrompt(
to: targetURL,
recordTypedNavigation: false,
preserveRestoredSessionHistory: true
)
return
}
webView.goBack() webView.goBack()
} }
/// Go forward in history /// Go forward in history
func goForward() { func goForward() {
guard canGoForward else { return } guard canGoForward else { return }
if usesRestoredSessionHistory {
guard let targetURL = restoredForwardHistoryStack.popLast() else {
refreshNavigationAvailability()
return
}
if let current = resolvedCurrentSessionHistoryURL() {
restoredBackHistoryStack.append(current)
}
restoredHistoryCurrentURL = targetURL
refreshNavigationAvailability()
navigateWithoutInsecureHTTPPrompt(
to: targetURL,
recordTypedNavigation: false,
preserveRestoredSessionHistory: true
)
return
}
webView.goForward() webView.goForward()
} }
/// Open a link in a new browser surface in the same pane /// Open a link in a new browser surface in the same pane
func openLinkInNewTab(url: URL, bypassInsecureHTTPHostOnce: String? = nil) { func openLinkInNewTab(url: URL, bypassInsecureHTTPHostOnce: String? = nil) {
guard let tabManager = AppDelegate.shared?.tabManager, #if DEBUG
let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }), dlog(
let paneId = workspace.paneId(forPanelId: id) else { return } "browser.newTab.open.begin panel=\(id.uuidString.prefix(5)) " +
"workspace=\(workspaceId.uuidString.prefix(5)) url=\(url.absoluteString) " +
"bypass=\(bypassInsecureHTTPHostOnce ?? "nil")"
)
#endif
guard let tabManager = AppDelegate.shared?.tabManager else {
#if DEBUG
dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=missingTabManager")
#endif
return
}
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else {
#if DEBUG
dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=workspaceMissing")
#endif
return
}
guard let paneId = workspace.paneId(forPanelId: id) else {
#if DEBUG
dlog("browser.newTab.open.abort panel=\(id.uuidString.prefix(5)) reason=paneMissing")
#endif
return
}
workspace.newBrowserSurface( workspace.newBrowserSurface(
inPane: paneId, inPane: paneId,
url: url, url: url,
focus: true, focus: true,
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce
) )
#if DEBUG
dlog(
"browser.newTab.open.done panel=\(id.uuidString.prefix(5)) " +
"workspace=\(workspace.id.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5))"
)
#endif
} }
/// Reload the current page /// Reload the current page
@ -2097,10 +2262,20 @@ extension BrowserPanel {
} }
func beginSuppressWebViewFocusForAddressBar() { func beginSuppressWebViewFocusForAddressBar() {
if !suppressWebViewFocusForAddressBar {
#if DEBUG
dlog("browser.focus.addressBarSuppress.begin panel=\(id.uuidString.prefix(5))")
#endif
}
suppressWebViewFocusForAddressBar = true suppressWebViewFocusForAddressBar = true
} }
func endSuppressWebViewFocusForAddressBar() { func endSuppressWebViewFocusForAddressBar() {
if suppressWebViewFocusForAddressBar {
#if DEBUG
dlog("browser.focus.addressBarSuppress.end panel=\(id.uuidString.prefix(5))")
#endif
}
suppressWebViewFocusForAddressBar = false suppressWebViewFocusForAddressBar = false
} }
@ -2140,6 +2315,64 @@ extension BrowserPanel {
return nil return nil
} }
private func resolvedCurrentSessionHistoryURL() -> URL? {
if let webViewURL = webView.url,
Self.serializableSessionHistoryURLString(webViewURL) != nil {
return webViewURL
}
if let currentURL,
Self.serializableSessionHistoryURLString(currentURL) != nil {
return currentURL
}
return restoredHistoryCurrentURL
}
private func refreshNavigationAvailability() {
let resolvedCanGoBack: Bool
let resolvedCanGoForward: Bool
if usesRestoredSessionHistory {
resolvedCanGoBack = !restoredBackHistoryStack.isEmpty
resolvedCanGoForward = !restoredForwardHistoryStack.isEmpty
} else {
resolvedCanGoBack = nativeCanGoBack
resolvedCanGoForward = nativeCanGoForward
}
if canGoBack != resolvedCanGoBack {
canGoBack = resolvedCanGoBack
}
if canGoForward != resolvedCanGoForward {
canGoForward = resolvedCanGoForward
}
}
private func abandonRestoredSessionHistoryIfNeeded() {
guard usesRestoredSessionHistory else { return }
usesRestoredSessionHistory = false
restoredBackHistoryStack.removeAll(keepingCapacity: false)
restoredForwardHistoryStack.removeAll(keepingCapacity: false)
restoredHistoryCurrentURL = nil
refreshNavigationAvailability()
}
private static func serializableSessionHistoryURLString(_ url: URL?) -> String? {
guard let url else { return nil }
let value = url.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty, value != "about:blank" else { return nil }
return value
}
private static func sanitizedSessionHistoryURL(_ raw: String?) -> URL? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed != "about:blank" else { return nil }
return URL(string: trimmed)
}
private static func sanitizedSessionHistoryURLs(_ values: [String]) -> [URL] {
values.compactMap { sanitizedSessionHistoryURL($0) }
}
} }
private extension BrowserPanel { private extension BrowserPanel {
@ -2459,6 +2692,39 @@ private class BrowserDownloadDelegate: NSObject, WKDownloadDelegate {
// MARK: - Navigation Delegate // MARK: - Navigation Delegate
func browserNavigationShouldOpenInNewTab(
navigationType: WKNavigationType,
modifierFlags: NSEvent.ModifierFlags,
buttonNumber: Int,
hasRecentMiddleClickIntent: Bool = false,
currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type,
currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber
) -> Bool {
guard navigationType == .linkActivated || navigationType == .other else {
return false
}
if modifierFlags.contains(.command) {
return true
}
if buttonNumber == 2 {
return true
}
// In some WebKit paths, middle-click arrives as buttonNumber=4.
// Recover intent when we just observed a local middle-click.
if buttonNumber == 4, hasRecentMiddleClickIntent {
return true
}
// WebKit can omit buttonNumber for middle-click link activations.
if let currentEventType,
(currentEventType == .otherMouseDown || currentEventType == .otherMouseUp),
currentEventButtonNumber == 2 {
return true
}
return false
}
private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
var didFinish: ((WKWebView) -> Void)? var didFinish: ((WKWebView) -> Void)?
var didFailNavigation: ((WKWebView, String) -> Void)? var didFailNavigation: ((WKWebView, String) -> Void)?
@ -2481,6 +2747,10 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
NSLog("BrowserPanel navigation failed: %@", error.localizedDescription) NSLog("BrowserPanel navigation failed: %@", error.localizedDescription)
// Treat committed-navigation failures the same as provisional ones so
// stale favicon/title state from the prior page gets cleared.
let failedURL = webView.url?.absoluteString ?? ""
didFailNavigation?(webView, failedURL)
} }
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
@ -2593,38 +2863,89 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
decidePolicyFor navigationAction: WKNavigationAction, decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) { ) {
let hasRecentMiddleClickIntent = CmuxWebView.hasRecentMiddleClickIntent(for: webView)
let shouldOpenInNewTab = browserNavigationShouldOpenInNewTab(
navigationType: navigationAction.navigationType,
modifierFlags: navigationAction.modifierFlags,
buttonNumber: navigationAction.buttonNumber,
hasRecentMiddleClickIntent: hasRecentMiddleClickIntent
)
#if DEBUG
let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil"
let navType = String(describing: navigationAction.navigationType)
dlog(
"browser.nav.decidePolicy navType=\(navType) button=\(navigationAction.buttonNumber) " +
"mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " +
"eventType=\(currentEventType) eventButton=\(currentEventButton) " +
"recentMiddleIntent=\(hasRecentMiddleClickIntent ? 1 : 0) " +
"openInNewTab=\(shouldOpenInNewTab ? 1 : 0)"
)
#endif
if let url = navigationAction.request.url, if let url = navigationAction.request.url,
navigationAction.targetFrame?.isMainFrame != false, navigationAction.targetFrame?.isMainFrame != false,
shouldBlockInsecureHTTPNavigation?(url) == true { shouldBlockInsecureHTTPNavigation?(url) == true {
let intent: BrowserInsecureHTTPNavigationIntent let intent: BrowserInsecureHTTPNavigationIntent
if navigationAction.navigationType == .linkActivated, if shouldOpenInNewTab {
navigationAction.modifierFlags.contains(.command) {
intent = .newTab intent = .newTab
} else { } else {
intent = .currentTab intent = .currentTab
} }
#if DEBUG
dlog(
"browser.nav.decidePolicy.action kind=blockedInsecure intent=\(intent == .newTab ? "newTab" : "currentTab") " +
"url=\(url.absoluteString)"
)
#endif
handleBlockedInsecureHTTPNavigation?(navigationAction.request, intent) handleBlockedInsecureHTTPNavigation?(navigationAction.request, intent)
decisionHandler(.cancel) decisionHandler(.cancel)
return return
} }
// target=_blank or window.open() navigate in the current webview // WebKit cannot open app-specific deeplinks (discord://, slack://, zoommtg://, etc.).
if navigationAction.targetFrame == nil, // Hand these off to macOS so the owning app can handle them.
navigationAction.request.url != nil { if let url = navigationAction.request.url,
webView.load(navigationAction.request) navigationAction.targetFrame?.isMainFrame != false,
browserShouldOpenURLExternally(url) {
let opened = NSWorkspace.shared.open(url)
if !opened {
NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString)
}
#if DEBUG
dlog("browser.navigation.external source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
#endif
decisionHandler(.cancel) decisionHandler(.cancel)
return return
} }
// Cmd+click on a regular link open in a new tab // Cmd+click and middle-click on regular links should always open in a new tab.
if navigationAction.navigationType == .linkActivated, if shouldOpenInNewTab,
navigationAction.modifierFlags.contains(.command),
let url = navigationAction.request.url { let url = navigationAction.request.url {
#if DEBUG
dlog("browser.nav.decidePolicy.action kind=openInNewTab url=\(url.absoluteString)")
#endif
openInNewTab?(url) openInNewTab?(url)
decisionHandler(.cancel) decisionHandler(.cancel)
return return
} }
// target=_blank or window.open() without explicit new-tab intent navigate in-place.
if navigationAction.targetFrame == nil,
navigationAction.request.url != nil {
#if DEBUG
let targetURL = navigationAction.request.url?.absoluteString ?? "nil"
dlog("browser.nav.decidePolicy.action kind=loadInPlaceFromNilTarget url=\(targetURL)")
#endif
webView.load(navigationAction.request)
decisionHandler(.cancel)
return
}
#if DEBUG
let targetURL = navigationAction.request.url?.absoluteString ?? "nil"
dlog("browser.nav.decidePolicy.action kind=allow url=\(targetURL)")
#endif
decisionHandler(.allow) decisionHandler(.allow)
} }
@ -2723,21 +3044,62 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate {
} }
/// Returning nil tells WebKit not to open a new window. /// Returning nil tells WebKit not to open a new window.
/// Cmd+click opens in a new tab; regular target=_blank navigates in-place. /// Cmd+click and middle-click open in a new tab; regular target=_blank navigates in-place.
func webView( func webView(
_ webView: WKWebView, _ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration, createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction, for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures windowFeatures: WKWindowFeatures
) -> WKWebView? { ) -> WKWebView? {
let hasRecentMiddleClickIntent = CmuxWebView.hasRecentMiddleClickIntent(for: webView)
let shouldOpenInNewTab = browserNavigationShouldOpenInNewTab(
navigationType: navigationAction.navigationType,
modifierFlags: navigationAction.modifierFlags,
buttonNumber: navigationAction.buttonNumber,
hasRecentMiddleClickIntent: hasRecentMiddleClickIntent
)
#if DEBUG
let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil"
let navType = String(describing: navigationAction.navigationType)
dlog(
"browser.nav.createWebView navType=\(navType) button=\(navigationAction.buttonNumber) " +
"mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " +
"eventType=\(currentEventType) eventButton=\(currentEventButton) " +
"recentMiddleIntent=\(hasRecentMiddleClickIntent ? 1 : 0) " +
"openInNewTab=\(shouldOpenInNewTab ? 1 : 0)"
)
#endif
if let url = navigationAction.request.url { if let url = navigationAction.request.url {
if browserShouldOpenURLExternally(url) {
let opened = NSWorkspace.shared.open(url)
if !opened {
NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString)
}
#if DEBUG
dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
#endif
return nil
}
if let requestNavigation { if let requestNavigation {
let intent: BrowserInsecureHTTPNavigationIntent = let intent: BrowserInsecureHTTPNavigationIntent =
navigationAction.modifierFlags.contains(.command) ? .newTab : .currentTab shouldOpenInNewTab ? .newTab : .currentTab
#if DEBUG
dlog(
"browser.nav.createWebView.action kind=requestNavigation intent=\(intent == .newTab ? "newTab" : "currentTab") " +
"url=\(url.absoluteString)"
)
#endif
requestNavigation(navigationAction.request, intent) requestNavigation(navigationAction.request, intent)
} else if navigationAction.modifierFlags.contains(.command) { } else if shouldOpenInNewTab {
#if DEBUG
dlog("browser.nav.createWebView.action kind=openInNewTab url=\(url.absoluteString)")
#endif
openInNewTab?(url) openInNewTab?(url)
} else { } else {
#if DEBUG
dlog("browser.nav.createWebView.action kind=loadInPlace url=\(url.absoluteString)")
#endif
webView.load(navigationAction.request) webView.load(navigationAction.request)
} }
} }

View file

@ -71,7 +71,7 @@ enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable {
// Matches Bonsplit tab icon tint for active tabs. // Matches Bonsplit tab icon tint for active tabs.
return Color(nsColor: .labelColor) return Color(nsColor: .labelColor)
case .accent: case .accent:
return .accentColor return cmuxAccentColor()
case .tertiary: case .tertiary:
return Color(nsColor: .tertiaryLabelColor) return Color(nsColor: .tertiaryLabelColor)
} }
@ -163,6 +163,46 @@ private extension View {
} }
} }
func resolvedBrowserChromeBackgroundColor(
for colorScheme: ColorScheme,
themeBackgroundColor: NSColor
) -> NSColor {
switch colorScheme {
case .dark, .light:
return themeBackgroundColor
@unknown default:
return themeBackgroundColor
}
}
func resolvedBrowserChromeColorScheme(
for colorScheme: ColorScheme,
themeBackgroundColor: NSColor
) -> ColorScheme {
let backgroundColor = resolvedBrowserChromeBackgroundColor(
for: colorScheme,
themeBackgroundColor: themeBackgroundColor
)
return backgroundColor.isLightColor ? .light : .dark
}
func resolvedBrowserOmnibarPillBackgroundColor(
for colorScheme: ColorScheme,
themeBackgroundColor: NSColor
) -> NSColor {
let darkenMix: CGFloat
switch colorScheme {
case .light:
darkenMix = 0.04
case .dark:
darkenMix = 0.05
@unknown default:
darkenMix = 0.04
}
return themeBackgroundColor.blended(withFraction: darkenMix, of: .black) ?? themeBackgroundColor
}
/// View for rendering a browser panel with address bar /// View for rendering a browser panel with address bar
struct BrowserPanelView: View { struct BrowserPanelView: View {
@ObservedObject var panel: BrowserPanel @ObservedObject var panel: BrowserPanel
@ -187,7 +227,7 @@ struct BrowserPanelView: View {
@State private var omnibarHasMarkedText: Bool = false @State private var omnibarHasMarkedText: Bool = false
@State private var suppressNextFocusLostRevert: Bool = false @State private var suppressNextFocusLostRevert: Bool = false
@State private var focusFlashOpacity: Double = 0.0 @State private var focusFlashOpacity: Double = 0.0
@State private var focusFlashFadeWorkItem: DispatchWorkItem? @State private var focusFlashAnimationGeneration: Int = 0
@State private var omnibarPillFrame: CGRect = .zero @State private var omnibarPillFrame: CGRect = .zero
@State private var lastHandledAddressBarFocusRequestId: UUID? @State private var lastHandledAddressBarFocusRequestId: UUID?
@State private var isBrowserThemeMenuPresented = false @State private var isBrowserThemeMenuPresented = false
@ -236,14 +276,24 @@ struct BrowserPanelView: View {
} }
private var browserChromeBackgroundColor: NSColor { private var browserChromeBackgroundColor: NSColor {
switch colorScheme { resolvedBrowserChromeBackgroundColor(
case .dark: for: colorScheme,
return GhosttyApp.shared.defaultBackgroundColor themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor
case .light: )
return .windowBackgroundColor }
@unknown default:
return .windowBackgroundColor private var browserChromeColorScheme: ColorScheme {
} resolvedBrowserChromeColorScheme(
for: colorScheme,
themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor
)
}
private var omnibarPillBackgroundColor: NSColor {
resolvedBrowserOmnibarPillBackgroundColor(
for: browserChromeColorScheme,
themeBackgroundColor: browserChromeBackgroundColor
)
} }
var body: some View { var body: some View {
@ -252,10 +302,10 @@ struct BrowserPanelView: View {
webView webView
} }
.overlay { .overlay {
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius)
.stroke(Color.accentColor.opacity(focusFlashOpacity), lineWidth: 3) .stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3)
.shadow(color: Color.accentColor.opacity(focusFlashOpacity * 0.35), radius: 10) .shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10)
.padding(6) .padding(FocusFlashPattern.ringInset)
.allowsHitTesting(false) .allowsHitTesting(false)
} }
.overlay(alignment: .topLeading) { .overlay(alignment: .topLeading) {
@ -275,8 +325,9 @@ struct BrowserPanelView: View {
} }
) )
.frame(width: omnibarPillFrame.width) .frame(width: omnibarPillFrame.width)
.offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 6) .offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 3)
.zIndex(1000) .zIndex(1000)
.environment(\.colorScheme, browserChromeColorScheme)
} }
} }
.coordinateSpace(name: "BrowserPanelViewSpace") .coordinateSpace(name: "BrowserPanelViewSpace")
@ -288,16 +339,15 @@ struct BrowserPanelView: View {
guard let webView = note.object as? CmuxWebView else { return false } guard let webView = note.object as? CmuxWebView else { return false }
return webView === panel?.webView return webView === panel?.webView
}) { _ in }) { _ in
#if DEBUG
dlog(
"browser.focus.clickIntent panel=\(panel.id.uuidString.prefix(5)) " +
"isFocused=\(isFocused ? 1 : 0) " +
"addressFocused=\(addressBarFocused ? 1 : 0)"
)
#endif
onRequestPanelFocus() onRequestPanelFocus()
} }
.onReceive(NotificationCenter.default.publisher(for: .webViewMiddleClickedLink).filter { [weak panel] note in
guard let webView = note.object as? CmuxWebView else { return false }
return webView === panel?.webView
}) { note in
if let url = note.userInfo?["url"] as? URL {
panel.openLinkInNewTab(url: url)
}
}
.onAppear { .onAppear {
UserDefaults.standard.register(defaults: [ UserDefaults.standard.register(defaults: [
BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue, BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue,
@ -314,6 +364,7 @@ struct BrowserPanelView: View {
syncURLFromPanel() syncURLFromPanel()
// If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar. // If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar.
autoFocusOmnibarIfBlank() autoFocusOmnibarIfBlank()
syncWebViewResponderPolicyWithViewState(reason: "onAppear")
BrowserHistoryStore.shared.loadIfNeeded() BrowserHistoryStore.shared.loadIfNeeded()
} }
.onChange(of: panel.focusFlashToken) { _ in .onChange(of: panel.focusFlashToken) { _ in
@ -353,6 +404,7 @@ struct BrowserPanelView: View {
hideSuggestions() hideSuggestions()
addressBarFocused = false addressBarFocused = false
} }
syncWebViewResponderPolicyWithViewState(reason: "panelFocusChanged")
} }
.onChange(of: addressBarFocused) { focused in .onChange(of: addressBarFocused) { focused in
let urlString = panel.preferredURLStringForOmnibar() ?? "" let urlString = panel.preferredURLStringForOmnibar() ?? ""
@ -380,6 +432,7 @@ struct BrowserPanelView: View {
} }
inlineCompletion = nil inlineCompletion = nil
} }
syncWebViewResponderPolicyWithViewState(reason: "addressBarFocusChanged")
} }
.onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in .onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in
guard let panelId = notification.object as? UUID, panelId == panel.id else { return } guard let panelId = notification.object as? UUID, panelId == panel.id else { return }
@ -421,6 +474,7 @@ struct BrowserPanelView: View {
.background(Color(nsColor: browserChromeBackgroundColor)) .background(Color(nsColor: browserChromeBackgroundColor))
// Keep the omnibar stack above WKWebView so the suggestions popup is visible. // Keep the omnibar stack above WKWebView so the suggestions popup is visible.
.zIndex(1) .zIndex(1)
.environment(\.colorScheme, browserChromeColorScheme)
} }
private var addressBarButtonBar: some View { private var addressBarButtonBar: some View {
@ -635,11 +689,11 @@ struct BrowserPanelView: View {
.padding(.vertical, 4) .padding(.vertical, 4)
.background( .background(
RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous) RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)
.fill(Color(nsColor: .textBackgroundColor)) .fill(Color(nsColor: omnibarPillBackgroundColor))
) )
.overlay( .overlay(
RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous) RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)
.stroke(addressBarFocused ? Color.accentColor : Color.clear, lineWidth: 1) .stroke(addressBarFocused ? cmuxAccentColor() : Color.clear, lineWidth: 1)
) )
.accessibilityElement(children: .contain) .accessibilityElement(children: .contain)
.background { .background {
@ -689,20 +743,42 @@ struct BrowserPanelView: View {
} }
private func triggerFocusFlashAnimation() { private func triggerFocusFlashAnimation() {
focusFlashFadeWorkItem?.cancel() focusFlashAnimationGeneration &+= 1
focusFlashFadeWorkItem = nil let generation = focusFlashAnimationGeneration
focusFlashOpacity = FocusFlashPattern.values.first ?? 0
withAnimation(.easeOut(duration: 0.08)) { for segment in FocusFlashPattern.segments {
focusFlashOpacity = 1.0 DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) {
} guard focusFlashAnimationGeneration == generation else { return }
withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) {
let item = DispatchWorkItem { focusFlashOpacity = segment.targetOpacity
withAnimation(.easeOut(duration: 0.35)) { }
focusFlashOpacity = 0.0
} }
} }
focusFlashFadeWorkItem = item }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: item)
private func focusFlashAnimation(for curve: FocusFlashCurve, duration: TimeInterval) -> Animation {
switch curve {
case .easeIn:
return .easeIn(duration: duration)
case .easeOut:
return .easeOut(duration: duration)
}
}
private func syncWebViewResponderPolicyWithViewState(reason: String) {
guard let cmuxWebView = panel.webView as? CmuxWebView else { return }
let next = isFocused && !panel.shouldSuppressWebViewFocus()
if cmuxWebView.allowsFirstResponderAcquisition != next {
#if DEBUG
dlog(
"browser.focus.policy.resync panel=\(panel.id.uuidString.prefix(5)) " +
"web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " +
"new=\(next ? 1 : 0) reason=\(reason)"
)
#endif
}
cmuxWebView.allowsFirstResponderAcquisition = next
} }
private func syncURLFromPanel() { private func syncURLFromPanel() {
@ -711,8 +787,32 @@ struct BrowserPanelView: View {
applyOmnibarEffects(effects) applyOmnibarEffects(effects)
} }
private func isCommandPaletteVisibleForPanelWindow() -> Bool {
guard let app = AppDelegate.shared else { return false }
if let window = panel.webView.window, app.isCommandPaletteVisible(for: window) {
return true
}
if let manager = app.tabManagerFor(tabId: panel.workspaceId),
let windowId = app.windowId(for: manager),
let window = app.mainWindow(for: windowId),
app.isCommandPaletteVisible(for: window) {
return true
}
if let keyWindow = NSApp.keyWindow, app.isCommandPaletteVisible(for: keyWindow) {
return true
}
if let mainWindow = NSApp.mainWindow, app.isCommandPaletteVisible(for: mainWindow) {
return true
}
return false
}
private func applyPendingAddressBarFocusRequestIfNeeded() { private func applyPendingAddressBarFocusRequestIfNeeded() {
guard let requestId = panel.pendingAddressBarFocusRequestId else { return } guard let requestId = panel.pendingAddressBarFocusRequestId else { return }
guard !isCommandPaletteVisibleForPanelWindow() else { return }
guard lastHandledAddressBarFocusRequestId != requestId else { return } guard lastHandledAddressBarFocusRequestId != requestId else { return }
lastHandledAddressBarFocusRequestId = requestId lastHandledAddressBarFocusRequestId = requestId
panel.beginSuppressWebViewFocusForAddressBar() panel.beginSuppressWebViewFocusForAddressBar()
@ -740,6 +840,7 @@ struct BrowserPanelView: View {
private func autoFocusOmnibarIfBlank() { private func autoFocusOmnibarIfBlank() {
guard isFocused else { return } guard isFocused else { return }
guard !addressBarFocused else { return } guard !addressBarFocused else { return }
guard !isCommandPaletteVisibleForPanelWindow() else { return }
// If a test/automation explicitly focused WebKit, don't steal focus back. // If a test/automation explicitly focused WebKit, don't steal focus back.
guard !panel.shouldSuppressOmnibarAutofocus() else { return } guard !panel.shouldSuppressOmnibarAutofocus() else { return }
// If a real navigation is underway (e.g. open_browser https://...), don't steal focus. // If a real navigation is underway (e.g. open_browser https://...), don't steal focus.
@ -2114,6 +2215,13 @@ struct OmnibarSuggestion: Identifiable, Hashable {
} }
} }
func browserOmnibarShouldReacquireFocusAfterEndEditing(
suppressWebViewFocus: Bool,
nextResponderIsOtherTextField: Bool
) -> Bool {
suppressWebViewFocus && !nextResponderIsOtherTextField
}
private final class OmnibarNativeTextField: NSTextField { private final class OmnibarNativeTextField: NSTextField {
var onPointerDown: (() -> Void)? var onPointerDown: (() -> Void)?
var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)? var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)?
@ -2226,6 +2334,29 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
} }
} }
private func nextResponderIsOtherTextField(window: NSWindow?) -> Bool {
guard let window, let field = parentField else { return false }
let responder = window.firstResponder
if let editor = responder as? NSTextView,
let delegateField = editor.delegate as? NSTextField {
return delegateField !== field
}
if let textField = responder as? NSTextField {
return textField !== field
}
return false
}
private func shouldReacquireFocusAfterEndEditing(window: NSWindow?) -> Bool {
return browserOmnibarShouldReacquireFocusAfterEndEditing(
suppressWebViewFocus: parent.shouldSuppressWebViewFocus(),
nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window)
)
}
func controlTextDidBeginEditing(_ obj: Notification) { func controlTextDidBeginEditing(_ obj: Notification) {
if !parent.isFocused { if !parent.isFocused {
DispatchQueue.main.async { DispatchQueue.main.async {
@ -2238,15 +2369,18 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
func controlTextDidEndEditing(_ obj: Notification) { func controlTextDidEndEditing(_ obj: Notification) {
if parent.isFocused { if parent.isFocused {
if parent.shouldSuppressWebViewFocus() { if shouldReacquireFocusAfterEndEditing(window: parentField?.window) {
guard pendingFocusRequest != true else { return } guard pendingFocusRequest != true else { return }
pendingFocusRequest = true pendingFocusRequest = true
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
self.pendingFocusRequest = nil self.pendingFocusRequest = nil
guard self.parent.isFocused else { return } guard self.parent.isFocused else { return }
guard self.parent.shouldSuppressWebViewFocus() else { return }
guard let field = self.parentField, let window = field.window else { return } guard let field = self.parentField, let window = field.window else { return }
guard self.shouldReacquireFocusAfterEndEditing(window: window) else {
self.parent.onFieldLostFocus()
return
}
// Check both the field itself AND its field editor (which becomes // Check both the field itself AND its field editor (which becomes
// the actual first responder when the text field is being edited). // the actual first responder when the text field is being edited).
let fr = window.firstResponder let fr = window.firstResponder
@ -2559,11 +2693,12 @@ private struct OmnibarSuggestionsView: View {
let searchSuggestionsEnabled: Bool let searchSuggestionsEnabled: Bool
let onCommit: (OmnibarSuggestion) -> Void let onCommit: (OmnibarSuggestion) -> Void
let onHighlight: (Int) -> Void let onHighlight: (Int) -> Void
@Environment(\.colorScheme) private var colorScheme
// Keep radii below the smallest rendered heights so corners don't get // Keep radii below half of the smallest rendered heights so this keeps a
// auto-clamped and visually change as popup height changes. // squircle silhouette instead of auto-clamping into a capsule.
private let popupCornerRadius: CGFloat = 16 private let popupCornerRadius: CGFloat = 12
private let rowHighlightCornerRadius: CGFloat = 12 private let rowHighlightCornerRadius: CGFloat = 9
private let singleLineRowHeight: CGFloat = 24 private let singleLineRowHeight: CGFloat = 24
private let rowSpacing: CGFloat = 1 private let rowSpacing: CGFloat = 1
private let topInset: CGFloat = 3 private let topInset: CGFloat = 3
@ -2616,6 +2751,101 @@ private struct OmnibarSuggestionsView: View {
contentHeight > maxPopupHeight contentHeight > maxPopupHeight
} }
private var listTextColor: Color {
switch colorScheme {
case .light:
return Color(nsColor: .labelColor)
case .dark:
return Color.white.opacity(0.9)
@unknown default:
return Color(nsColor: .labelColor)
}
}
private var badgeTextColor: Color {
switch colorScheme {
case .light:
return Color(nsColor: .secondaryLabelColor)
case .dark:
return Color.white.opacity(0.72)
@unknown default:
return Color(nsColor: .secondaryLabelColor)
}
}
private var badgeBackgroundColor: Color {
switch colorScheme {
case .light:
return Color.black.opacity(0.06)
case .dark:
return Color.white.opacity(0.08)
@unknown default:
return Color.black.opacity(0.06)
}
}
private var rowHighlightColor: Color {
switch colorScheme {
case .light:
return Color.black.opacity(0.07)
case .dark:
return Color.white.opacity(0.12)
@unknown default:
return Color.black.opacity(0.07)
}
}
private var popupOverlayGradientColors: [Color] {
switch colorScheme {
case .light:
return [
Color.white.opacity(0.55),
Color.white.opacity(0.2),
]
case .dark:
return [
Color.black.opacity(0.26),
Color.black.opacity(0.14),
]
@unknown default:
return [
Color.white.opacity(0.55),
Color.white.opacity(0.2),
]
}
}
private var popupBorderGradientColors: [Color] {
switch colorScheme {
case .light:
return [
Color.white.opacity(0.65),
Color.black.opacity(0.12),
]
case .dark:
return [
Color.white.opacity(0.22),
Color.white.opacity(0.06),
]
@unknown default:
return [
Color.white.opacity(0.65),
Color.black.opacity(0.12),
]
}
}
private var popupShadowColor: Color {
switch colorScheme {
case .light:
return Color.black.opacity(0.18)
case .dark:
return Color.black.opacity(0.45)
@unknown default:
return Color.black.opacity(0.18)
}
}
@ViewBuilder @ViewBuilder
private var rowsView: some View { private var rowsView: some View {
VStack(spacing: rowSpacing) { VStack(spacing: rowSpacing) {
@ -2629,18 +2859,18 @@ private struct OmnibarSuggestionsView: View {
HStack(spacing: 6) { HStack(spacing: 6) {
Text(item.listText) Text(item.listText)
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundStyle(Color.white.opacity(0.9)) .foregroundStyle(listTextColor)
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
if let badge = item.trailingBadgeText { if let badge = item.trailingBadgeText {
Text(badge) Text(badge)
.font(.system(size: 9.5, weight: .medium)) .font(.system(size: 9.5, weight: .medium))
.foregroundStyle(Color.white.opacity(0.72)) .foregroundStyle(badgeTextColor)
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 2) .padding(.vertical, 2)
.background( .background(
RoundedRectangle(cornerRadius: 7, style: .continuous) RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(Color.white.opacity(0.08)) .fill(badgeBackgroundColor)
) )
} }
Spacer(minLength: 0) Spacer(minLength: 0)
@ -2656,7 +2886,7 @@ private struct OmnibarSuggestionsView: View {
RoundedRectangle(cornerRadius: rowHighlightCornerRadius, style: .continuous) RoundedRectangle(cornerRadius: rowHighlightCornerRadius, style: .continuous)
.fill( .fill(
idx == selectedIndex idx == selectedIndex
? Color.white.opacity(0.12) ? rowHighlightColor
: Color.clear : Color.clear
) )
) )
@ -2711,10 +2941,7 @@ private struct OmnibarSuggestionsView: View {
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous) RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
.fill( .fill(
LinearGradient( LinearGradient(
colors: [ colors: popupOverlayGradientColors,
Color.black.opacity(0.26),
Color.black.opacity(0.14),
],
startPoint: .top, startPoint: .top,
endPoint: .bottom endPoint: .bottom
) )
@ -2725,18 +2952,16 @@ private struct OmnibarSuggestionsView: View {
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous) RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
.stroke( .stroke(
LinearGradient( LinearGradient(
colors: [ colors: popupBorderGradientColors,
Color.white.opacity(0.22),
Color.white.opacity(0.06),
],
startPoint: .top, startPoint: .top,
endPoint: .bottom endPoint: .bottom
), ),
lineWidth: 1 lineWidth: 1
) )
) )
.shadow(color: Color.black.opacity(0.45), radius: 20, y: 10) .clipShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous))
.contentShape(Rectangle()) .shadow(color: popupShadowColor, radius: 20, y: 10)
.contentShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous))
.accessibilityElement(children: .contain) .accessibilityElement(children: .contain)
.accessibilityRespondsToUserInteraction(true) .accessibilityRespondsToUserInteraction(true)
.accessibilityIdentifier("BrowserOmnibarSuggestions") .accessibilityIdentifier("BrowserOmnibarSuggestions")
@ -3035,6 +3260,7 @@ struct WebViewRepresentable: NSViewRepresentable {
coordinator: Coordinator, coordinator: Coordinator,
generation: Int generation: Int
) { ) {
let retryInterval: TimeInterval = 1.0 / 60.0
// Don't schedule multiple overlapping retries. // Don't schedule multiple overlapping retries.
guard coordinator.attachRetryWorkItem == nil else { return } guard coordinator.attachRetryWorkItem == nil else { return }
@ -3067,7 +3293,7 @@ struct WebViewRepresentable: NSViewRepresentable {
// Be generous here: bonsplit structural updates can keep a representable // Be generous here: bonsplit structural updates can keep a representable
// container off-window longer than a few seconds under load. // container off-window longer than a few seconds under load.
if coordinator.attachRetryCount < 400 { if coordinator.attachRetryCount < 400 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval) {
scheduleAttachRetry( scheduleAttachRetry(
webView, webView,
panel: panel, panel: panel,
@ -3104,13 +3330,18 @@ struct WebViewRepresentable: NSViewRepresentable {
} }
coordinator.attachRetryWorkItem = work coordinator.attachRetryWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work) DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval, execute: work)
} }
func updateNSView(_ nsView: NSView, context: Context) { func updateNSView(_ nsView: NSView, context: Context) {
let webView = panel.webView let webView = panel.webView
context.coordinator.panel = panel context.coordinator.panel = panel
context.coordinator.webView = webView context.coordinator.webView = webView
Self.applyWebViewFirstResponderPolicy(
panel: panel,
webView: webView,
isPanelFocused: isPanelFocused
)
let shouldUseWindowPortal = panel.shouldPreserveWebViewAttachmentDuringTransientHide() let shouldUseWindowPortal = panel.shouldPreserveWebViewAttachmentDuringTransientHide()
if shouldUseWindowPortal { if shouldUseWindowPortal {
@ -3358,6 +3589,26 @@ struct WebViewRepresentable: NSViewRepresentable {
} }
} }
private static func applyWebViewFirstResponderPolicy(
panel: BrowserPanel,
webView: WKWebView,
isPanelFocused: Bool
) {
guard let cmuxWebView = webView as? CmuxWebView else { return }
let next = isPanelFocused && !panel.shouldSuppressWebViewFocus()
if cmuxWebView.allowsFirstResponderAcquisition != next {
#if DEBUG
dlog(
"browser.focus.policy panel=\(panel.id.uuidString.prefix(5)) " +
"web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " +
"new=\(next ? 1 : 0) isPanelFocused=\(isPanelFocused ? 1 : 0) " +
"suppress=\(panel.shouldSuppressWebViewFocus() ? 1 : 0)"
)
#endif
}
cmuxWebView.allowsFirstResponderAcquisition = next
}
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
coordinator.attachRetryWorkItem?.cancel() coordinator.attachRetryWorkItem?.cancel()
coordinator.attachRetryWorkItem = nil coordinator.attachRetryWorkItem = nil

View file

@ -1,4 +1,5 @@
import AppKit import AppKit
import Bonsplit
import ObjectiveC import ObjectiveC
import WebKit import WebKit
@ -7,6 +8,37 @@ import WebKit
/// key equivalents first so app-level shortcuts continue to work when WebKit is /// key equivalents first so app-level shortcuts continue to work when WebKit is
/// the first responder. /// the first responder.
final class CmuxWebView: WKWebView { final class CmuxWebView: WKWebView {
// Some sites/WebKit paths report middle-click link activations as
// WKNavigationAction.buttonNumber=4 instead of 2. Track a recent local
// middle-click so navigation delegates can recover intent reliably.
private struct MiddleClickIntent {
let webViewID: ObjectIdentifier
let uptime: TimeInterval
}
private static var lastMiddleClickIntent: MiddleClickIntent?
private static let middleClickIntentMaxAge: TimeInterval = 0.8
static func hasRecentMiddleClickIntent(for webView: WKWebView) -> Bool {
guard let webView = webView as? CmuxWebView else { return false }
guard let intent = lastMiddleClickIntent else { return false }
let age = ProcessInfo.processInfo.systemUptime - intent.uptime
if age > middleClickIntentMaxAge {
lastMiddleClickIntent = nil
return false
}
return intent.webViewID == ObjectIdentifier(webView)
}
private static func recordMiddleClickIntent(for webView: CmuxWebView) {
lastMiddleClickIntent = MiddleClickIntent(
webViewID: ObjectIdentifier(webView),
uptime: ProcessInfo.processInfo.systemUptime
)
}
private final class ContextMenuFallbackBox: NSObject { private final class ContextMenuFallbackBox: NSObject {
weak var target: AnyObject? weak var target: AnyObject?
let action: Selector? let action: Selector?
@ -22,15 +54,78 @@ final class CmuxWebView: WKWebView {
var onContextMenuDownloadStateChanged: ((Bool) -> Void)? var onContextMenuDownloadStateChanged: ((Bool) -> Void)?
var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)? var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)?
var contextMenuDefaultBrowserOpener: ((URL) -> Bool)? var contextMenuDefaultBrowserOpener: ((URL) -> Bool)?
/// Guard against background panes stealing first responder (e.g. page autofocus).
/// BrowserPanelView updates this as pane focus state changes.
var allowsFirstResponderAcquisition: Bool = true
private var pointerFocusAllowanceDepth: Int = 0
var allowsFirstResponderAcquisitionEffective: Bool {
allowsFirstResponderAcquisition || pointerFocusAllowanceDepth > 0
}
var debugPointerFocusAllowanceDepth: Int { pointerFocusAllowanceDepth }
override func becomeFirstResponder() -> Bool {
guard allowsFirstResponderAcquisitionEffective else {
#if DEBUG
let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
dlog(
"browser.focus.blockedBecome web=\(ObjectIdentifier(self)) " +
"policy=\(allowsFirstResponderAcquisition ? 1 : 0) " +
"pointerDepth=\(pointerFocusAllowanceDepth) eventType=\(eventType)"
)
#endif
return false
}
let result = super.becomeFirstResponder()
if result {
NotificationCenter.default.post(name: .browserDidBecomeFirstResponderWebView, object: self)
}
#if DEBUG
let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
dlog(
"browser.focus.become web=\(ObjectIdentifier(self)) result=\(result ? 1 : 0) " +
"policy=\(allowsFirstResponderAcquisition ? 1 : 0) " +
"pointerDepth=\(pointerFocusAllowanceDepth) eventType=\(eventType)"
)
#endif
return result
}
/// Temporarily permits focus acquisition for explicit pointer-driven interactions
/// (mouse click into this webview) while keeping background autofocus blocked.
func withPointerFocusAllowance(_ body: () -> Void) {
pointerFocusAllowanceDepth += 1
#if DEBUG
dlog(
"browser.focus.pointerAllowance.enter web=\(ObjectIdentifier(self)) " +
"depth=\(pointerFocusAllowanceDepth)"
)
#endif
defer {
pointerFocusAllowanceDepth = max(0, pointerFocusAllowanceDepth - 1)
#if DEBUG
dlog(
"browser.focus.pointerAllowance.exit web=\(ObjectIdentifier(self)) " +
"depth=\(pointerFocusAllowanceDepth)"
)
#endif
}
body()
}
override func performKeyEquivalent(with event: NSEvent) -> Bool { override func performKeyEquivalent(with event: NSEvent) -> Bool {
// Preserve Cmd+Return/Enter for web content (e.g. editors/forms). Do not if event.keyCode == 36 || event.keyCode == 76 {
// route it through app/menu key equivalents, which can trigger unintended actions. // Always bypass app/menu key-equivalent routing for Return/Enter so WebKit
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) // receives the keyDown path used by form submission handlers.
if flags.contains(.command), event.keyCode == 36 || event.keyCode == 76 {
return false return false
} }
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
// Menu/app shortcut routing is only needed for Command equivalents
// (New Tab, Close Tab, tab switching, split commands, etc).
guard flags.contains(.command) else {
return super.performKeyEquivalent(with: event)
}
// Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc). // Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc).
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
return true return true
@ -63,20 +158,48 @@ final class CmuxWebView: WKWebView {
// NSView (WKWebView), not to sibling SwiftUI overlays. Notify the panel system so // NSView (WKWebView), not to sibling SwiftUI overlays. Notify the panel system so
// bonsplit focus tracks which pane the user clicked in. // bonsplit focus tracks which pane the user clicked in.
override func mouseDown(with event: NSEvent) { override func mouseDown(with event: NSEvent) {
#if DEBUG
let windowNumber = window?.windowNumber ?? -1
let firstResponderType = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
dlog(
"browser.focus.mouseDown web=\(ObjectIdentifier(self)) " +
"policy=\(allowsFirstResponderAcquisition ? 1 : 0) " +
"pointerDepth=\(pointerFocusAllowanceDepth) win=\(windowNumber) fr=\(firstResponderType)"
)
#endif
NotificationCenter.default.post(name: .webViewDidReceiveClick, object: self) NotificationCenter.default.post(name: .webViewDidReceiveClick, object: self)
super.mouseDown(with: event) withPointerFocusAllowance {
super.mouseDown(with: event)
}
} }
// MARK: - Mouse back/forward buttons & middle-click // MARK: - Mouse back/forward buttons
override func otherMouseDown(with event: NSEvent) { override func otherMouseDown(with event: NSEvent) {
if event.buttonNumber == 2 {
Self.recordMiddleClickIntent(for: self)
}
#if DEBUG
let point = convert(event.locationInWindow, from: nil)
let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue
dlog(
"browser.mouse.otherDown web=\(ObjectIdentifier(self)) button=\(event.buttonNumber) " +
"clicks=\(event.clickCount) mods=\(mods) point=(\(Int(point.x)),\(Int(point.y)))"
)
#endif
// Button 3 = back, button 4 = forward (multi-button mice like Logitech). // Button 3 = back, button 4 = forward (multi-button mice like Logitech).
// Consume the event so WebKit doesn't handle it. // Consume the event so WebKit doesn't handle it.
switch event.buttonNumber { switch event.buttonNumber {
case 3: case 3:
#if DEBUG
dlog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goBack canGoBack=\(canGoBack ? 1 : 0)")
#endif
goBack() goBack()
return return
case 4: case 4:
#if DEBUG
dlog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goForward canGoForward=\(canGoForward ? 1 : 0)")
#endif
goForward() goForward()
return return
default: default:
@ -86,25 +209,23 @@ final class CmuxWebView: WKWebView {
} }
override func otherMouseUp(with event: NSEvent) { override func otherMouseUp(with event: NSEvent) {
// Middle-click (button 2) on a link opens it in a new tab.
if event.buttonNumber == 2 { if event.buttonNumber == 2 {
let point = convert(event.locationInWindow, from: nil) Self.recordMiddleClickIntent(for: self)
findLinkAtPoint(point) { [weak self] url in
guard let self, let url else { return }
NotificationCenter.default.post(
name: .webViewMiddleClickedLink,
object: self,
userInfo: ["url": url]
)
}
return
} }
#if DEBUG
let point = convert(event.locationInWindow, from: nil)
let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue
dlog(
"browser.mouse.otherUp web=\(ObjectIdentifier(self)) button=\(event.buttonNumber) " +
"clicks=\(event.clickCount) mods=\(mods) point=(\(Int(point.x)),\(Int(point.y)))"
)
#endif
super.otherMouseUp(with: event) super.otherMouseUp(with: event)
} }
/// Use JavaScript to find the nearest anchor element at the given view-local point. /// Finds the nearest anchor element at a given view-local point.
/// Used as a context-menu download fallback.
private func findLinkAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) { private func findLinkAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) {
// WKWebView's coordinate system is flipped (origin top-left for web content).
let flippedY = bounds.height - point.y let flippedY = bounds.height - point.y
let js = """ let js = """
(() => { (() => {

View file

@ -7,6 +7,41 @@ public enum PanelType: String, Codable, Sendable {
case browser case browser
} }
enum FocusFlashCurve: Equatable {
case easeIn
case easeOut
}
struct FocusFlashSegment: Equatable {
let delay: TimeInterval
let duration: TimeInterval
let targetOpacity: Double
let curve: FocusFlashCurve
}
enum FocusFlashPattern {
static let values: [Double] = [0, 1, 0, 1, 0]
static let keyTimes: [Double] = [0, 0.25, 0.5, 0.75, 1]
static let duration: TimeInterval = 0.9
static let curves: [FocusFlashCurve] = [.easeOut, .easeIn, .easeOut, .easeIn]
static let ringInset: Double = 6
static let ringCornerRadius: Double = 10
static var segments: [FocusFlashSegment] {
let stepCount = min(curves.count, values.count - 1, keyTimes.count - 1)
return (0..<stepCount).map { index in
let startTime = keyTimes[index]
let endTime = keyTimes[index + 1]
return FocusFlashSegment(
delay: startTime * duration,
duration: (endTime - startTime) * duration,
targetOpacity: values[index + 1],
curve: curves[index]
)
}
}
}
/// Protocol for all panel types (terminal, browser, etc.) /// Protocol for all panel types (terminal, browser, etc.)
@MainActor @MainActor
public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUID { public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUID {
@ -33,6 +68,9 @@ public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUI
/// Unfocus the panel /// Unfocus the panel
func unfocus() func unfocus()
/// Trigger a focus flash animation for this panel.
func triggerFlash()
} }
/// Extension providing default implementations /// Extension providing default implementations

View file

@ -83,13 +83,15 @@ final class TerminalPanel: Panel, ObservableObject {
context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT, context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: ghostty_surface_config_s? = nil, configTemplate: ghostty_surface_config_s? = nil,
workingDirectory: String? = nil, workingDirectory: String? = nil,
additionalEnvironment: [String: String] = [:],
portOrdinal: Int = 0 portOrdinal: Int = 0
) { ) {
let surface = TerminalSurface( let surface = TerminalSurface(
tabId: workspaceId, tabId: workspaceId,
context: context, context: context,
configTemplate: configTemplate, configTemplate: configTemplate,
workingDirectory: workingDirectory workingDirectory: workingDirectory,
additionalEnvironment: additionalEnvironment
) )
surface.portOrdinal = portOrdinal surface.portOrdinal = portOrdinal
self.init(workspaceId: workspaceId, surface: surface) self.init(workspaceId: workspaceId, surface: surface)

View file

@ -0,0 +1,9 @@
import Sentry
/// Add a Sentry breadcrumb for user-action context in hang/crash reports.
func sentryBreadcrumb(_ message: String, category: String = "ui", data: [String: Any]? = nil) {
let crumb = Breadcrumb(level: .info, category: category)
crumb.message = message
crumb.data = data
SentrySDK.addBreadcrumb(crumb)
}

View file

@ -0,0 +1,474 @@
import CoreGraphics
import Foundation
import Bonsplit
enum SessionSnapshotSchema {
static let currentVersion = 1
}
enum SessionPersistencePolicy {
static let defaultSidebarWidth: Double = 200
static let minimumSidebarWidth: Double = 186
static let maximumSidebarWidth: Double = 600
static let minimumWindowWidth: Double = 300
static let minimumWindowHeight: Double = 200
static let autosaveInterval: TimeInterval = 8.0
static let maxWindowsPerSnapshot: Int = 12
static let maxWorkspacesPerWindow: Int = 128
static let maxPanelsPerWorkspace: Int = 512
static let maxScrollbackLinesPerTerminal: Int = 4000
static let maxScrollbackCharactersPerTerminal: Int = 400_000
static func sanitizedSidebarWidth(_ candidate: Double?) -> Double {
let fallback = defaultSidebarWidth
guard let candidate, candidate.isFinite else { return fallback }
return min(max(candidate, minimumSidebarWidth), maximumSidebarWidth)
}
static func truncatedScrollback(_ text: String?) -> String? {
guard let text, !text.isEmpty else { return nil }
if text.count <= maxScrollbackCharactersPerTerminal {
return text
}
let initialStart = text.index(text.endIndex, offsetBy: -maxScrollbackCharactersPerTerminal)
let safeStart = ansiSafeTruncationStart(in: text, initialStart: initialStart)
return String(text[safeStart...])
}
/// If truncation starts in the middle of an ANSI CSI escape sequence, advance
/// to the first printable character after that sequence to avoid replaying
/// malformed control bytes.
private static func ansiSafeTruncationStart(in text: String, initialStart: String.Index) -> String.Index {
guard initialStart > text.startIndex else { return initialStart }
let escape = "\u{001B}"
guard let lastEscape = text[..<initialStart].lastIndex(of: Character(escape)) else {
return initialStart
}
let csiMarker = text.index(after: lastEscape)
guard csiMarker < text.endIndex, text[csiMarker] == "[" else {
return initialStart
}
// If a final CSI byte exists before the truncation boundary, we are not
// inside a partial sequence.
if csiFinalByteIndex(in: text, from: csiMarker, upperBound: initialStart) != nil {
return initialStart
}
// We are inside a CSI sequence. Skip to the first character after the
// sequence terminator if it exists.
guard let final = csiFinalByteIndex(in: text, from: csiMarker, upperBound: text.endIndex) else {
return initialStart
}
let next = text.index(after: final)
return next < text.endIndex ? next : text.endIndex
}
private static func csiFinalByteIndex(
in text: String,
from csiMarker: String.Index,
upperBound: String.Index
) -> String.Index? {
var index = text.index(after: csiMarker)
while index < upperBound {
guard let scalar = text[index].unicodeScalars.first?.value else {
index = text.index(after: index)
continue
}
if scalar >= 0x40, scalar <= 0x7E {
return index
}
index = text.index(after: index)
}
return nil
}
}
enum SessionRestorePolicy {
static func isRunningUnderAutomatedTests(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> Bool {
if environment["CMUX_UI_TEST_MODE"] == "1" {
return true
}
if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) {
return true
}
if environment["XCTestConfigurationFilePath"] != nil {
return true
}
if environment["XCTestBundlePath"] != nil {
return true
}
if environment["XCTestSessionIdentifier"] != nil {
return true
}
if environment["XCInjectBundle"] != nil {
return true
}
if environment["XCInjectBundleInto"] != nil {
return true
}
if environment["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true {
return true
}
return false
}
static func shouldAttemptRestore(
arguments: [String] = CommandLine.arguments,
environment: [String: String] = ProcessInfo.processInfo.environment
) -> Bool {
if environment["CMUX_DISABLE_SESSION_RESTORE"] == "1" {
return false
}
if isRunningUnderAutomatedTests(environment: environment) {
return false
}
let extraArgs = arguments
.dropFirst()
.filter { !$0.hasPrefix("-psn_") }
// Any explicit launch argument is treated as an explicit open intent.
return extraArgs.isEmpty
}
}
struct SessionRectSnapshot: Codable, Equatable, Sendable {
let x: Double
let y: Double
let width: Double
let height: Double
init(x: Double, y: Double, width: Double, height: Double) {
self.x = x
self.y = y
self.width = width
self.height = height
}
init(_ rect: CGRect) {
self.x = Double(rect.origin.x)
self.y = Double(rect.origin.y)
self.width = Double(rect.size.width)
self.height = Double(rect.size.height)
}
var cgRect: CGRect {
CGRect(x: x, y: y, width: width, height: height)
}
}
struct SessionDisplaySnapshot: Codable, Sendable {
var displayID: UInt32?
var frame: SessionRectSnapshot?
var visibleFrame: SessionRectSnapshot?
}
enum SessionSidebarSelection: String, Codable, Sendable, Equatable {
case tabs
case notifications
init(selection: SidebarSelection) {
switch selection {
case .tabs:
self = .tabs
case .notifications:
self = .notifications
}
}
var sidebarSelection: SidebarSelection {
switch self {
case .tabs:
return .tabs
case .notifications:
return .notifications
}
}
}
struct SessionSidebarSnapshot: Codable, Sendable {
var isVisible: Bool
var selection: SessionSidebarSelection
var width: Double?
}
struct SessionStatusEntrySnapshot: Codable, Sendable {
var key: String
var value: String
var icon: String?
var color: String?
var timestamp: TimeInterval
}
struct SessionLogEntrySnapshot: Codable, Sendable {
var message: String
var level: String
var source: String?
var timestamp: TimeInterval
}
struct SessionProgressSnapshot: Codable, Sendable {
var value: Double
var label: String?
}
struct SessionGitBranchSnapshot: Codable, Sendable {
var branch: String
var isDirty: Bool
}
struct SessionTerminalPanelSnapshot: Codable, Sendable {
var workingDirectory: String?
var scrollback: String?
}
struct SessionBrowserPanelSnapshot: Codable, Sendable {
var urlString: String?
var shouldRenderWebView: Bool
var pageZoom: Double
var developerToolsVisible: Bool
var backHistoryURLStrings: [String]?
var forwardHistoryURLStrings: [String]?
}
struct SessionPanelSnapshot: Codable, Sendable {
var id: UUID
var type: PanelType
var title: String?
var customTitle: String?
var directory: String?
var isPinned: Bool
var isManuallyUnread: Bool
var gitBranch: SessionGitBranchSnapshot?
var listeningPorts: [Int]
var ttyName: String?
var terminal: SessionTerminalPanelSnapshot?
var browser: SessionBrowserPanelSnapshot?
}
enum SessionSplitOrientation: String, Codable, Sendable {
case horizontal
case vertical
init(_ orientation: SplitOrientation) {
switch orientation {
case .horizontal:
self = .horizontal
case .vertical:
self = .vertical
}
}
var splitOrientation: SplitOrientation {
switch self {
case .horizontal:
return .horizontal
case .vertical:
return .vertical
}
}
}
struct SessionPaneLayoutSnapshot: Codable, Sendable {
var panelIds: [UUID]
var selectedPanelId: UUID?
}
struct SessionSplitLayoutSnapshot: Codable, Sendable {
var orientation: SessionSplitOrientation
var dividerPosition: Double
var first: SessionWorkspaceLayoutSnapshot
var second: SessionWorkspaceLayoutSnapshot
}
indirect enum SessionWorkspaceLayoutSnapshot: Codable, Sendable {
case pane(SessionPaneLayoutSnapshot)
case split(SessionSplitLayoutSnapshot)
private enum CodingKeys: String, CodingKey {
case type
case pane
case split
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "pane":
self = .pane(try container.decode(SessionPaneLayoutSnapshot.self, forKey: .pane))
case "split":
self = .split(try container.decode(SessionSplitLayoutSnapshot.self, forKey: .split))
default:
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unsupported layout node type: \(type)")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .pane(let pane):
try container.encode("pane", forKey: .type)
try container.encode(pane, forKey: .pane)
case .split(let split):
try container.encode("split", forKey: .type)
try container.encode(split, forKey: .split)
}
}
}
struct SessionWorkspaceSnapshot: Codable, Sendable {
var processTitle: String
var customTitle: String?
var customColor: String?
var isPinned: Bool
var currentDirectory: String
var focusedPanelId: UUID?
var layout: SessionWorkspaceLayoutSnapshot
var panels: [SessionPanelSnapshot]
var statusEntries: [SessionStatusEntrySnapshot]
var logEntries: [SessionLogEntrySnapshot]
var progress: SessionProgressSnapshot?
var gitBranch: SessionGitBranchSnapshot?
}
struct SessionTabManagerSnapshot: Codable, Sendable {
var selectedWorkspaceIndex: Int?
var workspaces: [SessionWorkspaceSnapshot]
}
struct SessionWindowSnapshot: Codable, Sendable {
var frame: SessionRectSnapshot?
var display: SessionDisplaySnapshot?
var tabManager: SessionTabManagerSnapshot
var sidebar: SessionSidebarSnapshot
}
struct AppSessionSnapshot: Codable, Sendable {
var version: Int
var createdAt: TimeInterval
var windows: [SessionWindowSnapshot]
}
enum SessionPersistenceStore {
static func load(fileURL: URL? = nil) -> AppSessionSnapshot? {
guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return nil }
guard let data = try? Data(contentsOf: fileURL) else { return nil }
let decoder = JSONDecoder()
guard let snapshot = try? decoder.decode(AppSessionSnapshot.self, from: data) else { return nil }
guard snapshot.version == SessionSnapshotSchema.currentVersion else { return nil }
guard !snapshot.windows.isEmpty else { return nil }
return snapshot
}
@discardableResult
static func save(_ snapshot: AppSessionSnapshot, fileURL: URL? = nil) -> Bool {
guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return false }
let directory = fileURL.deletingLastPathComponent()
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
let data = try encoder.encode(snapshot)
try data.write(to: fileURL, options: .atomic)
return true
} catch {
return false
}
}
static func removeSnapshot(fileURL: URL? = nil) {
guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return }
try? FileManager.default.removeItem(at: fileURL)
}
static func defaultSnapshotFileURL(
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
appSupportDirectory: URL? = nil
) -> URL? {
let resolvedAppSupport: URL
if let appSupportDirectory {
resolvedAppSupport = appSupportDirectory
} else if let discovered = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
resolvedAppSupport = discovered
} else {
return nil
}
let bundleId = (bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? bundleIdentifier!
: "com.cmuxterm.app"
let safeBundleId = bundleId.replacingOccurrences(
of: "[^A-Za-z0-9._-]",
with: "_",
options: .regularExpression
)
return resolvedAppSupport
.appendingPathComponent("cmux", isDirectory: true)
.appendingPathComponent("session-\(safeBundleId).json", isDirectory: false)
}
}
enum SessionScrollbackReplayStore {
static let environmentKey = "CMUX_RESTORE_SCROLLBACK_FILE"
private static let directoryName = "cmux-session-scrollback"
private static let ansiEscape = "\u{001B}"
private static let ansiReset = "\u{001B}[0m"
static func replayEnvironment(
for scrollback: String?,
tempDirectory: URL = FileManager.default.temporaryDirectory
) -> [String: String] {
guard let replayText = normalizedScrollback(scrollback) else { return [:] }
guard let replayFileURL = writeReplayFile(
contents: replayText,
tempDirectory: tempDirectory
) else {
return [:]
}
return [environmentKey: replayFileURL.path]
}
private static func normalizedScrollback(_ scrollback: String?) -> String? {
guard let scrollback else { return nil }
guard scrollback.contains(where: { !$0.isWhitespace }) else { return nil }
guard let truncated = SessionPersistencePolicy.truncatedScrollback(scrollback) else { return nil }
return ansiSafeReplayText(truncated)
}
/// Preserve ANSI color state safely across replay boundaries.
private static func ansiSafeReplayText(_ text: String) -> String {
guard text.contains(ansiEscape) else { return text }
var output = text
if !output.hasPrefix(ansiReset) {
output = ansiReset + output
}
if !output.hasSuffix(ansiReset) {
output += ansiReset
}
return output
}
private static func writeReplayFile(contents: String, tempDirectory: URL) -> URL? {
guard let data = contents.data(using: .utf8) else { return nil }
let directory = tempDirectory.appendingPathComponent(directoryName, isDirectory: true)
do {
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true,
attributes: nil
)
let fileURL = directory
.appendingPathComponent(UUID().uuidString, isDirectory: false)
.appendingPathExtension("txt")
try data.write(to: fileURL, options: .atomic)
return fileURL
} catch {
return nil
}
}
}

View file

@ -2,6 +2,9 @@ import SwiftUI
@MainActor @MainActor
final class SidebarSelectionState: ObservableObject { final class SidebarSelectionState: ObservableObject {
@Published var selection: SidebarSelection = .tabs @Published var selection: SidebarSelection
}
init(selection: SidebarSelection = .tabs) {
self.selection = selection
}
}

View file

@ -163,6 +163,8 @@ struct SocketControlSettings {
static let legacyEnabledKey = "socketControlEnabled" static let legacyEnabledKey = "socketControlEnabled"
static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE" static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE"
static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD" static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD"
static let launchTagEnvKey = "CMUX_TAG"
static let baseDebugBundleIdentifier = "com.cmuxterm.app.debug"
private static func normalizeMode(_ raw: String) -> String { private static func normalizeMode(_ raw: String) -> String {
raw raw
@ -211,6 +213,53 @@ struct SocketControlSettings {
#endif #endif
} }
static func launchTag(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> String? {
guard let raw = environment[launchTagEnvKey] else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
static func shouldBlockUntaggedDebugLaunch(
environment: [String: String] = ProcessInfo.processInfo.environment,
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
isDebugBuild: Bool = SocketControlSettings.isDebugBuild
) -> Bool {
guard isDebugBuild else { return false }
if isRunningUnderXCTest(environment: environment) {
return false
}
guard let bundleIdentifier = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleIdentifier.isEmpty else {
return false
}
if bundleIdentifier.hasPrefix("\(baseDebugBundleIdentifier).") {
return false
}
guard bundleIdentifier == baseDebugBundleIdentifier else {
return false
}
return launchTag(environment: environment) == nil
}
static func isRunningUnderXCTest(environment: [String: String]) -> Bool {
let indicators = [
"XCTestConfigurationFilePath",
"XCTestBundlePath",
"XCTestSessionIdentifier",
"XCInjectBundleInto",
]
return indicators.contains { key in
guard let value = environment[key] else { return false }
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
static func socketPath( static func socketPath(
environment: [String: String] = ProcessInfo.processInfo.environment, environment: [String: String] = ProcessInfo.processInfo.environment,
bundleIdentifier: String? = Bundle.main.bundleIdentifier, bundleIdentifier: String? = Bundle.main.bundleIdentifier,

View file

@ -558,6 +558,10 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback(
@MainActor @MainActor
class TabManager: ObservableObject { class TabManager: ObservableObject {
/// The window that owns this TabManager. Set by AppDelegate.registerMainWindow().
/// Used to apply title updates to the correct window instead of NSApp.keyWindow.
weak var window: NSWindow?
@Published var tabs: [Workspace] = [] @Published var tabs: [Workspace] = []
@Published private(set) var isWorkspaceCycleHot: Bool = false @Published private(set) var isWorkspaceCycleHot: Bool = false
@ -567,6 +571,9 @@ class TabManager: ObservableObject {
@Published var selectedTabId: UUID? { @Published var selectedTabId: UUID? {
didSet { didSet {
guard selectedTabId != oldValue else { return } guard selectedTabId != oldValue else { return }
sentryBreadcrumb("workspace.switch", data: [
"tabCount": tabs.count
])
let previousTabId = oldValue let previousTabId = oldValue
if let previousTabId, if let previousTabId,
let previousPanelId = focusedPanelId(for: previousTabId) { let previousPanelId = focusedPanelId(for: previousTabId) {
@ -751,13 +758,24 @@ class TabManager: ObservableObject {
} }
@discardableResult @discardableResult
func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil, select: Bool = true) -> Workspace { func addWorkspace(
workingDirectory overrideWorkingDirectory: String? = nil,
select: Bool = true,
placementOverride: NewWorkspacePlacement? = nil
) -> Workspace {
sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1])
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab() let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
let inheritedConfig = inheritedTerminalConfigForNewWorkspace()
let ordinal = Self.nextPortOrdinal let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1 Self.nextPortOrdinal += 1
let newWorkspace = Workspace(title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory, portOrdinal: ordinal) let newWorkspace = Workspace(
title: "Terminal \(tabs.count + 1)",
workingDirectory: workingDirectory,
portOrdinal: ordinal,
configTemplate: inheritedConfig
)
wireClosedBrowserTracking(for: newWorkspace) wireClosedBrowserTracking(for: newWorkspace)
let insertIndex = newTabInsertIndex() let insertIndex = newTabInsertIndex(placementOverride: placementOverride)
if insertIndex >= 0 && insertIndex <= tabs.count { if insertIndex >= 0 && insertIndex <= tabs.count {
tabs.insert(newWorkspace, at: insertIndex) tabs.insert(newWorkspace, at: insertIndex)
} else { } else {
@ -785,6 +803,36 @@ class TabManager: ObservableObject {
@discardableResult @discardableResult
func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) } func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) }
func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? {
guard let workspace = selectedWorkspace else { return nil }
if let focusedTerminal = workspace.focusedTerminalPanel {
return focusedTerminal
}
if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance() {
return rememberedTerminal
}
if let focusedPaneId = workspace.bonsplitController.focusedPaneId,
let paneTerminal = workspace.terminalPanelForConfigInheritance(inPane: focusedPaneId) {
return paneTerminal
}
return workspace.terminalPanelForConfigInheritance()
}
private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? {
if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource()?.surface.surface {
return cmuxInheritedSurfaceConfig(
sourceSurface: sourceSurface,
context: GHOSTTY_SURFACE_CONTEXT_TAB
)
}
if let fallbackFontPoints = selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() {
var config = ghostty_surface_config_new()
config.font_size = fallbackFontPoints
return config
}
return nil
}
private func normalizedWorkingDirectory(_ directory: String?) -> String? { private func normalizedWorkingDirectory(_ directory: String?) -> String? {
guard let directory else { return nil } guard let directory else { return nil }
let normalized = normalizeDirectory(directory) let normalized = normalizeDirectory(directory)
@ -792,8 +840,8 @@ class TabManager: ObservableObject {
return trimmed.isEmpty ? nil : normalized return trimmed.isEmpty ? nil : normalized
} }
private func newTabInsertIndex() -> Int { private func newTabInsertIndex(placementOverride: NewWorkspacePlacement? = nil) -> Int {
let placement = WorkspacePlacementSettings.current() let placement = placementOverride ?? WorkspacePlacementSettings.current()
let pinnedCount = tabs.filter { $0.isPinned }.count let pinnedCount = tabs.filter { $0.isPinned }.count
let selectedIndex = selectedTabId.flatMap { tabId in let selectedIndex = selectedTabId.flatMap { tabId in
tabs.firstIndex(where: { $0.id == tabId }) tabs.firstIndex(where: { $0.id == tabId })
@ -927,6 +975,7 @@ class TabManager: ObservableObject {
func closeWorkspace(_ workspace: Workspace) { func closeWorkspace(_ workspace: Workspace) {
guard tabs.count > 1 else { return } guard tabs.count > 1 else { return }
sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
unwireClosedBrowserTracking(for: workspace) unwireClosedBrowserTracking(for: workspace)
@ -1137,11 +1186,24 @@ class TabManager: ObservableObject {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return } guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
guard tab.panels[surfaceId] != nil else { return } guard tab.panels[surfaceId] != nil else { return }
#if DEBUG
dlog(
"surface.close.runtime tab=\(tabId.uuidString.prefix(5)) " +
"surface=\(surfaceId.uuidString.prefix(5)) panelsBefore=\(tab.panels.count)"
)
#endif
// Keep AppKit first responder in sync with workspace focus before routing the close. // Keep AppKit first responder in sync with workspace focus before routing the close.
// If split reparenting caused a temporary model/view mismatch, fallback close logic in // If split reparenting caused a temporary model/view mismatch, fallback close logic in
// Workspace.closePanel uses focused selection to resolve the correct tab deterministically. // Workspace.closePanel uses focused selection to resolve the correct tab deterministically.
reconcileFocusedPanelFromFirstResponderForKeyboard() reconcileFocusedPanelFromFirstResponderForKeyboard()
_ = tab.closePanel(surfaceId, force: true) let closed = tab.closePanel(surfaceId, force: true)
#if DEBUG
dlog(
"surface.close.runtime.done tab=\(tabId.uuidString.prefix(5)) " +
"surface=\(surfaceId.uuidString.prefix(5)) closed=\(closed ? 1 : 0) panelsAfter=\(tab.panels.count)"
)
#endif
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id, surfaceId: surfaceId) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id, surfaceId: surfaceId)
} }
@ -1153,6 +1215,13 @@ class TabManager: ObservableObject {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return } guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
guard tab.panels[surfaceId] != nil else { return } guard tab.panels[surfaceId] != nil else { return }
#if DEBUG
dlog(
"surface.close.childExited tab=\(tabId.uuidString.prefix(5)) " +
"surface=\(surfaceId.uuidString.prefix(5)) panels=\(tab.panels.count) workspaces=\(tabs.count)"
)
#endif
// Child-exit on the last panel should collapse the workspace, matching explicit close // Child-exit on the last panel should collapse the workspace, matching explicit close
// semantics (and close the window when it was the last workspace). // semantics (and close the window when it was the last workspace).
if tab.panels.count <= 1 { if tab.panels.count <= 1 {
@ -1435,8 +1504,8 @@ class TabManager: ObservableObject {
private func updateWindowTitle(for tab: Workspace?) { private func updateWindowTitle(for tab: Workspace?) {
let title = windowTitle(for: tab) let title = windowTitle(for: tab)
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first guard let targetWindow = window else { return }
targetWindow?.title = title targetWindow.title = title
} }
private func windowTitle(for tab: Workspace?) -> String { private func windowTitle(for tab: Workspace?) -> String {
@ -1450,7 +1519,11 @@ class TabManager: ObservableObject {
} }
func focusTab(_ tabId: UUID, surfaceId: UUID? = nil, suppressFlash: Bool = false) { func focusTab(_ tabId: UUID, surfaceId: UUID? = nil, suppressFlash: Bool = false) {
guard tabs.contains(where: { $0.id == tabId }) else { return } guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
if let surfaceId, tab.panels[surfaceId] != nil {
// Keep selected-surface intent stable across selectedTabId didSet async restore.
lastFocusedPanelByTab[tabId] = surfaceId
}
selectedTabId = tabId selectedTabId = tabId
NotificationCenter.default.post( NotificationCenter.default.post(
name: .ghosttyDidFocusTab, name: .ghosttyDidFocusTab,
@ -1458,10 +1531,15 @@ class TabManager: ObservableObject {
userInfo: [GhosttyNotificationKey.tabId: tabId] userInfo: [GhosttyNotificationKey.tabId: tabId]
) )
DispatchQueue.main.async { DispatchQueue.main.async { [weak self] in
guard let self else { return }
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
NSApp.unhide(nil) NSApp.unhide(nil)
if let window = NSApp.keyWindow ?? NSApp.windows.first { if let app = AppDelegate.shared,
let windowId = app.windowId(for: self),
let window = app.mainWindow(for: windowId) {
window.makeKeyAndOrderFront(nil)
} else if let window = NSApp.keyWindow ?? NSApp.windows.first {
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
} }
} }
@ -1469,7 +1547,7 @@ class TabManager: ObservableObject {
if let surfaceId { if let surfaceId {
if !suppressFlash { if !suppressFlash {
focusSurface(tabId: tabId, surfaceId: surfaceId) focusSurface(tabId: tabId, surfaceId: surfaceId)
} else if let tab = tabs.first(where: { $0.id == tabId }) { } else {
tab.focusPanel(surfaceId) tab.focusPanel(surfaceId)
} }
} }
@ -1665,6 +1743,7 @@ class TabManager: ObservableObject {
guard let selectedTabId, guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }), let tab = tabs.first(where: { $0.id == selectedTabId }),
let focusedPanelId = tab.focusedPanelId else { return } let focusedPanelId = tab.focusedPanelId else { return }
sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)])
_ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) _ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
} }
@ -2732,6 +2811,10 @@ class TabManager: ObservableObject {
let strictKeyOnly = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] == "1" let strictKeyOnly = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] == "1"
let triggerMode = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] ?? "shell_input") let triggerMode = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] ?? "shell_input")
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
let useEarlyCtrlShiftTrigger = triggerMode == "early_ctrl_shift_d"
let useEarlyCtrlDTrigger = triggerMode == "early_ctrl_d"
let useEarlyTrigger = useEarlyCtrlShiftTrigger || useEarlyCtrlDTrigger
let triggerUsesShift = triggerMode == "ctrl_shift_d" || useEarlyCtrlShiftTrigger
let layout = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] ?? "lr") let layout = (env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] ?? "lr")
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
let expectedPanelsAfter = max( let expectedPanelsAfter = max(
@ -2870,7 +2953,9 @@ class TabManager: ObservableObject {
} }
tab.focusPanel(exitPanelId) tab.focusPanel(exitPanelId)
try? await Task.sleep(nanoseconds: 100_000_000) if !useEarlyTrigger {
try? await Task.sleep(nanoseconds: 100_000_000)
}
let focusedPanelBefore = tab.focusedPanelId?.uuidString ?? "" let focusedPanelBefore = tab.focusedPanelId?.uuidString ?? ""
let firstResponderPanelBefore = tab.panels.compactMap { (panelId, panel) -> UUID? in let firstResponderPanelBefore = tab.panels.compactMap { (panelId, panel) -> UUID? in
@ -2974,21 +3059,31 @@ class TabManager: ObservableObject {
return return
} }
// Wait for the target panel to be fully attached after split churn. let triggerModifiers: NSEvent.ModifierFlags = triggerUsesShift
let readyDeadline = Date().addingTimeInterval(2.0) ? [.control, .shift]
: [.control]
let shouldWaitForSurface = !useEarlyTrigger
var attachedBeforeTrigger = false var attachedBeforeTrigger = false
var hasSurfaceBeforeTrigger = false var hasSurfaceBeforeTrigger = false
while Date() < readyDeadline { if shouldWaitForSurface {
guard let panel = tab.terminalPanel(for: exitPanelId) else { // Wait for the target panel to be fully attached after split churn.
write(["autoTriggerError": "missingExitPanelBeforeTrigger"]) let readyDeadline = Date().addingTimeInterval(2.0)
return while Date() < readyDeadline {
guard let panel = tab.terminalPanel(for: exitPanelId) else {
write(["autoTriggerError": "missingExitPanelBeforeTrigger"])
return
}
attachedBeforeTrigger = panel.hostedView.window != nil
hasSurfaceBeforeTrigger = panel.surface.surface != nil
if attachedBeforeTrigger, hasSurfaceBeforeTrigger {
break
}
try? await Task.sleep(nanoseconds: 50_000_000)
} }
} else if let panel = tab.terminalPanel(for: exitPanelId) {
attachedBeforeTrigger = panel.hostedView.window != nil attachedBeforeTrigger = panel.hostedView.window != nil
hasSurfaceBeforeTrigger = panel.surface.surface != nil hasSurfaceBeforeTrigger = panel.surface.surface != nil
if attachedBeforeTrigger, hasSurfaceBeforeTrigger {
break
}
try? await Task.sleep(nanoseconds: 50_000_000)
} }
write([ write([
"exitPanelAttachedBeforeTrigger": attachedBeforeTrigger ? "1" : "0", "exitPanelAttachedBeforeTrigger": attachedBeforeTrigger ? "1" : "0",
@ -3000,7 +3095,7 @@ class TabManager: ObservableObject {
return return
} }
// Exercise the real key path (ghostty_surface_key for Ctrl+D). // Exercise the real key path (ghostty_surface_key for Ctrl+D).
if panel.hostedView.sendSyntheticCtrlDForUITest() { if panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) {
write(["autoTriggerSentCtrlDKey1": "1"]) write(["autoTriggerSentCtrlDKey1": "1"])
} else { } else {
write([ write([
@ -3012,13 +3107,20 @@ class TabManager: ObservableObject {
// In strict mode, never mask routing bugs with fallback writes. // In strict mode, never mask routing bugs with fallback writes.
if strictKeyOnly { if strictKeyOnly {
write(["autoTriggerMode": "strict_ctrl_d"]) let strictModeLabel: String = {
if useEarlyCtrlShiftTrigger { return "strict_early_ctrl_shift_d" }
if useEarlyCtrlDTrigger { return "strict_early_ctrl_d" }
if triggerUsesShift { return "strict_ctrl_shift_d" }
return "strict_ctrl_d"
}()
write(["autoTriggerMode": strictModeLabel])
return return
} }
// Non-strict mode keeps one additional Ctrl+D retry for startup timing variance. // Non-strict mode keeps one additional Ctrl+D retry for startup timing variance.
try? await Task.sleep(nanoseconds: 450_000_000) try? await Task.sleep(nanoseconds: 450_000_000)
if tab.panels[exitPanelId] != nil, panel.hostedView.sendSyntheticCtrlDForUITest() { if tab.panels[exitPanelId] != nil,
panel.hostedView.sendSyntheticCtrlDForUITest(modifierFlags: triggerModifiers) {
write(["autoTriggerSentCtrlDKey2": "1"]) write(["autoTriggerSentCtrlDKey2": "1"])
} }
} }
@ -3028,6 +3130,75 @@ class TabManager: ObservableObject {
#endif #endif
} }
extension TabManager {
func sessionSnapshot(includeScrollback: Bool) -> SessionTabManagerSnapshot {
let workspaceSnapshots = tabs
.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow)
.map { $0.sessionSnapshot(includeScrollback: includeScrollback) }
let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in
tabs.firstIndex(where: { $0.id == selectedTabId })
}
return SessionTabManagerSnapshot(
selectedWorkspaceIndex: selectedWorkspaceIndex,
workspaces: workspaceSnapshots
)
}
func restoreSessionSnapshot(_ snapshot: SessionTabManagerSnapshot) {
for tab in tabs {
unwireClosedBrowserTracking(for: tab)
}
tabs.removeAll(keepingCapacity: false)
lastFocusedPanelByTab.removeAll()
pendingPanelTitleUpdates.removeAll()
tabHistory.removeAll()
historyIndex = -1
isNavigatingHistory = false
pendingWorkspaceUnfocusTarget = nil
workspaceCycleCooldownTask?.cancel()
workspaceCycleCooldownTask = nil
isWorkspaceCycleHot = false
selectionSideEffectsGeneration &+= 1
recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20)
let workspaceSnapshots = snapshot.workspaces
.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow)
for workspaceSnapshot in workspaceSnapshots {
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
let workspace = Workspace(
title: workspaceSnapshot.processTitle,
workingDirectory: workspaceSnapshot.currentDirectory,
portOrdinal: ordinal
)
workspace.restoreSessionSnapshot(workspaceSnapshot)
wireClosedBrowserTracking(for: workspace)
tabs.append(workspace)
}
if tabs.isEmpty {
_ = addWorkspace(select: false)
}
selectedTabId = nil
if let selectedWorkspaceIndex = snapshot.selectedWorkspaceIndex,
tabs.indices.contains(selectedWorkspaceIndex) {
selectedTabId = tabs[selectedWorkspaceIndex].id
} else {
selectedTabId = tabs.first?.id
}
if let selectedTabId {
NotificationCenter.default.post(
name: .ghosttyDidFocusTab,
object: nil,
userInfo: [GhosttyNotificationKey.tabId: selectedTabId]
)
}
}
}
// MARK: - Direction Types for Backwards Compatibility // MARK: - Direction Types for Backwards Compatibility
/// Split direction for backwards compatibility with old API /// Split direction for backwards compatibility with old API
@ -3055,15 +3226,22 @@ enum ResizeDirection {
} }
extension Notification.Name { extension Notification.Name {
static let commandPaletteToggleRequested = Notification.Name("cmux.commandPaletteToggleRequested")
static let commandPaletteRequested = Notification.Name("cmux.commandPaletteRequested")
static let commandPaletteSwitcherRequested = Notification.Name("cmux.commandPaletteSwitcherRequested")
static let commandPaletteRenameTabRequested = Notification.Name("cmux.commandPaletteRenameTabRequested")
static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection")
static let commandPaletteRenameInputInteractionRequested = Notification.Name("cmux.commandPaletteRenameInputInteractionRequested")
static let commandPaletteRenameInputDeleteBackwardRequested = Notification.Name("cmux.commandPaletteRenameInputDeleteBackwardRequested")
static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle") static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle")
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab") static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface") static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")
static let ghosttyDidBecomeFirstResponderSurface = Notification.Name("ghosttyDidBecomeFirstResponderSurface") static let ghosttyDidBecomeFirstResponderSurface = Notification.Name("ghosttyDidBecomeFirstResponderSurface")
static let browserDidBecomeFirstResponderWebView = Notification.Name("browserDidBecomeFirstResponderWebView")
static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar") static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar")
static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection") static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection")
static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar") static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar")
static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar") static let browserDidFocusAddressBar = Notification.Name("browserDidFocusAddressBar")
static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar") static let browserDidBlurAddressBar = Notification.Name("browserDidBlurAddressBar")
static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick") static let webViewDidReceiveClick = Notification.Name("webViewDidReceiveClick")
static let webViewMiddleClickedLink = Notification.Name("webViewMiddleClickedLink")
} }

View file

@ -45,6 +45,7 @@ class TerminalController {
"browser.focus_webview", "browser.focus_webview",
"browser.focus", "browser.focus",
"browser.tab.switch", "browser.tab.switch",
"debug.command_palette.toggle",
"debug.notification.focus", "debug.notification.focus",
"debug.app.activate" "debug.app.activate"
] ]
@ -1336,6 +1337,28 @@ class TerminalController {
return v2Result(id: id, self.v2DebugType(params: params)) return v2Result(id: id, self.v2DebugType(params: params))
case "debug.app.activate": case "debug.app.activate":
return v2Result(id: id, self.v2DebugActivateApp()) return v2Result(id: id, self.v2DebugActivateApp())
case "debug.command_palette.toggle":
return v2Result(id: id, self.v2DebugToggleCommandPalette(params: params))
case "debug.command_palette.rename_tab.open":
return v2Result(id: id, self.v2DebugOpenCommandPaletteRenameTabInput(params: params))
case "debug.command_palette.visible":
return v2Result(id: id, self.v2DebugCommandPaletteVisible(params: params))
case "debug.command_palette.selection":
return v2Result(id: id, self.v2DebugCommandPaletteSelection(params: params))
case "debug.command_palette.results":
return v2Result(id: id, self.v2DebugCommandPaletteResults(params: params))
case "debug.command_palette.rename_input.interact":
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputInteraction(params: params))
case "debug.command_palette.rename_input.delete_backward":
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputDeleteBackward(params: params))
case "debug.command_palette.rename_input.selection":
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelection(params: params))
case "debug.command_palette.rename_input.select_all":
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelectAll(params: params))
case "debug.browser.address_bar_focused":
return v2Result(id: id, self.v2DebugBrowserAddressBarFocused(params: params))
case "debug.sidebar.visible":
return v2Result(id: id, self.v2DebugSidebarVisible(params: params))
case "debug.terminal.is_focused": case "debug.terminal.is_focused":
return v2Result(id: id, self.v2DebugIsTerminalFocused(params: params)) return v2Result(id: id, self.v2DebugIsTerminalFocused(params: params))
case "debug.terminal.read_text": case "debug.terminal.read_text":
@ -1532,6 +1555,17 @@ class TerminalController {
"debug.shortcut.simulate", "debug.shortcut.simulate",
"debug.type", "debug.type",
"debug.app.activate", "debug.app.activate",
"debug.command_palette.toggle",
"debug.command_palette.rename_tab.open",
"debug.command_palette.visible",
"debug.command_palette.selection",
"debug.command_palette.results",
"debug.command_palette.rename_input.interact",
"debug.command_palette.rename_input.delete_backward",
"debug.command_palette.rename_input.selection",
"debug.command_palette.rename_input.select_all",
"debug.browser.address_bar_focused",
"debug.sidebar.visible",
"debug.terminal.is_focused", "debug.terminal.is_focused",
"debug.terminal.read_text", "debug.terminal.read_text",
"debug.terminal.render_stats", "debug.terminal.render_stats",
@ -3543,6 +3577,154 @@ class TerminalController {
return "OK \(base64)" return "OK \(base64)"
} }
private struct PasteboardItemSnapshot {
let representations: [(type: NSPasteboard.PasteboardType, data: Data)]
}
nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if let url = URL(string: trimmed),
url.isFileURL,
!url.path.isEmpty {
return url.path
}
return trimmed.hasPrefix("/") ? trimmed : nil
}
nonisolated static func shouldRemoveExportedScreenFile(
fileURL: URL,
temporaryDirectory: URL = FileManager.default.temporaryDirectory
) -> Bool {
let standardizedFile = fileURL.standardizedFileURL
let temporary = temporaryDirectory.standardizedFileURL
return standardizedFile.path.hasPrefix(temporary.path + "/")
}
nonisolated static func shouldRemoveExportedScreenDirectory(
fileURL: URL,
temporaryDirectory: URL = FileManager.default.temporaryDirectory
) -> Bool {
let directory = fileURL.deletingLastPathComponent().standardizedFileURL
let temporary = temporaryDirectory.standardizedFileURL
return directory.path.hasPrefix(temporary.path + "/")
}
private func snapshotPasteboardItems(_ pasteboard: NSPasteboard) -> [PasteboardItemSnapshot] {
guard let items = pasteboard.pasteboardItems else { return [] }
return items.map { item in
let representations = item.types.compactMap { type -> (type: NSPasteboard.PasteboardType, data: Data)? in
guard let data = item.data(forType: type) else { return nil }
return (type: type, data: data)
}
return PasteboardItemSnapshot(representations: representations)
}
}
private func restorePasteboardItems(
_ snapshots: [PasteboardItemSnapshot],
to pasteboard: NSPasteboard
) {
_ = pasteboard.clearContents()
guard !snapshots.isEmpty else { return }
let restoredItems = snapshots.compactMap { snapshot -> NSPasteboardItem? in
guard !snapshot.representations.isEmpty else { return nil }
let item = NSPasteboardItem()
for representation in snapshot.representations {
item.setData(representation.data, forType: representation.type)
}
return item
}
guard !restoredItems.isEmpty else { return }
_ = pasteboard.writeObjects(restoredItems)
}
private func readGeneralPasteboardString(_ pasteboard: NSPasteboard) -> String? {
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL],
let firstURL = urls.first,
firstURL.isFileURL {
return firstURL.path
}
if let value = pasteboard.string(forType: .string) {
return value
}
return pasteboard.string(forType: NSPasteboard.PasteboardType("public.utf8-plain-text"))
}
private func readTerminalTextFromVTExportForSnapshot(
terminalPanel: TerminalPanel,
lineLimit: Int?
) -> String? {
// read_text strips style state; VT export keeps ANSI escape sequences.
let pasteboard = NSPasteboard.general
let snapshot = snapshotPasteboardItems(pasteboard)
defer {
restorePasteboardItems(snapshot, to: pasteboard)
}
let initialChangeCount = pasteboard.changeCount
guard terminalPanel.performBindingAction("write_screen_file:copy,vt") else {
return nil
}
guard pasteboard.changeCount != initialChangeCount else {
return nil
}
guard let exportedPath = Self.normalizedExportedScreenPath(readGeneralPasteboardString(pasteboard)) else {
return nil
}
let fileURL = URL(fileURLWithPath: exportedPath)
defer {
if Self.shouldRemoveExportedScreenFile(fileURL: fileURL) {
try? FileManager.default.removeItem(at: fileURL)
if Self.shouldRemoveExportedScreenDirectory(fileURL: fileURL) {
try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent())
}
}
}
guard let data = try? Data(contentsOf: fileURL),
var output = String(data: data, encoding: .utf8) else {
return nil
}
if let lineLimit {
output = tailTerminalLines(output, maxLines: lineLimit)
}
return output
}
func readTerminalTextForSnapshot(
terminalPanel: TerminalPanel,
includeScrollback: Bool = false,
lineLimit: Int? = nil
) -> String? {
if includeScrollback,
let vtOutput = readTerminalTextFromVTExportForSnapshot(
terminalPanel: terminalPanel,
lineLimit: lineLimit
) {
return vtOutput
}
let response = readTerminalTextBase64(
terminalPanel: terminalPanel,
includeScrollback: includeScrollback,
lineLimit: lineLimit
)
guard response.hasPrefix("OK ") else { return nil }
let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
if base64.isEmpty {
return ""
}
guard let data = Data(base64Encoded: base64),
let decoded = String(data: data, encoding: .utf8) else {
return nil
}
return decoded
}
private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult { private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else { guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil) return .err(code: "unavailable", message: "TabManager not available", data: nil)
@ -7621,6 +7803,294 @@ class TerminalController {
return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil) return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil)
} }
private func v2DebugToggleCommandPalette(params: [String: Any]) -> V2CallResult {
let requestedWindowId = v2UUID(params, "window_id")
var result: V2CallResult = .ok([:])
DispatchQueue.main.sync {
let targetWindow: NSWindow?
if let requestedWindowId {
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
result = .err(
code: "not_found",
message: "Window not found",
data: ["window_id": requestedWindowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: requestedWindowId)]
)
return
}
targetWindow = window
} else {
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
}
NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: targetWindow)
}
return result
}
private func v2DebugOpenCommandPaletteRenameTabInput(params: [String: Any]) -> V2CallResult {
let requestedWindowId = v2UUID(params, "window_id")
var result: V2CallResult = .ok([:])
DispatchQueue.main.sync {
let targetWindow: NSWindow?
if let requestedWindowId {
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
result = .err(
code: "not_found",
message: "Window not found",
data: [
"window_id": requestedWindowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: requestedWindowId)
]
)
return
}
targetWindow = window
} else {
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
}
NotificationCenter.default.post(name: .commandPaletteRenameTabRequested, object: targetWindow)
}
return result
}
private func v2DebugCommandPaletteVisible(params: [String: Any]) -> V2CallResult {
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
}
var visible = false
DispatchQueue.main.sync {
visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false
}
return .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"visible": visible
])
}
private func v2DebugCommandPaletteSelection(params: [String: Any]) -> V2CallResult {
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
}
var visible = false
var selectedIndex = 0
DispatchQueue.main.sync {
visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false
selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0
}
return .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"visible": visible,
"selected_index": max(0, selectedIndex)
])
}
private func v2DebugCommandPaletteResults(params: [String: Any]) -> V2CallResult {
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
}
let requestedLimit = params["limit"] as? Int
let limit = max(1, min(100, requestedLimit ?? 20))
var visible = false
var selectedIndex = 0
var snapshot = CommandPaletteDebugSnapshot.empty
DispatchQueue.main.sync {
visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false
selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0
snapshot = AppDelegate.shared?.commandPaletteSnapshot(windowId: windowId) ?? .empty
}
let rows = Array(snapshot.results.prefix(limit)).map { row in
[
"command_id": row.commandId,
"title": row.title,
"shortcut_hint": v2OrNull(row.shortcutHint),
"trailing_label": v2OrNull(row.trailingLabel),
"score": row.score
] as [String: Any]
}
return .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"visible": visible,
"selected_index": max(0, selectedIndex),
"query": snapshot.query,
"mode": snapshot.mode,
"results": rows
])
}
private func v2DebugCommandPaletteRenameInputInteraction(params: [String: Any]) -> V2CallResult {
let requestedWindowId = v2UUID(params, "window_id")
var result: V2CallResult = .ok([:])
DispatchQueue.main.sync {
let targetWindow: NSWindow?
if let requestedWindowId {
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
result = .err(
code: "not_found",
message: "Window not found",
data: [
"window_id": requestedWindowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: requestedWindowId)
]
)
return
}
targetWindow = window
} else {
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
}
NotificationCenter.default.post(name: .commandPaletteRenameInputInteractionRequested, object: targetWindow)
}
return result
}
private func v2DebugCommandPaletteRenameInputDeleteBackward(params: [String: Any]) -> V2CallResult {
let requestedWindowId = v2UUID(params, "window_id")
var result: V2CallResult = .ok([:])
DispatchQueue.main.sync {
let targetWindow: NSWindow?
if let requestedWindowId {
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
result = .err(
code: "not_found",
message: "Window not found",
data: [
"window_id": requestedWindowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: requestedWindowId)
]
)
return
}
targetWindow = window
} else {
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
}
NotificationCenter.default.post(name: .commandPaletteRenameInputDeleteBackwardRequested, object: targetWindow)
}
return result
}
private func v2DebugCommandPaletteRenameInputSelection(params: [String: Any]) -> V2CallResult {
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
}
var result: V2CallResult = .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"focused": false,
"selection_location": 0,
"selection_length": 0,
"text_length": 0
])
DispatchQueue.main.sync {
guard let window = AppDelegate.shared?.mainWindow(for: windowId) else {
result = .err(
code: "not_found",
message: "Window not found",
data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)]
)
return
}
guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else {
return
}
let selectedRange = editor.selectedRange()
let textLength = (editor.string as NSString).length
result = .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"focused": true,
"selection_location": max(0, selectedRange.location),
"selection_length": max(0, selectedRange.length),
"text_length": max(0, textLength)
])
}
return result
}
private func v2DebugCommandPaletteRenameInputSelectAll(params: [String: Any]) -> V2CallResult {
if let rawEnabled = params["enabled"] {
guard let enabled = rawEnabled as? Bool else {
return .err(
code: "invalid_params",
message: "enabled must be a bool",
data: ["enabled": rawEnabled]
)
}
DispatchQueue.main.sync {
UserDefaults.standard.set(
enabled,
forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey
)
}
}
var enabled = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
DispatchQueue.main.sync {
enabled = CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled()
}
return .ok([
"enabled": enabled
])
}
private func v2DebugBrowserAddressBarFocused(params: [String: Any]) -> V2CallResult {
let requestedSurfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "panel_id")
var focusedSurfaceId: UUID?
DispatchQueue.main.sync {
focusedSurfaceId = AppDelegate.shared?.focusedBrowserAddressBarPanelId()
}
var payload: [String: Any] = [
"focused_surface_id": v2OrNull(focusedSurfaceId?.uuidString),
"focused_surface_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId),
"focused_panel_id": v2OrNull(focusedSurfaceId?.uuidString),
"focused_panel_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId),
"focused": focusedSurfaceId != nil
]
if let requestedSurfaceId {
payload["surface_id"] = requestedSurfaceId.uuidString
payload["surface_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId)
payload["panel_id"] = requestedSurfaceId.uuidString
payload["panel_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId)
payload["focused"] = (focusedSurfaceId == requestedSurfaceId)
}
return .ok(payload)
}
private func v2DebugSidebarVisible(params: [String: Any]) -> V2CallResult {
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
}
var visibility: Bool?
DispatchQueue.main.sync {
visibility = AppDelegate.shared?.sidebarVisibility(windowId: windowId)
}
guard let visible = visibility else {
return .err(
code: "not_found",
message: "Window not found",
data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)]
)
}
return .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"visible": visible
])
}
private func v2DebugIsTerminalFocused(params: [String: Any]) -> V2CallResult { private func v2DebugIsTerminalFocused(params: [String: Any]) -> V2CallResult {
guard let surfaceId = v2String(params, "surface_id") else { guard let surfaceId = v2String(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing surface_id", data: nil) return .err(code: "invalid_params", message: "Missing surface_id", data: nil)
@ -8003,6 +8473,37 @@ class TerminalController {
} }
#if DEBUG #if DEBUG
private func debugShortcutName(for action: KeyboardShortcutSettings.Action) -> String {
let snakeCase = action.rawValue.replacingOccurrences(
of: "([a-z0-9])([A-Z])",
with: "$1_$2",
options: .regularExpression
)
return snakeCase.lowercased()
}
private func debugShortcutAction(named rawName: String) -> KeyboardShortcutSettings.Action? {
let normalized = rawName
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.replacingOccurrences(of: "-", with: "_")
for action in KeyboardShortcutSettings.Action.allCases {
let snakeCaseName = debugShortcutName(for: action)
if normalized == snakeCaseName || normalized == snakeCaseName.replacingOccurrences(of: "_", with: "") {
return action
}
}
return nil
}
private func debugShortcutSupportedNames() -> String {
KeyboardShortcutSettings.Action.allCases
.map(debugShortcutName(for:))
.sorted()
.joined(separator: ", ")
}
private func setShortcut(_ args: String) -> String { private func setShortcut(_ args: String) -> String {
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
@ -8010,29 +8511,15 @@ class TerminalController {
return "ERROR: Usage: set_shortcut <name> <combo|clear>" return "ERROR: Usage: set_shortcut <name> <combo|clear>"
} }
let name = parts[0].lowercased() let name = parts[0]
let combo = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) let combo = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
let defaultsKey: String? guard let action = debugShortcutAction(named: name) else {
switch name { return "ERROR: Unknown shortcut name. Supported: \(debugShortcutSupportedNames())"
case "focus_left", "focusleft":
defaultsKey = KeyboardShortcutSettings.focusLeftKey
case "focus_right", "focusright":
defaultsKey = KeyboardShortcutSettings.focusRightKey
case "focus_up", "focusup":
defaultsKey = KeyboardShortcutSettings.focusUpKey
case "focus_down", "focusdown":
defaultsKey = KeyboardShortcutSettings.focusDownKey
default:
defaultsKey = nil
}
guard let defaultsKey else {
return "ERROR: Unknown shortcut name. Supported: focus_left, focus_right, focus_up, focus_down"
} }
if combo.lowercased() == "clear" || combo.lowercased() == "default" || combo.lowercased() == "reset" { if combo.lowercased() == "clear" || combo.lowercased() == "default" || combo.lowercased() == "reset" {
UserDefaults.standard.removeObject(forKey: defaultsKey) UserDefaults.standard.removeObject(forKey: action.defaultsKey)
return "OK" return "OK"
} }
@ -8050,7 +8537,7 @@ class TerminalController {
guard let data = try? JSONEncoder().encode(shortcut) else { guard let data = try? JSONEncoder().encode(shortcut) else {
return "ERROR: Failed to encode shortcut" return "ERROR: Failed to encode shortcut"
} }
UserDefaults.standard.set(data, forKey: defaultsKey) UserDefaults.standard.set(data, forKey: action.defaultsKey)
return "OK" return "OK"
} }
@ -8069,17 +8556,24 @@ class TerminalController {
var result = "ERROR: Failed to create event" var result = "ERROR: Failed to create event"
DispatchQueue.main.sync { DispatchQueue.main.sync {
// Tests can run while the app is activating (no keyWindow yet). Prefer a visible // Prefer the current active-tab-manager window so shortcut simulation stays
// window to keep input simulation deterministic in debug builds. // scoped to the intended window even when NSApp.keyWindow is stale.
let targetWindow = NSApp.keyWindow let targetWindow: NSWindow? = {
?? NSApp.mainWindow if let activeTabManager = self.tabManager,
?? NSApp.windows.first(where: { $0.isVisible }) let windowId = AppDelegate.shared?.windowId(for: activeTabManager),
?? NSApp.windows.first let window = AppDelegate.shared?.mainWindow(for: windowId) {
return window
}
return NSApp.keyWindow
?? NSApp.mainWindow
?? NSApp.windows.first(where: { $0.isVisible })
?? NSApp.windows.first
}()
if let targetWindow { if let targetWindow {
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
targetWindow.makeKeyAndOrderFront(nil) targetWindow.makeKeyAndOrderFront(nil)
} }
let windowNumber = (NSApp.keyWindow ?? targetWindow)?.windowNumber ?? 0 let windowNumber = targetWindow?.windowNumber ?? 0
guard let keyDownEvent = NSEvent.keyEvent( guard let keyDownEvent = NSEvent.keyEvent(
with: .keyDown, with: .keyDown,
location: .zero, location: .zero,
@ -8158,20 +8652,20 @@ class TerminalController {
// Socket commands are line-based; allow callers to express control chars with backslash escapes. // Socket commands are line-based; allow callers to express control chars with backslash escapes.
let text = unescapeSocketText(raw) let text = unescapeSocketText(raw)
var result = "ERROR: No window" var result = "ERROR: No window"
DispatchQueue.main.sync { DispatchQueue.main.sync {
// Like simulate_shortcut, prefer a visible window so debug automation doesn't // Like simulate_shortcut, prefer a visible window so debug automation doesn't
// fail during key window transitions. // fail during key window transitions.
guard let window = NSApp.keyWindow guard let window = NSApp.keyWindow
?? NSApp.mainWindow ?? NSApp.mainWindow
?? NSApp.windows.first(where: { $0.isVisible }) ?? NSApp.windows.first(where: { $0.isVisible })
?? NSApp.windows.first else { return } ?? NSApp.windows.first else { return }
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
guard let fr = window.firstResponder else { guard let fr = window.firstResponder else {
result = "ERROR: No first responder" result = "ERROR: No first responder"
return return
} }
if let client = fr as? NSTextInputClient { if let client = fr as? NSTextInputClient {
client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0))
@ -8179,7 +8673,22 @@ class TerminalController {
return return
} }
// Fall back to the responder chain insertText action. // If workspace handoff temporarily leaves a non-terminal first responder,
// route debug typing to the selected terminal's focused panel directly.
if let tabManager,
let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
let panelId = tab.focusedPanelId,
let terminalPanel = tab.terminalPanel(for: panelId),
!terminalPanel.hostedView.isSurfaceViewFirstResponder() {
// Match Enter semantics expected by tests/debug tooling when bypassing AppKit.
let directText = text.replacingOccurrences(of: "\n", with: "\r")
terminalPanel.surface.sendText(directText)
result = "OK"
return
}
// Fall back to the responder-chain insertText action.
(fr as? NSResponder)?.insertText(text) (fr as? NSResponder)?.insertText(text)
result = "OK" result = "OK"
} }
@ -8772,6 +9281,10 @@ class TerminalController {
let charactersIgnoringModifiers: String let charactersIgnoringModifiers: String
switch keyToken.lowercased() { switch keyToken.lowercased() {
case "esc", "escape":
storedKey = "\u{1b}"
keyCode = UInt16(kVK_Escape)
charactersIgnoringModifiers = storedKey
case "left": case "left":
storedKey = "" storedKey = ""
keyCode = 123 keyCode = 123
@ -8792,6 +9305,10 @@ class TerminalController {
storedKey = "\r" storedKey = "\r"
keyCode = UInt16(kVK_Return) keyCode = UInt16(kVK_Return)
charactersIgnoringModifiers = storedKey charactersIgnoringModifiers = storedKey
case "backspace", "delete", "del":
storedKey = "\u{7f}"
keyCode = UInt16(kVK_Delete)
charactersIgnoringModifiers = storedKey
default: default:
let key = keyToken.lowercased() let key = keyToken.lowercased()
guard let code = keyCodeForShortcutKey(key) else { return nil } guard let code = keyCodeForShortcutKey(key) else { return nil }

View file

@ -17,6 +17,12 @@ private func portalDebugToken(_ view: NSView?) -> String {
private func portalDebugFrame(_ rect: NSRect) -> String { private func portalDebugFrame(_ rect: NSRect) -> String {
String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height) String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height)
} }
private func portalDebugFrameInWindow(_ view: NSView?) -> String {
guard let view else { return "nil" }
guard view.window != nil else { return "no-window" }
return portalDebugFrame(view.convert(view.bounds, to: nil))
}
#endif #endif
final class WindowTerminalHostView: NSView { final class WindowTerminalHostView: NSView {
@ -529,6 +535,10 @@ private final class SplitDividerOverlayView: NSView {
@MainActor @MainActor
final class WindowTerminalPortal: NSObject { final class WindowTerminalPortal: NSObject {
private static let tinyHideThreshold: CGFloat = 1
private static let minimumRevealWidth: CGFloat = 24
private static let minimumRevealHeight: CGFloat = 18
private weak var window: NSWindow? private weak var window: NSWindow?
private let hostView = WindowTerminalHostView(frame: .zero) private let hostView = WindowTerminalHostView(frame: .zero)
private let dividerOverlayView = SplitDividerOverlayView(frame: .zero) private let dividerOverlayView = SplitDividerOverlayView(frame: .zero)
@ -536,6 +546,11 @@ final class WindowTerminalPortal: NSObject {
private weak var installedReferenceView: NSView? private weak var installedReferenceView: NSView?
private var installConstraints: [NSLayoutConstraint] = [] private var installConstraints: [NSLayoutConstraint] = []
private var hasDeferredFullSyncScheduled = false private var hasDeferredFullSyncScheduled = false
private var hasExternalGeometrySyncScheduled = false
private var geometryObservers: [NSObjectProtocol] = []
#if DEBUG
private var lastLoggedBonsplitContainerSignature: String?
#endif
private struct Entry { private struct Entry {
weak var hostedView: GhosttySurfaceScrollView? weak var hostedView: GhosttySurfaceScrollView?
@ -550,13 +565,141 @@ final class WindowTerminalPortal: NSObject {
init(window: NSWindow) { init(window: NSWindow) {
self.window = window self.window = window
super.init() super.init()
hostView.wantsLayer = false hostView.wantsLayer = true
hostView.layer?.masksToBounds = true
hostView.postsFrameChangedNotifications = true
hostView.postsBoundsChangedNotifications = true
hostView.translatesAutoresizingMaskIntoConstraints = false hostView.translatesAutoresizingMaskIntoConstraints = false
dividerOverlayView.translatesAutoresizingMaskIntoConstraints = true dividerOverlayView.translatesAutoresizingMaskIntoConstraints = true
dividerOverlayView.autoresizingMask = [.width, .height] dividerOverlayView.autoresizingMask = [.width, .height]
installGeometryObservers(for: window)
_ = ensureInstalled() _ = ensureInstalled()
} }
private func installGeometryObservers(for window: NSWindow) {
guard geometryObservers.isEmpty else { return }
let center = NotificationCenter.default
geometryObservers.append(center.addObserver(
forName: NSWindow.didResizeNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSWindow.didEndLiveResizeNotification,
object: window,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSSplitView.didResizeSubviewsNotification,
object: nil,
queue: .main
) { [weak self] notification in
MainActor.assumeIsolated {
guard let self,
let splitView = notification.object as? NSSplitView,
let window = self.window,
splitView.window === window else { return }
self.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSView.frameDidChangeNotification,
object: hostView,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
geometryObservers.append(center.addObserver(
forName: NSView.boundsDidChangeNotification,
object: hostView,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.scheduleExternalGeometrySynchronize()
}
})
}
private func removeGeometryObservers() {
for observer in geometryObservers {
NotificationCenter.default.removeObserver(observer)
}
geometryObservers.removeAll()
}
private func scheduleExternalGeometrySynchronize() {
guard !hasExternalGeometrySyncScheduled else { return }
hasExternalGeometrySyncScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.hasExternalGeometrySyncScheduled = false
self.synchronizeAllEntriesFromExternalGeometryChange()
}
}
private func synchronizeLayoutHierarchy() {
installedContainerView?.layoutSubtreeIfNeeded()
installedReferenceView?.layoutSubtreeIfNeeded()
hostView.superview?.layoutSubtreeIfNeeded()
hostView.layoutSubtreeIfNeeded()
_ = synchronizeHostFrameToReference()
}
@discardableResult
private func synchronizeHostFrameToReference() -> Bool {
guard let container = installedContainerView,
let reference = installedReferenceView else {
return false
}
let frameInContainer = container.convert(reference.bounds, from: reference)
let hasFiniteFrame =
frameInContainer.origin.x.isFinite &&
frameInContainer.origin.y.isFinite &&
frameInContainer.size.width.isFinite &&
frameInContainer.size.height.isFinite
guard hasFiniteFrame else { return false }
if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostView.frame = frameInContainer
CATransaction.commit()
#if DEBUG
dlog(
"portal.hostFrame.update host=\(portalDebugToken(hostView)) " +
"frame=\(portalDebugFrame(frameInContainer))"
)
#endif
}
return frameInContainer.width > 1 && frameInContainer.height > 1
}
private func synchronizeAllEntriesFromExternalGeometryChange() {
guard ensureInstalled() else { return }
synchronizeLayoutHierarchy()
synchronizeAllHostedViews(excluding: nil)
// During live resize, AppKit can deliver frame churn where host/container geometry
// settles a tick before the terminal's own scroll/surface hierarchy. Force a final
// in-place geometry + surface refresh for all visible entries in this window.
for entry in entriesByHostedId.values {
guard let hostedView = entry.hostedView, !hostedView.isHidden else { continue }
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow()
}
}
private func ensureDividerOverlayOnTop() { private func ensureDividerOverlayOnTop() {
if dividerOverlayView.superview !== hostView { if dividerOverlayView.superview !== hostView {
dividerOverlayView.frame = hostView.bounds dividerOverlayView.frame = hostView.bounds
@ -605,6 +748,8 @@ final class WindowTerminalPortal: NSObject {
container.addSubview(overlay, positioned: .above, relativeTo: hostView) container.addSubview(overlay, positioned: .above, relativeTo: hostView)
} }
synchronizeLayoutHierarchy()
_ = synchronizeHostFrameToReference()
ensureDividerOverlayOnTop() ensureDividerOverlayOnTop()
return true return true
@ -634,13 +779,32 @@ final class WindowTerminalPortal: NSObject {
return false return false
} }
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool { private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= epsilon && abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
abs(lhs.origin.y - rhs.origin.y) <= epsilon && abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
abs(lhs.size.width - rhs.size.width) <= epsilon && abs(lhs.size.width - rhs.size.width) <= epsilon &&
abs(lhs.size.height - rhs.size.height) <= epsilon abs(lhs.size.height - rhs.size.height) <= epsilon
} }
private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect {
guard rect.origin.x.isFinite,
rect.origin.y.isFinite,
rect.size.width.isFinite,
rect.size.height.isFinite else {
return rect
}
let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0)
func snap(_ value: CGFloat) -> CGFloat {
(value * scale).rounded(.toNearestOrAwayFromZero) / scale
}
return NSRect(
x: snap(rect.origin.x),
y: snap(rect.origin.y),
width: max(0, snap(rect.size.width)),
height: max(0, snap(rect.size.height))
)
}
private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool { private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool {
guard let viewIndex = container.subviews.firstIndex(of: view), guard let viewIndex = container.subviews.firstIndex(of: view),
let referenceIndex = container.subviews.firstIndex(of: reference) else { let referenceIndex = container.subviews.firstIndex(of: reference) else {
@ -649,6 +813,87 @@ final class WindowTerminalPortal: NSObject {
return viewIndex > referenceIndex return viewIndex > referenceIndex
} }
#if DEBUG
private func nearestBonsplitContainer(from anchorView: NSView) -> NSView? {
var current: NSView? = anchorView
while let view = current {
let className = NSStringFromClass(type(of: view))
if className.contains("PaneDragContainerView") || className.contains("Bonsplit") {
return view
}
current = view.superview
}
return installedReferenceView
}
private func logBonsplitContainerFrameIfNeeded(anchorView: NSView, hostedView: GhosttySurfaceScrollView) {
guard let container = nearestBonsplitContainer(from: anchorView) else { return }
let containerFrame = container.convert(container.bounds, to: nil)
let signature = "\(ObjectIdentifier(container)):\(portalDebugFrame(containerFrame))"
guard signature != lastLoggedBonsplitContainerSignature else { return }
lastLoggedBonsplitContainerSignature = signature
let containerClass = NSStringFromClass(type(of: container))
dlog(
"portal.bonsplit.container hosted=\(portalDebugToken(hostedView)) " +
"class=\(containerClass) frame=\(portalDebugFrame(containerFrame)) " +
"host=\(portalDebugFrameInWindow(hostView)) anchor=\(portalDebugFrameInWindow(anchorView))"
)
}
#endif
/// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping.
/// SwiftUI/AppKit hosting layers can report an anchor bounds wider than its split pane when
/// intrinsic-size content overflows; intersecting through ancestor bounds gives the effective
/// visible rect that should drive portal geometry.
private func effectiveAnchorFrameInWindow(for anchorView: NSView) -> NSRect {
var frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
var current = anchorView.superview
while let ancestor = current {
let ancestorBoundsInWindow = ancestor.convert(ancestor.bounds, to: nil)
let finiteAncestorBounds =
ancestorBoundsInWindow.origin.x.isFinite &&
ancestorBoundsInWindow.origin.y.isFinite &&
ancestorBoundsInWindow.size.width.isFinite &&
ancestorBoundsInWindow.size.height.isFinite
if finiteAncestorBounds {
frameInWindow = frameInWindow.intersection(ancestorBoundsInWindow)
if frameInWindow.isNull { return .zero }
}
if ancestor === installedReferenceView { break }
current = ancestor.superview
}
return frameInWindow
}
private func seededFrameInHost(for anchorView: NSView) -> NSRect? {
_ = synchronizeHostFrameToReference()
let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView)
let frameInHostRaw = hostView.convert(frameInWindow, from: nil)
let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView)
let hasFiniteFrame =
frameInHost.origin.x.isFinite &&
frameInHost.origin.y.isFinite &&
frameInHost.size.width.isFinite &&
frameInHost.size.height.isFinite
guard hasFiniteFrame else { return nil }
let hostBounds = hostView.bounds
let hasFiniteHostBounds =
hostBounds.origin.x.isFinite &&
hostBounds.origin.y.isFinite &&
hostBounds.size.width.isFinite &&
hostBounds.size.height.isFinite
if hasFiniteHostBounds {
let clampedFrame = frameInHost.intersection(hostBounds)
if !clampedFrame.isNull, clampedFrame.width > 1, clampedFrame.height > 1 {
return clampedFrame
}
}
return frameInHost
}
func detachHostedView(withId hostedId: ObjectIdentifier) { func detachHostedView(withId hostedId: ObjectIdentifier) {
guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return } guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return }
if let anchor = entry.anchorView { if let anchor = entry.anchorView {
@ -689,6 +934,12 @@ final class WindowTerminalPortal: NSObject {
entriesByHostedId[hostedId] = entry entriesByHostedId[hostedId] = entry
} }
func isHostedViewBoundToAnchor(withId hostedId: ObjectIdentifier, anchorView: NSView) -> Bool {
guard let entry = entriesByHostedId[hostedId],
let boundAnchor = entry.anchorView else { return false }
return boundAnchor === anchorView
}
func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
guard ensureInstalled() else { return } guard ensureInstalled() else { return }
@ -740,6 +991,32 @@ final class WindowTerminalPortal: NSObject {
} }
#endif #endif
_ = synchronizeHostFrameToReference()
// Seed frame/bounds before entering the window so a freshly reparented
// surface doesn't do a transient 800x600 size update on viewDidMoveToWindow.
if let seededFrame = seededFrameInHost(for: anchorView),
seededFrame.width > 0,
seededFrame.height > 0 {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostedView.frame = seededFrame
hostedView.bounds = NSRect(origin: .zero, size: seededFrame.size)
CATransaction.commit()
} else {
// If anchor geometry is still unsettled, keep this hidden/zero-sized until
// synchronizeHostedView resolves a valid target frame on the next layout tick.
CATransaction.begin()
CATransaction.setDisableActions(true)
hostedView.frame = .zero
hostedView.bounds = .zero
CATransaction.commit()
hostedView.isHidden = true
}
// Keep inner scroll/surface geometry in sync with the seeded outer frame
// before the hosted view enters a window.
hostedView.reconcileGeometryNow()
if hostedView.superview !== hostView { if hostedView.superview !== hostView {
#if DEBUG #if DEBUG
dlog( dlog(
@ -765,10 +1042,13 @@ final class WindowTerminalPortal: NSObject {
ensureDividerOverlayOnTop() ensureDividerOverlayOnTop()
synchronizeHostedView(withId: hostedId) synchronizeHostedView(withId: hostedId)
scheduleDeferredFullSynchronizeAll()
pruneDeadEntries() pruneDeadEntries()
} }
func synchronizeHostedViewForAnchor(_ anchorView: NSView) { func synchronizeHostedViewForAnchor(_ anchorView: NSView) {
guard ensureInstalled() else { return }
synchronizeLayoutHierarchy()
pruneDeadEntries() pruneDeadEntries()
let anchorId = ObjectIdentifier(anchorView) let anchorId = ObjectIdentifier(anchorView)
let primaryHostedId = hostedByAnchorId[anchorId] let primaryHostedId = hostedByAnchorId[anchorId]
@ -795,6 +1075,7 @@ final class WindowTerminalPortal: NSObject {
private func synchronizeAllHostedViews(excluding hostedIdToSkip: ObjectIdentifier?) { private func synchronizeAllHostedViews(excluding hostedIdToSkip: ObjectIdentifier?) {
guard ensureInstalled() else { return } guard ensureInstalled() else { return }
synchronizeLayoutHierarchy()
pruneDeadEntries() pruneDeadEntries()
let hostedIds = Array(entriesByHostedId.keys) let hostedIds = Array(entriesByHostedId.keys)
for hostedId in hostedIds { for hostedId in hostedIds {
@ -837,63 +1118,161 @@ final class WindowTerminalPortal: NSObject {
return return
} }
let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) _ = synchronizeHostFrameToReference()
let frameInHost = hostView.convert(frameInWindow, from: nil) let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView)
let frameInHostRaw = hostView.convert(frameInWindow, from: nil)
let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView)
#if DEBUG
logBonsplitContainerFrameIfNeeded(anchorView: anchorView, hostedView: hostedView)
#endif
let hostBounds = hostView.bounds
let hasFiniteHostBounds =
hostBounds.origin.x.isFinite &&
hostBounds.origin.y.isFinite &&
hostBounds.size.width.isFinite &&
hostBounds.size.height.isFinite
let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1
if !hostBoundsReady {
#if DEBUG
dlog(
"portal.sync.defer hosted=\(portalDebugToken(hostedView)) " +
"reason=hostBoundsNotReady host=\(portalDebugFrame(hostBounds)) " +
"anchor=\(portalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)"
)
#endif
hostedView.isHidden = true
scheduleDeferredFullSynchronizeAll()
return
}
let hasFiniteFrame = let hasFiniteFrame =
frameInHost.origin.x.isFinite && frameInHost.origin.x.isFinite &&
frameInHost.origin.y.isFinite && frameInHost.origin.y.isFinite &&
frameInHost.size.width.isFinite && frameInHost.size.width.isFinite &&
frameInHost.size.height.isFinite frameInHost.size.height.isFinite
let clampedFrame = frameInHost.intersection(hostBounds)
let hasVisibleIntersection =
!clampedFrame.isNull &&
clampedFrame.width > 1 &&
clampedFrame.height > 1
let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost
let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView) let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView)
let tinyFrame = frameInHost.width <= 1 || frameInHost.height <= 1 let tinyFrame =
let outsideHostBounds = !frameInHost.intersects(hostView.bounds) targetFrame.width <= Self.tinyHideThreshold ||
targetFrame.height <= Self.tinyHideThreshold
let revealReadyForDisplay =
targetFrame.width >= Self.minimumRevealWidth &&
targetFrame.height >= Self.minimumRevealHeight
let outsideHostBounds = !hasVisibleIntersection
let shouldHide = let shouldHide =
!entry.visibleInUI || !entry.visibleInUI ||
anchorHidden || anchorHidden ||
tinyFrame || tinyFrame ||
!hasFiniteFrame || !hasFiniteFrame ||
outsideHostBounds outsideHostBounds
let shouldDeferReveal = !shouldHide && hostedView.isHidden && !revealReadyForDisplay
let oldFrame = hostedView.frame let oldFrame = hostedView.frame
#if DEBUG #if DEBUG
let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame)
if frameWasClamped {
dlog(
"portal.frame.clamp hosted=\(portalDebugToken(hostedView)) " +
"anchor=\(portalDebugToken(anchorView)) " +
"raw=\(portalDebugFrame(frameInHost)) clamped=\(portalDebugFrame(targetFrame)) " +
"host=\(portalDebugFrame(hostBounds))"
)
}
let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame
let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame
if collapsedToTiny { if collapsedToTiny {
dlog( dlog(
"portal.frame.collapse hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " + "portal.frame.collapse hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " +
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))" "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))"
) )
} else if restoredFromTiny { } else if restoredFromTiny {
dlog( dlog(
"portal.frame.restore hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " + "portal.frame.restore hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " +
"old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))" "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))"
) )
} }
#endif #endif
if !Self.rectApproximatelyEqual(oldFrame, frameInHost) {
// Hide before updating the frame when this entry should not be visible.
// This avoids a one-frame flash of unrendered terminal background when a portal
// briefly transitions through offscreen/tiny geometry during rapid split churn.
if shouldHide, !hostedView.isHidden {
#if DEBUG
dlog(
"portal.hidden hosted=\(portalDebugToken(hostedView)) value=1 " +
"visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " +
"tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " +
"host=\(portalDebugFrame(hostBounds))"
)
#endif
hostedView.isHidden = true
}
if hasFiniteFrame && !Self.rectApproximatelyEqual(oldFrame, targetFrame) {
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(true) CATransaction.setDisableActions(true)
hostedView.frame = frameInHost hostedView.frame = targetFrame
CATransaction.commit() CATransaction.commit()
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow()
}
if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 || if hasFiniteFrame {
abs(oldFrame.size.height - frameInHost.size.height) > 0.5 { let expectedBounds = NSRect(origin: .zero, size: targetFrame.size)
hostedView.reconcileGeometryNow() if !Self.rectApproximatelyEqual(hostedView.bounds, expectedBounds) {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostedView.bounds = expectedBounds
CATransaction.commit()
} }
} }
if hostedView.isHidden != shouldHide { if shouldDeferReveal {
#if DEBUG
if !Self.rectApproximatelyEqual(oldFrame, frameInHost) {
dlog(
"portal.hidden.deferReveal hosted=\(portalDebugToken(hostedView)) " +
"frame=\(portalDebugFrame(frameInHost)) min=\(Int(Self.minimumRevealWidth))x\(Int(Self.minimumRevealHeight))"
)
}
#endif
}
if !shouldHide, hostedView.isHidden, revealReadyForDisplay {
#if DEBUG #if DEBUG
dlog( dlog(
"portal.hidden hosted=\(portalDebugToken(hostedView)) value=\(shouldHide ? 1 : 0) " + "portal.hidden hosted=\(portalDebugToken(hostedView)) value=0 " +
"visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " +
"tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + "tiny=\(tinyFrame ? 1 : 0) revealReady=\(revealReadyForDisplay ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
"outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))" "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " +
"host=\(portalDebugFrame(hostBounds))"
) )
#endif #endif
hostedView.isHidden = shouldHide hostedView.isHidden = false
// A reveal can happen without any frame delta (same targetFrame), which means the
// normal frame-change refresh path won't run. Nudge geometry + redraw so newly
// revealed terminals don't sit on a stale/blank IOSurface until later focus churn.
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow()
} }
#if DEBUG
dlog(
"portal.sync.result hosted=\(portalDebugToken(hostedView)) " +
"anchor=\(portalDebugToken(anchorView)) host=\(portalDebugToken(hostView)) " +
"hostWin=\(hostView.window?.windowNumber ?? -1) " +
"old=\(portalDebugFrame(oldFrame)) raw=\(portalDebugFrame(frameInHost)) " +
"target=\(portalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " +
"entryVisible=\(entry.visibleInUI ? 1 : 0) hostedHidden=\(hostedView.isHidden ? 1 : 0) " +
"hostBounds=\(portalDebugFrame(hostBounds))"
)
#endif
ensureDividerOverlayOnTop() ensureDividerOverlayOnTop()
} }
@ -927,6 +1306,7 @@ final class WindowTerminalPortal: NSObject {
} }
func tearDown() { func tearDown() {
removeGeometryObservers()
for hostedId in Array(entriesByHostedId.keys) { for hostedId in Array(entriesByHostedId.keys) {
detachHostedView(withId: hostedId) detachHostedView(withId: hostedId)
} }
@ -1093,6 +1473,15 @@ enum TerminalWindowPortalRegistry {
portal.updateEntryVisibility(forHostedId: hostedId, visibleInUI: visibleInUI) portal.updateEntryVisibility(forHostedId: hostedId, visibleInUI: visibleInUI)
} }
static func isHostedView(_ hostedView: GhosttySurfaceScrollView, boundTo anchorView: NSView) -> Bool {
let hostedId = ObjectIdentifier(hostedView)
guard let window = anchorView.window else { return false }
let windowId = ObjectIdentifier(window)
guard hostedToWindowId[hostedId] == windowId,
let portal = portalsByWindowId[windowId] else { return false }
return portal.isHostedViewBoundToAnchor(withId: hostedId, anchorView: anchorView)
}
static func viewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> NSView? { static func viewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> NSView? {
let portal = portal(for: window) let portal = portal(for: window)
return portal.viewAtWindowPoint(windowPoint) return portal.viewAtWindowPoint(windowPoint)

View file

@ -8,6 +8,8 @@ class UpdateController {
private(set) var updater: SPUUpdater private(set) var updater: SPUUpdater
private let userDriver: UpdateDriver private let userDriver: UpdateDriver
private var installCancellable: AnyCancellable? private var installCancellable: AnyCancellable?
private var attemptInstallCancellable: AnyCancellable?
private var didObserveAttemptUpdateProgress: Bool = false
private var noUpdateDismissCancellable: AnyCancellable? private var noUpdateDismissCancellable: AnyCancellable?
private var noUpdateDismissWorkItem: DispatchWorkItem? private var noUpdateDismissWorkItem: DispatchWorkItem?
private var readyCheckWorkItem: DispatchWorkItem? private var readyCheckWorkItem: DispatchWorkItem?
@ -46,6 +48,7 @@ class UpdateController {
deinit { deinit {
installCancellable?.cancel() installCancellable?.cancel()
attemptInstallCancellable?.cancel()
noUpdateDismissCancellable?.cancel() noUpdateDismissCancellable?.cancel()
noUpdateDismissWorkItem?.cancel() noUpdateDismissWorkItem?.cancel()
readyCheckWorkItem?.cancel() readyCheckWorkItem?.cancel()
@ -107,6 +110,35 @@ class UpdateController {
} }
} }
/// Check for updates and auto-confirm install if one is found.
func attemptUpdate() {
stopAttemptUpdateMonitoring()
didObserveAttemptUpdateProgress = false
attemptInstallCancellable = viewModel.$state
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self else { return }
if state.isInstallable || !state.isIdle {
self.didObserveAttemptUpdateProgress = true
}
if case .updateAvailable = state {
UpdateLogStore.shared.append("attemptUpdate auto-confirming available update")
state.confirm()
return
}
guard self.didObserveAttemptUpdateProgress, !state.isInstallable else {
return
}
self.stopAttemptUpdateMonitoring()
}
checkForUpdates()
}
/// Check for updates (used by the menu item). /// Check for updates (used by the menu item).
@objc func checkForUpdates() { @objc func checkForUpdates() {
UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"))") UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"))")
@ -175,6 +207,12 @@ class UpdateController {
return true return true
} }
private func stopAttemptUpdateMonitoring() {
attemptInstallCancellable?.cancel()
attemptInstallCancellable = nil
didObserveAttemptUpdateProgress = false
}
private func installNoUpdateDismissObserver() { private func installNoUpdateDismissObserver() {
noUpdateDismissCancellable = Publishers.CombineLatest(viewModel.$state, viewModel.$overrideState) noUpdateDismissCancellable = Publishers.CombineLatest(viewModel.$state, viewModel.$overrideState)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)

View file

@ -80,7 +80,9 @@ extension UpdateDriver: SPUUpdaterDelegate {
} }
} }
@MainActor
func updaterWillRelaunchApplication(_ updater: SPUUpdater) { func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
AppDelegate.shared?.persistSessionForUpdateRelaunch()
TerminalController.shared.stop() TerminalController.shared.stop()
NSApp.invalidateRestorableState() NSApp.invalidateRestorableState()
for window in NSApp.windows { for window in NSApp.windows {

View file

@ -333,7 +333,7 @@ struct TitlebarControlsView: View {
.foregroundColor(.white) .foregroundColor(.white)
.frame(width: config.badgeSize, height: config.badgeSize) .frame(width: config.badgeSize, height: config.badgeSize)
.background( .background(
Circle().fill(Color.accentColor) Circle().fill(cmuxAccentColor())
) )
.offset(x: config.badgeOffset.width, y: config.badgeOffset.height) .offset(x: config.badgeOffset.width, y: config.badgeOffset.height)
} }
@ -905,11 +905,11 @@ private struct NotificationPopoverRow: View {
Button(action: onOpen) { Button(action: onOpen) {
HStack(alignment: .top, spacing: 10) { HStack(alignment: .top, spacing: 10) {
Circle() Circle()
.fill(notification.isRead ? Color.clear : Color.accentColor) .fill(notification.isRead ? Color.clear : cmuxAccentColor())
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
.overlay( .overlay(
Circle() Circle()
.stroke(Color.accentColor.opacity(notification.isRead ? 0.2 : 1), lineWidth: 1) .stroke(cmuxAccentColor().opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
) )
.padding(.top, 6) .padding(.top, 6)

View file

@ -132,7 +132,7 @@ class UpdateViewModel: ObservableObject {
case .checking: case .checking:
return .secondary return .secondary
case .updateAvailable: case .updateAvailable:
return .accentColor return cmuxAccentColor()
case .downloading, .extracting, .installing: case .downloading, .extracting, .installing:
return .secondary return .secondary
case .notFound: case .notFound:
@ -147,7 +147,7 @@ class UpdateViewModel: ObservableObject {
case .permissionRequest: case .permissionRequest:
return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue) return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue)
case .updateAvailable: case .updateAvailable:
return .accentColor return cmuxAccentColor()
case .notFound: case .notFound:
return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.5, of: .black) ?? .systemBlue) return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.5, of: .black) ?? .systemBlue)
case .error: case .error:

View file

@ -1,6 +1,246 @@
import AppKit import AppKit
import Bonsplit
import SwiftUI import SwiftUI
private func windowDragHandleFormatPoint(_ point: NSPoint) -> String {
String(format: "(%.1f,%.1f)", point.x, point.y)
}
/// Runs the same action macOS titlebars use for double-click:
/// zoom by default, or minimize when the user preference is set.
@discardableResult
func performStandardTitlebarDoubleClick(window: NSWindow?) -> Bool {
guard let window else { return false }
let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) ?? [:]
if let action = (globalDefaults["AppleActionOnDoubleClick"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() {
switch action {
case "minimize":
window.miniaturize(nil)
return true
case "none":
return false
case "maximize", "zoom":
window.zoom(nil)
return true
default:
break
}
}
if let miniaturizeOnDoubleClick = globalDefaults["AppleMiniaturizeOnDoubleClick"] as? Bool,
miniaturizeOnDoubleClick {
window.miniaturize(nil)
return true
}
window.zoom(nil)
return true
}
private var windowDragSuppressionDepthKey: UInt8 = 0
func beginWindowDragSuppression(window: NSWindow?) -> Int? {
guard let window else { return nil }
let current = windowDragSuppressionDepth(window: window)
let next = current + 1
objc_setAssociatedObject(
window,
&windowDragSuppressionDepthKey,
NSNumber(value: next),
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
return next
}
@discardableResult
func endWindowDragSuppression(window: NSWindow?) -> Int {
guard let window else { return 0 }
let current = windowDragSuppressionDepth(window: window)
let next = max(0, current - 1)
if next == 0 {
objc_setAssociatedObject(window, &windowDragSuppressionDepthKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
} else {
objc_setAssociatedObject(
window,
&windowDragSuppressionDepthKey,
NSNumber(value: next),
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
return next
}
func windowDragSuppressionDepth(window: NSWindow?) -> Int {
guard let window,
let value = objc_getAssociatedObject(window, &windowDragSuppressionDepthKey) as? NSNumber else {
return 0
}
return value.intValue
}
func isWindowDragSuppressed(window: NSWindow?) -> Bool {
windowDragSuppressionDepth(window: window) > 0
}
@discardableResult
func clearWindowDragSuppression(window: NSWindow?) -> Int {
guard let window else { return 0 }
var depth = windowDragSuppressionDepth(window: window)
while depth > 0 {
depth = endWindowDragSuppression(window: window)
}
return depth
}
/// Temporarily enables window movability for explicit drag-handle drags, then
/// restores the previous movability state after `body` finishes.
@discardableResult
func withTemporaryWindowMovableEnabled(window: NSWindow?, _ body: () -> Void) -> Bool? {
guard let window else {
body()
return nil
}
let previousMovableState = window.isMovable
if !previousMovableState {
window.isMovable = true
}
defer {
if window.isMovable != previousMovableState {
window.isMovable = previousMovableState
}
}
body()
return previousMovableState
}
private enum WindowDragHandleHitTestState {
static var isResolvingTopHit = false
}
/// SwiftUI/AppKit hosting wrappers can appear as the top hit even for empty
/// titlebar space. Treat those as pass-through so explicit sibling checks decide.
func windowDragHandleShouldTreatTopHitAsPassiveHost(_ view: NSView) -> Bool {
let className = String(describing: type(of: view))
if className.contains("HostContainerView")
|| className.contains("AppKitWindowHostingView")
|| className.contains("NSHostingView") {
return true
}
if let window = view.window, view === window.contentView {
return true
}
return false
}
/// Returns whether the titlebar drag handle should capture a hit at `point`.
/// We only claim the hit when no sibling view already handles it, so interactive
/// controls layered in the titlebar (e.g. proxy folder icon) keep their gestures.
func windowDragHandleShouldCaptureHit(_ point: NSPoint, in dragHandleView: NSView) -> Bool {
if isWindowDragSuppressed(window: dragHandleView.window) {
// Recover from stale suppression if a prior interaction missed cleanup.
// We only keep suppression active while the left mouse button is down.
if (NSEvent.pressedMouseButtons & 0x1) == 0 {
let clearedDepth = clearWindowDragSuppression(window: dragHandleView.window)
#if DEBUG
dlog(
"titlebar.dragHandle.hitTest suppressionRecovered clearedDepth=\(clearedDepth) point=\(windowDragHandleFormatPoint(point))"
)
#endif
} else {
#if DEBUG
let depth = windowDragSuppressionDepth(window: dragHandleView.window)
dlog(
"titlebar.dragHandle.hitTest capture=false reason=suppressed depth=\(depth) point=\(windowDragHandleFormatPoint(point))"
)
#endif
return false
}
}
guard dragHandleView.bounds.contains(point) else {
#if DEBUG
dlog("titlebar.dragHandle.hitTest capture=false reason=outside point=\(windowDragHandleFormatPoint(point))")
#endif
return false
}
guard let superview = dragHandleView.superview else {
#if DEBUG
dlog("titlebar.dragHandle.hitTest capture=true reason=noSuperview point=\(windowDragHandleFormatPoint(point))")
#endif
return true
}
if let window = dragHandleView.window,
let contentView = window.contentView,
!WindowDragHandleHitTestState.isResolvingTopHit {
let pointInWindow = dragHandleView.convert(point, to: nil)
let pointInContent = contentView.convert(pointInWindow, from: nil)
WindowDragHandleHitTestState.isResolvingTopHit = true
let topHit = contentView.hitTest(pointInContent)
WindowDragHandleHitTestState.isResolvingTopHit = false
if let topHit {
let ownsTopHit = topHit === dragHandleView || topHit.isDescendant(of: dragHandleView)
let topHitBelongsToTitlebarOverlay = topHit === superview || topHit.isDescendant(of: superview)
let isPassiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(topHit)
#if DEBUG
dlog(
"titlebar.dragHandle.hitTest capture=\(ownsTopHit) strategy=windowTopHit point=\(windowDragHandleFormatPoint(point)) top=\(type(of: topHit)) inTitlebarOverlay=\(topHitBelongsToTitlebarOverlay) passiveHost=\(isPassiveHostHit)"
)
#endif
if ownsTopHit {
return true
}
// Underlay content can transiently overlap titlebar space (notably browser
// chrome/webview layers). Only let top-hits block capture when they belong
// to this titlebar overlay stack.
if topHitBelongsToTitlebarOverlay && !isPassiveHostHit {
return false
}
}
}
#if DEBUG
let siblingCount = superview.subviews.count
#endif
for sibling in superview.subviews.reversed() {
guard sibling !== dragHandleView else { continue }
guard !sibling.isHidden, sibling.alphaValue > 0 else { continue }
let pointInSibling = dragHandleView.convert(point, to: sibling)
if let hitView = sibling.hitTest(pointInSibling) {
let passiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(hitView)
if passiveHostHit {
#if DEBUG
dlog(
"titlebar.dragHandle.hitTest capture=defer point=\(windowDragHandleFormatPoint(point)) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=true"
)
#endif
continue
}
#if DEBUG
dlog(
"titlebar.dragHandle.hitTest capture=false point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=false"
)
#endif
return false
}
}
#if DEBUG
dlog("titlebar.dragHandle.hitTest capture=true point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount)")
#endif
return true
}
/// A transparent view that enables dragging the window when clicking in empty titlebar space. /// A transparent view that enables dragging the window when clicking in empty titlebar space.
/// This lets us keep `window.isMovableByWindowBackground = false` so drags in the app content /// This lets us keep `window.isMovableByWindowBackground = false` so drags in the app content
/// (e.g. sidebar tab reordering) don't move the whole window. /// (e.g. sidebar tab reordering) don't move the whole window.
@ -14,8 +254,55 @@ struct WindowDragHandleView: NSViewRepresentable {
} }
private final class DraggableView: NSView { private final class DraggableView: NSView {
override var mouseDownCanMoveWindow: Bool { true } override var mouseDownCanMoveWindow: Bool { false }
override func hitTest(_ point: NSPoint) -> NSView? { self }
override func hitTest(_ point: NSPoint) -> NSView? {
let shouldCapture = windowDragHandleShouldCaptureHit(point, in: self)
#if DEBUG
dlog(
"titlebar.dragHandle.hitTestResult capture=\(shouldCapture) point=\(windowDragHandleFormatPoint(point)) window=\(window != nil)"
)
#endif
return shouldCapture ? self : nil
}
override func mouseDown(with event: NSEvent) {
#if DEBUG
let point = convert(event.locationInWindow, from: nil)
let depth = windowDragSuppressionDepth(window: window)
dlog(
"titlebar.dragHandle.mouseDown point=\(windowDragHandleFormatPoint(point)) clickCount=\(event.clickCount) depth=\(depth)"
)
#endif
if event.clickCount >= 2 {
let handled = performStandardTitlebarDoubleClick(window: window)
#if DEBUG
dlog("titlebar.dragHandle.mouseDownDoubleClick handled=\(handled ? 1 : 0)")
#endif
if handled {
return
}
}
guard !isWindowDragSuppressed(window: window) else {
#if DEBUG
dlog("titlebar.dragHandle.mouseDownIgnored reason=suppressed")
#endif
return
}
if let window {
let previousMovableState = withTemporaryWindowMovableEnabled(window: window) {
window.performDrag(with: event)
}
#if DEBUG
let restored = previousMovableState.map { String($0) } ?? "nil"
dlog("titlebar.dragHandle.mouseDownComplete restoredMovable=\(restored) nowMovable=\(window.isMovable)")
#endif
} else {
super.mouseDown(with: event)
}
}
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -9,10 +9,27 @@ struct WorkspaceContentView: View {
let isWorkspaceVisible: Bool let isWorkspaceVisible: Bool
let isWorkspaceInputActive: Bool let isWorkspaceInputActive: Bool
let workspacePortalPriority: Int let workspacePortalPriority: Int
@State private var config = GhosttyConfig.load() let onThemeRefreshRequest: ((
_ reason: String,
_ backgroundEventId: UInt64?,
_ backgroundSource: String?,
_ notificationPayloadHex: String?
) -> Void)?
@State private var config = WorkspaceContentView.resolveGhosttyAppearanceConfig(reason: "stateInit")
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@EnvironmentObject var notificationStore: TerminalNotificationStore @EnvironmentObject var notificationStore: TerminalNotificationStore
static func panelVisibleInUI(
isWorkspaceVisible: Bool,
isSelectedInPane: Bool,
isFocused: Bool
) -> Bool {
guard isWorkspaceVisible else { return false }
// During pane/tab reparenting, Bonsplit can transiently report selected=false
// for the currently focused panel. Keep focused content visible to avoid blank frames.
return isSelectedInPane || isFocused
}
var body: some View { var body: some View {
let appearance = PanelAppearance.fromConfig(config) let appearance = PanelAppearance.fromConfig(config)
let isSplit = workspace.bonsplitController.allPaneIds.count > 1 || let isSplit = workspace.bonsplitController.allPaneIds.count > 1 ||
@ -41,7 +58,11 @@ struct WorkspaceContentView: View {
if let panel = workspace.panel(for: tab.id) { if let panel = workspace.panel(for: tab.id) {
let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
let isVisibleInUI = isWorkspaceVisible && isSelectedInPane let isVisibleInUI = Self.panelVisibleInUI(
isWorkspaceVisible: isWorkspaceVisible,
isSelectedInPane: isSelectedInPane,
isFocused: isFocused
)
let hasUnreadNotification = Workspace.shouldShowUnreadIndicator( let hasUnreadNotification = Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id), hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id),
isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id) isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id)
@ -61,7 +82,7 @@ struct WorkspaceContentView: View {
// indicator and where keyboard input/flash-focus actually lands. // indicator and where keyboard input/flash-focus actually lands.
guard isWorkspaceInputActive else { return } guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return } guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id) workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)
}, },
onRequestPanelFocus: { onRequestPanelFocus: {
guard isWorkspaceInputActive else { return } guard isWorkspaceInputActive else { return }
@ -87,7 +108,7 @@ struct WorkspaceContentView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { .onAppear {
syncBonsplitNotificationBadges() syncBonsplitNotificationBadges()
workspace.applyGhosttyChrome(backgroundColor: GhosttyApp.shared.defaultBackgroundColor) refreshGhosttyAppearanceConfig(reason: "onAppear")
} }
.onChange(of: notificationStore.notifications) { _, _ in .onChange(of: notificationStore.notifications) { _, _ in
syncBonsplitNotificationBadges() syncBonsplitNotificationBadges()
@ -96,18 +117,28 @@ struct WorkspaceContentView: View {
syncBonsplitNotificationBadges() syncBonsplitNotificationBadges()
} }
.onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in .onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in
refreshGhosttyAppearanceConfig() refreshGhosttyAppearanceConfig(reason: "ghosttyConfigDidReload")
} }
.onChange(of: colorScheme) { _, _ in .onChange(of: colorScheme) { oldValue, newValue in
// Keep split overlay color/opacity in sync with light/dark theme transitions. // Keep split overlay color/opacity in sync with light/dark theme transitions.
refreshGhosttyAppearanceConfig() refreshGhosttyAppearanceConfig(reason: "colorSchemeChanged:\(oldValue)->\(newValue)")
} }
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { notification in .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { notification in
if let backgroundColor = notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor { let payloadHex = (notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil"
workspace.applyGhosttyChrome(backgroundColor: backgroundColor) let eventId = (notification.userInfo?[GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value
} else { let source = (notification.userInfo?[GhosttyNotificationKey.backgroundSource] as? String) ?? "nil"
workspace.applyGhosttyChrome(backgroundColor: GhosttyApp.shared.defaultBackgroundColor) logTheme(
} "theme notification workspace=\(workspace.id.uuidString) event=\(eventId.map(String.init) ?? "nil") source=\(source) payload=\(payloadHex) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))"
)
// Payload ordering can lag across rapid config/theme updates.
// Resolve from GhosttyApp.shared.defaultBackgroundColor to keep tabs aligned
// with Ghostty's current runtime theme.
refreshGhosttyAppearanceConfig(
reason: "ghosttyDefaultBackgroundDidChange",
backgroundEventId: eventId,
backgroundSource: source,
notificationPayloadHex: payloadHex
)
} }
} }
@ -141,10 +172,95 @@ struct WorkspaceContentView: View {
} }
} }
private func refreshGhosttyAppearanceConfig() { static func resolveGhosttyAppearanceConfig(
let next = GhosttyConfig.load() reason: String = "unspecified",
config = next backgroundOverride: NSColor? = nil,
workspace.applyGhosttyChrome(from: next) loadConfig: () -> GhosttyConfig = GhosttyConfig.load,
defaultBackground: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor }
) -> GhosttyConfig {
var next = loadConfig()
let loadedBackgroundHex = next.backgroundColor.hexString()
let defaultBackgroundHex: String
let resolvedBackground: NSColor
if let backgroundOverride {
resolvedBackground = backgroundOverride
defaultBackgroundHex = "skipped"
} else {
let fallback = defaultBackground()
resolvedBackground = fallback
defaultBackgroundHex = fallback.hexString()
}
next.backgroundColor = resolvedBackground
if GhosttyApp.shared.backgroundLogEnabled {
GhosttyApp.shared.logBackground(
"theme resolve reason=\(reason) loadedBg=\(loadedBackgroundHex) overrideBg=\(backgroundOverride?.hexString() ?? "nil") defaultBg=\(defaultBackgroundHex) finalBg=\(next.backgroundColor.hexString()) theme=\(next.theme ?? "nil")"
)
}
return next
}
private func refreshGhosttyAppearanceConfig(
reason: String,
backgroundOverride: NSColor? = nil,
backgroundEventId: UInt64? = nil,
backgroundSource: String? = nil,
notificationPayloadHex: String? = nil
) {
let previousBackgroundHex = config.backgroundColor.hexString()
let next = Self.resolveGhosttyAppearanceConfig(
reason: reason,
backgroundOverride: backgroundOverride
)
let eventLabel = backgroundEventId.map(String.init) ?? "nil"
let sourceLabel = backgroundSource ?? "nil"
let payloadLabel = notificationPayloadHex ?? "nil"
let backgroundChanged = previousBackgroundHex != next.backgroundColor.hexString()
let shouldRequestTitlebarRefresh = backgroundChanged || reason == "onAppear"
logTheme(
"theme refresh begin workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) source=\(sourceLabel) payload=\(payloadLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString()) overrideBg=\(backgroundOverride?.hexString() ?? "nil")"
)
withTransaction(Transaction(animation: nil)) {
config = next
if shouldRequestTitlebarRefresh {
onThemeRefreshRequest?(
reason,
backgroundEventId,
backgroundSource,
notificationPayloadHex
)
}
}
if !shouldRequestTitlebarRefresh {
logTheme(
"theme refresh titlebar-skip workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString())"
)
}
logTheme(
"theme refresh config-applied workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) configBg=\(config.backgroundColor.hexString())"
)
let chromeReason =
"refreshGhosttyAppearanceConfig:reason=\(reason):event=\(eventLabel):source=\(sourceLabel):payload=\(payloadLabel)"
workspace.applyGhosttyChrome(from: next, reason: chromeReason)
if let terminalPanel = workspace.focusedTerminalPanel {
terminalPanel.applyWindowBackgroundIfActive()
logTheme(
"theme refresh terminal-applied workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) panel=\(workspace.focusedPanelId?.uuidString ?? "nil")"
)
} else {
logTheme(
"theme refresh terminal-skipped workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) focusedPanel=\(workspace.focusedPanelId?.uuidString ?? "nil")"
)
}
logTheme(
"theme refresh end workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) chromeBg=\(workspace.bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil")"
)
}
private func logTheme(_ message: String) {
guard GhosttyApp.shared.backgroundLogEnabled else { return }
GhosttyApp.shared.logBackground(message)
} }
} }

View file

@ -35,6 +35,10 @@ struct cmuxApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
init() { init() {
if SocketControlSettings.shouldBlockUntaggedDebugLaunch() {
Self.terminateForMissingLaunchTag()
}
Self.configureGhosttyEnvironment() Self.configureGhosttyEnvironment()
let startupAppearance = AppearanceSettings.resolvedMode() let startupAppearance = AppearanceSettings.resolvedMode()
@ -58,6 +62,14 @@ struct cmuxApp: App {
appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState) appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState)
} }
private static func terminateForMissingLaunchTag() -> Never {
let message = "error: refusing to launch untagged cmux DEV; start with ./scripts/reload.sh --tag <name> (or set CMUX_TAG for test harnesses)"
fputs("\(message)\n", stderr)
fflush(stderr)
NSLog("%@", message)
Darwin.exit(64)
}
private static func configureGhosttyEnvironment() { private static func configureGhosttyEnvironment() {
let fileManager = FileManager.default let fileManager = FileManager.default
let ghosttyAppResources = "/Applications/Ghostty.app/Contents/Resources/ghostty" let ghosttyAppResources = "/Applications/Ghostty.app/Contents/Resources/ghostty"
@ -211,7 +223,7 @@ struct cmuxApp: App {
GhosttyApp.shared.openConfigurationInTextEdit() GhosttyApp.shared.openConfigurationInTextEdit()
} }
Button("Reload Configuration") { Button("Reload Configuration") {
GhosttyApp.shared.reloadConfiguration() GhosttyApp.shared.reloadConfiguration(source: "menu.reload_configuration")
} }
.keyboardShortcut(",", modifiers: [.command, .shift]) .keyboardShortcut(",", modifiers: [.command, .shift])
Divider() Divider()
@ -357,12 +369,37 @@ struct cmuxApp: App {
} }
splitCommandButton(title: "New Workspace", shortcut: newWorkspaceMenuShortcut) { splitCommandButton(title: "New Workspace", shortcut: newWorkspaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).addTab() if let appDelegate = AppDelegate.shared {
if appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "menu.newWorkspace") == nil {
#if DEBUG
FocusLogStore.shared.append(
"cmdn.route phase=fallback_new_window src=menu.newWorkspace reason=workspace_creation_returned_nil"
)
#endif
appDelegate.openNewMainWindow(nil)
}
} else {
activeTabManager.addTab()
}
} }
} }
// Close tab/workspace // Close tab/workspace
CommandGroup(after: .newItem) { CommandGroup(after: .newItem) {
Button("Go to Workspace or Tab…") {
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow)
}
.keyboardShortcut("p", modifiers: [.command])
Button("Command Palette…") {
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow)
}
.keyboardShortcut("p", modifiers: [.command, .shift])
Divider()
// Terminal semantics: // Terminal semantics:
// Cmd+W closes the focused tab (with confirmation if needed). If this is the last // Cmd+W closes the focused tab (with confirmation if needed). If this is the last
// tab in the last workspace, it closes the window. // tab in the last workspace, it closes the window.
@ -378,7 +415,7 @@ struct cmuxApp: App {
} }
Button("Reopen Closed Browser Panel") { Button("Reopen Closed Browser Panel") {
_ = (AppDelegate.shared?.tabManager ?? tabManager).reopenMostRecentlyClosedBrowserPanel() _ = activeTabManager.reopenMostRecentlyClosedBrowserPanel()
} }
.keyboardShortcut("t", modifiers: [.command, .shift]) .keyboardShortcut("t", modifiers: [.command, .shift])
} }
@ -387,95 +424,97 @@ struct cmuxApp: App {
CommandGroup(after: .textEditing) { CommandGroup(after: .textEditing) {
Menu("Find") { Menu("Find") {
Button("Find…") { Button("Find…") {
(AppDelegate.shared?.tabManager ?? tabManager).startSearch() activeTabManager.startSearch()
} }
.keyboardShortcut("f", modifiers: .command) .keyboardShortcut("f", modifiers: .command)
Button("Find Next") { Button("Find Next") {
(AppDelegate.shared?.tabManager ?? tabManager).findNext() activeTabManager.findNext()
} }
.keyboardShortcut("g", modifiers: .command) .keyboardShortcut("g", modifiers: .command)
Button("Find Previous") { Button("Find Previous") {
(AppDelegate.shared?.tabManager ?? tabManager).findPrevious() activeTabManager.findPrevious()
} }
.keyboardShortcut("g", modifiers: [.command, .shift]) .keyboardShortcut("g", modifiers: [.command, .shift])
Divider() Divider()
Button("Hide Find Bar") { Button("Hide Find Bar") {
(AppDelegate.shared?.tabManager ?? tabManager).hideFind() activeTabManager.hideFind()
} }
.keyboardShortcut("f", modifiers: [.command, .shift]) .keyboardShortcut("f", modifiers: [.command, .shift])
.disabled(!((AppDelegate.shared?.tabManager ?? tabManager).isFindVisible)) .disabled(!(activeTabManager.isFindVisible))
Divider() Divider()
Button("Use Selection for Find") { Button("Use Selection for Find") {
(AppDelegate.shared?.tabManager ?? tabManager).searchSelection() activeTabManager.searchSelection()
} }
.keyboardShortcut("e", modifiers: .command) .keyboardShortcut("e", modifiers: .command)
.disabled(!((AppDelegate.shared?.tabManager ?? tabManager).canUseSelectionForFind)) .disabled(!(activeTabManager.canUseSelectionForFind))
} }
} }
// Tab navigation // Tab navigation
CommandGroup(after: .toolbar) { CommandGroup(after: .toolbar) {
splitCommandButton(title: "Toggle Sidebar", shortcut: toggleSidebarMenuShortcut) { splitCommandButton(title: "Toggle Sidebar", shortcut: toggleSidebarMenuShortcut) {
sidebarState.toggle() if AppDelegate.shared?.toggleSidebarInActiveMainWindow() != true {
sidebarState.toggle()
}
} }
Divider() Divider()
splitCommandButton(title: "Next Surface", shortcut: nextSurfaceMenuShortcut) { splitCommandButton(title: "Next Surface", shortcut: nextSurfaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).selectNextSurface() activeTabManager.selectNextSurface()
} }
splitCommandButton(title: "Previous Surface", shortcut: prevSurfaceMenuShortcut) { splitCommandButton(title: "Previous Surface", shortcut: prevSurfaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).selectPreviousSurface() activeTabManager.selectPreviousSurface()
} }
Button("Back") { Button("Back") {
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goBack() activeTabManager.focusedBrowserPanel?.goBack()
} }
.keyboardShortcut("[", modifiers: .command) .keyboardShortcut("[", modifiers: .command)
Button("Forward") { Button("Forward") {
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goForward() activeTabManager.focusedBrowserPanel?.goForward()
} }
.keyboardShortcut("]", modifiers: .command) .keyboardShortcut("]", modifiers: .command)
Button("Reload Page") { Button("Reload Page") {
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.reload() activeTabManager.focusedBrowserPanel?.reload()
} }
.keyboardShortcut("r", modifiers: .command) .keyboardShortcut("r", modifiers: .command)
splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) { splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) {
let manager = (AppDelegate.shared?.tabManager ?? tabManager) let manager = activeTabManager
if !manager.toggleDeveloperToolsFocusedBrowser() { if !manager.toggleDeveloperToolsFocusedBrowser() {
NSSound.beep() NSSound.beep()
} }
} }
splitCommandButton(title: "Show JavaScript Console", shortcut: showBrowserJavaScriptConsoleMenuShortcut) { splitCommandButton(title: "Show JavaScript Console", shortcut: showBrowserJavaScriptConsoleMenuShortcut) {
let manager = (AppDelegate.shared?.tabManager ?? tabManager) let manager = activeTabManager
if !manager.showJavaScriptConsoleFocusedBrowser() { if !manager.showJavaScriptConsoleFocusedBrowser() {
NSSound.beep() NSSound.beep()
} }
} }
Button("Zoom In") { Button("Zoom In") {
_ = (AppDelegate.shared?.tabManager ?? tabManager).zoomInFocusedBrowser() _ = activeTabManager.zoomInFocusedBrowser()
} }
.keyboardShortcut("=", modifiers: .command) .keyboardShortcut("=", modifiers: .command)
Button("Zoom Out") { Button("Zoom Out") {
_ = (AppDelegate.shared?.tabManager ?? tabManager).zoomOutFocusedBrowser() _ = activeTabManager.zoomOutFocusedBrowser()
} }
.keyboardShortcut("-", modifiers: .command) .keyboardShortcut("-", modifiers: .command)
Button("Actual Size") { Button("Actual Size") {
_ = (AppDelegate.shared?.tabManager ?? tabManager).resetZoomFocusedBrowser() _ = activeTabManager.resetZoomFocusedBrowser()
} }
.keyboardShortcut("0", modifiers: .command) .keyboardShortcut("0", modifiers: .command)
@ -484,11 +523,11 @@ struct cmuxApp: App {
} }
splitCommandButton(title: "Next Workspace", shortcut: nextWorkspaceMenuShortcut) { splitCommandButton(title: "Next Workspace", shortcut: nextWorkspaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).selectNextTab() activeTabManager.selectNextTab()
} }
splitCommandButton(title: "Previous Workspace", shortcut: prevWorkspaceMenuShortcut) { splitCommandButton(title: "Previous Workspace", shortcut: prevWorkspaceMenuShortcut) {
(AppDelegate.shared?.tabManager ?? tabManager).selectPreviousTab() activeTabManager.selectPreviousTab()
} }
splitCommandButton(title: "Rename Workspace…", shortcut: renameWorkspaceMenuShortcut) { splitCommandButton(title: "Rename Workspace…", shortcut: renameWorkspaceMenuShortcut) {
@ -518,7 +557,7 @@ struct cmuxApp: App {
// Cmd+1 through Cmd+9 for workspace selection (9 = last workspace) // Cmd+1 through Cmd+9 for workspace selection (9 = last workspace)
ForEach(1...9, id: \.self) { number in ForEach(1...9, id: \.self) { number in
Button("Workspace \(number)") { Button("Workspace \(number)") {
let manager = (AppDelegate.shared?.tabManager ?? tabManager) let manager = activeTabManager
if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: number, workspaceCount: manager.tabs.count) { if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: number, workspaceCount: manager.tabs.count) {
manager.selectTab(at: targetIndex) manager.selectTab(at: targetIndex)
} }
@ -689,6 +728,12 @@ struct cmuxApp: App {
NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications) NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications)
} }
private var activeTabManager: TabManager {
AppDelegate.shared?.synchronizeActiveMainWindowContext(
preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow
) ?? tabManager
}
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
guard !data.isEmpty, guard !data.isEmpty,
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
@ -740,11 +785,11 @@ struct cmuxApp: App {
window.performClose(nil) window.performClose(nil)
return return
} }
(AppDelegate.shared?.tabManager ?? tabManager).closeCurrentPanelWithConfirmation() activeTabManager.closeCurrentPanelWithConfirmation()
} }
private func closeTabOrWindow() { private func closeTabOrWindow() {
(AppDelegate.shared?.tabManager ?? tabManager).closeCurrentTabWithConfirmation() activeTabManager.closeCurrentTabWithConfirmation()
} }
private func showNotificationsPopover() { private func showNotificationsPopover() {
@ -2533,6 +2578,18 @@ enum QuitWarningSettings {
} }
} }
enum CommandPaletteRenameSelectionSettings {
static let selectAllOnFocusKey = "commandPalette.renameSelectAllOnFocus"
static let defaultSelectAllOnFocus = true
static func selectAllOnFocusEnabled(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: selectAllOnFocusKey) == nil {
return defaultSelectAllOnFocus
}
return defaults.bool(forKey: selectAllOnFocusKey)
}
}
enum ClaudeCodeIntegrationSettings { enum ClaudeCodeIntegrationSettings {
static let hooksEnabledKey = "claudeCodeHooksEnabled" static let hooksEnabledKey = "claudeCodeHooksEnabled"
static let defaultHooksEnabled = true static let defaultHooksEnabled = true
@ -2559,10 +2616,14 @@ struct SettingsView: View {
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
@AppStorage(BrowserThemeSettings.modeKey) private var browserThemeMode = BrowserThemeSettings.defaultMode.rawValue @AppStorage(BrowserThemeSettings.modeKey) private var browserThemeMode = BrowserThemeSettings.defaultMode.rawValue
@AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser @AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
@AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey)
private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue()
@AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist @AppStorage(BrowserLinkOpenSettings.browserHostWhitelistKey) private var browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist
@AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
@AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
@ -2765,6 +2826,19 @@ struct SettingsView: View {
SettingsCardDivider() SettingsCardDivider()
SettingsCardRow(
"Rename Selects Existing Name",
subtitle: commandPaletteRenameSelectAllOnFocus
? "Command Palette rename starts with all text selected."
: "Command Palette rename keeps the caret at the end."
) {
Toggle("", isOn: $commandPaletteRenameSelectAllOnFocus)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow( SettingsCardRow(
"Sidebar Branch Layout", "Sidebar Branch Layout",
subtitle: sidebarBranchVerticalLayout subtitle: sidebarBranchVerticalLayout
@ -3094,13 +3168,24 @@ struct SettingsView: View {
.controlSize(.small) .controlSize(.small)
} }
if openTerminalLinksInCmuxBrowser { SettingsCardDivider()
SettingsCardRow(
"Intercept open http(s) in Terminal",
subtitle: "When off, `open https://...` and `open http://...` always use your default browser."
) {
Toggle("", isOn: $interceptTerminalOpenCommandInCmuxBrowser)
.labelsHidden()
.controlSize(.small)
}
if openTerminalLinksInCmuxBrowser || interceptTerminalOpenCommandInCmuxBrowser {
SettingsCardDivider() SettingsCardDivider()
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
SettingsCardRow( SettingsCardRow(
"Hosts to Open in Embedded Browser", "Hosts to Open in Embedded Browser",
subtitle: "When you click links in terminal output, only these hosts open in cmux. Other hosts open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all links in cmux." subtitle: "Applies to terminal link clicks and intercepted `open https://...` calls. Only these hosts open in cmux. Others open in your default browser. One host or wildcard per line (for example: example.com, *.internal.example). Leave empty to open all hosts in cmux."
) { ) {
EmptyView() EmptyView()
} }
@ -3362,11 +3447,13 @@ struct SettingsView: View {
browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
browserThemeMode = BrowserThemeSettings.defaultMode.rawValue browserThemeMode = BrowserThemeSettings.defaultMode.rawValue
openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser
browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist browserHostWhitelist = BrowserLinkOpenSettings.defaultBrowserHostWhitelist
browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout

View file

@ -0,0 +1,450 @@
import XCTest
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
@MainActor
final class AppDelegateShortcutRoutingTests: XCTestCase {
func testCmdNUsesEventWindowContextWhenActiveManagerIsStale() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let firstWindowId = appDelegate.createMainWindow()
let secondWindowId = appDelegate.createMainWindow()
defer {
closeWindow(withId: firstWindowId)
closeWindow(withId: secondWindowId)
}
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
let secondWindow = window(withId: secondWindowId) else {
XCTFail("Expected both window contexts to exist")
return
}
let firstCount = firstManager.tabs.count
let secondCount = secondManager.tabs.count
XCTAssertTrue(appDelegate.focusMainWindow(windowId: firstWindowId))
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: secondWindow.windowNumber,
context: nil,
characters: "n",
charactersIgnoringModifiers: "n",
isARepeat: false,
keyCode: 45
) else {
XCTFail("Failed to construct Cmd+N event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
XCTAssertEqual(firstManager.tabs.count, firstCount, "Cmd+N should not add workspace to stale active window")
XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Cmd+N should add workspace to the event's window")
}
func testAddWorkspaceInPreferredMainWindowIgnoresStaleTabManagerPointer() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let firstWindowId = appDelegate.createMainWindow()
let secondWindowId = appDelegate.createMainWindow()
defer {
closeWindow(withId: firstWindowId)
closeWindow(withId: secondWindowId)
}
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
let secondWindow = window(withId: secondWindowId) else {
XCTFail("Expected both window contexts to exist")
return
}
let firstCount = firstManager.tabs.count
let secondCount = secondManager.tabs.count
secondWindow.makeKeyAndOrderFront(nil)
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
// Force a stale app-level pointer to a different manager.
appDelegate.tabManager = firstManager
XCTAssertTrue(appDelegate.tabManager === firstManager)
_ = appDelegate.addWorkspaceInPreferredMainWindow()
XCTAssertEqual(firstManager.tabs.count, firstCount, "Stale pointer must not receive menu-driven workspace creation")
XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Workspace creation should target key/main window context")
}
func testCmdNResolvesEventWindowWhenObjectKeyLookupIsMismatched() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let firstWindowId = appDelegate.createMainWindow()
let secondWindowId = appDelegate.createMainWindow()
defer {
closeWindow(withId: firstWindowId)
closeWindow(withId: secondWindowId)
}
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
let secondWindow = window(withId: secondWindowId) else {
XCTFail("Expected both window contexts to exist")
return
}
secondWindow.makeKeyAndOrderFront(nil)
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
#if DEBUG
XCTAssertTrue(appDelegate.debugInjectWindowContextKeyMismatch(windowId: secondWindowId))
#else
XCTFail("debugInjectWindowContextKeyMismatch is only available in DEBUG")
#endif
// Ensure stale active-manager pointer does not mask routing errors.
appDelegate.tabManager = firstManager
let firstCount = firstManager.tabs.count
let secondCount = secondManager.tabs.count
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: secondWindow.windowNumber,
context: nil,
characters: "n",
charactersIgnoringModifiers: "n",
isARepeat: false,
keyCode: 45
) else {
XCTFail("Failed to construct Cmd+N event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
XCTAssertEqual(firstManager.tabs.count, firstCount, "Cmd+N should not route to another window when object-key lookup misses")
XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Cmd+N should still route by event window metadata when object-key lookup misses")
}
func testAddWorkspaceInPreferredMainWindowUsesKeyWindowWhenObjectKeyLookupIsMismatched() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let firstWindowId = appDelegate.createMainWindow()
let secondWindowId = appDelegate.createMainWindow()
defer {
closeWindow(withId: firstWindowId)
closeWindow(withId: secondWindowId)
}
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
let secondWindow = window(withId: secondWindowId) else {
XCTFail("Expected both window contexts to exist")
return
}
secondWindow.makeKeyAndOrderFront(nil)
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
#if DEBUG
XCTAssertTrue(appDelegate.debugInjectWindowContextKeyMismatch(windowId: secondWindowId))
#else
XCTFail("debugInjectWindowContextKeyMismatch is only available in DEBUG")
#endif
// Stale pointer should not receive the new workspace.
appDelegate.tabManager = firstManager
let firstCount = firstManager.tabs.count
let secondCount = secondManager.tabs.count
_ = appDelegate.addWorkspaceInPreferredMainWindow()
XCTAssertEqual(firstManager.tabs.count, firstCount, "Menu-driven add workspace should not route to stale window")
XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Menu-driven add workspace should still route to key window context when object-key lookup misses")
}
func testCmdDigitRoutesToEventWindowWhenActiveManagerIsStale() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let firstWindowId = appDelegate.createMainWindow()
let secondWindowId = appDelegate.createMainWindow()
defer {
closeWindow(withId: firstWindowId)
closeWindow(withId: secondWindowId)
}
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
let secondWindow = window(withId: secondWindowId) else {
XCTFail("Expected both window contexts to exist")
return
}
_ = firstManager.addTab(select: true)
_ = secondManager.addTab(select: true)
guard let firstSelectedBefore = firstManager.selectedTabId,
let secondSelectedBefore = secondManager.selectedTabId else {
XCTFail("Expected selected tabs in both windows")
return
}
guard let secondFirstTabId = secondManager.tabs.first?.id else {
XCTFail("Expected at least one tab in second window")
return
}
appDelegate.tabManager = firstManager
XCTAssertTrue(appDelegate.tabManager === firstManager)
guard let event = makeKeyDownEvent(
key: "1",
modifiers: [.command],
keyCode: 18, // kVK_ANSI_1
windowNumber: secondWindow.windowNumber
) else {
XCTFail("Failed to construct Cmd+1 event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
XCTAssertEqual(firstManager.selectedTabId, firstSelectedBefore, "Cmd+1 must not select a tab in stale active window")
XCTAssertNotEqual(secondManager.selectedTabId, secondSelectedBefore, "Cmd+1 should change tab selection in event window")
XCTAssertEqual(secondManager.selectedTabId, secondFirstTabId, "Cmd+1 should select first tab in the event window")
XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window")
}
func testCmdTRoutesToEventWindowWhenActiveManagerIsStale() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let firstWindowId = appDelegate.createMainWindow()
let secondWindowId = appDelegate.createMainWindow()
defer {
closeWindow(withId: firstWindowId)
closeWindow(withId: secondWindowId)
}
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
let secondWindow = window(withId: secondWindowId),
let firstWorkspace = firstManager.selectedWorkspace,
let secondWorkspace = secondManager.selectedWorkspace else {
XCTFail("Expected both window contexts to exist")
return
}
let firstSurfaceCount = firstWorkspace.panels.count
let secondSurfaceCount = secondWorkspace.panels.count
appDelegate.tabManager = firstManager
XCTAssertTrue(appDelegate.tabManager === firstManager)
guard let event = makeKeyDownEvent(
key: "t",
modifiers: [.command],
keyCode: 17, // kVK_ANSI_T
windowNumber: secondWindow.windowNumber
) else {
XCTFail("Failed to construct Cmd+T event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
XCTAssertEqual(firstWorkspace.panels.count, firstSurfaceCount, "Cmd+T must not create a surface in stale active window")
XCTAssertEqual(secondWorkspace.panels.count, secondSurfaceCount + 1, "Cmd+T should create a surface in the event window")
XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window")
}
func testCmdDigitDoesNotFallbackToOtherWindowWhenEventWindowContextIsMissing() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let firstWindowId = appDelegate.createMainWindow()
let secondWindowId = appDelegate.createMainWindow()
defer {
closeWindow(withId: firstWindowId)
closeWindow(withId: secondWindowId)
}
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
let secondWindow = window(withId: secondWindowId) else {
XCTFail("Expected both window contexts to exist")
return
}
_ = firstManager.addTab(select: true)
_ = secondManager.addTab(select: true)
guard let firstSelectedBefore = firstManager.selectedTabId,
let secondSelectedBefore = secondManager.selectedTabId else {
XCTFail("Expected selected tabs in both windows")
return
}
secondWindow.makeKeyAndOrderFront(nil)
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
// Force stale app-level manager to first window while keyboard event
// references no known window.
appDelegate.tabManager = firstManager
guard let event = makeKeyDownEvent(
key: "1",
modifiers: [.command],
keyCode: 18,
windowNumber: Int.max
) else {
XCTFail("Failed to construct Cmd+1 event")
return
}
#if DEBUG
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
XCTAssertEqual(firstManager.selectedTabId, firstSelectedBefore, "Unresolved event window must not route Cmd+1 into stale manager")
XCTAssertEqual(secondManager.selectedTabId, secondSelectedBefore, "Unresolved event window must not route Cmd+1 into key/main fallback manager")
XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager")
}
func testCmdNDoesNotFallbackToOtherWindowWhenEventWindowContextIsMissing() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let firstWindowId = appDelegate.createMainWindow()
let secondWindowId = appDelegate.createMainWindow()
defer {
closeWindow(withId: firstWindowId)
closeWindow(withId: secondWindowId)
}
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
let secondWindow = window(withId: secondWindowId) else {
XCTFail("Expected both window contexts to exist")
return
}
secondWindow.makeKeyAndOrderFront(nil)
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
let firstCount = firstManager.tabs.count
let secondCount = secondManager.tabs.count
appDelegate.tabManager = firstManager
guard let event = makeKeyDownEvent(
key: "n",
modifiers: [.command],
keyCode: 45,
windowNumber: Int.max
) else {
XCTFail("Failed to construct Cmd+N event")
return
}
#if DEBUG
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
XCTAssertEqual(firstManager.tabs.count, firstCount, "Unresolved event window must not create workspace in stale manager")
XCTAssertEqual(secondManager.tabs.count, secondCount, "Unresolved event window must not create workspace in fallback window")
XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager")
}
private func makeKeyDownEvent(
key: String,
modifiers: NSEvent.ModifierFlags,
keyCode: UInt16,
windowNumber: Int
) -> NSEvent? {
NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: modifiers,
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: windowNumber,
context: nil,
characters: key,
charactersIgnoringModifiers: key,
isARepeat: false,
keyCode: keyCode
)
}
private func window(withId windowId: UUID) -> NSWindow? {
let identifier = "cmux.main.\(windowId.uuidString)"
return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier })
}
private func closeWindow(withId windowId: UUID) {
guard let window = window(withId: windowId) else { return }
window.performClose(nil)
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
}
}

File diff suppressed because it is too large Load diff

View file

@ -162,6 +162,69 @@ final class GhosttyConfigTests: XCTestCase {
) )
} }
func testDefaultBackgroundUpdateScopePrioritizesSurfaceOverAppAndUnscoped() {
XCTAssertTrue(
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
currentScope: .unscoped,
incomingScope: .app
)
)
XCTAssertTrue(
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
currentScope: .app,
incomingScope: .surface
)
)
XCTAssertTrue(
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
currentScope: .surface,
incomingScope: .surface
)
)
XCTAssertFalse(
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
currentScope: .surface,
incomingScope: .app
)
)
XCTAssertFalse(
GhosttyApp.shouldApplyDefaultBackgroundUpdate(
currentScope: .surface,
incomingScope: .unscoped
)
)
}
func testAppearanceChangeReloadsWhenColorSchemeChanges() {
XCTAssertTrue(
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
previousColorScheme: .dark,
currentColorScheme: .light
)
)
XCTAssertTrue(
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
previousColorScheme: nil,
currentColorScheme: .dark
)
)
}
func testAppearanceChangeSkipsReloadWhenColorSchemeUnchanged() {
XCTAssertFalse(
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
previousColorScheme: .light,
currentColorScheme: .light
)
)
XCTAssertFalse(
GhosttyApp.shouldReloadConfigurationForAppearanceChange(
previousColorScheme: .dark,
currentColorScheme: .dark
)
)
}
func testClaudeCodeIntegrationDefaultsToEnabledWhenUnset() { func testClaudeCodeIntegrationDefaultsToEnabledWhenUnset() {
let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)" let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else { guard let defaults = UserDefaults(suiteName: suiteName) else {
@ -208,6 +271,75 @@ final class GhosttyConfigTests: XCTestCase {
} }
} }
final class WorkspaceChromeThemeTests: XCTestCase {
func testResolvedChromeColorsUsesLightGhosttyBackground() {
guard let backgroundColor = NSColor(hex: "#FDF6E3") else {
XCTFail("Expected valid test color")
return
}
let colors = Workspace.resolvedChromeColors(from: backgroundColor)
XCTAssertEqual(colors.backgroundHex, "#FDF6E3")
XCTAssertNil(colors.borderHex)
}
func testResolvedChromeColorsUsesDarkGhosttyBackground() {
guard let backgroundColor = NSColor(hex: "#272822") else {
XCTFail("Expected valid test color")
return
}
let colors = Workspace.resolvedChromeColors(from: backgroundColor)
XCTAssertEqual(colors.backgroundHex, "#272822")
XCTAssertNil(colors.borderHex)
}
}
final class WorkspaceAppearanceConfigResolutionTests: XCTestCase {
func testResolvedAppearanceConfigPrefersGhosttyRuntimeBackgroundOverLoadedConfig() {
guard let loadedBackground = NSColor(hex: "#112233"),
let runtimeBackground = NSColor(hex: "#FDF6E3"),
let loadedForeground = NSColor(hex: "#ABCDEF") else {
XCTFail("Expected valid test colors")
return
}
var loaded = GhosttyConfig()
loaded.backgroundColor = loadedBackground
loaded.foregroundColor = loadedForeground
loaded.unfocusedSplitOpacity = 0.42
let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig(
loadConfig: { loaded },
defaultBackground: { runtimeBackground }
)
XCTAssertEqual(resolved.backgroundColor.hexString(), "#FDF6E3")
XCTAssertEqual(resolved.foregroundColor.hexString(), "#ABCDEF")
XCTAssertEqual(resolved.unfocusedSplitOpacity, 0.42, accuracy: 0.0001)
}
func testResolvedAppearanceConfigPrefersExplicitBackgroundOverride() {
guard let loadedBackground = NSColor(hex: "#112233"),
let runtimeBackground = NSColor(hex: "#FDF6E3"),
let explicitOverride = NSColor(hex: "#272822") else {
XCTFail("Expected valid test colors")
return
}
var loaded = GhosttyConfig()
loaded.backgroundColor = loadedBackground
let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig(
backgroundOverride: explicitOverride,
loadConfig: { loaded },
defaultBackground: { runtimeBackground }
)
XCTAssertEqual(resolved.backgroundColor.hexString(), "#272822")
}
}
final class NotificationBurstCoalescerTests: XCTestCase { final class NotificationBurstCoalescerTests: XCTestCase {
func testSignalsInSameBurstFlushOnce() { func testSignalsInSameBurstFlushOnce() {
let coalescer = NotificationBurstCoalescer(delay: 0.01) let coalescer = NotificationBurstCoalescer(delay: 0.01)
@ -271,6 +403,95 @@ final class NotificationBurstCoalescerTests: XCTestCase {
} }
} }
final class GhosttyDefaultBackgroundNotificationDispatcherTests: XCTestCase {
func testSignalCoalescesBurstToLatestBackground() {
guard let dark = NSColor(hex: "#272822"),
let light = NSColor(hex: "#FDF6E3") else {
XCTFail("Expected valid test colors")
return
}
let expectation = expectation(description: "coalesced notification")
expectation.expectedFulfillmentCount = 1
var postedUserInfos: [[AnyHashable: Any]] = []
let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(
delay: 0.01,
postNotification: { userInfo in
postedUserInfos.append(userInfo)
expectation.fulfill()
}
)
DispatchQueue.main.async {
dispatcher.signal(backgroundColor: dark, opacity: 0.95, eventId: 1, source: "test.dark")
dispatcher.signal(backgroundColor: light, opacity: 0.75, eventId: 2, source: "test.light")
}
wait(for: [expectation], timeout: 1.0)
XCTAssertEqual(postedUserInfos.count, 1)
XCTAssertEqual(
(postedUserInfos[0][GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString(),
"#FDF6E3"
)
XCTAssertEqual(
postedOpacity(from: postedUserInfos[0][GhosttyNotificationKey.backgroundOpacity]),
0.75,
accuracy: 0.0001
)
XCTAssertEqual(
(postedUserInfos[0][GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value,
2
)
XCTAssertEqual(
postedUserInfos[0][GhosttyNotificationKey.backgroundSource] as? String,
"test.light"
)
}
func testSignalAcrossSeparateBurstsPostsMultipleNotifications() {
guard let dark = NSColor(hex: "#272822"),
let light = NSColor(hex: "#FDF6E3") else {
XCTFail("Expected valid test colors")
return
}
let expectation = expectation(description: "two notifications")
expectation.expectedFulfillmentCount = 2
var postedHexes: [String] = []
let dispatcher = GhosttyDefaultBackgroundNotificationDispatcher(
delay: 0.01,
postNotification: { userInfo in
let hex = (userInfo[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil"
postedHexes.append(hex)
expectation.fulfill()
}
)
DispatchQueue.main.async {
dispatcher.signal(backgroundColor: dark, opacity: 1.0, eventId: 1, source: "test.dark")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
dispatcher.signal(backgroundColor: light, opacity: 1.0, eventId: 2, source: "test.light")
}
}
wait(for: [expectation], timeout: 1.0)
XCTAssertEqual(postedHexes, ["#272822", "#FDF6E3"])
}
private func postedOpacity(from value: Any?) -> Double {
if let value = value as? Double {
return value
}
if let value = value as? NSNumber {
return value.doubleValue
}
XCTFail("Expected background opacity payload")
return -1
}
}
final class RecentlyClosedBrowserStackTests: XCTestCase { final class RecentlyClosedBrowserStackTests: XCTestCase {
func testPopReturnsEntriesInLIFOOrder() { func testPopReturnsEntriesInLIFOOrder() {
var stack = RecentlyClosedBrowserStack(capacity: 20) var stack = RecentlyClosedBrowserStack(capacity: 20)
@ -470,6 +691,56 @@ final class SocketControlSettingsTests: XCTestCase {
"/tmp/cmux-staging.sock" "/tmp/cmux-staging.sock"
) )
} }
func testUntaggedDebugBundleBlockedWithoutLaunchTag() {
XCTAssertTrue(
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
environment: [:],
bundleIdentifier: "com.cmuxterm.app.debug",
isDebugBuild: true
)
)
}
func testUntaggedDebugBundleAllowedWithLaunchTag() {
XCTAssertFalse(
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
environment: ["CMUX_TAG": "tests-v1"],
bundleIdentifier: "com.cmuxterm.app.debug",
isDebugBuild: true
)
)
}
func testTaggedDebugBundleAllowedWithoutLaunchTag() {
XCTAssertFalse(
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
environment: [:],
bundleIdentifier: "com.cmuxterm.app.debug.tests-v1",
isDebugBuild: true
)
)
}
func testReleaseBuildIgnoresLaunchTagGate() {
XCTAssertFalse(
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
environment: [:],
bundleIdentifier: "com.cmuxterm.app.debug",
isDebugBuild: false
)
)
}
func testXCTestLaunchIgnoresLaunchTagGate() {
XCTAssertFalse(
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
environment: ["XCTestConfigurationFilePath": "/tmp/fake.xctestconfiguration"],
bundleIdentifier: "com.cmuxterm.app.debug",
isDebugBuild: true
)
)
}
} }
final class PostHogAnalyticsPropertiesTests: XCTestCase { final class PostHogAnalyticsPropertiesTests: XCTestCase {

View file

@ -0,0 +1,645 @@
import XCTest
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
final class SessionPersistenceTests: XCTestCase {
func testSaveAndLoadRoundTripWithCustomSnapshotPath() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false)
let snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion)
XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL))
let loaded = SessionPersistenceStore.load(fileURL: snapshotURL)
XCTAssertNotNil(loaded)
XCTAssertEqual(loaded?.version, SessionSnapshotSchema.currentVersion)
XCTAssertEqual(loaded?.windows.count, 1)
XCTAssertEqual(loaded?.windows.first?.sidebar.selection, .tabs)
let frame = try XCTUnwrap(loaded?.windows.first?.frame)
XCTAssertEqual(frame.x, 10, accuracy: 0.001)
XCTAssertEqual(frame.y, 20, accuracy: 0.001)
XCTAssertEqual(frame.width, 900, accuracy: 0.001)
XCTAssertEqual(frame.height, 700, accuracy: 0.001)
XCTAssertEqual(loaded?.windows.first?.display?.displayID, 42)
let visibleFrame = try XCTUnwrap(loaded?.windows.first?.display?.visibleFrame)
XCTAssertEqual(visibleFrame.y, 25, accuracy: 0.001)
}
func testSaveAndLoadRoundTripPreservesWorkspaceCustomColor() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false)
var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion)
snapshot.windows[0].tabManager.workspaces[0].customColor = "#C0392B"
XCTAssertTrue(SessionPersistenceStore.save(snapshot, fileURL: snapshotURL))
let loaded = SessionPersistenceStore.load(fileURL: snapshotURL)
XCTAssertEqual(
loaded?.windows.first?.tabManager.workspaces.first?.customColor,
"#C0392B"
)
}
func testWorkspaceCustomColorDecodeSupportsMissingLegacyField() throws {
var snapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion)
snapshot.windows[0].tabManager.workspaces[0].customColor = nil
let encoder = JSONEncoder()
let data = try encoder.encode(snapshot)
let json = try XCTUnwrap(String(data: data, encoding: .utf8))
XCTAssertFalse(json.contains("\"customColor\""))
let decoded = try JSONDecoder().decode(AppSessionSnapshot.self, from: data)
XCTAssertNil(decoded.windows.first?.tabManager.workspaces.first?.customColor)
}
func testLoadRejectsSchemaVersionMismatch() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let snapshotURL = tempDir.appendingPathComponent("session.json", isDirectory: false)
XCTAssertTrue(SessionPersistenceStore.save(makeSnapshot(version: SessionSnapshotSchema.currentVersion + 1), fileURL: snapshotURL))
XCTAssertNil(SessionPersistenceStore.load(fileURL: snapshotURL))
}
func testDefaultSnapshotPathSanitizesBundleIdentifier() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let path = SessionPersistenceStore.defaultSnapshotFileURL(
bundleIdentifier: "com.example/unsafe id",
appSupportDirectory: tempDir
)
XCTAssertNotNil(path)
XCTAssertTrue(path?.path.contains("com.example_unsafe_id") == true)
}
func testRestorePolicySkipsWhenLaunchHasExplicitArguments() {
let shouldRestore = SessionRestorePolicy.shouldAttemptRestore(
arguments: ["/Applications/cmux.app/Contents/MacOS/cmux", "--window", "window:1"],
environment: [:]
)
XCTAssertFalse(shouldRestore)
}
func testRestorePolicyAllowsFinderStyleLaunchArgumentsOnly() {
let shouldRestore = SessionRestorePolicy.shouldAttemptRestore(
arguments: ["/Applications/cmux.app/Contents/MacOS/cmux", "-psn_0_12345"],
environment: [:]
)
XCTAssertTrue(shouldRestore)
}
func testRestorePolicySkipsWhenRunningUnderXCTest() {
let shouldRestore = SessionRestorePolicy.shouldAttemptRestore(
arguments: ["/Applications/cmux.app/Contents/MacOS/cmux"],
environment: ["XCTestConfigurationFilePath": "/tmp/xctest.xctestconfiguration"]
)
XCTAssertFalse(shouldRestore)
}
func testSidebarWidthSanitizationClampsToPolicyRange() {
XCTAssertEqual(
SessionPersistencePolicy.sanitizedSidebarWidth(-20),
SessionPersistencePolicy.minimumSidebarWidth,
accuracy: 0.001
)
XCTAssertEqual(
SessionPersistencePolicy.sanitizedSidebarWidth(10_000),
SessionPersistencePolicy.maximumSidebarWidth,
accuracy: 0.001
)
XCTAssertEqual(
SessionPersistencePolicy.sanitizedSidebarWidth(nil),
SessionPersistencePolicy.defaultSidebarWidth,
accuracy: 0.001
)
}
func testSessionRectSnapshotEncodesXYWidthHeightKeys() throws {
let snapshot = SessionRectSnapshot(x: 101.25, y: 202.5, width: 903.75, height: 704.5)
let data = try JSONEncoder().encode(snapshot)
let object = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Double])
XCTAssertEqual(Set(object.keys), Set(["x", "y", "width", "height"]))
XCTAssertEqual(try XCTUnwrap(object["x"]), 101.25, accuracy: 0.001)
XCTAssertEqual(try XCTUnwrap(object["y"]), 202.5, accuracy: 0.001)
XCTAssertEqual(try XCTUnwrap(object["width"]), 903.75, accuracy: 0.001)
XCTAssertEqual(try XCTUnwrap(object["height"]), 704.5, accuracy: 0.001)
}
func testSessionBrowserPanelSnapshotHistoryRoundTrip() throws {
let source = SessionBrowserPanelSnapshot(
urlString: "https://example.com/current",
shouldRenderWebView: true,
pageZoom: 1.2,
developerToolsVisible: true,
backHistoryURLStrings: [
"https://example.com/a",
"https://example.com/b"
],
forwardHistoryURLStrings: [
"https://example.com/d"
]
)
let data = try JSONEncoder().encode(source)
let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: data)
XCTAssertEqual(decoded.urlString, source.urlString)
XCTAssertEqual(decoded.backHistoryURLStrings, source.backHistoryURLStrings)
XCTAssertEqual(decoded.forwardHistoryURLStrings, source.forwardHistoryURLStrings)
}
func testSessionBrowserPanelSnapshotHistoryDecodesWhenKeysAreMissing() throws {
let json = """
{
"urlString": "https://example.com/current",
"shouldRenderWebView": true,
"pageZoom": 1.0,
"developerToolsVisible": false
}
""".data(using: .utf8)!
let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: json)
XCTAssertEqual(decoded.urlString, "https://example.com/current")
XCTAssertNil(decoded.backHistoryURLStrings)
XCTAssertNil(decoded.forwardHistoryURLStrings)
}
func testScrollbackReplayEnvironmentWritesReplayFile() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let environment = SessionScrollbackReplayStore.replayEnvironment(
for: "line one\nline two\n",
tempDirectory: tempDir
)
let path = environment[SessionScrollbackReplayStore.environmentKey]
XCTAssertNotNil(path)
XCTAssertTrue(path?.hasPrefix(tempDir.path) == true)
guard let path else { return }
let contents = try? String(contentsOfFile: path, encoding: .utf8)
XCTAssertEqual(contents, "line one\nline two\n")
}
func testScrollbackReplayEnvironmentSkipsWhitespaceOnlyContent() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let environment = SessionScrollbackReplayStore.replayEnvironment(
for: " \n\t ",
tempDirectory: tempDir
)
XCTAssertTrue(environment.isEmpty)
}
func testScrollbackReplayEnvironmentPreservesANSIColorSequences() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let red = "\u{001B}[31m"
let reset = "\u{001B}[0m"
let source = "\(red)RED\(reset)\n"
let environment = SessionScrollbackReplayStore.replayEnvironment(
for: source,
tempDirectory: tempDir
)
guard let path = environment[SessionScrollbackReplayStore.environmentKey] else {
XCTFail("Expected replay file path")
return
}
guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else {
XCTFail("Expected replay file contents")
return
}
XCTAssertTrue(contents.contains("\(red)RED\(reset)"))
XCTAssertTrue(contents.hasPrefix(reset))
XCTAssertTrue(contents.hasSuffix(reset))
}
func testTruncatedScrollbackAvoidsLeadingPartialANSICSISequence() {
let maxChars = SessionPersistencePolicy.maxScrollbackCharactersPerTerminal
let source = "\u{001B}[31m"
+ String(repeating: "X", count: maxChars - 7)
+ "\u{001B}[0m"
guard let truncated = SessionPersistencePolicy.truncatedScrollback(source) else {
XCTFail("Expected truncated scrollback")
return
}
XCTAssertFalse(truncated.hasPrefix("31m"))
XCTAssertFalse(truncated.hasPrefix("[31m"))
XCTAssertFalse(truncated.hasPrefix("m"))
}
func testNormalizedExportedScreenPathAcceptsAbsoluteAndFileURL() {
XCTAssertEqual(
TerminalController.normalizedExportedScreenPath("/tmp/cmux-screen.txt"),
"/tmp/cmux-screen.txt"
)
XCTAssertEqual(
TerminalController.normalizedExportedScreenPath(" file:///tmp/cmux-screen.txt "),
"/tmp/cmux-screen.txt"
)
}
func testNormalizedExportedScreenPathRejectsRelativeAndWhitespace() {
XCTAssertNil(TerminalController.normalizedExportedScreenPath("relative/path.txt"))
XCTAssertNil(TerminalController.normalizedExportedScreenPath(" "))
XCTAssertNil(TerminalController.normalizedExportedScreenPath(nil))
}
func testShouldRemoveExportedScreenDirectoryOnlyWithinTemporaryRoot() {
let tempRoot = URL(fileURLWithPath: "/tmp")
.appendingPathComponent("cmux-export-tests-\(UUID().uuidString)", isDirectory: true)
let tempFile = tempRoot
.appendingPathComponent(UUID().uuidString, isDirectory: true)
.appendingPathComponent("screen.txt", isDirectory: false)
let outsideFile = URL(fileURLWithPath: "/Users/example/screen.txt")
XCTAssertTrue(
TerminalController.shouldRemoveExportedScreenDirectory(
fileURL: tempFile,
temporaryDirectory: tempRoot
)
)
XCTAssertFalse(
TerminalController.shouldRemoveExportedScreenDirectory(
fileURL: outsideFile,
temporaryDirectory: tempRoot
)
)
}
func testShouldRemoveExportedScreenFileOnlyWithinTemporaryRoot() {
let tempRoot = URL(fileURLWithPath: "/tmp")
.appendingPathComponent("cmux-export-tests-\(UUID().uuidString)", isDirectory: true)
let tempFile = tempRoot
.appendingPathComponent(UUID().uuidString, isDirectory: true)
.appendingPathComponent("screen.txt", isDirectory: false)
let outsideFile = URL(fileURLWithPath: "/Users/example/screen.txt")
XCTAssertTrue(
TerminalController.shouldRemoveExportedScreenFile(
fileURL: tempFile,
temporaryDirectory: tempRoot
)
)
XCTAssertFalse(
TerminalController.shouldRemoveExportedScreenFile(
fileURL: outsideFile,
temporaryDirectory: tempRoot
)
)
}
func testWindowUnregisterSnapshotPersistencePolicy() {
XCTAssertTrue(
AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: false)
)
XCTAssertFalse(
AppDelegate.shouldPersistSnapshotOnWindowUnregister(isTerminatingApp: true)
)
XCTAssertTrue(
AppDelegate.shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister(isTerminatingApp: false)
)
XCTAssertFalse(
AppDelegate.shouldRemoveSnapshotWhenNoWindowsRemainOnWindowUnregister(isTerminatingApp: true)
)
}
func testShouldSkipSessionSaveDuringStartupRestorePolicy() {
XCTAssertTrue(
AppDelegate.shouldSkipSessionSaveDuringStartupRestore(
isApplyingStartupSessionRestore: true,
includeScrollback: false
)
)
XCTAssertFalse(
AppDelegate.shouldSkipSessionSaveDuringStartupRestore(
isApplyingStartupSessionRestore: true,
includeScrollback: true
)
)
XCTAssertFalse(
AppDelegate.shouldSkipSessionSaveDuringStartupRestore(
isApplyingStartupSessionRestore: false,
includeScrollback: false
)
)
}
func testResolvedWindowFramePrefersSavedDisplayIdentity() {
let savedFrame = SessionRectSnapshot(x: 1_200, y: 100, width: 600, height: 400)
let savedDisplay = SessionDisplaySnapshot(
displayID: 2,
frame: SessionRectSnapshot(x: 1_000, y: 0, width: 1_000, height: 800),
visibleFrame: SessionRectSnapshot(x: 1_000, y: 0, width: 1_000, height: 800)
)
// Display 1 and 2 swapped horizontal positions between snapshot and restore.
let display1 = AppDelegate.SessionDisplayGeometry(
displayID: 1,
frame: CGRect(x: 1_000, y: 0, width: 1_000, height: 800),
visibleFrame: CGRect(x: 1_000, y: 0, width: 1_000, height: 800)
)
let display2 = AppDelegate.SessionDisplayGeometry(
displayID: 2,
frame: CGRect(x: 0, y: 0, width: 1_000, height: 800),
visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800)
)
let restored = AppDelegate.resolvedWindowFrame(
from: savedFrame,
display: savedDisplay,
availableDisplays: [display1, display2],
fallbackDisplay: display1
)
XCTAssertNotNil(restored)
guard let restored else { return }
XCTAssertTrue(display2.visibleFrame.intersects(restored))
XCTAssertFalse(display1.visibleFrame.intersects(restored))
XCTAssertEqual(restored.width, 600, accuracy: 0.001)
XCTAssertEqual(restored.height, 400, accuracy: 0.001)
XCTAssertEqual(restored.minX, 200, accuracy: 0.001)
XCTAssertEqual(restored.minY, 100, accuracy: 0.001)
}
func testResolvedWindowFrameKeepsIntersectingFrameWithoutDisplayMetadata() {
let savedFrame = SessionRectSnapshot(x: 120, y: 80, width: 500, height: 350)
let display = AppDelegate.SessionDisplayGeometry(
displayID: 1,
frame: CGRect(x: 0, y: 0, width: 1_000, height: 800),
visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800)
)
let restored = AppDelegate.resolvedWindowFrame(
from: savedFrame,
display: nil,
availableDisplays: [display],
fallbackDisplay: display
)
XCTAssertNotNil(restored)
guard let restored else { return }
XCTAssertEqual(restored.minX, 120, accuracy: 0.001)
XCTAssertEqual(restored.minY, 80, accuracy: 0.001)
XCTAssertEqual(restored.width, 500, accuracy: 0.001)
XCTAssertEqual(restored.height, 350, accuracy: 0.001)
}
func testResolvedStartupPrimaryWindowFrameFallsBackToPersistedGeometryWhenPrimaryMissing() {
let fallbackFrame = SessionRectSnapshot(x: 180, y: 140, width: 900, height: 640)
let fallbackDisplay = SessionDisplaySnapshot(
displayID: 1,
frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000),
visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000)
)
let display = AppDelegate.SessionDisplayGeometry(
displayID: 1,
frame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000),
visibleFrame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000)
)
let restored = AppDelegate.resolvedStartupPrimaryWindowFrame(
primarySnapshot: nil,
fallbackFrame: fallbackFrame,
fallbackDisplaySnapshot: fallbackDisplay,
availableDisplays: [display],
fallbackDisplay: display
)
XCTAssertNotNil(restored)
guard let restored else { return }
XCTAssertEqual(restored.minX, 180, accuracy: 0.001)
XCTAssertEqual(restored.minY, 140, accuracy: 0.001)
XCTAssertEqual(restored.width, 900, accuracy: 0.001)
XCTAssertEqual(restored.height, 640, accuracy: 0.001)
}
func testResolvedStartupPrimaryWindowFramePrefersPrimarySnapshotOverFallback() {
let primarySnapshot = SessionWindowSnapshot(
frame: SessionRectSnapshot(x: 220, y: 160, width: 980, height: 700),
display: SessionDisplaySnapshot(
displayID: 1,
frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000),
visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000)
),
tabManager: SessionTabManagerSnapshot(selectedWorkspaceIndex: nil, workspaces: []),
sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: 220)
)
let fallbackFrame = SessionRectSnapshot(x: 40, y: 30, width: 700, height: 500)
let fallbackDisplay = SessionDisplaySnapshot(
displayID: 1,
frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000),
visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000)
)
let display = AppDelegate.SessionDisplayGeometry(
displayID: 1,
frame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000),
visibleFrame: CGRect(x: 0, y: 0, width: 1_600, height: 1_000)
)
let restored = AppDelegate.resolvedStartupPrimaryWindowFrame(
primarySnapshot: primarySnapshot,
fallbackFrame: fallbackFrame,
fallbackDisplaySnapshot: fallbackDisplay,
availableDisplays: [display],
fallbackDisplay: display
)
XCTAssertNotNil(restored)
guard let restored else { return }
XCTAssertEqual(restored.minX, 220, accuracy: 0.001)
XCTAssertEqual(restored.minY, 160, accuracy: 0.001)
XCTAssertEqual(restored.width, 980, accuracy: 0.001)
XCTAssertEqual(restored.height, 700, accuracy: 0.001)
}
func testResolvedWindowFrameCentersInFallbackDisplayWhenOffscreen() {
let savedFrame = SessionRectSnapshot(x: 4_000, y: 4_000, width: 900, height: 700)
let display = AppDelegate.SessionDisplayGeometry(
displayID: 1,
frame: CGRect(x: 0, y: 0, width: 1_000, height: 800),
visibleFrame: CGRect(x: 0, y: 0, width: 1_000, height: 800)
)
let restored = AppDelegate.resolvedWindowFrame(
from: savedFrame,
display: nil,
availableDisplays: [display],
fallbackDisplay: display
)
XCTAssertNotNil(restored)
guard let restored else { return }
XCTAssertTrue(display.visibleFrame.contains(restored))
XCTAssertEqual(restored.minX, 50, accuracy: 0.001)
XCTAssertEqual(restored.minY, 50, accuracy: 0.001)
XCTAssertEqual(restored.width, 900, accuracy: 0.001)
XCTAssertEqual(restored.height, 700, accuracy: 0.001)
}
func testResolvedWindowFramePreservesExactGeometryWhenDisplayIsUnchanged() {
let savedFrame = SessionRectSnapshot(x: 1_303, y: -90, width: 1_280, height: 1_410)
let savedDisplay = SessionDisplaySnapshot(
displayID: 2,
frame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_440),
visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_410)
)
let display = AppDelegate.SessionDisplayGeometry(
displayID: 2,
frame: CGRect(x: 0, y: 0, width: 2_560, height: 1_440),
visibleFrame: CGRect(x: 0, y: 0, width: 2_560, height: 1_410)
)
let restored = AppDelegate.resolvedWindowFrame(
from: savedFrame,
display: savedDisplay,
availableDisplays: [display],
fallbackDisplay: display
)
XCTAssertNotNil(restored)
guard let restored else { return }
XCTAssertEqual(restored.minX, 1_303, accuracy: 0.001)
XCTAssertEqual(restored.minY, -90, accuracy: 0.001)
XCTAssertEqual(restored.width, 1_280, accuracy: 0.001)
XCTAssertEqual(restored.height, 1_410, accuracy: 0.001)
}
func testResolvedWindowFrameClampsWhenDisplayGeometryChangesEvenWithSameDisplayID() {
let savedFrame = SessionRectSnapshot(x: 1_303, y: -90, width: 1_280, height: 1_410)
let savedDisplay = SessionDisplaySnapshot(
displayID: 2,
frame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_440),
visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_410)
)
let resizedDisplay = AppDelegate.SessionDisplayGeometry(
displayID: 2,
frame: CGRect(x: 0, y: 0, width: 1_920, height: 1_080),
visibleFrame: CGRect(x: 0, y: 0, width: 1_920, height: 1_050)
)
let restored = AppDelegate.resolvedWindowFrame(
from: savedFrame,
display: savedDisplay,
availableDisplays: [resizedDisplay],
fallbackDisplay: resizedDisplay
)
XCTAssertNotNil(restored)
guard let restored else { return }
XCTAssertTrue(resizedDisplay.visibleFrame.contains(restored))
XCTAssertNotEqual(restored.minX, 1_303, "Changed display geometry should clamp/remap frame")
XCTAssertNotEqual(restored.minY, -90, "Changed display geometry should clamp/remap frame")
}
func testResolvedSnapshotTerminalScrollbackPrefersCaptured() {
let resolved = Workspace.resolvedSnapshotTerminalScrollback(
capturedScrollback: "captured-value",
fallbackScrollback: "fallback-value"
)
XCTAssertEqual(resolved, "captured-value")
}
func testResolvedSnapshotTerminalScrollbackFallsBackWhenCaptureMissing() {
let resolved = Workspace.resolvedSnapshotTerminalScrollback(
capturedScrollback: nil,
fallbackScrollback: "fallback-value"
)
XCTAssertEqual(resolved, "fallback-value")
}
func testResolvedSnapshotTerminalScrollbackTruncatesFallback() {
let oversizedFallback = String(
repeating: "x",
count: SessionPersistencePolicy.maxScrollbackCharactersPerTerminal + 37
)
let resolved = Workspace.resolvedSnapshotTerminalScrollback(
capturedScrollback: nil,
fallbackScrollback: oversizedFallback
)
XCTAssertEqual(
resolved?.count,
SessionPersistencePolicy.maxScrollbackCharactersPerTerminal
)
}
private func makeSnapshot(version: Int) -> AppSessionSnapshot {
let workspace = SessionWorkspaceSnapshot(
processTitle: "Terminal",
customTitle: "Restored",
customColor: nil,
isPinned: true,
currentDirectory: "/tmp",
focusedPanelId: nil,
layout: .pane(SessionPaneLayoutSnapshot(panelIds: [], selectedPanelId: nil)),
panels: [],
statusEntries: [],
logEntries: [],
progress: nil,
gitBranch: nil
)
let tabManager = SessionTabManagerSnapshot(
selectedWorkspaceIndex: 0,
workspaces: [workspace]
)
let window = SessionWindowSnapshot(
frame: SessionRectSnapshot(x: 10, y: 20, width: 900, height: 700),
display: SessionDisplaySnapshot(
displayID: 42,
frame: SessionRectSnapshot(x: 0, y: 0, width: 1920, height: 1200),
visibleFrame: SessionRectSnapshot(x: 0, y: 25, width: 1920, height: 1175)
),
tabManager: tabManager,
sidebar: SessionSidebarSnapshot(isVisible: true, selection: .tabs, width: 240)
)
return AppSessionSnapshot(
version: version,
createdAt: Date().timeIntervalSince1970,
windows: [window]
)
}
}

View file

@ -0,0 +1,49 @@
import XCTest
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
final class WorkspaceContentViewVisibilityTests: XCTestCase {
func testPanelVisibleInUIReturnsFalseWhenWorkspaceHidden() {
XCTAssertFalse(
WorkspaceContentView.panelVisibleInUI(
isWorkspaceVisible: false,
isSelectedInPane: true,
isFocused: true
)
)
}
func testPanelVisibleInUIReturnsTrueForSelectedPanel() {
XCTAssertTrue(
WorkspaceContentView.panelVisibleInUI(
isWorkspaceVisible: true,
isSelectedInPane: true,
isFocused: false
)
)
}
func testPanelVisibleInUIReturnsTrueForFocusedPanelDuringTransientSelectionGap() {
XCTAssertTrue(
WorkspaceContentView.panelVisibleInUI(
isWorkspaceVisible: true,
isSelectedInPane: false,
isFocused: true
)
)
}
func testPanelVisibleInUIReturnsFalseWhenNeitherSelectedNorFocused() {
XCTAssertFalse(
WorkspaceContentView.panelVisibleInUI(
isWorkspaceVisible: true,
isSelectedInPane: false,
isFocused: false
)
)
}
}

View file

@ -1,4 +1,5 @@
import XCTest import XCTest
import AppKit
#if canImport(cmux_DEV) #if canImport(cmux_DEV)
@testable import cmux_DEV @testable import cmux_DEV
@ -106,3 +107,333 @@ final class WorkspaceManualUnreadTests: XCTestCase {
) )
} }
} }
final class CommandPaletteFuzzyMatcherTests: XCTestCase {
func testExactMatchScoresHigherThanPrefixAndContains() {
let exact = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "rename tab")
let prefix = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "rename tab now")
let contains = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "command rename tab flow")
XCTAssertNotNil(exact)
XCTAssertNotNil(prefix)
XCTAssertNotNil(contains)
XCTAssertGreaterThan(exact ?? 0, prefix ?? 0)
XCTAssertGreaterThan(prefix ?? 0, contains ?? 0)
}
func testInitialismMatchReturnsScore() {
let score = CommandPaletteFuzzyMatcher.score(query: "ocdi", candidate: "open current directory in ide")
XCTAssertNotNil(score)
XCTAssertGreaterThan(score ?? 0, 0)
}
func testLongTokenLooseSubsequenceDoesNotMatch() {
let score = CommandPaletteFuzzyMatcher.score(query: "rename", candidate: "open current directory in ide")
XCTAssertNil(score)
}
func testStitchedWordPrefixMatchesRetabForRenameTab() {
let score = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Rename Tab…")
XCTAssertNotNil(score)
XCTAssertGreaterThan(score ?? 0, 0)
}
func testRetabPrefersRenameTabOverDistantTabWord() {
let renameTabScore = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Rename Tab…")
let reopenTabScore = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Reopen Closed Browser Tab")
XCTAssertNotNil(renameTabScore)
XCTAssertNotNil(reopenTabScore)
XCTAssertGreaterThan(renameTabScore ?? 0, reopenTabScore ?? 0)
}
func testRenameScoresHigherThanUnrelatedCommand() {
let renameScore = CommandPaletteFuzzyMatcher.score(
query: "rename",
candidates: ["Rename Tab…", "Tab • Terminal 1", "rename", "tab", "title"]
)
let unrelatedScore = CommandPaletteFuzzyMatcher.score(
query: "rename",
candidates: [
"Open Current Directory in IDE",
"Terminal • Terminal 1",
"terminal",
"directory",
"open",
"ide",
"code",
"default app"
]
)
XCTAssertNotNil(renameScore)
XCTAssertNotNil(unrelatedScore)
XCTAssertGreaterThan(renameScore ?? 0, unrelatedScore ?? 0)
}
func testTokenMatchingRequiresAllTokens() {
let match = CommandPaletteFuzzyMatcher.score(
query: "rename workspace",
candidates: ["Rename Workspace", "Workspace settings"]
)
let miss = CommandPaletteFuzzyMatcher.score(
query: "rename workspace",
candidates: ["Rename Tab", "Tab settings"]
)
XCTAssertNotNil(match)
XCTAssertNil(miss)
}
func testEmptyQueryReturnsZeroScore() {
let score = CommandPaletteFuzzyMatcher.score(query: " ", candidate: "anything")
XCTAssertEqual(score, 0)
}
func testMatchCharacterIndicesForContainsMatch() {
let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices(
query: "workspace",
candidate: "New Workspace"
)
XCTAssertTrue(indices.contains(4))
XCTAssertTrue(indices.contains(12))
XCTAssertFalse(indices.contains(0))
}
func testMatchCharacterIndicesForSubsequenceMatch() {
let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices(
query: "nws",
candidate: "New Workspace"
)
XCTAssertTrue(indices.contains(0))
XCTAssertTrue(indices.contains(2))
XCTAssertTrue(indices.contains(8))
}
func testMatchCharacterIndicesForStitchedWordPrefixMatch() {
let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices(
query: "retab",
candidate: "Rename Tab…"
)
XCTAssertTrue(indices.contains(0))
XCTAssertTrue(indices.contains(1))
XCTAssertTrue(indices.contains(7))
XCTAssertTrue(indices.contains(8))
XCTAssertTrue(indices.contains(9))
}
}
final class CommandPaletteSwitcherSearchIndexerTests: XCTestCase {
func testKeywordsIncludeDirectoryBranchAndPortMetadata() {
let metadata = CommandPaletteSwitcherSearchMetadata(
directories: ["/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"],
branches: ["feature/cmd-palette-indexing"],
ports: [3000, 9222]
)
let keywords = CommandPaletteSwitcherSearchIndexer.keywords(
baseKeywords: ["workspace", "switch"],
metadata: metadata
)
XCTAssertTrue(keywords.contains("/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"))
XCTAssertTrue(keywords.contains("feat-cmd-palette"))
XCTAssertTrue(keywords.contains("feature/cmd-palette-indexing"))
XCTAssertTrue(keywords.contains("cmd-palette-indexing"))
XCTAssertTrue(keywords.contains("3000"))
XCTAssertTrue(keywords.contains(":9222"))
}
func testFuzzyMatcherMatchesDirectoryBranchAndPortMetadata() {
let metadata = CommandPaletteSwitcherSearchMetadata(
directories: ["/tmp/cmuxterm/worktrees/issue-123-switcher-search"],
branches: ["fix/switcher-metadata"],
ports: [4317]
)
let candidates = CommandPaletteSwitcherSearchIndexer.keywords(
baseKeywords: ["workspace"],
metadata: metadata
)
XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "switcher-search", candidates: candidates))
XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "switcher-metadata", candidates: candidates))
XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "4317", candidates: candidates))
}
func testWorkspaceDetailOmitsSplitDirectoryAndBranchTokens() {
let metadata = CommandPaletteSwitcherSearchMetadata(
directories: ["/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"],
branches: ["feature/cmd-palette-indexing"],
ports: [3000]
)
let keywords = CommandPaletteSwitcherSearchIndexer.keywords(
baseKeywords: ["workspace"],
metadata: metadata,
detail: .workspace
)
XCTAssertTrue(keywords.contains("/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"))
XCTAssertTrue(keywords.contains("feature/cmd-palette-indexing"))
XCTAssertTrue(keywords.contains("3000"))
XCTAssertFalse(keywords.contains("feat-cmd-palette"))
XCTAssertFalse(keywords.contains("cmd-palette-indexing"))
}
func testSurfaceDetailOutranksWorkspaceDetailForPathToken() {
let metadata = CommandPaletteSwitcherSearchMetadata(
directories: ["/tmp/worktrees/cmux"],
branches: ["feature/cmd-palette"],
ports: []
)
let workspaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords(
baseKeywords: ["workspace"],
metadata: metadata,
detail: .workspace
)
let surfaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords(
baseKeywords: ["surface"],
metadata: metadata,
detail: .surface
)
let workspaceScore = try XCTUnwrap(
CommandPaletteFuzzyMatcher.score(query: "cmux", candidates: workspaceKeywords)
)
let surfaceScore = try XCTUnwrap(
CommandPaletteFuzzyMatcher.score(query: "cmux", candidates: surfaceKeywords)
)
XCTAssertGreaterThan(
surfaceScore,
workspaceScore,
"Surface rows should rank ahead of workspace rows for directory-token matches."
)
}
}
@MainActor
final class CommandPaletteRequestRoutingTests: XCTestCase {
private func makeWindow() -> NSWindow {
NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
}
func testRequestedWindowTargetsOnlyMatchingObservedWindow() {
let windowA = makeWindow()
let windowB = makeWindow()
XCTAssertTrue(
ContentView.shouldHandleCommandPaletteRequest(
observedWindow: windowA,
requestedWindow: windowA,
keyWindow: windowA,
mainWindow: windowA
)
)
XCTAssertFalse(
ContentView.shouldHandleCommandPaletteRequest(
observedWindow: windowB,
requestedWindow: windowA,
keyWindow: windowA,
mainWindow: windowA
)
)
}
func testNilRequestedWindowFallsBackToKeyWindow() {
let key = makeWindow()
let other = makeWindow()
XCTAssertTrue(
ContentView.shouldHandleCommandPaletteRequest(
observedWindow: key,
requestedWindow: nil,
keyWindow: key,
mainWindow: nil
)
)
XCTAssertFalse(
ContentView.shouldHandleCommandPaletteRequest(
observedWindow: other,
requestedWindow: nil,
keyWindow: key,
mainWindow: nil
)
)
}
func testNilRequestedAndKeyFallsBackToMainWindow() {
let main = makeWindow()
let other = makeWindow()
XCTAssertTrue(
ContentView.shouldHandleCommandPaletteRequest(
observedWindow: main,
requestedWindow: nil,
keyWindow: nil,
mainWindow: main
)
)
XCTAssertFalse(
ContentView.shouldHandleCommandPaletteRequest(
observedWindow: other,
requestedWindow: nil,
keyWindow: nil,
mainWindow: main
)
)
}
func testNoObservedWindowNeverHandlesRequest() {
XCTAssertFalse(
ContentView.shouldHandleCommandPaletteRequest(
observedWindow: nil,
requestedWindow: makeWindow(),
keyWindow: makeWindow(),
mainWindow: makeWindow()
)
)
}
}
final class CommandPaletteBackNavigationTests: XCTestCase {
func testBackspaceOnEmptyRenameInputReturnsToCommandList() {
XCTAssertTrue(
ContentView.commandPaletteShouldPopRenameInputOnDelete(
renameDraft: "",
modifiers: []
)
)
}
func testBackspaceWithRenameTextDoesNotReturnToCommandList() {
XCTAssertFalse(
ContentView.commandPaletteShouldPopRenameInputOnDelete(
renameDraft: "Terminal 1",
modifiers: []
)
)
}
func testModifiedBackspaceDoesNotReturnToCommandList() {
XCTAssertFalse(
ContentView.commandPaletteShouldPopRenameInputOnDelete(
renameDraft: "",
modifiers: [.control]
)
)
XCTAssertFalse(
ContentView.commandPaletteShouldPopRenameInputOnDelete(
renameDraft: "",
modifiers: [.command]
)
)
}
}

View file

@ -546,6 +546,68 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
} }
} }
func testCtrlDEarlyDuringSplitStartupKeepsWindowOpen() {
let attempts = 12
for attempt in 1...attempts {
let app = XCUIApplication()
let dataPath = "/tmp/cmux-ui-test-child-exit-keyboard-lr-early-ctrl-\(UUID().uuidString).json"
try? FileManager.default.removeItem(atPath: dataPath)
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_LAYOUT"] = "lr"
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_EXPECTED_PANELS_AFTER"] = "1"
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_AUTO_TRIGGER"] = "1"
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_STRICT"] = "1"
app.launchEnvironment["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_TRIGGER_MODE"] = "early_ctrl_d"
app.launch()
app.activate()
defer { app.terminate() }
XCTAssertTrue(
waitForAnyJSON(atPath: dataPath, timeout: 12.0),
"Attempt \(attempt): expected early Ctrl+D setup data at \(dataPath)"
)
guard let done = waitForJSONKey("done", equals: "1", atPath: dataPath, timeout: 10.0) else {
XCTFail("Attempt \(attempt): timed out waiting for done=1 after early Ctrl+D. data=\(loadJSON(atPath: dataPath) ?? [:])")
return
}
if let setupError = done["setupError"], !setupError.isEmpty {
XCTFail("Attempt \(attempt): setup failed: \(setupError)")
return
}
let workspaceCountAfter = Int(done["workspaceCountAfter"] ?? "") ?? -1
let panelCountAfter = Int(done["panelCountAfter"] ?? "") ?? -1
let closedWorkspace = (done["closedWorkspace"] ?? "") == "1"
let timedOut = (done["timedOut"] ?? "") == "1"
let triggerMode = done["autoTriggerMode"] ?? ""
let exitPanelId = done["exitPanelId"] ?? ""
let workspaceId = done["workspaceId"] ?? ""
let probeSurfaceId = done["probeShowChildExitedSurfaceId"] ?? ""
let probeTabId = done["probeShowChildExitedTabId"] ?? ""
XCTAssertFalse(timedOut, "Attempt \(attempt): early Ctrl+D timed out. data=\(done)")
XCTAssertEqual(triggerMode, "strict_early_ctrl_d", "Attempt \(attempt): expected strict early Ctrl+D trigger mode. data=\(done)")
XCTAssertFalse(closedWorkspace, "Attempt \(attempt): workspace/window should stay open after early Ctrl+D. data=\(done)")
XCTAssertEqual(workspaceCountAfter, 1, "Attempt \(attempt): workspace should remain open after early Ctrl+D. data=\(done)")
XCTAssertEqual(panelCountAfter, 1, "Attempt \(attempt): only focused pane should close after early Ctrl+D. data=\(done)")
if let showChildExitedCount = Int(done["probeShowChildExitedCount"] ?? "") {
XCTAssertEqual(showChildExitedCount, 1, "Attempt \(attempt): expected exactly one SHOW_CHILD_EXITED callback for one early Ctrl+D. data=\(done)")
}
if !exitPanelId.isEmpty, !probeSurfaceId.isEmpty {
XCTAssertEqual(probeSurfaceId, exitPanelId, "Attempt \(attempt): SHOW_CHILD_EXITED should target the split opened by Cmd+D. data=\(done)")
}
if !workspaceId.isEmpty, !probeTabId.isEmpty {
XCTAssertEqual(probeTabId, workspaceId, "Attempt \(attempt): SHOW_CHILD_EXITED should resolve to the active workspace. data=\(done)")
}
XCTAssertTrue(
waitForWindowCount(app: app, atLeast: 1, timeout: 2.0),
"Attempt \(attempt): app window should remain open after early Ctrl+D. data=\(done)"
)
}
}
private func waitForCloseWorkspaceAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { private func waitForCloseWorkspaceAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline { while Date() < deadline {

View file

@ -35,4 +35,31 @@ final class SidebarResizeUITests: XCTestCase {
XCTAssertLessThanOrEqual(leftDelta, -40, "Expected drag-left to move resizer left") XCTAssertLessThanOrEqual(leftDelta, -40, "Expected drag-left to move resizer left")
XCTAssertGreaterThanOrEqual(leftDelta, -122, "Resizer moved farther than requested drag-left offset") XCTAssertGreaterThanOrEqual(leftDelta, -122, "Resizer moved farther than requested drag-left offset")
} }
func testSidebarResizerHasMaximumWidthCap() {
let app = XCUIApplication()
app.launch()
let window = app.windows.firstMatch
XCTAssertTrue(window.waitForExistence(timeout: 5.0))
let elements = app.descendants(matching: .any)
let resizer = elements["SidebarResizer"]
XCTAssertTrue(resizer.waitForExistence(timeout: 5.0))
let start = resizer.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
let farRight = start.withOffset(CGVector(dx: 5000, dy: 0))
start.press(forDuration: 0.1, thenDragTo: farRight)
let windowFrame = window.frame
let remainingWidth = max(0, windowFrame.maxX - resizer.frame.maxX)
let minimumExpectedRemaining = windowFrame.width * 0.45
XCTAssertGreaterThanOrEqual(
remainingWidth,
minimumExpectedRemaining,
"Expected sidebar max-width clamp to leave substantial terminal width. " +
"remaining=\(remainingWidth), window=\(windowFrame.width)"
)
}
} }

View file

@ -13,6 +13,7 @@ cd "$(dirname "$0")/.."
DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v1" DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v1"
APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app" APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app"
RUN_TAG="tests-v1"
echo "== build ==" echo "== build =="
# Work around stale explicit-module cache artifacts (notably Sentry headers) that can # Work around stale explicit-module cache artifacts (notably Sentry headers) that can
@ -51,7 +52,7 @@ launch_and_wait() {
defaults write com.cmuxterm.app.debug socketControlMode -string full >/dev/null 2>&1 || true defaults write com.cmuxterm.app.debug socketControlMode -string full >/dev/null 2>&1 || true
# Launch directly with UI test mode enabled so startup follows deterministic test codepaths. # Launch directly with UI test mode enabled so startup follows deterministic test codepaths.
CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 & CMUX_TAG="$RUN_TAG" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 &
SOCK="" SOCK=""
for _ in {1..120}; do for _ in {1..120}; do
@ -70,7 +71,7 @@ launch_and_wait() {
export CMUX_SOCKET="$SOCK" export CMUX_SOCKET="$SOCK"
# Ensure LaunchServices has a visible/main window attached for rendering checks. # Ensure LaunchServices has a visible/main window attached for rendering checks.
open "$APP" >/dev/null 2>&1 || true CMUX_TAG="$RUN_TAG" open "$APP" >/dev/null 2>&1 || true
sleep 0.5 sleep 0.5
echo "== wait ready ==" echo "== wait ready =="

View file

@ -13,6 +13,7 @@ cd "$(dirname "$0")/.."
DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v2" DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v2"
APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app" APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app"
RUN_TAG="tests-v2"
echo "== build ==" echo "== build =="
# Work around stale explicit-module cache artifacts (notably Sentry headers) that can # Work around stale explicit-module cache artifacts (notably Sentry headers) that can
@ -51,7 +52,7 @@ launch_and_wait() {
defaults write com.cmuxterm.app.debug socketControlMode -string full >/dev/null 2>&1 || true defaults write com.cmuxterm.app.debug socketControlMode -string full >/dev/null 2>&1 || true
# Launch directly with UI test mode enabled so startup follows deterministic test codepaths. # Launch directly with UI test mode enabled so startup follows deterministic test codepaths.
CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 & CMUX_TAG="$RUN_TAG" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/dev/null 2>&1 &
SOCK="" SOCK=""
for _ in {1..120}; do for _ in {1..120}; do
@ -70,7 +71,7 @@ launch_and_wait() {
export CMUX_SOCKET="$SOCK" export CMUX_SOCKET="$SOCK"
# Ensure LaunchServices has a visible/main window attached for rendering checks. # Ensure LaunchServices has a visible/main window attached for rendering checks.
open "$APP" >/dev/null 2>&1 || true CMUX_TAG="$RUN_TAG" open "$APP" >/dev/null 2>&1 || true
sleep 0.5 sleep 0.5
echo "== wait ready ==" echo "== wait ready =="

View file

@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Static regression guards for browser chrome contrast in mixed theme setups."""
from __future__ import annotations
import subprocess
from pathlib import Path
def repo_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
)
if result.returncode == 0:
return Path(result.stdout.strip())
return Path(__file__).resolve().parents[1]
def extract_block(source: str, signature: str) -> str:
start = source.find(signature)
if start < 0:
raise ValueError(f"Missing signature: {signature}")
brace_start = source.find("{", start)
if brace_start < 0:
raise ValueError(f"Missing opening brace for: {signature}")
depth = 0
for idx in range(brace_start, len(source)):
char = source[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return source[brace_start : idx + 1]
raise ValueError(f"Unbalanced braces for: {signature}")
def main() -> int:
root = repo_root()
view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift"
source = view_path.read_text(encoding="utf-8")
failures: list[str] = []
try:
browser_panel_view_block = extract_block(source, "struct BrowserPanelView: View")
except ValueError as error:
failures.append(str(error))
browser_panel_view_block = ""
try:
resolver_block = extract_block(source, "func resolvedBrowserChromeColorScheme(")
except ValueError as error:
failures.append(str(error))
resolver_block = ""
if resolver_block:
if "backgroundColor.isLightColor ? .light : .dark" not in resolver_block:
failures.append(
"resolvedBrowserChromeColorScheme must map luminance to a light/dark ColorScheme"
)
try:
chrome_scheme_block = extract_block(
browser_panel_view_block,
"private var browserChromeColorScheme: ColorScheme",
)
except ValueError as error:
failures.append(str(error))
chrome_scheme_block = ""
if chrome_scheme_block and "resolvedBrowserChromeColorScheme(" not in chrome_scheme_block:
failures.append("browserChromeColorScheme must use resolvedBrowserChromeColorScheme")
try:
omnibar_background_block = extract_block(
browser_panel_view_block,
"private var omnibarPillBackgroundColor: NSColor",
)
except ValueError as error:
failures.append(str(error))
omnibar_background_block = ""
if omnibar_background_block and "for: browserChromeColorScheme" not in omnibar_background_block:
failures.append("omnibar pill background must use browserChromeColorScheme")
try:
address_bar_block = extract_block(
browser_panel_view_block,
"private var addressBar: some View",
)
except ValueError as error:
failures.append(str(error))
address_bar_block = ""
if address_bar_block and ".environment(\\.colorScheme, browserChromeColorScheme)" not in address_bar_block:
failures.append("addressBar must apply browserChromeColorScheme via environment")
try:
body_block = extract_block(browser_panel_view_block, "var body: some View")
except ValueError as error:
failures.append(str(error))
body_block = ""
if body_block:
if "OmnibarSuggestionsView(" not in body_block:
failures.append("Expected OmnibarSuggestionsView block in BrowserPanelView body")
elif ".environment(\\.colorScheme, browserChromeColorScheme)" not in body_block:
failures.append("Omnibar suggestions must apply browserChromeColorScheme via environment")
if failures:
print("FAIL: browser chrome contrast regression guards failed")
for failure in failures:
print(f" - {failure}")
return 1
print("PASS: browser chrome contrast regression guards are in place")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Static regression guard for browser console/errors CLI output formatting.
Ensures non-JSON `browser console list` and `browser errors list` do not fall
back to unconditional `OK` when logs exist.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
def repo_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
)
if result.returncode == 0:
return Path(result.stdout.strip())
return Path(__file__).resolve().parents[1]
def extract_block(source: str, signature: str) -> str:
start = source.find(signature)
if start < 0:
raise ValueError(f"Missing signature: {signature}")
brace_start = source.find("{", start)
if brace_start < 0:
raise ValueError(f"Missing opening brace for: {signature}")
depth = 0
for idx in range(brace_start, len(source)):
char = source[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return source[brace_start : idx + 1]
raise ValueError(f"Unbalanced braces for: {signature}")
def main() -> int:
root = repo_root()
failures: list[str] = []
cli_path = root / "CLI" / "cmux.swift"
cli_source = cli_path.read_text(encoding="utf-8")
browser_block = extract_block(cli_source, "private func runBrowserCommand(")
if "func displayBrowserLogItems(_ value: Any?) -> String?" not in browser_block:
failures.append("runBrowserCommand() is missing displayBrowserLogItems() helper")
else:
helper_block = extract_block(browser_block, "func displayBrowserLogItems(_ value: Any?) -> String?")
if "return \"[\\(level)] \\(text)\"" not in helper_block:
failures.append("displayBrowserLogItems() no longer renders level-prefixed log lines")
if "return \"[error] \\(message)\"" not in helper_block:
failures.append("displayBrowserLogItems() no longer renders concise JS error messages")
if "return displayBrowserValue(dict)" not in helper_block:
failures.append("displayBrowserLogItems() no longer falls back to structured formatting")
console_block = extract_block(browser_block, 'if subcommand == "console"')
if 'displayBrowserLogItems(payload["entries"])' not in console_block:
failures.append("browser console path no longer formats entries for non-JSON output")
if 'output(payload, fallback: "OK")' in console_block:
failures.append("browser console path regressed to unconditional OK output")
errors_block = extract_block(browser_block, 'if subcommand == "errors"')
if 'displayBrowserLogItems(payload["errors"])' not in errors_block:
failures.append("browser errors path no longer formats errors for non-JSON output")
if 'output(payload, fallback: "OK")' in errors_block:
failures.append("browser errors path regressed to unconditional OK output")
if failures:
print("FAIL: browser console/errors CLI output regression guard failed")
for item in failures:
print(f" - {item}")
return 1
print("PASS: browser console/errors CLI output regression guard is in place")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Static regression guard for browser eval CLI output formatting.
Ensures `cmux browser <surface> eval <script>` prints the evaluated value
instead of always printing `OK`.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
def repo_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
)
if result.returncode == 0:
return Path(result.stdout.strip())
return Path(__file__).resolve().parents[1]
def extract_block(source: str, signature: str) -> str:
start = source.find(signature)
if start < 0:
raise ValueError(f"Missing signature: {signature}")
brace_start = source.find("{", start)
if brace_start < 0:
raise ValueError(f"Missing opening brace for: {signature}")
depth = 0
for idx in range(brace_start, len(source)):
char = source[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return source[brace_start : idx + 1]
raise ValueError(f"Unbalanced braces for: {signature}")
def main() -> int:
root = repo_root()
failures: list[str] = []
cli_path = root / "CLI" / "cmux.swift"
cli_source = cli_path.read_text(encoding="utf-8")
browser_block = extract_block(cli_source, "private func runBrowserCommand(")
if "func displayBrowserValue(_ value: Any) -> String" not in browser_block:
failures.append("runBrowserCommand() is missing displayBrowserValue() helper")
else:
value_block = extract_block(browser_block, "func displayBrowserValue(_ value: Any) -> String")
required_guards = [
"if value is NSNull",
"if let string = value as? String",
"if let bool = value as? Bool",
"if let number = value as? NSNumber",
]
for guard in required_guards:
if guard not in value_block:
failures.append(f"displayBrowserValue() no longer handles: {guard}")
eval_block = extract_block(browser_block, 'if subcommand == "eval"')
if 'let payload = try client.sendV2(method: "browser.eval"' not in eval_block:
failures.append("browser eval path no longer calls browser.eval v2 method")
if 'if let value = payload["value"]' not in eval_block:
failures.append("browser eval path no longer reads payload value")
if "fallback = displayBrowserValue(value)" not in eval_block:
failures.append("browser eval path no longer formats payload value for CLI output")
if 'output(payload, fallback: "OK")' in eval_block:
failures.append("browser eval path regressed to unconditional OK output")
if failures:
print("FAIL: browser eval CLI output regression guard failed")
for item in failures:
print(f" - {item}")
return 1
print("PASS: browser eval CLI output regression guard is in place")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,388 @@
#!/usr/bin/env python3
"""
Regression test:
1. Focusing a blank browser surface should focus the omnibar.
2. Focusing a pane that contains a blank browser should focus the omnibar.
3. If command palette is open, focusing that blank browser surface must not steal input.
4. Cmd+P switcher focusing an existing blank browser surface should focus the omnibar.
"""
import json
import os
import sys
import time
from typing import Any
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError
def v2_call(client: cmux, method: str, params: dict[str, Any] | None = None, request_id: str = "1") -> dict[str, Any]:
payload = {
"id": request_id,
"method": method,
"params": params or {},
}
raw = client._send_command(json.dumps(payload))
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise cmuxError(f"Invalid v2 JSON response for {method}: {raw}") from exc
if not parsed.get("ok"):
raise cmuxError(f"v2 {method} failed: {parsed.get('error')}")
result = parsed.get("result")
return result if isinstance(result, dict) else {}
def wait_for(predicate, timeout_s: float, interval_s: float = 0.1) -> bool:
deadline = time.time() + timeout_s
while time.time() < deadline:
if predicate():
return True
time.sleep(interval_s)
return False
def browser_address_bar_focus_state(client: cmux, surface_id: str | None = None, request_id: str = "browser-focus") -> dict[str, Any]:
params: dict[str, Any] = {}
if surface_id:
params["surface_id"] = surface_id
return v2_call(client, "debug.browser.address_bar_focused", params, request_id=request_id)
def set_command_palette_visible(client: cmux, window_id: str, target_visible: bool) -> bool:
for idx in range(5):
state = v2_call(
client,
"debug.command_palette.visible",
{"window_id": window_id},
request_id=f"palette-visible-{idx}",
)
is_visible = bool(state.get("visible"))
if is_visible == target_visible:
return True
v2_call(
client,
"debug.command_palette.toggle",
{"window_id": window_id},
request_id=f"palette-toggle-{idx}",
)
time.sleep(0.15)
return False
def command_palette_results(client: cmux, window_id: str, limit: int = 20) -> list[dict[str, Any]]:
payload = v2_call(
client,
"debug.command_palette.results",
{"window_id": window_id, "limit": limit},
request_id="palette-results"
)
rows = payload.get("results")
if isinstance(rows, list):
return [row for row in rows if isinstance(row, dict)]
return []
def command_palette_selected_index(client: cmux, window_id: str) -> int:
payload = v2_call(
client,
"debug.command_palette.selection",
{"window_id": window_id},
request_id="palette-selection"
)
selected_index = payload.get("selected_index")
if isinstance(selected_index, int):
return max(0, selected_index)
return 0
def move_command_palette_selection_to_index(client: cmux, window_id: str, target_index: int) -> bool:
target = max(0, target_index)
for _ in range(40):
current = command_palette_selected_index(client, window_id)
if current == target:
return True
if current < target:
client.simulate_shortcut("down")
else:
client.simulate_shortcut("up")
time.sleep(0.05)
return False
def current_window_id(client: cmux) -> str:
window_current = v2_call(client, "window.current", request_id="window-current")
window_id = window_current.get("window_id")
if not isinstance(window_id, str) or not window_id:
raise cmuxError(f"Invalid window.current payload: {window_current}")
return window_id
def main() -> int:
client = cmux()
workspace_ids: list[str] = []
window_id: str | None = None
try:
client.connect()
client.activate_app()
# Scenario 1: focus_surface on a blank browser should focus omnibar.
workspace_id = client.new_workspace()
workspace_ids.append(workspace_id)
client.select_workspace(workspace_id)
time.sleep(0.4)
window_id = current_window_id(client)
if not set_command_palette_visible(client, window_id, False):
raise cmuxError("Failed to ensure command palette is hidden for scenario 1")
browser_id = client.new_surface(panel_type="browser")
time.sleep(0.3)
surfaces = client.list_surfaces()
terminal_id = next((surface_id for _, surface_id, _ in surfaces if surface_id != browser_id), None)
if not terminal_id:
raise cmuxError("Missing terminal surface for focus setup")
client.focus_surface_by_panel(terminal_id)
time.sleep(0.2)
# Primary behavior: focusing a blank browser tab should focus the omnibar.
client.focus_surface_by_panel(browser_id)
did_focus_address_bar = wait_for(
lambda: bool(
browser_address_bar_focus_state(
client,
surface_id=browser_id,
request_id="browser-focus-primary"
).get("focused")
),
timeout_s=3.0,
interval_s=0.1
)
if not did_focus_address_bar:
raise cmuxError("Blank browser surface did not focus omnibar after focus_surface")
client.close_workspace(workspace_id)
workspace_ids.remove(workspace_id)
time.sleep(0.3)
# Scenario 2: focusing a pane that contains a blank browser should focus omnibar.
workspace_id = client.new_workspace()
workspace_ids.append(workspace_id)
client.select_workspace(workspace_id)
time.sleep(0.4)
window_id = current_window_id(client)
if not set_command_palette_visible(client, window_id, False):
raise cmuxError("Failed to ensure command palette is hidden for scenario 2")
initial_surfaces = client.list_surfaces()
left_terminal_id = next((surface_id for _, surface_id, _ in initial_surfaces), None)
if not left_terminal_id:
raise cmuxError("Missing initial terminal surface for split setup")
split_browser_id = client.new_pane(direction="right", panel_type="browser")
time.sleep(0.3)
pane_rows = client.list_panes()
left_pane: str | None = None
browser_pane: str | None = None
for _, pane_id, _, _ in pane_rows:
pane_surface_ids = {surface_id for _, surface_id, _, _ in client.list_pane_surfaces(pane_id)}
if left_terminal_id in pane_surface_ids:
left_pane = pane_id
if split_browser_id in pane_surface_ids:
browser_pane = pane_id
if not left_pane or not browser_pane:
raise cmuxError("Failed to locate split panes for pane-focus scenario")
client.focus_pane(left_pane)
time.sleep(0.2)
client.focus_pane(browser_pane)
did_focus_split_browser = wait_for(
lambda: bool(
browser_address_bar_focus_state(
client,
surface_id=split_browser_id,
request_id="browser-focus-pane"
).get("focused")
),
timeout_s=3.0,
interval_s=0.1
)
if not did_focus_split_browser:
raise cmuxError("Blank browser pane did not focus omnibar after focus_pane")
client.close_workspace(workspace_id)
workspace_ids.remove(workspace_id)
time.sleep(0.3)
# Scenario 3: command palette should keep input focus when switching to a blank browser surface.
workspace_id = client.new_workspace()
workspace_ids.append(workspace_id)
client.select_workspace(workspace_id)
time.sleep(0.4)
window_id = current_window_id(client)
if not set_command_palette_visible(client, window_id, False):
raise cmuxError("Failed to reset command palette before scenario 3")
blank_browser_id = client.new_surface(panel_type="browser")
time.sleep(0.3)
surfaces = client.list_surfaces()
terminal_id = next((surface_id for _, surface_id, _ in surfaces if surface_id != blank_browser_id), None)
if not terminal_id:
raise cmuxError("Missing terminal surface for command palette scenario")
client.focus_surface_by_panel(terminal_id)
wait_for(
lambda: not bool(
browser_address_bar_focus_state(
client,
request_id="browser-focus-cleared"
).get("focused")
),
timeout_s=2.0,
interval_s=0.1
)
if not set_command_palette_visible(client, window_id, True):
raise cmuxError("Failed to open command palette")
client.focus_surface_by_panel(blank_browser_id)
time.sleep(0.2)
palette_visible_after_focus = bool(
v2_call(
client,
"debug.command_palette.visible",
{"window_id": window_id},
request_id="palette-visible-after-focus"
).get("visible")
)
if not palette_visible_after_focus:
raise cmuxError("Command palette closed unexpectedly after focus_surface")
blank_focus_state = browser_address_bar_focus_state(
client,
surface_id=blank_browser_id,
request_id="browser-focus-palette"
)
if bool(blank_focus_state.get("focused")):
raise cmuxError("Blank browser tab stole omnibar focus while command palette was visible")
client.close_workspace(workspace_id)
workspace_ids.remove(workspace_id)
time.sleep(0.3)
# Scenario 4: Cmd+P switcher selecting an existing blank browser surface should focus omnibar.
workspace_id = client.new_workspace()
workspace_ids.append(workspace_id)
client.select_workspace(workspace_id)
time.sleep(0.4)
window_id = current_window_id(client)
if not set_command_palette_visible(client, window_id, False):
raise cmuxError("Failed to reset command palette before scenario 4")
switcher_browser_id = client.new_surface(panel_type="browser")
time.sleep(0.3)
switcher_surfaces = client.list_surfaces()
switcher_terminal_id = next((surface_id for _, surface_id, _ in switcher_surfaces if surface_id != switcher_browser_id), None)
if not switcher_terminal_id:
raise cmuxError("Missing terminal surface for Cmd+P switcher scenario")
client.focus_surface_by_panel(switcher_terminal_id)
time.sleep(0.2)
client.simulate_shortcut("cmd+p")
if not wait_for(
lambda: bool(
v2_call(
client,
"debug.command_palette.visible",
{"window_id": window_id},
request_id="palette-visible-switcher-open"
).get("visible")
),
timeout_s=2.0,
interval_s=0.1
):
raise cmuxError("Cmd+P did not open command palette switcher")
client.simulate_type("new tab")
time.sleep(0.2)
target_command_id = f"switcher.surface.{workspace_id.lower()}.{switcher_browser_id.lower()}"
switcher_results = command_palette_results(client, window_id, limit=50)
target_index = next(
(
idx for idx, row in enumerate(switcher_results)
if isinstance(row.get("command_id"), str) and row.get("command_id") == target_command_id
),
None
)
if target_index is None:
raise cmuxError(f"Cmd+P switcher did not list target surface command {target_command_id}")
if not move_command_palette_selection_to_index(client, window_id, target_index):
raise cmuxError(f"Failed to move Cmd+P selection to result index {target_index}")
client.simulate_shortcut("enter")
did_focus_switcher_target = wait_for(
lambda: (
not bool(
v2_call(
client,
"debug.command_palette.visible",
{"window_id": window_id},
request_id="palette-visible-switcher-after-enter"
).get("visible")
)
and bool(
browser_address_bar_focus_state(
client,
surface_id=switcher_browser_id,
request_id="browser-focus-switcher"
).get("focused")
)
),
timeout_s=3.0,
interval_s=0.1
)
if not did_focus_switcher_target:
raise cmuxError("Cmd+P switcher focus to blank browser did not focus omnibar")
print("PASS: blank-browser focus paths (surface, pane, and Cmd+P switcher) drive omnibar, while command palette visibility blocks focus stealing")
return 0
except cmuxError as exc:
print(f"FAIL: {exc}")
return 1
finally:
if window_id:
try:
_ = set_command_palette_visible(client, window_id, False)
except Exception:
pass
for workspace_id in list(workspace_ids):
try:
client.close_workspace(workspace_id)
except Exception:
pass
try:
client.close()
except Exception:
pass
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Regression test for https://github.com/manaflow-ai/cmux/issues/385.
# Ensures self-hosted UI tests are never run for fork pull requests.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
WORKFLOW_FILE="$ROOT_DIR/.github/workflows/ci.yml"
EXPECTED_IF="if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository"
if ! grep -Fq "$EXPECTED_IF" "$WORKFLOW_FILE"; then
echo "FAIL: Missing fork pull_request guard for ui-tests in $WORKFLOW_FILE"
echo "Expected line:"
echo " $EXPECTED_IF"
exit 1
fi
if ! awk '
/^ ui-tests:/ { in_ui_tests=1; next }
in_ui_tests && /^ [^[:space:]]/ { in_ui_tests=0 }
in_ui_tests && /runs-on: self-hosted/ { saw_self_hosted=1 }
in_ui_tests && /github.event.pull_request.head.repo.full_name == github.repository/ { saw_guard=1 }
END { exit !(saw_self_hosted && saw_guard) }
' "$WORKFLOW_FILE"; then
echo "FAIL: ui-tests block must keep both self-hosted and fork guard"
exit 1
fi
echo "PASS: ui-tests self-hosted fork guard is present"

View file

@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Regression test for command-palette update command wiring."""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
def get_repo_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
)
if result.returncode == 0:
return Path(result.stdout.strip())
return Path.cwd()
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def expect_regex(content: str, pattern: str, message: str, failures: list[str]) -> None:
if re.search(pattern, content, flags=re.DOTALL) is None:
failures.append(message)
def main() -> int:
repo_root = get_repo_root()
content_view_path = repo_root / "Sources" / "ContentView.swift"
app_delegate_path = repo_root / "Sources" / "AppDelegate.swift"
controller_path = repo_root / "Sources" / "Update" / "UpdateController.swift"
missing_paths = [
str(path)
for path in [content_view_path, app_delegate_path, controller_path]
if not path.exists()
]
if missing_paths:
print("Missing expected files:")
for path in missing_paths:
print(f" - {path}")
return 1
content_view = read_text(content_view_path)
app_delegate = read_text(app_delegate_path)
controller = read_text(controller_path)
failures: list[str] = []
expect_regex(
content_view,
r'static\s+let\s+updateHasAvailable\s*=\s*"update\.hasAvailable"',
"Missing `CommandPaletteContextKeys.updateHasAvailable`",
failures,
)
expect_regex(
content_view,
r'if\s+case\s+\.updateAvailable\s*=\s*updateViewModel\.effectiveState\s*\{\s*snapshot\.setBool\(CommandPaletteContextKeys\.updateHasAvailable,\s*true\)\s*\}',
"Command palette context no longer tracks update-available state",
failures,
)
expect_regex(
content_view,
r'commandId:\s*"palette\.applyUpdateIfAvailable".*?title:\s*constant\("Apply Update \(If Available\)"\).*?keywords:\s*\[[^\]]*"apply"[^\]]*"install"[^\]]*"update"[^\]]*"available"[^\]]*\].*?when:\s*\{\s*\$0\.bool\(CommandPaletteContextKeys\.updateHasAvailable\)\s*\}',
"Missing or incomplete `palette.applyUpdateIfAvailable` contribution visibility gating",
failures,
)
expect_regex(
content_view,
r'commandId:\s*"palette\.attemptUpdate".*?title:\s*constant\("Attempt Update"\).*?keywords:\s*\[[^\]]*"attempt"[^\]]*"check"[^\]]*"update"[^\]]*\]',
"Missing or incomplete `palette.attemptUpdate` contribution",
failures,
)
expect_regex(
content_view,
r'registry\.register\(commandId:\s*"palette\.applyUpdateIfAvailable"\)\s*\{\s*AppDelegate\.shared\?\.applyUpdateIfAvailable\(nil\)\s*\}',
"Missing handler registration for `palette.applyUpdateIfAvailable`",
failures,
)
expect_regex(
content_view,
r'registry\.register\(commandId:\s*"palette\.attemptUpdate"\)\s*\{\s*AppDelegate\.shared\?\.attemptUpdate\(nil\)\s*\}',
"Missing handler registration for `palette.attemptUpdate`",
failures,
)
expect_regex(
app_delegate,
r'@objc\s+func\s+applyUpdateIfAvailable\(_\s+sender:\s+Any\?\)\s*\{\s*updateViewModel\.overrideState\s*=\s*nil\s*updateController\.installUpdate\(\)\s*\}',
"`AppDelegate.applyUpdateIfAvailable` is missing or does not call `updateController.installUpdate()`",
failures,
)
expect_regex(
app_delegate,
r'@objc\s+func\s+attemptUpdate\(_\s+sender:\s+Any\?\)\s*\{\s*updateViewModel\.overrideState\s*=\s*nil\s*updateController\.attemptUpdate\(\)\s*\}',
"`AppDelegate.attemptUpdate` is missing or does not call `updateController.attemptUpdate()`",
failures,
)
expect_regex(
controller,
r'func\s+attemptUpdate\(\)\s*\{',
"`UpdateController.attemptUpdate()` is missing",
failures,
)
if "state.confirm()" not in controller:
failures.append("`UpdateController.attemptUpdate()` no longer auto-confirms update installation")
if "checkForUpdates()" not in controller:
failures.append("`UpdateController.attemptUpdate()` no longer triggers a check before install")
if failures:
print("FAIL: command-palette update command regression(s) detected")
for failure in failures:
print(f"- {failure}")
return 1
print("PASS: command-palette update commands expose apply + attempt wiring")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Static regression checks for re-entrant terminal focus guard.
Guards the fix for split-drag focus churn where:
becomeFirstResponder -> onFocus -> Workspace.focusPanel -> refocus side-effects
could repeatedly re-enter and spike CPU.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
def repo_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
)
if result.returncode == 0:
return Path(result.stdout.strip())
return Path(__file__).resolve().parents[1]
def main() -> int:
root = repo_root()
failures: list[str] = []
workspace_path = root / "Sources" / "Workspace.swift"
workspace_source = workspace_path.read_text(encoding="utf-8")
required_workspace_snippets = [
"enum FocusPanelTrigger {",
"case terminalFirstResponder",
"trigger: FocusPanelTrigger = .standard",
"let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged",
"if let targetPaneId, !shouldSuppressReentrantRefocus {",
"reason=firstResponderAlreadyConverged",
]
for snippet in required_workspace_snippets:
if snippet not in workspace_source:
failures.append(f"Workspace focus guard missing snippet: {snippet}")
workspace_content_view_path = root / "Sources" / "WorkspaceContentView.swift"
workspace_content_view_source = workspace_content_view_path.read_text(encoding="utf-8")
focus_callback_snippet = "workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)"
if focus_callback_snippet not in workspace_content_view_source:
failures.append(
"WorkspaceContentView terminal onFocus callback no longer passes .terminalFirstResponder trigger"
)
if failures:
print("FAIL: focus-panel re-entrant guard regression checks failed")
for item in failures:
print(f" - {item}")
return 1
print("PASS: focus-panel re-entrant guard is in place")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -9,6 +9,7 @@ This test checks for:
from __future__ import annotations from __future__ import annotations
import re
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@ -94,6 +95,48 @@ def check_autoupdating_text_styles(files: List[Path]) -> List[Tuple[Path, int, s
return violations return violations
def check_command_palette_caret_tint(repo_root: Path) -> List[str]:
"""Ensure command palette text inputs keep a white caret tint."""
content_view = repo_root / "Sources" / "ContentView.swift"
if not content_view.exists():
return [f"Missing expected file: {content_view}"]
try:
content = content_view.read_text()
except Exception as e:
return [f"Could not read {content_view}: {e}"]
checks = [
(
"search input",
r"TextField\(commandPaletteSearchPlaceholder, text: \$commandPaletteQuery\)(?P<body>.*?)"
r"\.focused\(\$isCommandPaletteSearchFocused\)",
),
(
"rename input",
r"TextField\(target\.placeholder, text: \$commandPaletteRenameDraft\)(?P<body>.*?)"
r"\.focused\(\$isCommandPaletteRenameFocused\)",
),
]
violations: List[str] = []
for label, pattern in checks:
match = re.search(pattern, content, flags=re.DOTALL)
if not match:
violations.append(
f"Could not locate command palette {label} TextField block in Sources/ContentView.swift"
)
continue
body = match.group("body")
if ".tint(.white)" not in body:
violations.append(
f"Command palette {label} TextField must use `.tint(.white)` in Sources/ContentView.swift"
)
return violations
def main(): def main():
"""Run the lint checks.""" """Run the lint checks."""
repo_root = get_repo_root() repo_root = get_repo_root()
@ -102,15 +145,18 @@ def main():
print(f"Checking {len(swift_files)} Swift files for performance issues...") print(f"Checking {len(swift_files)} Swift files for performance issues...")
# Check for auto-updating Text styles # Check for auto-updating Text styles
violations = check_autoupdating_text_styles(swift_files) style_violations = check_autoupdating_text_styles(swift_files)
tint_violations = check_command_palette_caret_tint(repo_root)
has_failures = False
if violations: if style_violations:
has_failures = True
print("\n❌ LINT FAILURES: Auto-updating Text styles found") print("\n❌ LINT FAILURES: Auto-updating Text styles found")
print("=" * 60) print("=" * 60)
print("These patterns cause continuous SwiftUI view updates and high CPU usage:") print("These patterns cause continuous SwiftUI view updates and high CPU usage:")
print() print()
for file_path, line_num, line in violations: for file_path, line_num, line in style_violations:
rel_path = file_path.relative_to(repo_root) rel_path = file_path.relative_to(repo_root)
print(f" {rel_path}:{line_num}") print(f" {rel_path}:{line_num}")
print(f" {line}") print(f" {line}")
@ -120,9 +166,23 @@ def main():
print(" Instead of: Text(date, style: .time)") print(" Instead of: Text(date, style: .time)")
print(" Use: Text(date.formatted(date: .omitted, time: .shortened))") print(" Use: Text(date.formatted(date: .omitted, time: .shortened))")
print() print()
if tint_violations:
has_failures = True
print("\n❌ LINT FAILURES: Command palette caret tint drifted")
print("=" * 60)
print("The command palette search and rename text fields must keep a white caret:")
print()
for message in tint_violations:
print(f" {message}")
print()
print("FIX: Set command palette TextField tint modifiers to `.white`.")
print()
if has_failures:
return 1 return 1
print("✅ No auto-updating Text style patterns found") print("✅ No linted SwiftUI pattern regressions found")
return 0 return 0

333
tests/test_open_wrapper.py Executable file
View file

@ -0,0 +1,333 @@
#!/usr/bin/env python3
"""
Regression tests for Resources/bin/open.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SOURCE_WRAPPER = ROOT / "Resources" / "bin" / "open"
def make_executable(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8")
path.chmod(0o755)
def read_log(path: Path) -> list[str]:
if not path.exists():
return []
return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
def run_wrapper(
*,
args: list[str],
intercept_setting: str | None,
legacy_open_setting: str | None = None,
whitelist: str | None,
fail_urls: list[str] | None = None,
) -> tuple[list[str], list[str], int, str]:
with tempfile.TemporaryDirectory(prefix="cmux-open-wrapper-test-") as td:
tmp = Path(td)
wrapper = tmp / "open"
shutil.copy2(SOURCE_WRAPPER, wrapper)
wrapper.chmod(0o755)
open_log = tmp / "open.log"
cmux_log = tmp / "cmux.log"
system_open = tmp / "system-open"
defaults = tmp / "defaults"
cmux = tmp / "cmux"
make_executable(
system_open,
"""#!/usr/bin/env bash
set -euo pipefail
printf '%s\\n' "$*" >> "$FAKE_OPEN_LOG"
""",
)
make_executable(
defaults,
"""#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" != "read" ]]; then
exit 1
fi
key="${3:-}"
case "$key" in
browserInterceptTerminalOpenCommandInCmuxBrowser)
if [[ "${FAKE_DEFAULTS_INTERCEPT_OPEN+x}" == "x" ]]; then
printf '%s\\n' "$FAKE_DEFAULTS_INTERCEPT_OPEN"
exit 0
fi
exit 1
;;
browserOpenTerminalLinksInCmuxBrowser)
if [[ "${FAKE_DEFAULTS_LEGACY_OPEN+x}" == "x" ]]; then
printf '%s\\n' "$FAKE_DEFAULTS_LEGACY_OPEN"
exit 0
fi
exit 1
;;
browserHostWhitelist)
if [[ "${FAKE_DEFAULTS_WHITELIST+x}" == "x" ]]; then
printf '%s' "$FAKE_DEFAULTS_WHITELIST"
exit 0
fi
exit 1
;;
*)
exit 1
;;
esac
""",
)
make_executable(
cmux,
"""#!/usr/bin/env bash
set -euo pipefail
printf '%s\\n' "$*" >> "$FAKE_CMUX_LOG"
url=""
for arg in "$@"; do
url="$arg"
done
if [[ -n "${FAKE_CMUX_FAIL_URLS:-}" ]]; then
IFS=',' read -r -a failures <<< "$FAKE_CMUX_FAIL_URLS"
for fail_url in "${failures[@]}"; do
if [[ "$url" == "$fail_url" ]]; then
exit 1
fi
done
fi
exit 0
""",
)
env = os.environ.copy()
env["CMUX_SOCKET_PATH"] = "/tmp/cmux-open-wrapper-test.sock"
env["CMUX_BUNDLE_ID"] = "com.cmuxterm.app.debug.test"
env["CMUX_OPEN_WRAPPER_SYSTEM_OPEN"] = str(system_open)
env["CMUX_OPEN_WRAPPER_DEFAULTS"] = str(defaults)
env["FAKE_OPEN_LOG"] = str(open_log)
env["FAKE_CMUX_LOG"] = str(cmux_log)
if intercept_setting is None:
env.pop("FAKE_DEFAULTS_INTERCEPT_OPEN", None)
else:
env["FAKE_DEFAULTS_INTERCEPT_OPEN"] = intercept_setting
if legacy_open_setting is None:
env.pop("FAKE_DEFAULTS_LEGACY_OPEN", None)
else:
env["FAKE_DEFAULTS_LEGACY_OPEN"] = legacy_open_setting
if whitelist is None:
env.pop("FAKE_DEFAULTS_WHITELIST", None)
else:
env["FAKE_DEFAULTS_WHITELIST"] = whitelist
if fail_urls:
env["FAKE_CMUX_FAIL_URLS"] = ",".join(fail_urls)
else:
env.pop("FAKE_CMUX_FAIL_URLS", None)
result = subprocess.run(
["/bin/bash", str(wrapper), *args],
env=env,
capture_output=True,
text=True,
check=False,
)
return read_log(open_log), read_log(cmux_log), result.returncode, result.stderr.strip()
def expect(condition: bool, message: str, failures: list[str]) -> None:
if not condition:
failures.append(message)
def test_toggle_disabled_passthrough(failures: list[str]) -> None:
url = "https://example.com"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting="0",
whitelist="",
)
expect(code == 0, f"toggle off: wrapper exited {code}: {stderr}", failures)
expect(cmux_log == [], f"toggle off: cmux should not be called, got {cmux_log}", failures)
expect(open_log == [url], f"toggle off: expected system open [{url}], got {open_log}", failures)
def test_toggle_disabled_case_insensitive_passthrough(failures: list[str]) -> None:
url = "https://example.com"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting=" FaLsE ",
whitelist="",
)
expect(code == 0, f"toggle off (case-insensitive): wrapper exited {code}: {stderr}", failures)
expect(
cmux_log == [],
f"toggle off (case-insensitive): cmux should not be called, got {cmux_log}",
failures,
)
expect(
open_log == [url],
f"toggle off (case-insensitive): expected system open [{url}], got {open_log}",
failures,
)
def test_whitelist_miss_passthrough(failures: list[str]) -> None:
url = "https://example.com"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting="1",
whitelist="localhost\n127.0.0.1",
)
expect(code == 0, f"whitelist miss: wrapper exited {code}: {stderr}", failures)
expect(cmux_log == [], f"whitelist miss: cmux should not be called, got {cmux_log}", failures)
expect(open_log == [url], f"whitelist miss: expected system open [{url}], got {open_log}", failures)
def test_whitelist_match_routes_to_cmux(failures: list[str]) -> None:
url = "https://api.example.com/path?q=1"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting="1",
whitelist="*.example.com",
)
expect(code == 0, f"whitelist match: wrapper exited {code}: {stderr}", failures)
expect(open_log == [], f"whitelist match: system open should not be called, got {open_log}", failures)
expect(cmux_log == [f"browser open {url}"], f"whitelist match: unexpected cmux log {cmux_log}", failures)
def test_partial_failures_only_fallback_failed_urls(failures: list[str]) -> None:
good = "https://api.example.com"
failed = "https://fail.example.com"
external = "https://outside.test"
open_log, cmux_log, code, stderr = run_wrapper(
args=[good, failed, external],
intercept_setting="1",
whitelist="*.example.com",
fail_urls=[failed],
)
expect(code == 0, f"partial failure: wrapper exited {code}: {stderr}", failures)
expect(
cmux_log == [f"browser open {good}", f"browser open {failed}"],
f"partial failure: cmux log mismatch {cmux_log}",
failures,
)
expect(
open_log == [f"{failed} {external}"],
f"partial failure: expected fallback for failed/external only, got {open_log}",
failures,
)
def test_legacy_toggle_fallback_passthrough(failures: list[str]) -> None:
url = "https://example.com"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting=None,
legacy_open_setting="0",
whitelist="",
)
expect(code == 0, f"legacy fallback: wrapper exited {code}: {stderr}", failures)
expect(cmux_log == [], f"legacy fallback: cmux should not be called, got {cmux_log}", failures)
expect(open_log == [url], f"legacy fallback: expected system open [{url}], got {open_log}", failures)
def test_legacy_toggle_fallback_case_insensitive_passthrough(failures: list[str]) -> None:
url = "https://example.com"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting=None,
legacy_open_setting=" Off ",
whitelist="",
)
expect(code == 0, f"legacy fallback (case-insensitive): wrapper exited {code}: {stderr}", failures)
expect(
cmux_log == [],
f"legacy fallback (case-insensitive): cmux should not be called, got {cmux_log}",
failures,
)
expect(
open_log == [url],
f"legacy fallback (case-insensitive): expected system open [{url}], got {open_log}",
failures,
)
def test_uppercase_scheme_routes_to_cmux(failures: list[str]) -> None:
url = "HTTPS://api.example.com/path?q=1"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting="1",
whitelist="*.example.com",
)
expect(code == 0, f"uppercase scheme: wrapper exited {code}: {stderr}", failures)
expect(open_log == [], f"uppercase scheme: system open should not be called, got {open_log}", failures)
expect(cmux_log == [f"browser open {url}"], f"uppercase scheme: unexpected cmux log {cmux_log}", failures)
def test_unicode_whitelist_matches_punycode_url(failures: list[str]) -> None:
url = "https://xn--bcher-kva.example/path"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting="1",
whitelist="bücher.example",
)
expect(code == 0, f"unicode whitelist: wrapper exited {code}: {stderr}", failures)
expect(open_log == [], f"unicode whitelist: system open should not be called, got {open_log}", failures)
expect(cmux_log == [f"browser open {url}"], f"unicode whitelist: unexpected cmux log {cmux_log}", failures)
def test_punycode_whitelist_matches_unicode_url(failures: list[str]) -> None:
url = "https://bücher.example/path"
open_log, cmux_log, code, stderr = run_wrapper(
args=[url],
intercept_setting="1",
whitelist="xn--bcher-kva.example",
)
expect(code == 0, f"punycode whitelist: wrapper exited {code}: {stderr}", failures)
expect(open_log == [], f"punycode whitelist: system open should not be called, got {open_log}", failures)
expect(cmux_log == [f"browser open {url}"], f"punycode whitelist: unexpected cmux log {cmux_log}", failures)
def main() -> int:
failures: list[str] = []
test_toggle_disabled_passthrough(failures)
test_toggle_disabled_case_insensitive_passthrough(failures)
test_whitelist_miss_passthrough(failures)
test_whitelist_match_routes_to_cmux(failures)
test_partial_failures_only_fallback_failed_urls(failures)
test_legacy_toggle_fallback_passthrough(failures)
test_legacy_toggle_fallback_case_insensitive_passthrough(failures)
test_uppercase_scheme_routes_to_cmux(failures)
test_unicode_whitelist_matches_punycode_url(failures)
test_punycode_whitelist_matches_unicode_url(failures)
if failures:
print("open wrapper regression tests failed:")
for failure in failures:
print(f" - {failure}")
return 1
print("open wrapper regression tests passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,324 @@
#!/usr/bin/env python3
"""
Regression: unfocused workspace scrollback must persist across relaunchs in multi-window setups.
"""
from __future__ import annotations
import os
import plistlib
import re
import socket
import subprocess
import time
from pathlib import Path
from cmux import cmux
def _bundle_id(app_path: Path) -> str:
info_path = app_path / "Contents" / "Info.plist"
if not info_path.exists():
raise RuntimeError(f"Missing Info.plist at {info_path}")
with info_path.open("rb") as f:
info = plistlib.load(f)
bundle_id = str(info.get("CFBundleIdentifier", "")).strip()
if not bundle_id:
raise RuntimeError("Missing CFBundleIdentifier")
return bundle_id
def _snapshot_path(bundle_id: str) -> Path:
safe_bundle = re.sub(r"[^A-Za-z0-9._-]", "_", bundle_id)
return Path.home() / "Library/Application Support/cmux" / f"session-{safe_bundle}.json"
def _sanitize_tag_slug(raw: str) -> str:
cleaned = re.sub(r"[^a-z0-9]+", "-", (raw or "").strip().lower())
cleaned = re.sub(r"-+", "-", cleaned).strip("-")
return cleaned or "agent"
def _socket_candidates(app_path: Path, preferred: Path) -> list[Path]:
candidates = [preferred]
app_name = app_path.stem
prefix = "cmux DEV "
if app_name.startswith(prefix):
tag = app_name[len(prefix):]
slug = _sanitize_tag_slug(tag)
candidates.append(Path(f"/tmp/cmux-debug-{slug}.sock"))
deduped: list[Path] = []
seen: set[str] = set()
for candidate in candidates:
key = str(candidate)
if key in seen:
continue
seen.add(key)
deduped.append(candidate)
return deduped
def _socket_reachable(socket_path: Path) -> bool:
if not socket_path.exists():
return False
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.settimeout(0.3)
sock.connect(str(socket_path))
sock.sendall(b"ping\n")
data = sock.recv(1024)
return b"PONG" in data
except OSError:
return False
finally:
sock.close()
def _wait_for_socket(candidates: list[Path], timeout: float = 20.0) -> Path:
deadline = time.time() + timeout
while time.time() < deadline:
for candidate in candidates:
if _socket_reachable(candidate):
return candidate
time.sleep(0.2)
joined = ", ".join(str(path) for path in candidates)
raise RuntimeError(f"Socket did not become reachable: {joined}")
def _wait_for_socket_closed(socket_path: Path, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if not _socket_reachable(socket_path):
return
time.sleep(0.2)
raise RuntimeError(f"Socket still reachable after quit: {socket_path}")
def _kill_existing(app_path: Path) -> None:
exe = app_path / "Contents" / "MacOS" / "cmux DEV"
subprocess.run(["pkill", "-f", str(exe)], capture_output=True, text=True)
time.sleep(1.0)
def _launch(app_path: Path, preferred_socket_path: Path) -> Path:
try:
preferred_socket_path.unlink()
except FileNotFoundError:
pass
subprocess.run(
[
"open",
"-na",
str(app_path),
"--env",
f"CMUX_SOCKET_PATH={preferred_socket_path}",
"--env",
"CMUX_ALLOW_SOCKET_OVERRIDE=1",
],
check=True,
)
resolved_socket_path = _wait_for_socket(_socket_candidates(app_path, preferred_socket_path))
time.sleep(1.5)
return resolved_socket_path
def _quit(bundle_id: str, socket_path: Path) -> None:
subprocess.run(
["osascript", "-e", f'tell application id "{bundle_id}" to quit'],
capture_output=True,
text=True,
check=True,
)
_wait_for_socket_closed(socket_path)
try:
socket_path.unlink()
except FileNotFoundError:
pass
time.sleep(0.8)
def _connect(socket_path: Path) -> cmux:
client = cmux(socket_path=str(socket_path))
client.connect()
if not client.ping():
raise RuntimeError("ping failed")
return client
def _read_scrollback(client: cmux) -> str:
return client._send_command("read_screen --scrollback")
def _wait_for_marker(client: cmux, marker: str, timeout: float = 8.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if marker in _read_scrollback(client):
return True
time.sleep(0.25)
return False
def _consume_visible_markers(client: cmux, remaining: set[str], timeout: float = 4.0) -> None:
if not remaining:
return
deadline = time.time() + timeout
while time.time() < deadline and remaining:
text = _read_scrollback(client)
matched = [marker for marker in remaining if marker in text]
if matched:
for marker in matched:
remaining.discard(marker)
if not remaining:
return
time.sleep(0.25)
def _ensure_workspaces(client: cmux, count: int) -> None:
while len(client.list_workspaces()) < count:
client.new_workspace()
time.sleep(0.3)
def _list_windows(client: cmux) -> list[str]:
response = client._send_command("list_windows")
if response == "No windows":
return []
window_ids: list[str] = []
for line in response.splitlines():
line = line.strip()
if not line:
continue
parts = line.lstrip("* ").split(" ", 2)
if len(parts) >= 2:
window_ids.append(parts[1])
return window_ids
def _new_window(client: cmux) -> str:
response = client._send_command("new_window")
if not response.startswith("OK "):
raise RuntimeError(f"new_window failed: {response}")
return response.split(" ", 1)[1].strip()
def _focus_window(client: cmux, window_id: str) -> None:
response = client._send_command(f"focus_window {window_id}")
if response != "OK":
raise RuntimeError(f"focus_window failed for {window_id}: {response}")
def main() -> int:
app_path_str = os.environ.get("CMUX_APP_PATH", "").strip()
if not app_path_str:
print("SKIP: set CMUX_APP_PATH to a built cmux DEV .app path")
return 0
app_path = Path(app_path_str)
if not app_path.exists():
print(f"SKIP: CMUX_APP_PATH does not exist: {app_path}")
return 0
bundle_id = _bundle_id(app_path)
snapshot = _snapshot_path(bundle_id)
# Keep the override path short enough for Darwin's Unix socket path limit.
bundle_suffix = re.sub(r"[^A-Za-z0-9]", "", bundle_id)[-16:] or "bundle"
socket_path = Path(f"/tmp/cmux-mw-restore-{bundle_suffix}.sock")
markers = {
"w1_ws0": "CMUX_MW_RESTORE_W1_WS0",
"w1_ws1": "CMUX_MW_RESTORE_W1_WS1",
"w2_ws0": "CMUX_MW_RESTORE_W2_WS0",
"w2_ws1": "CMUX_MW_RESTORE_W2_WS1",
}
failures: list[str] = []
_kill_existing(app_path)
snapshot.unlink(missing_ok=True)
try:
# Launch 1: create 2 windows x 2 workspaces; write markers.
socket_path = _launch(app_path, socket_path)
client = _connect(socket_path)
try:
# Window 1 setup.
_ensure_workspaces(client, 2)
client.select_workspace(0)
client.send(f"echo {markers['w1_ws0']}\n")
if not _wait_for_marker(client, markers["w1_ws0"]):
failures.append("missing marker for window1 workspace0 during setup")
client.select_workspace(1)
client.send(f"echo {markers['w1_ws1']}\n")
if not _wait_for_marker(client, markers["w1_ws1"]):
failures.append("missing marker for window1 workspace1 during setup")
client.select_workspace(0) # leave workspace 1 unfocused in window 1
# Window 2 setup.
_new_window(client)
time.sleep(0.5)
_ensure_workspaces(client, 2)
client.select_workspace(0)
client.send(f"echo {markers['w2_ws0']}\n")
if not _wait_for_marker(client, markers["w2_ws0"]):
failures.append("missing marker for window2 workspace0 during setup")
client.select_workspace(1)
client.send(f"echo {markers['w2_ws1']}\n")
if not _wait_for_marker(client, markers["w2_ws1"]):
failures.append("missing marker for window2 workspace1 during setup")
client.select_workspace(0) # leave workspace 1 unfocused in window 2
finally:
client.close()
_quit(bundle_id, socket_path)
# Launch 2: immediate quit without focusing unfocused workspaces.
socket_path = _launch(app_path, socket_path)
client = _connect(socket_path)
try:
window_ids = _list_windows(client)
if len(window_ids) < 2:
failures.append(f"expected >=2 windows after first relaunch, got {len(window_ids)}")
finally:
client.close()
_quit(bundle_id, socket_path)
# Launch 3: verify all markers still present across windows/workspaces.
socket_path = _launch(app_path, socket_path)
client = _connect(socket_path)
try:
window_ids = _list_windows(client)
if len(window_ids) < 2:
failures.append(f"expected >=2 windows after second relaunch, got {len(window_ids)}")
remaining = set(markers.values())
for window_id in window_ids:
_focus_window(client, window_id)
time.sleep(0.3)
workspace_count = len(client.list_workspaces())
for idx in range(min(workspace_count, 2)):
client.select_workspace(idx)
_consume_visible_markers(client, remaining, timeout=6.0)
if not remaining:
break
if not remaining:
break
if remaining:
failures.append(f"missing markers after second relaunch: {sorted(remaining)}")
finally:
client.close()
_quit(bundle_id, socket_path)
finally:
_kill_existing(app_path)
socket_path.unlink(missing_ok=True)
snapshot.unlink(missing_ok=True)
if failures:
print("FAIL:")
for failure in failures:
print(f"- {failure}")
return 1
print("PASS: multi-window unfocused workspaces survive repeated relaunch")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""
Regression: unfocused restored workspaces must survive a second relaunch.
Repro for the historical bug:
1) Launch and save workspaces with marker scrollback.
2) Relaunch, do not focus the non-selected workspaces, then quit again.
3) Relaunch and verify marker scrollback still exists for every workspace.
"""
from __future__ import annotations
import os
import plistlib
import re
import socket
import subprocess
import time
from pathlib import Path
from cmux import cmux
def _bundle_id(app_path: Path) -> str:
info_path = app_path / "Contents" / "Info.plist"
if not info_path.exists():
raise RuntimeError(f"Missing Info.plist at {info_path}")
with info_path.open("rb") as f:
info = plistlib.load(f)
bundle_id = str(info.get("CFBundleIdentifier", "")).strip()
if not bundle_id:
raise RuntimeError("Missing CFBundleIdentifier")
return bundle_id
def _snapshot_path(bundle_id: str) -> Path:
safe_bundle = re.sub(r"[^A-Za-z0-9._-]", "_", bundle_id)
return Path.home() / "Library/Application Support/cmux" / f"session-{safe_bundle}.json"
def _socket_reachable(socket_path: Path) -> bool:
if not socket_path.exists():
return False
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.settimeout(0.3)
sock.connect(str(socket_path))
sock.sendall(b"ping\n")
data = sock.recv(1024)
return b"PONG" in data
except OSError:
return False
finally:
sock.close()
def _wait_for_socket(socket_path: Path, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if _socket_reachable(socket_path):
return
time.sleep(0.2)
raise RuntimeError(f"Socket did not become reachable: {socket_path}")
def _wait_for_socket_closed(socket_path: Path, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if not _socket_reachable(socket_path):
return
time.sleep(0.2)
raise RuntimeError(f"Socket still reachable after quit: {socket_path}")
def _kill_existing(app_path: Path) -> None:
exe = app_path / "Contents" / "MacOS" / "cmux DEV"
subprocess.run(["pkill", "-f", str(exe)], capture_output=True, text=True)
time.sleep(1.0)
def _launch(app_path: Path, socket_path: Path) -> None:
try:
socket_path.unlink()
except FileNotFoundError:
pass
subprocess.run(
[
"open",
"-na",
str(app_path),
"--env",
f"CMUX_SOCKET_PATH={socket_path}",
"--env",
"CMUX_ALLOW_SOCKET_OVERRIDE=1",
],
check=True,
)
_wait_for_socket(socket_path)
time.sleep(1.5)
def _quit(bundle_id: str, socket_path: Path) -> None:
subprocess.run(
["osascript", "-e", f'tell application id "{bundle_id}" to quit'],
capture_output=True,
text=True,
check=True,
)
_wait_for_socket_closed(socket_path)
try:
socket_path.unlink()
except FileNotFoundError:
pass
time.sleep(0.8)
def _connect(socket_path: Path) -> cmux:
client = cmux(socket_path=str(socket_path))
client.connect()
if not client.ping():
raise RuntimeError("ping failed")
return client
def _read_scrollback(client: cmux) -> str:
return client._send_command("read_screen --scrollback")
def _wait_for_marker(client: cmux, marker: str, timeout: float = 8.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if marker in _read_scrollback(client):
return True
time.sleep(0.25)
return False
def main() -> int:
app_path_str = os.environ.get("CMUX_APP_PATH", "").strip()
if not app_path_str:
print("SKIP: set CMUX_APP_PATH to a built cmux DEV .app path")
return 0
app_path = Path(app_path_str)
if not app_path.exists():
print(f"SKIP: CMUX_APP_PATH does not exist: {app_path}")
return 0
bundle_id = _bundle_id(app_path)
snapshot = _snapshot_path(bundle_id)
socket_path = Path(f"/tmp/cmux-session-restore-cycle-{bundle_id.replace('.', '-')}.sock")
markers = [f"CMUX_RESTORE_EDGE_{i}" for i in range(3)]
failures: list[str] = []
_kill_existing(app_path)
snapshot.unlink(missing_ok=True)
try:
# First launch: seed three workspaces with marker scrollback.
_launch(app_path, socket_path)
client = _connect(socket_path)
try:
while len(client.list_workspaces()) < 3:
client.new_workspace()
time.sleep(0.3)
for idx, marker in enumerate(markers):
client.select_workspace(idx)
time.sleep(0.4)
client.send(f"echo {marker}\n")
if not _wait_for_marker(client, marker, timeout=6.0):
failures.append(f"setup marker missing in workspace {idx}: {marker}")
# Keep selected workspace deterministic.
client.select_workspace(1)
time.sleep(0.3)
finally:
client.close()
_quit(bundle_id, socket_path)
# Second launch: do not focus unfocused workspaces. Quit immediately.
_launch(app_path, socket_path)
client = _connect(socket_path)
try:
restored = client.list_workspaces()
if len(restored) < 3:
failures.append(f"expected >=3 workspaces after first relaunch, got {len(restored)}")
selected_indices = [idx for idx, _wid, _title, selected in restored if selected]
if selected_indices != [1]:
failures.append(f"expected selected workspace index [1], got {selected_indices}")
finally:
client.close()
_quit(bundle_id, socket_path)
# Third launch: every workspace should still contain its marker.
_launch(app_path, socket_path)
client = _connect(socket_path)
try:
restored = client.list_workspaces()
if len(restored) < 3:
failures.append(f"expected >=3 workspaces after second relaunch, got {len(restored)}")
for idx, marker in enumerate(markers):
client.select_workspace(idx)
if not _wait_for_marker(client, marker, timeout=8.0):
tail = "\n".join(_read_scrollback(client).splitlines()[-10:])
failures.append(
f"workspace {idx} missing marker {marker} after second relaunch; tail:\n{tail}"
)
finally:
client.close()
_quit(bundle_id, socket_path)
finally:
_kill_existing(app_path)
socket_path.unlink(missing_ok=True)
snapshot.unlink(missing_ok=True)
if failures:
print("FAIL:")
for failure in failures:
print(f"- {failure}")
return 1
print("PASS: unfocused workspace scrollback survives repeated relaunch")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""
Regression: ANSI color escape bytes in replay content must be preserved.
"""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
def main() -> int:
root = Path(__file__).resolve().parents[1]
integration_script = root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh"
if not integration_script.exists():
print(f"SKIP: missing zsh integration script at {integration_script}")
return 0
base = Path("/tmp") / f"cmux_scrollback_color_replay_{os.getpid()}"
try:
shutil.rmtree(base, ignore_errors=True)
base.mkdir(parents=True, exist_ok=True)
replay_file = base / "replay.bin"
replay_file.write_bytes(b"\x1b[31mRED\x1b[0m\n")
env = dict(os.environ)
env["PATH"] = str(base / "empty-bin")
env["CMUX_RESTORE_SCROLLBACK_FILE"] = str(replay_file)
env["CMUX_TEST_INTEGRATION_SCRIPT"] = str(integration_script)
result = subprocess.run(
["/bin/zsh", "-f", "-c", 'source "$CMUX_TEST_INTEGRATION_SCRIPT"'],
env=env,
capture_output=True,
timeout=5,
)
if result.returncode != 0:
print(f"FAIL: zsh exited non-zero rc={result.returncode}")
if result.stderr:
print(result.stderr.decode("utf-8", errors="replace").strip())
return 1
output = (result.stdout or b"") + (result.stderr or b"")
if b"\x1b[31mRED\x1b[0m" not in output:
print("FAIL: ANSI color escape sequence not preserved in replay output")
return 1
if replay_file.exists():
print("FAIL: replay file was not deleted after replay")
return 1
print("PASS: ANSI color escape sequence preserved during replay")
return 0
finally:
shutil.rmtree(base, ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""
Regression: scrollback replay must not depend on PATH containing coreutils.
cmux can launch shells with PATH initially pointing at app resources. If replay
relies on bare `cat`/`rm`, startup replay silently fails before user rc files
restore PATH.
"""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
def main() -> int:
root = Path(__file__).resolve().parents[1]
integration_script = root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh"
if not integration_script.exists():
print(f"SKIP: missing zsh integration script at {integration_script}")
return 0
base = Path("/tmp") / f"cmux_scrollback_restore_{os.getpid()}"
try:
shutil.rmtree(base, ignore_errors=True)
base.mkdir(parents=True, exist_ok=True)
replay_file = base / "replay.txt"
replay_file.write_text("scrollback-line-1\nscrollback-line-2\n", encoding="utf-8")
env = dict(os.environ)
env["PATH"] = str(base / "empty-bin")
env["CMUX_RESTORE_SCROLLBACK_FILE"] = str(replay_file)
env["CMUX_TEST_INTEGRATION_SCRIPT"] = str(integration_script)
result = subprocess.run(
["/bin/zsh", "-f", "-c", 'source "$CMUX_TEST_INTEGRATION_SCRIPT"'],
env=env,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
print(f"FAIL: zsh exited non-zero rc={result.returncode}")
if result.stderr.strip():
print(result.stderr.strip())
return 1
output = (result.stdout or "") + (result.stderr or "")
if "scrollback-line-1" not in output or "scrollback-line-2" not in output:
print("FAIL: replay text was not printed during integration startup")
return 1
if replay_file.exists():
print("FAIL: replay file was not deleted after replay")
return 1
print("PASS: scrollback replay works with minimal PATH")
return 0
finally:
shutil.rmtree(base, ignore_errors=True)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Static regression checks for terminal tiny-pane resize/overflow fixes.
Guards the key invariants for issue #348:
1) Terminal portal sync must stabilize layout and clamp hosted frames to host bounds.
2) Surface sizing must prefer live bounds over stale pending values when available.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
def repo_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
)
if result.returncode == 0:
return Path(result.stdout.strip())
return Path(__file__).resolve().parents[1]
def extract_block(source: str, signature: str) -> str:
start = source.find(signature)
if start < 0:
raise ValueError(f"Missing signature: {signature}")
brace_start = source.find("{", start)
if brace_start < 0:
raise ValueError(f"Missing opening brace for: {signature}")
depth = 0
for idx in range(brace_start, len(source)):
char = source[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return source[brace_start : idx + 1]
raise ValueError(f"Unbalanced braces for: {signature}")
def main() -> int:
root = repo_root()
failures: list[str] = []
portal_path = root / "Sources" / "TerminalWindowPortal.swift"
portal_source = portal_path.read_text(encoding="utf-8")
if "hostView.layer?.masksToBounds = true" not in portal_source:
failures.append("WindowTerminalPortal init no longer enables hostView layer clipping")
if "hostView.postsFrameChangedNotifications = true" not in portal_source:
failures.append("WindowTerminalPortal init no longer enables hostView frame-change notifications")
if "hostView.postsBoundsChangedNotifications = true" not in portal_source:
failures.append("WindowTerminalPortal init no longer enables hostView bounds-change notifications")
if "private func synchronizeLayoutHierarchy()" not in portal_source:
failures.append("WindowTerminalPortal missing synchronizeLayoutHierarchy()")
if "private func synchronizeHostFrameToReference() -> Bool" not in portal_source:
failures.append("WindowTerminalPortal missing synchronizeHostFrameToReference()")
if "hostedView.reconcileGeometryNow()" not in extract_block(
portal_source,
"func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0)",
):
failures.append("bind() no longer pre-reconciles hosted geometry before attach")
sync_block = extract_block(portal_source, "private func synchronizeHostedView(withId hostedId: ObjectIdentifier)")
for required in [
"let hostBounds = hostView.bounds",
"let clampedFrame = frameInHost.intersection(hostBounds)",
"let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost",
"scheduleDeferredFullSynchronizeAll()",
"hostedView.reconcileGeometryNow()",
"hostedView.refreshSurfaceNow()",
]:
if required not in sync_block:
failures.append(f"terminal portal sync missing: {required}")
terminal_view_path = root / "Sources" / "GhosttyTerminalView.swift"
terminal_view_source = terminal_view_path.read_text(encoding="utf-8")
resolved_block = extract_block(terminal_view_source, "private func resolvedSurfaceSize(preferred size: CGSize?) -> CGSize")
bounds_index = resolved_block.find("let currentBounds = bounds.size")
pending_index = resolved_block.find("if let pending = pendingSurfaceSize")
if bounds_index < 0 or pending_index < 0 or bounds_index > pending_index:
failures.append("resolvedSurfaceSize() no longer prefers bounds before pendingSurfaceSize")
update_block = extract_block(terminal_view_source, "private func updateSurfaceSize(size: CGSize? = nil)")
if "let size = resolvedSurfaceSize(preferred: size)" not in update_block:
failures.append("updateSurfaceSize() no longer resolves size via resolvedSurfaceSize()")
if failures:
print("FAIL: terminal resize/portal regression guards failed")
for item in failures:
print(f" - {item}")
return 1
print("PASS: terminal resize/portal regression guards are in place")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -918,6 +918,27 @@ class cmux:
def activate_app(self) -> None: def activate_app(self) -> None:
self._call("debug.app.activate") self._call("debug.app.activate")
def open_command_palette_rename_tab_input(self, window_id: Optional[str] = None) -> None:
params: Dict[str, Any] = {}
if window_id is not None:
params["window_id"] = str(window_id)
self._call("debug.command_palette.rename_tab.open", params)
def command_palette_results(self, window_id: str, limit: int = 20) -> dict:
res = self._call(
"debug.command_palette.results",
{"window_id": str(window_id), "limit": int(limit)},
) or {}
return dict(res)
def command_palette_rename_select_all(self) -> bool:
res = self._call("debug.command_palette.rename_input.select_all") or {}
return bool(res.get("enabled"))
def set_command_palette_rename_select_all(self, enabled: bool) -> bool:
res = self._call("debug.command_palette.rename_input.select_all", {"enabled": bool(enabled)}) or {}
return bool(res.get("enabled"))
def is_terminal_focused(self, panel: Union[str, int]) -> bool: def is_terminal_focused(self, panel: Union[str, int]) -> bool:
sid = self._resolve_surface_id(panel) sid = self._resolve_surface_id(panel)
res = self._call("debug.terminal.is_focused", {"surface_id": sid}) or {} res = self._call("debug.terminal.is_focused", {"surface_id": sid}) or {}

View file

@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""
Regression test: backspace on empty rename input returns to command list.
Coverage:
- First backspace clears selected rename text.
- Second backspace on empty rename input navigates back to command list mode.
"""
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 _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"):
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return
time.sleep(interval_s)
raise cmuxError(message)
def _palette_visible(client, window_id):
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
return bool(payload.get("visible"))
def _palette_results(client, window_id):
return client.command_palette_results(window_id, limit=20)
def _rename_selection(client, window_id):
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
def _int_or(value, default):
try:
return int(value)
except (TypeError, ValueError):
return int(default)
def _open_rename_input(client, window_id):
client.activate_app()
client.focus_window(window_id)
time.sleep(0.1)
if _palette_visible(client, window_id):
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: not _palette_visible(client, window_id),
message="command palette failed to close before setup",
)
client.open_command_palette_rename_tab_input(window_id=window_id)
_wait_until(
lambda: _palette_visible(client, window_id),
message="command palette failed to open",
)
_wait_until(
lambda: str(_palette_results(client, window_id).get("mode") or "") == "rename_input",
message="command palette did not enter rename input mode",
)
def main():
with cmux(SOCKET_PATH) as client:
client.activate_app()
time.sleep(0.2)
window_id = client.current_window()
original_select_all = client.command_palette_rename_select_all()
try:
client.set_command_palette_rename_select_all(True)
_open_rename_input(client, window_id)
_wait_until(
lambda: bool(_rename_selection(client, window_id).get("focused")),
message="rename input did not focus",
)
selection = _rename_selection(client, window_id)
text_length = _int_or(selection.get("text_length"), 0)
selection_location = _int_or(selection.get("selection_location"), -1)
selection_length = _int_or(selection.get("selection_length"), -1)
if not (
text_length > 0
and selection_location in (-1, 0)
and selection_length == text_length
):
raise cmuxError(
"rename input was not select-all on open: "
f"text_length={text_length} selection=({selection_location}, {selection_length})"
)
client._call(
"debug.command_palette.rename_input.delete_backward",
{"window_id": window_id},
)
first_backspace_cleared = False
last_selection = {}
for _ in range(40):
last_selection = _rename_selection(client, window_id)
if _int_or(last_selection.get("text_length"), -1) == 0:
first_backspace_cleared = True
break
time.sleep(0.05)
if not first_backspace_cleared:
raise cmuxError(
"first backspace did not clear rename input: "
f"selection={last_selection} results={_palette_results(client, window_id)}"
)
after_first = _palette_results(client, window_id)
if str(after_first.get("mode") or "") != "rename_input":
raise cmuxError(f"palette exited rename mode too early after first backspace: {after_first}")
client._call(
"debug.command_palette.rename_input.delete_backward",
{"window_id": window_id},
)
_wait_until(
lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands",
message="second backspace on empty input did not return to commands mode",
)
if not _palette_visible(client, window_id):
raise cmuxError("palette closed unexpectedly instead of navigating back to command list")
finally:
try:
client.set_command_palette_rename_select_all(original_select_all)
except Exception:
pass
if _palette_visible(client, window_id):
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: not _palette_visible(client, window_id),
message="command palette failed to close during cleanup",
)
print("PASS: backspace on empty rename input navigates back to command list")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""
Regression test: opening the command palette must move focus away from terminal.
Why: if terminal remains first responder under the palette, typing goes into the shell
instead of the palette search field.
"""
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 _focused_surface_id(client: cmux) -> str:
surfaces = client.list_surfaces()
for _, sid, focused in surfaces:
if focused:
return sid
raise cmuxError(f"No focused surface in list_surfaces: {surfaces}")
def _palette_visible(client: cmux, window_id: str) -> bool:
res = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
return bool(res.get("visible"))
def _wait_until(predicate, timeout_s: float = 3.0, interval_s: float = 0.05, message: str = "timeout") -> None:
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return
time.sleep(interval_s)
raise cmuxError(message)
def main() -> int:
token = "CMUX_PALETTE_FOCUS_PROBE_9412"
restore_token = "CMUX_PALETTE_RESTORE_PROBE_7731"
with cmux(SOCKET_PATH) as client:
client.new_workspace()
client.activate_app()
time.sleep(0.2)
window_id = client.current_window()
panel_id = _focused_surface_id(client)
_wait_until(
lambda: client.is_terminal_focused(panel_id),
timeout_s=5.0,
message=f"terminal never became focused for panel {panel_id}",
)
pre_text = client.read_terminal_text(panel_id)
# Open palette via debug method and assert terminal focus drops.
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: _palette_visible(client, window_id),
timeout_s=3.0,
message="command palette did not open",
)
# Typing now should target palette input, not the terminal.
client.simulate_type(token)
time.sleep(0.15)
post_text = client.read_terminal_text(panel_id)
if token in post_text and token not in pre_text:
raise cmuxError("typed probe text leaked into terminal while palette is open")
# Close palette and ensure focus returns to previously-focused terminal.
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: not _palette_visible(client, window_id),
timeout_s=3.0,
message="command palette did not close",
)
client.simulate_type(restore_token)
time.sleep(0.15)
restore_text = client.read_terminal_text(panel_id)
if restore_token not in restore_text:
raise cmuxError("terminal did not receive typing after closing command palette")
print("PASS: command palette steals and restores terminal focus")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""
Regression test: command palette focus must remain stable while a new workspace shell spawns.
Why: when a terminal steals first responder during workspace bootstrap, the command-palette
search field can re-focus with full selection, so the next keystroke replaces the whole query.
"""
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 _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05, message: str = "timeout") -> None:
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return
time.sleep(interval_s)
raise cmuxError(message)
def _palette_visible(client: cmux, window_id: str) -> bool:
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
return bool(payload.get("visible"))
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
return client.command_palette_results(window_id=window_id, limit=limit)
def _palette_input_selection(client: cmux, window_id: str) -> dict:
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
def _close_palette_if_open(client: cmux, window_id: str) -> None:
if _palette_visible(client, window_id):
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: not _palette_visible(client, window_id),
message="command palette failed to close",
)
def _assert_caret_at_end(selection: dict, context: str) -> None:
if not selection.get("focused"):
raise cmuxError(f"{context}: palette input is not focused")
text_length = int(selection.get("text_length") or 0)
selection_location = int(selection.get("selection_location") or 0)
selection_length = int(selection.get("selection_length") or 0)
if selection_location != text_length or selection_length != 0:
raise cmuxError(
f"{context}: expected caret-at-end, got location={selection_location}, "
f"length={selection_length}, text_length={text_length}"
)
def main() -> int:
with cmux(SOCKET_PATH) as client:
client.activate_app()
time.sleep(0.2)
window_id = client.current_window()
for row in client.list_windows():
other_id = str(row.get("id") or "")
if other_id and other_id != window_id:
client.close_window(other_id)
time.sleep(0.2)
client.focus_window(window_id)
client.activate_app()
time.sleep(0.2)
_close_palette_if_open(client, window_id)
workspace_count_before = len(client.list_workspaces(window_id=window_id))
client.simulate_shortcut("cmd+shift+p")
_wait_until(
lambda: _palette_visible(client, window_id),
message="cmd+shift+p did not open command palette",
)
_wait_until(
lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands",
message="palette did not open in commands mode",
)
selection = _palette_input_selection(client, window_id)
_assert_caret_at_end(selection, "initial state")
client.new_workspace(window_id=window_id)
_wait_until(
lambda: len(client.list_workspaces(window_id=window_id)) >= workspace_count_before + 1,
message="workspace.create did not add a new workspace",
)
# Sample across shell bootstrap; focus and caret should stay stable.
sample_deadline = time.time() + 2.0
while time.time() < sample_deadline:
selection = _palette_input_selection(client, window_id)
_assert_caret_at_end(selection, "after workspace spawn")
time.sleep(0.01)
client.simulate_type("focuslock")
_wait_until(
lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands",
message="typing after workspace spawn switched palette out of commands mode",
)
_wait_until(
lambda: "focuslock" in str(_palette_results(client, window_id).get("query") or "").lower(),
message="typing after workspace spawn did not append into command query",
)
print("PASS: command palette keeps focus/caret during workspace shell spawn")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""
Regression test: command palette fuzzy ranking for rename commands.
Validates:
- Typing `rename` is captured by the palette query.
- The top-ranked command is a rename command.
- Pressing Enter opens rename input (instead of running an unrelated command).
"""
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")
RENAME_COMMAND_IDS = {"palette.renameTab", "palette.renameWorkspace"}
def _wait_until(predicate, timeout_s=5.0, interval_s=0.05, message="timeout"):
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return
time.sleep(interval_s)
raise cmuxError(message)
def _palette_visible(client: cmux, window_id: str) -> bool:
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
return bool(payload.get("visible"))
def _rename_input_selection(client: cmux, window_id: str) -> dict:
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
def _palette_results(client: cmux, window_id: str, limit: int = 10) -> dict:
return client.command_palette_results(window_id=window_id, limit=limit)
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
if _palette_visible(client, window_id) == visible:
return
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: _palette_visible(client, window_id) == visible,
message=f"palette visibility did not become {visible}",
)
def main() -> int:
with cmux(SOCKET_PATH) as client:
client.activate_app()
time.sleep(0.2)
window_id = client.current_window()
for row in client.list_windows():
other_id = str(row.get("id") or "")
if other_id and other_id != window_id:
client.close_window(other_id)
time.sleep(0.2)
client.focus_window(window_id)
client.activate_app()
time.sleep(0.2)
workspace_id = client.new_workspace(window_id=window_id)
client.select_workspace(workspace_id)
time.sleep(0.2)
_set_palette_visible(client, window_id, False)
_set_palette_visible(client, window_id, True)
# Force command mode query regardless transient field-editor selection state.
time.sleep(0.2)
client.simulate_shortcut("cmd+a")
client.simulate_type(">rename")
_wait_until(
lambda: "rename" in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
message="palette query did not update to 'rename'",
)
payload = _palette_results(client, window_id, limit=12)
rows = payload.get("results") or []
if not rows:
raise cmuxError(f"palette returned no results for rename query: {payload}")
top = rows[0] or {}
top_id = str(top.get("command_id") or "")
top_title = str(top.get("title") or "")
if top_id not in RENAME_COMMAND_IDS:
titles = [str(row.get("title") or "") for row in rows]
raise cmuxError(
f"unexpected top result for 'rename': id={top_id!r} title={top_title!r} results={titles}"
)
client.simulate_shortcut("cmd+a")
client.simulate_type(">retab")
_wait_until(
lambda: "retab" in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
message="palette query did not update to 'retab'",
)
retab_payload = _palette_results(client, window_id, limit=12)
retab_rows = retab_payload.get("results") or []
if not retab_rows:
raise cmuxError(f"palette returned no results for retab query: {retab_payload}")
top_retabs = [str(row.get("command_id") or "") for row in retab_rows[:3]]
if "palette.renameTab" not in top_retabs:
raise cmuxError(
f"'retab' did not rank Rename Tab near top: top3={top_retabs} rows={retab_rows}"
)
client.simulate_shortcut("enter")
_wait_until(
lambda: _palette_visible(client, window_id)
and bool(_rename_input_selection(client, window_id).get("focused")),
message="Enter did not open rename input for top rename result",
)
_set_palette_visible(client, window_id, False)
print("PASS: command palette fuzzy ranking prioritizes rename commands")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""
Regression test: VSCode-like command palette modes.
Validates:
- Cmd+Shift+P opens commands mode (leading '>' semantics).
- Cmd+P opens workspace/tab switcher mode.
- Repeating Cmd+Shift+P or Cmd+P toggles visibility (open/close).
- Switcher search can jump to another workspace by pressing Enter.
"""
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 _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05, message: str = "timeout") -> None:
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return
time.sleep(interval_s)
raise cmuxError(message)
def _palette_visible(client: cmux, window_id: str) -> bool:
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
return bool(payload.get("visible"))
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
return client.command_palette_results(window_id=window_id, limit=limit)
def _palette_input_selection(client: cmux, window_id: str) -> dict:
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
def _wait_for_palette_input_caret_at_end(
client: cmux,
window_id: str,
expected_text_length: int,
message: str,
timeout_s: float = 1.2,
) -> None:
def _matches() -> bool:
selection = _palette_input_selection(client, window_id)
if not selection.get("focused"):
return False
text_length = int(selection.get("text_length") or 0)
selection_location = int(selection.get("selection_location") or 0)
selection_length = int(selection.get("selection_length") or 0)
return (
text_length == expected_text_length
and selection_location == expected_text_length
and selection_length == 0
)
_wait_until(_matches, timeout_s=timeout_s, message=message)
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
if _palette_visible(client, window_id) == visible:
return
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: _palette_visible(client, window_id) == visible,
timeout_s=3.0,
message=f"palette visibility did not become {visible}",
)
def main() -> int:
with cmux(SOCKET_PATH) as client:
client.activate_app()
time.sleep(0.2)
window_id = client.current_window()
for row in client.list_windows():
other_id = str(row.get("id") or "")
if other_id and other_id != window_id:
client.close_window(other_id)
time.sleep(0.2)
client.focus_window(window_id)
client.activate_app()
time.sleep(0.2)
ws_a = client.new_workspace(window_id=window_id)
client.select_workspace(ws_a)
client.rename_workspace("alpha-workspace", workspace=ws_a)
ws_b = client.new_workspace(window_id=window_id)
client.select_workspace(ws_b)
client.rename_workspace("bravo-workspace", workspace=ws_b)
client.select_workspace(ws_a)
_wait_until(
lambda: client.current_workspace() == ws_a,
message="failed to select workspace alpha before switcher jump",
)
_set_palette_visible(client, window_id, False)
# Cmd+P: switcher mode.
client.simulate_shortcut("cmd+p")
_wait_until(
lambda: _palette_visible(client, window_id),
message="cmd+p did not open command palette",
)
_wait_until(
lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher",
message="cmd+p did not open switcher mode",
)
time.sleep(0.2)
client.simulate_type("bravo")
_wait_until(
lambda: "bravo" in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
message="switcher query did not include bravo",
)
switched_rows = (_palette_results(client, window_id, limit=12).get("results") or [])
if not switched_rows:
raise cmuxError("switcher returned no rows for workspace query")
top_id = str((switched_rows[0] or {}).get("command_id") or "")
if not top_id.startswith("switcher."):
raise cmuxError(f"expected switcher row on top for cmd+p query, got: {switched_rows[0]}")
client.simulate_shortcut("enter")
_wait_until(
lambda: not _palette_visible(client, window_id),
message="palette did not close after selecting switcher row",
)
_wait_until(
lambda: client.current_workspace() == ws_b,
message="Enter on switcher result did not move to target workspace",
)
# Cmd+Shift+P: commands mode.
client.simulate_shortcut("cmd+shift+p")
_wait_until(
lambda: _palette_visible(client, window_id),
message="cmd+shift+p did not open command palette",
)
_wait_until(
lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands",
message="cmd+shift+p did not open commands mode",
)
_wait_for_palette_input_caret_at_end(
client,
window_id,
expected_text_length=1,
message="cmd+shift+p should prefill '>' with caret at end (not selected)",
)
command_rows = (_palette_results(client, window_id, limit=8).get("results") or [])
if not command_rows:
raise cmuxError("commands mode returned no rows")
top_command_id = str((command_rows[0] or {}).get("command_id") or "")
if not top_command_id.startswith("palette."):
raise cmuxError(f"expected command row in commands mode, got: {command_rows[0]}")
# Repeating either shortcut should toggle visibility.
client.simulate_shortcut("cmd+shift+p")
_wait_until(
lambda: not _palette_visible(client, window_id),
message="second cmd+shift+p did not close the command palette",
)
client.simulate_shortcut("cmd+p")
_wait_until(
lambda: _palette_visible(client, window_id)
and str(_palette_results(client, window_id).get("mode") or "") == "switcher",
message="cmd+p did not reopen switcher mode after toggle-close",
)
client.simulate_shortcut("cmd+p")
_wait_until(
lambda: not _palette_visible(client, window_id),
message="second cmd+p did not close the command palette",
)
print("PASS: command palette cmd+p/cmd+shift+p open correct modes and toggle on repeat")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""
Regression test: command palette list navigation keys.
Validates:
- Down: ArrowDown, Ctrl+N, Ctrl+J
- Up: ArrowUp, Ctrl+P, Ctrl+K
"""
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 _wait_until(
predicate,
timeout_s: float = 4.0,
interval_s: float = 0.05,
message: str = "timeout",
) -> None:
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return
time.sleep(interval_s)
raise cmuxError(message)
def _palette_visible(client: cmux, window_id: str) -> bool:
res = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
return bool(res.get("visible"))
def _palette_selected_index(client: cmux, window_id: str) -> int:
res = client._call("debug.command_palette.selection", {"window_id": window_id}) or {}
return int(res.get("selected_index") or 0)
def _has_focused_surface(client: cmux) -> bool:
try:
return any(bool(row[2]) for row in client.list_surfaces())
except Exception:
return False
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
if _palette_visible(client, window_id) == visible:
return
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: _palette_visible(client, window_id) == visible,
message=f"palette visibility did not become {visible}",
)
def _open_palette_with_query(client: cmux, window_id: str, query: str) -> None:
_set_palette_visible(client, window_id, False)
_set_palette_visible(client, window_id, True)
client.simulate_type(query)
_wait_until(
lambda: _palette_selected_index(client, window_id) == 0,
message="palette selected index did not reset to zero",
)
def _assert_move(client: cmux, window_id: str, combo: str, start_index: int, expected_index: int) -> None:
_open_palette_with_query(client, window_id, "new")
for _ in range(start_index):
client.simulate_shortcut("down")
_wait_until(
lambda: _palette_selected_index(client, window_id) == start_index,
message=f"failed to seed start index {start_index}",
)
client.simulate_shortcut(combo)
_wait_until(
lambda: _palette_visible(client, window_id)
and _palette_selected_index(client, window_id) == expected_index,
message=f"{combo} did not move selection from {start_index} to {expected_index}",
)
def _assert_can_navigate_past_ten_results(client: cmux, window_id: str) -> None:
_open_palette_with_query(client, window_id, "")
for _ in range(12):
client.simulate_shortcut("down")
_wait_until(
lambda: _palette_visible(client, window_id)
and _palette_selected_index(client, window_id) >= 10,
message="selection did not move past index 9 (results may be capped)",
)
def main() -> int:
with cmux(SOCKET_PATH) as client:
client.activate_app()
time.sleep(0.2)
client.new_workspace()
time.sleep(0.2)
window_id = client.current_window()
# Isolate this test to one window so stale palettes in other windows
# cannot steal navigation notifications.
for row in client.list_windows():
other_id = str(row.get("id") or "")
if other_id and other_id != window_id:
client.close_window(other_id)
time.sleep(0.2)
client.focus_window(window_id)
client.activate_app()
time.sleep(0.2)
_wait_until(
lambda: _has_focused_surface(client),
timeout_s=5.0,
message="no focused surface available for command palette context",
)
for combo in ("down", "ctrl+n", "ctrl+j"):
_assert_move(client, window_id, combo, start_index=0, expected_index=1)
for combo in ("up", "ctrl+p", "ctrl+k"):
_assert_move(client, window_id, combo, start_index=1, expected_index=0)
_assert_can_navigate_past_ten_results(client, window_id)
_set_palette_visible(client, window_id, False)
print("PASS: command palette navigation keys and uncapped result navigation")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""
Regression test: command-palette rename flow responds to Enter.
Coverage:
- Enter in rename input applies the new tab name and closes the palette.
"""
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 _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"):
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return
time.sleep(interval_s)
raise cmuxError(message)
def _palette_visible(client, window_id):
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
return bool(payload.get("visible"))
def _rename_input_selection(client, window_id):
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
def _focused_pane_id(client):
panes = client.list_panes()
focused = [row for row in panes if bool(row[3])]
if not focused:
raise cmuxError(f"no focused pane: {panes}")
return str(focused[0][1])
def _selected_surface_title(client, pane_id):
rows = client.list_pane_surfaces(pane_id)
selected = [row for row in rows if bool(row[3])]
if not selected:
raise cmuxError(f"no selected surface in pane {pane_id}: {rows}")
return str(selected[0][2])
def main():
with cmux(SOCKET_PATH) as client:
client.activate_app()
time.sleep(0.2)
window_id = client.current_window()
for row in client.list_windows():
other_id = str(row.get("id") or "")
if other_id and other_id != window_id:
client.close_window(other_id)
time.sleep(0.2)
client.focus_window(window_id)
client.activate_app()
time.sleep(0.2)
workspace_id = client.new_workspace(window_id=window_id)
client.select_workspace(workspace_id)
time.sleep(0.2)
pane_id = _focused_pane_id(client)
rename_to = f"rename-enter-{int(time.time())}"
client.open_command_palette_rename_tab_input(window_id=window_id)
_wait_until(
lambda: _palette_visible(client, window_id),
message="command palette did not open",
)
_wait_until(
lambda: bool(_rename_input_selection(client, window_id).get("focused")),
message="rename input did not focus",
)
client.simulate_type(rename_to)
time.sleep(0.1)
client.simulate_shortcut("enter")
_wait_until(
lambda: not _palette_visible(client, window_id),
message="Enter did not apply rename and close palette",
)
new_title = _selected_surface_title(client, pane_id)
if new_title != rename_to:
raise cmuxError(f"rename not applied: expected '{rename_to}', got '{new_title}'")
print("PASS: command-palette rename flow accepts Enter in input")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""
Regression test: command-palette rename input keeps select-all on interaction.
Coverage:
- With select-all setting enabled, rename input selects all existing text
immediately and stays selected after interaction.
"""
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 _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"):
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return
time.sleep(interval_s)
raise cmuxError(message)
def _palette_visible(client, window_id):
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
return bool(payload.get("visible"))
def _rename_input_selection(client, window_id):
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
def _rename_select_all_setting(client):
payload = client._call("debug.command_palette.rename_input.select_all", {}) or {}
return bool(payload.get("enabled"))
def _set_rename_select_all_setting(client, enabled):
payload = client._call(
"debug.command_palette.rename_input.select_all",
{"enabled": bool(enabled)},
) or {}
return bool(payload.get("enabled"))
def _wait_for_rename_selection(
client,
window_id,
expect_select_all,
message,
timeout_s=0.6,
):
def _matches():
selection = _rename_input_selection(client, window_id)
if not selection.get("focused"):
return False
text_length = int(selection.get("text_length") or 0)
selection_location = int(selection.get("selection_location") or 0)
selection_length = int(selection.get("selection_length") or 0)
if expect_select_all:
return text_length > 0 and selection_location == 0 and selection_length == text_length
return selection_location == text_length and selection_length == 0
_wait_until(_matches, timeout_s=timeout_s, message=message)
def _exercise_rename_selection_setting(
client,
window_id,
expect_select_all,
cycles,
label,
):
for cycle in range(cycles):
_open_rename_tab_input(client, window_id)
_wait_for_rename_selection(
client,
window_id,
expect_select_all=expect_select_all,
timeout_s=0.4,
message=(
f"{label}: rename input not ready with expected selection "
f"on open (cycle {cycle + 1}/{cycles})"
),
)
client._call("debug.command_palette.rename_input.interact", {"window_id": window_id})
_wait_for_rename_selection(
client,
window_id,
expect_select_all=expect_select_all,
timeout_s=0.6,
message=(
f"{label}: rename input selection changed after interaction "
f"(cycle {cycle + 1}/{cycles})"
),
)
if _palette_visible(client, window_id):
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: not _palette_visible(client, window_id),
message=f"{label}: command palette failed to close (cycle {cycle + 1}/{cycles})",
)
def _open_rename_tab_input(client, window_id):
client.activate_app()
client.focus_window(window_id)
time.sleep(0.1)
if _palette_visible(client, window_id):
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: not _palette_visible(client, window_id),
message="command palette failed to close before setup",
)
client.open_command_palette_rename_tab_input(window_id=window_id)
_wait_until(
lambda: _palette_visible(client, window_id),
message="command palette failed to open rename-tab input",
)
def main():
with cmux(SOCKET_PATH) as client:
client.activate_app()
time.sleep(0.2)
original_select_all = _rename_select_all_setting(client)
workspace_id = client.new_workspace()
client.select_workspace(workspace_id)
client.rename_workspace("SeedName", workspace_id)
time.sleep(0.25)
window_id = client.current_window()
try:
stress_cycles = 8
# ON: immediate select-all and interaction-preserved select-all.
_set_rename_select_all_setting(client, True)
_exercise_rename_selection_setting(
client,
window_id,
expect_select_all=True,
cycles=stress_cycles,
label="select-all enabled",
)
# OFF: immediate caret-at-end and interaction-preserved caret-at-end.
_set_rename_select_all_setting(client, False)
_exercise_rename_selection_setting(
client,
window_id,
expect_select_all=False,
cycles=stress_cycles,
label="select-all disabled",
)
finally:
try:
_set_rename_select_all_setting(client, original_select_all)
except Exception:
pass
if _palette_visible(client, window_id):
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: not _palette_visible(client, window_id),
message="command palette failed to close during cleanup",
)
print("PASS: command-palette rename input obeys select-all setting (on/off)")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""
Regression test: command-palette search updates rows and executed action in sync.
Why: if query replacement doesn't fully refresh the result list, the top row text
can lag behind the action executed on Enter.
"""
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 _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"):
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return
time.sleep(interval_s)
raise cmuxError(message)
def _palette_visible(client, window_id):
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
return bool(payload.get("visible"))
def _set_palette_visible(client, window_id, visible):
if _palette_visible(client, window_id) == visible:
return
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: _palette_visible(client, window_id) == visible,
message=f"command palette did not become visible={visible}",
)
def _palette_results(client, window_id, limit=10):
return client.command_palette_results(window_id=window_id, limit=limit)
def _palette_input_selection(client, window_id):
# Shared field-editor probe used by other command palette regressions.
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
def main():
with cmux(SOCKET_PATH) as client:
client.activate_app()
time.sleep(0.2)
window_id = client.current_window()
for row in client.list_windows():
other_id = str(row.get("id") or "")
if other_id and other_id != window_id:
client.close_window(other_id)
time.sleep(0.2)
client.focus_window(window_id)
client.activate_app()
time.sleep(0.2)
workspace_id = client.new_workspace(window_id=window_id)
client.select_workspace(workspace_id)
time.sleep(0.2)
_set_palette_visible(client, window_id, False)
_set_palette_visible(client, window_id, True)
_wait_until(
lambda: bool(_palette_input_selection(client, window_id).get("focused")),
message="palette search input did not focus",
)
client.simulate_shortcut("cmd+a")
client.simulate_type(">open")
_wait_until(
lambda: "open" in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
message="palette query did not become 'open'",
)
before = _palette_results(client, window_id, limit=8)
before_rows = before.get("results") or []
if not before_rows:
raise cmuxError(f"no results for 'open': {before}")
if str(before_rows[0].get("command_id") or "") != "palette.terminalOpenDirectory":
raise cmuxError(f"unexpected top command for 'open': {before_rows[0]}")
client.simulate_shortcut("cmd+a")
client.simulate_type(">rename")
_wait_until(
lambda: "rename" in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
message="palette query did not become 'rename' after replacement",
)
after = _palette_results(client, window_id, limit=8)
after_rows = after.get("results") or []
if not after_rows:
raise cmuxError(f"no results for 'rename' after replacement: {after}")
top_after = str(after_rows[0].get("command_id") or "")
if top_after not in {"palette.renameWorkspace", "palette.renameTab"}:
raise cmuxError(f"top result did not update to rename command after replacement: {after_rows[0]}")
client.simulate_shortcut("enter")
_wait_until(
lambda: bool(_palette_input_selection(client, window_id).get("focused")),
message="Enter did not trigger renamed top command input",
)
_set_palette_visible(client, window_id, False)
print("PASS: command-palette search replacement keeps row text/action in sync")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""
Regression test: command-palette search typing should not reset selection.
Why: if focus-lock logic repeatedly re-focuses the text field, typing behaves
like Cmd+A is being spammed and each character replaces the previous query.
"""
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 _wait_until(predicate, timeout_s=4.0, interval_s=0.04, message="timeout"):
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return
time.sleep(interval_s)
raise cmuxError(message)
def _palette_visible(client, window_id):
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
return bool(payload.get("visible"))
def _palette_input_selection(client, window_id):
# Uses the shared field-editor probe; works for search and rename modes.
return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {}
def _wait_for_input_state(client, window_id, expected_text_length, message, timeout_s=0.8):
def _matches():
selection = _palette_input_selection(client, window_id)
if not selection.get("focused"):
return False
text_length = int(selection.get("text_length") or 0)
selection_location = int(selection.get("selection_location") or 0)
selection_length = int(selection.get("selection_length") or 0)
return (
text_length == expected_text_length
and selection_location == expected_text_length
and selection_length == 0
)
_wait_until(_matches, timeout_s=timeout_s, message=message)
def _close_palette_if_open(client, window_id):
if _palette_visible(client, window_id):
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: not _palette_visible(client, window_id),
message="command palette failed to close",
)
def _open_palette(client, window_id):
_close_palette_if_open(client, window_id)
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: _palette_visible(client, window_id),
message="command palette failed to open",
)
_wait_for_input_state(
client,
window_id,
expected_text_length=0,
message="search input did not focus with empty query",
)
def main():
with cmux(SOCKET_PATH) as client:
client.activate_app()
time.sleep(0.2)
window_id = client.current_window()
# Keep a single active window for deterministic first-responder behavior.
for row in client.list_windows():
other_id = str(row.get("id") or "")
if other_id and other_id != window_id:
client.close_window(other_id)
time.sleep(0.2)
client.focus_window(window_id)
client.activate_app()
time.sleep(0.2)
probe = "typingstability"
cycles = 4
for cycle in range(cycles):
_open_palette(client, window_id)
for idx, ch in enumerate(probe, start=1):
client.simulate_type(ch)
_wait_for_input_state(
client,
window_id,
expected_text_length=idx,
timeout_s=0.7,
message=(
f"search typing did not accumulate at cycle {cycle + 1}/{cycles}, "
f"char {idx}/{len(probe)}"
),
)
_close_palette_if_open(client, window_id)
print("PASS: command-palette search typing accumulates text without select-all churn")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
Regression test: command-palette shortcut hints stay in sync with editable shortcuts.
Validates:
- New Window / Close Window / Rename Tab commands are present in command mode.
- Their displayed shortcut hints reflect the current KeyboardShortcutSettings values.
"""
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 _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"):
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return
time.sleep(interval_s)
raise cmuxError(message)
def _palette_visible(client: cmux, window_id: str) -> bool:
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
return bool(payload.get("visible"))
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
if _palette_visible(client, window_id) == visible:
return
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: _palette_visible(client, window_id) == visible,
message=f"command palette did not become visible={visible}",
)
def _palette_results(client: cmux, window_id: str, limit=12) -> dict:
return client.command_palette_results(window_id=window_id, limit=limit)
def _open_palette_and_rows(client: cmux, window_id: str, limit: int = 80) -> list:
_set_palette_visible(client, window_id, False)
_set_palette_visible(client, window_id, True)
payload = _palette_results(client, window_id, limit=limit)
rows = payload.get("results") or []
if not rows:
raise cmuxError(f"command palette returned no rows: {payload}")
return rows
def _assert_shortcut_hint(rows: list, command_id: str, expected_hint: str) -> None:
row = next((row for row in rows if str((row or {}).get("command_id") or "") == command_id), None)
if row is None:
raise cmuxError(f"missing command palette row for {command_id!r}; rows={rows}")
shortcut_hint = str((row or {}).get("shortcut_hint") or "")
if shortcut_hint != expected_hint:
raise cmuxError(
f"unexpected shortcut hint for {command_id}: expected {expected_hint!r}, got {shortcut_hint!r} row={row}"
)
def main() -> int:
with cmux(SOCKET_PATH) as client:
client.activate_app()
time.sleep(0.2)
window_id = client.current_window()
for row in client.list_windows():
other_id = str(row.get("id") or "")
if other_id and other_id != window_id:
client.close_window(other_id)
time.sleep(0.2)
client.focus_window(window_id)
client.activate_app()
time.sleep(0.2)
workspace_id = client.new_workspace(window_id=window_id)
client.select_workspace(workspace_id)
time.sleep(0.2)
shortcut_names = ["new_window", "close_window", "rename_tab"]
try:
rows = _open_palette_and_rows(client, window_id)
_assert_shortcut_hint(rows, "palette.newWindow", "⇧⌘N")
_assert_shortcut_hint(rows, "palette.closeWindow", "⌃⌘W")
_assert_shortcut_hint(rows, "palette.renameTab", "⌘R")
client.set_shortcut("new_window", "cmd+opt+n")
client.set_shortcut("close_window", "cmd+opt+w")
client.set_shortcut("rename_tab", "cmd+ctrl+r")
rows = _open_palette_and_rows(client, window_id)
_assert_shortcut_hint(rows, "palette.newWindow", "⌥⌘N")
_assert_shortcut_hint(rows, "palette.closeWindow", "⌥⌘W")
_assert_shortcut_hint(rows, "palette.renameTab", "⌃⌘R")
finally:
for name in shortcut_names:
try:
client.set_shortcut(name, "clear")
except cmuxError:
pass
_set_palette_visible(client, window_id, False)
print("PASS: command-palette shortcut hints track editable shortcuts for new/close/rename window-tab actions")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""
Regression test: cmd+p switcher should include workspaces from every window.
Why: switcher rows were sourced from the current window's TabManager only, so
Cmd+P could not jump to workspaces/tabs owned by other windows.
"""
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 _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None:
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return
time.sleep(interval_s)
raise cmuxError(message)
def _palette_visible(client: cmux, window_id: str) -> bool:
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
return bool(payload.get("visible"))
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
return client.command_palette_results(window_id=window_id, limit=limit)
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
if _palette_visible(client, window_id) == visible:
return
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: _palette_visible(client, window_id) == visible,
message=f"palette visibility in {window_id} did not become {visible}",
)
def main() -> int:
with cmux(SOCKET_PATH) as client:
client.activate_app()
time.sleep(0.2)
window_a = client.current_window()
for row in client.list_windows():
other_id = str(row.get("id") or "")
if other_id and other_id != window_a:
client.close_window(other_id)
time.sleep(0.2)
client.focus_window(window_a)
client.activate_app()
time.sleep(0.2)
window_b = client.new_window()
time.sleep(0.25)
token_suffix = f"{int(time.time() * 1000)}"
token_a = f"cmdp-window-a-{token_suffix}"
token_b = f"cmdp-window-b-{token_suffix}"
workspace_a = client.new_workspace(window_id=window_a)
client.rename_workspace(token_a, workspace=workspace_a)
workspace_b = client.new_workspace(window_id=window_b)
client.rename_workspace(token_b, workspace=workspace_b)
time.sleep(0.25)
client.focus_window(window_a)
client.activate_app()
time.sleep(0.2)
_set_palette_visible(client, window_a, False)
_set_palette_visible(client, window_b, False)
client.simulate_shortcut("cmd+p")
_wait_until(
lambda: _palette_visible(client, window_a),
message="cmd+p did not open palette in window A",
)
_wait_until(
lambda: str(_palette_results(client, window_a).get("mode") or "") == "switcher",
message="cmd+p did not open switcher mode in window A",
)
client.simulate_type(token_b)
_wait_until(
lambda: token_b in str(_palette_results(client, window_a).get("query") or "").strip().lower(),
message="switcher query did not update with window B token",
)
result_rows = (_palette_results(client, window_a, limit=64).get("results") or [])
target_workspace_command = f"switcher.workspace.{workspace_b.lower()}"
if not any(str((row or {}).get("command_id") or "") == target_workspace_command for row in result_rows):
raise cmuxError(
f"cmd+p switcher in window A did not include workspace from window B "
f"(expected {target_workspace_command}); rows={result_rows[:8]}"
)
client.simulate_shortcut("enter")
_wait_until(
lambda: not _palette_visible(client, window_a),
message="palette did not close after selecting cross-window switcher row",
)
_wait_until(
lambda: client.current_workspace().lower() == workspace_b.lower(),
message="Enter on cross-window switcher row did not move to window B workspace",
)
_wait_until(
lambda: client.current_window().lower() == window_b.lower(),
message="Enter on cross-window switcher row did not focus window B",
)
print("PASS: cmd+p switcher includes and navigates to workspaces from other windows")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""
Regression test: cmd+p switcher surface selection across workspaces must focus that surface.
Why: switching workspaces with an explicit target surface could be overridden by stale
per-workspace remembered focus, leaving the destination workspace selected but the wrong
surface focused.
"""
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 _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None:
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return
time.sleep(interval_s)
raise cmuxError(message)
def _palette_visible(client: cmux, window_id: str) -> bool:
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
return bool(payload.get("visible"))
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
return client.command_palette_results(window_id=window_id, limit=limit)
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
if _palette_visible(client, window_id) == visible:
return
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: _palette_visible(client, window_id) == visible,
message=f"palette visibility did not become {visible}",
)
def _open_switcher(client: cmux, window_id: str) -> None:
_set_palette_visible(client, window_id, False)
client.simulate_shortcut("cmd+p")
_wait_until(
lambda: _palette_visible(client, window_id),
message="cmd+p did not open switcher",
)
_wait_until(
lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher",
message="cmd+p did not open switcher mode",
)
def _rename_surface(client: cmux, surface_id: str, title: str) -> None:
client._call(
"surface.action",
{
"surface_id": surface_id,
"action": "rename",
"title": title,
},
)
def _current_surface_id(client: cmux, workspace_id: str) -> str:
payload = client._call("surface.current", {"workspace_id": workspace_id}) or {}
return str(payload.get("surface_id") or "")
def main() -> int:
with cmux(SOCKET_PATH) as client:
client.activate_app()
time.sleep(0.2)
window_id = client.current_window()
for row in client.list_windows():
other_id = str(row.get("id") or "")
if other_id and other_id != window_id:
client.close_window(other_id)
time.sleep(0.2)
client.focus_window(window_id)
client.activate_app()
time.sleep(0.2)
ws_a = client.new_workspace(window_id=window_id)
client.select_workspace(ws_a)
client.rename_workspace("source-workspace", workspace=ws_a)
ws_b = client.new_workspace(window_id=window_id)
client.select_workspace(ws_b)
client.rename_workspace("target-workspace", workspace=ws_b)
time.sleep(0.2)
right_surface_id = client.new_split("right")
time.sleep(0.2)
payload = client._call("surface.list", {"workspace_id": ws_b}) or {}
rows = payload.get("surfaces") or []
if len(rows) < 2:
raise cmuxError(f"expected at least two surfaces after split: {payload}")
left_surface_id = ""
for row in rows:
sid = str(row.get("id") or "")
if sid and sid != right_surface_id:
left_surface_id = sid
break
if not left_surface_id:
raise cmuxError(f"failed to resolve left surface id: {payload}")
token = f"cmdp-crossws-{int(time.time() * 1000)}"
_rename_surface(client, right_surface_id, token)
time.sleep(0.2)
client.focus_surface(left_surface_id)
_wait_until(
lambda: _current_surface_id(client, ws_b).lower() == left_surface_id.lower(),
message="failed to prime remembered focus on non-target surface",
)
client.select_workspace(ws_a)
_wait_until(
lambda: client.current_workspace() == ws_a,
message="failed to return to source workspace before cmd+p navigation",
)
_open_switcher(client, window_id)
client.simulate_type(token)
_wait_until(
lambda: token in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
message="switcher query did not update to target token",
)
target_command_id = f"switcher.surface.{ws_b.lower()}.{right_surface_id.lower()}"
_wait_until(
lambda: str(((_palette_results(client, window_id, limit=24).get("results") or [{}])[0] or {}).get("command_id") or "") == target_command_id,
message="target surface row did not become top switcher result",
)
client.simulate_shortcut("enter")
_wait_until(
lambda: not _palette_visible(client, window_id),
message="palette did not close after selecting cross-workspace surface row",
)
_wait_until(
lambda: client.current_workspace() == ws_b,
message="Enter on switcher surface row did not move to target workspace",
)
_wait_until(
lambda: _current_surface_id(client, ws_b).lower() == right_surface_id.lower(),
message="Enter on cross-workspace switcher surface row did not focus target surface",
)
client.close_workspace(ws_b)
client.close_workspace(ws_a)
print("PASS: cmd+p switcher focuses selected surface after cross-workspace navigation")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""
Regression test: cmd+p switcher should search and navigate to renamed surfaces.
"""
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 _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None:
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return
time.sleep(interval_s)
raise cmuxError(message)
def _palette_visible(client: cmux, window_id: str) -> bool:
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
return bool(payload.get("visible"))
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
return client.command_palette_results(window_id=window_id, limit=limit)
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
if _palette_visible(client, window_id) == visible:
return
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: _palette_visible(client, window_id) == visible,
message=f"palette visibility did not become {visible}",
)
def _open_switcher(client: cmux, window_id: str) -> None:
_set_palette_visible(client, window_id, False)
client.simulate_shortcut("cmd+p")
_wait_until(
lambda: _palette_visible(client, window_id),
message="cmd+p did not open switcher",
)
_wait_until(
lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher",
message="cmd+p did not open switcher mode",
)
def _rename_surface(client: cmux, surface_id: str, title: str) -> None:
client._call(
"surface.action",
{
"surface_id": surface_id,
"action": "rename",
"title": title,
},
)
def _current_surface_id(client: cmux, workspace_id: str) -> str:
payload = client._call("surface.current", {"workspace_id": workspace_id}) or {}
return str(payload.get("surface_id") or "")
def main() -> int:
with cmux(SOCKET_PATH) as client:
client.activate_app()
time.sleep(0.2)
window_id = client.current_window()
for row in client.list_windows():
other_id = str(row.get("id") or "")
if other_id and other_id != window_id:
client.close_window(other_id)
time.sleep(0.2)
client.focus_window(window_id)
client.activate_app()
time.sleep(0.2)
workspace_id = client.new_workspace(window_id=window_id)
client.select_workspace(workspace_id)
time.sleep(0.2)
right_surface_id = client.new_split("right")
time.sleep(0.2)
payload = client._call("surface.list", {"workspace_id": workspace_id}) or {}
rows = payload.get("surfaces") or []
if len(rows) < 2:
raise cmuxError(f"expected at least two surfaces after split: {payload}")
left_surface_id = ""
for row in rows:
sid = str(row.get("id") or "")
if sid and sid != right_surface_id:
left_surface_id = sid
break
if not left_surface_id:
raise cmuxError(f"failed to resolve left surface id: {payload}")
token = f"renamed-surface-{int(time.time() * 1000)}"
_rename_surface(client, right_surface_id, token)
time.sleep(0.2)
client.focus_surface(left_surface_id)
time.sleep(0.2)
_open_switcher(client, window_id)
client.simulate_type(token)
_wait_until(
lambda: token in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
message="switcher query did not update to renamed surface token",
)
result_rows = (_palette_results(client, window_id, limit=24).get("results") or [])
if not result_rows:
raise cmuxError("switcher returned no rows for renamed surface query")
top_row = result_rows[0] or {}
top_id = str(top_row.get("command_id") or "")
top_title = str(top_row.get("title") or "")
if not top_id.startswith("switcher.surface."):
raise cmuxError(
f"expected renamed surface row on top, got top={top_id!r} rows={result_rows}"
)
if top_title != token:
raise cmuxError(
f"expected top surface row title to match renamed title {token!r}, got {top_title!r}"
)
client.simulate_shortcut("enter")
_wait_until(
lambda: not _palette_visible(client, window_id),
message="palette did not close after selecting renamed surface row",
)
_wait_until(
lambda: _current_surface_id(client, workspace_id).lower() == right_surface_id.lower(),
message="Enter on renamed surface switcher row did not focus target surface",
)
client.close_workspace(workspace_id)
print("PASS: cmd+p switcher searches and navigates renamed surfaces")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""
Regression test: switcher should prioritize matching surfaces over workspace rows.
Why: workspace rows used to index metadata from all surfaces, so a path-token query
could rank the workspace row above the actual surface row (because of stable rank
tie-breaks), making Enter jump to workspace instead of the intended surface.
"""
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 _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None:
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return
time.sleep(interval_s)
raise cmuxError(message)
def _palette_visible(client: cmux, window_id: str) -> bool:
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
return bool(payload.get("visible"))
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
return client.command_palette_results(window_id=window_id, limit=limit)
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
if _palette_visible(client, window_id) == visible:
return
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: _palette_visible(client, window_id) == visible,
message=f"palette visibility did not become {visible}",
)
def _open_switcher(client: cmux, window_id: str) -> None:
_set_palette_visible(client, window_id, False)
client.simulate_shortcut("cmd+p")
_wait_until(
lambda: _palette_visible(client, window_id),
message="cmd+p did not open switcher",
)
_wait_until(
lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher",
message="cmd+p did not open switcher mode",
)
def main() -> int:
with cmux(SOCKET_PATH) as client:
client.activate_app()
time.sleep(0.2)
window_id = client.current_window()
for row in client.list_windows():
other_id = str(row.get("id") or "")
if other_id and other_id != window_id:
client.close_window(other_id)
time.sleep(0.2)
client.focus_window(window_id)
client.activate_app()
time.sleep(0.2)
workspace_id = client.new_workspace(window_id=window_id)
client.select_workspace(workspace_id)
client.rename_workspace("workspace-no-token", workspace=workspace_id)
time.sleep(0.2)
right_surface_id = client.new_split("right")
time.sleep(0.2)
payload = client._call("surface.list", {"workspace_id": workspace_id}) or {}
rows = payload.get("surfaces") or []
if len(rows) < 2:
raise cmuxError(f"expected at least two surfaces after split: {payload}")
left_surface_id = ""
for row in rows:
sid = str(row.get("id") or "")
if sid and sid != right_surface_id:
left_surface_id = sid
break
if not left_surface_id:
raise cmuxError(f"failed to resolve left surface id: {payload}")
token = f"cmdp-switcher-target-{int(time.time() * 1000)}"
target_dir = f"/tmp/{token}"
client.send_surface(left_surface_id, "cd /tmp\n")
client.send_surface(
right_surface_id,
f"mkdir -p {target_dir} && cd {target_dir}\n",
)
client.focus_surface(left_surface_id)
time.sleep(0.8)
_open_switcher(client, window_id)
client.simulate_type(token)
_wait_until(
lambda: token in str(_palette_results(client, window_id).get("query") or "").strip().lower(),
message="switcher query did not update to target token",
)
def _has_surface_match() -> bool:
result_rows = (_palette_results(client, window_id, limit=24).get("results") or [])
return any(str((row or {}).get("command_id") or "").startswith("switcher.surface.") for row in result_rows)
_wait_until(
_has_surface_match,
timeout_s=8.0,
message="switcher results never produced a matching surface row for token query",
)
result_rows = (_palette_results(client, window_id, limit=24).get("results") or [])
if not result_rows:
raise cmuxError("switcher returned no rows for token query")
top_id = str((result_rows[0] or {}).get("command_id") or "")
if not top_id.startswith("switcher.surface."):
raise cmuxError(f"expected a surface row on top for token query, got top={top_id!r} rows={result_rows}")
workspace_matches = [
str((row or {}).get("command_id") or "")
for row in result_rows
if str((row or {}).get("command_id") or "").startswith("switcher.workspace.")
]
if workspace_matches:
raise cmuxError(
f"workspace row should not match a non-focused surface path token; workspace matches={workspace_matches} rows={result_rows}"
)
_set_palette_visible(client, window_id, False)
client.close_workspace(workspace_id)
print("PASS: switcher ranks matching surface rows ahead of workspace rows for path-token queries")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""
Regression test: cmd+p switcher rows expose right-side type labels.
Expected trailing labels:
- switcher.workspace.* => Workspace
- switcher.surface.* => Surface
"""
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 _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None:
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return
time.sleep(interval_s)
raise cmuxError(message)
def _palette_visible(client: cmux, window_id: str) -> bool:
payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
return bool(payload.get("visible"))
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
return client.command_palette_results(window_id=window_id, limit=limit)
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
if _palette_visible(client, window_id) == visible:
return
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: _palette_visible(client, window_id) == visible,
message=f"palette visibility did not become {visible}",
)
def _open_switcher(client: cmux, window_id: str) -> None:
_set_palette_visible(client, window_id, False)
client.simulate_shortcut("cmd+p")
_wait_until(
lambda: _palette_visible(client, window_id),
message="cmd+p did not open switcher",
)
_wait_until(
lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher",
message="cmd+p did not open switcher mode",
)
def main() -> int:
with cmux(SOCKET_PATH) as client:
client.activate_app()
time.sleep(0.2)
window_id = client.current_window()
for row in client.list_windows():
other_id = str(row.get("id") or "")
if other_id and other_id != window_id:
client.close_window(other_id)
time.sleep(0.2)
client.focus_window(window_id)
client.activate_app()
time.sleep(0.2)
workspace_id = client.new_workspace(window_id=window_id)
client.select_workspace(workspace_id)
token = f"switchertype{int(time.time() * 1000)}"
client.rename_workspace(token, workspace=workspace_id)
_ = client.new_split("right")
time.sleep(0.3)
_open_switcher(client, window_id)
client.simulate_type(token)
_wait_until(
lambda: token in str(_palette_results(client, window_id, limit=60).get("query") or "").strip().lower(),
message="switcher query did not update to workspace token",
)
rows = (_palette_results(client, window_id, limit=60).get("results") or [])
if not rows:
raise cmuxError("switcher returned no rows for token query")
workspace_rows = [
row for row in rows
if str((row or {}).get("command_id") or "").startswith("switcher.workspace.")
]
surface_rows = [
row for row in rows
if str((row or {}).get("command_id") or "").startswith("switcher.surface.")
]
if not workspace_rows:
raise cmuxError(f"expected workspace rows for switcher query: rows={rows}")
if not surface_rows:
raise cmuxError(f"expected surface rows for switcher query: rows={rows}")
bad_workspace = [row for row in workspace_rows if str((row or {}).get("trailing_label") or "") != "Workspace"]
if bad_workspace:
raise cmuxError(f"workspace rows missing 'Workspace' trailing label: {bad_workspace}")
bad_surface = [row for row in surface_rows if str((row or {}).get("trailing_label") or "") != "Surface"]
if bad_surface:
raise cmuxError(f"surface rows missing 'Surface' trailing label: {bad_surface}")
_set_palette_visible(client, window_id, False)
client.close_workspace(workspace_id)
print("PASS: cmd+p switcher rows report Workspace/Surface trailing labels")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""
Regression test: command palette should open only in the active window.
Why: if command-palette toggle is broadcast to all windows, inactive windows can
end up with an open palette that steals focus once they become key.
"""
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 _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05, message: str = "timeout") -> None:
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return
time.sleep(interval_s)
raise cmuxError(message)
def _palette_visible(client: cmux, window_id: str) -> bool:
res = client._call("debug.command_palette.visible", {"window_id": window_id}) or {}
return bool(res.get("visible"))
def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict:
return client.command_palette_results(window_id=window_id, limit=limit)
def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None:
if _palette_visible(client, window_id) == visible:
return
client._call("debug.command_palette.toggle", {"window_id": window_id})
_wait_until(
lambda: _palette_visible(client, window_id) == visible,
timeout_s=3.0,
message=f"palette in {window_id} did not become {visible}",
)
def _focus_window(client: cmux, window_id: str) -> None:
client.focus_window(window_id)
client.activate_app()
_wait_until(
lambda: client.current_window().lower() == window_id.lower(),
timeout_s=3.0,
message=f"failed to focus window {window_id}",
)
time.sleep(0.15)
def _assert_shortcut_window_scoped(client: cmux, shortcut: str, w1: str, w2: str) -> None:
_set_palette_visible(client, w1, False)
_set_palette_visible(client, w2, False)
_focus_window(client, w1)
client.simulate_shortcut(shortcut)
_wait_until(
lambda: _palette_visible(client, w1),
timeout_s=3.0,
message=f"{shortcut} did not open palette in window1",
)
if _palette_visible(client, w2):
raise cmuxError(f"{shortcut} in window1 incorrectly opened palette in window2")
_focus_window(client, w2)
client.simulate_shortcut(shortcut)
_wait_until(
lambda: _palette_visible(client, w2),
timeout_s=3.0,
message=f"{shortcut} did not open palette in window2",
)
if not _palette_visible(client, w1):
raise cmuxError(
f"{shortcut} in window2 incorrectly toggled window1 palette off "
"(cross-window routing regression)"
)
client.simulate_shortcut(shortcut)
_wait_until(
lambda: not _palette_visible(client, w2),
timeout_s=3.0,
message=f"second {shortcut} did not close palette in window2",
)
if not _palette_visible(client, w1):
raise cmuxError(
f"second {shortcut} in window2 incorrectly changed window1 palette visibility"
)
_focus_window(client, w1)
client.simulate_shortcut(shortcut)
_wait_until(
lambda: not _palette_visible(client, w1),
timeout_s=3.0,
message=f"second {shortcut} did not close palette in window1",
)
def _assert_cross_window_typing_after_mixed_shortcuts(client: cmux, w1: str, w2: str) -> None:
_set_palette_visible(client, w1, False)
_set_palette_visible(client, w2, False)
_focus_window(client, w1)
client.simulate_shortcut("cmd+shift+p")
_wait_until(
lambda: _palette_visible(client, w1),
timeout_s=3.0,
message="cmd+shift+p did not open palette in window1",
)
_wait_until(
lambda: str(_palette_results(client, w1).get("mode") or "") == "commands",
timeout_s=3.0,
message="window1 palette did not enter commands mode",
)
window1_query_before = str(_palette_results(client, w1).get("query") or "")
_focus_window(client, w2)
client.simulate_shortcut("cmd+p")
_wait_until(
lambda: _palette_visible(client, w2),
timeout_s=3.0,
message="cmd+p did not open palette in window2",
)
_wait_until(
lambda: str(_palette_results(client, w2).get("mode") or "") == "switcher",
timeout_s=3.0,
message="window2 palette did not enter switcher mode",
)
typed = ""
for ch in "crosswindow":
typed += ch
client.simulate_type(ch)
_wait_until(
lambda expected=typed: str(_palette_results(client, w2).get("query") or "").lower() == expected,
timeout_s=1.8,
message=(
"typing into window2 palette did not accumulate query text "
f"(expected {typed!r})"
),
)
window1_query_now = str(_palette_results(client, w1).get("query") or "")
if window1_query_now != window1_query_before:
raise cmuxError(
"typing in window2 changed window1 command-palette query "
f"(before={window1_query_before!r}, now={window1_query_now!r})"
)
def main() -> int:
with cmux(SOCKET_PATH) as client:
client.activate_app()
time.sleep(0.2)
w1 = client.current_window()
w2 = client.new_window()
time.sleep(0.25)
_ = client.new_workspace(window_id=w1)
_ = client.new_workspace(window_id=w2)
time.sleep(0.25)
_set_palette_visible(client, w1, False)
_set_palette_visible(client, w2, False)
# Open palette in window1 and verify window2 remains untouched.
client._call("debug.command_palette.toggle", {"window_id": w1})
_wait_until(
lambda: _palette_visible(client, w1),
timeout_s=3.0,
message="window1 command palette did not open",
)
if _palette_visible(client, w2):
raise cmuxError("window2 palette became visible when toggling window1")
# Closing window1 palette should not affect window2.
client._call("debug.command_palette.toggle", {"window_id": w1})
_wait_until(
lambda: not _palette_visible(client, w1),
timeout_s=3.0,
message="window1 command palette did not close",
)
# Mirror the same check in the other direction.
client._call("debug.command_palette.toggle", {"window_id": w2})
_wait_until(
lambda: _palette_visible(client, w2),
timeout_s=3.0,
message="window2 command palette did not open",
)
if _palette_visible(client, w1):
raise cmuxError("window1 palette became visible when toggling window2")
client._call("debug.command_palette.toggle", {"window_id": w2})
_wait_until(
lambda: not _palette_visible(client, w2),
timeout_s=3.0,
message="window2 command palette did not close",
)
# Reproduce keyboard-shortcut window-scoping path:
# opening from window2 must not jump back and toggle window1.
_assert_shortcut_window_scoped(client, "cmd+shift+p", w1, w2)
_assert_shortcut_window_scoped(client, "cmd+p", w1, w2)
_assert_cross_window_typing_after_mixed_shortcuts(client, w1, w2)
print("PASS: command palette is scoped to active window")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -9,6 +9,7 @@ This test checks for:
from __future__ import annotations from __future__ import annotations
import re
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@ -94,6 +95,48 @@ def check_autoupdating_text_styles(files: List[Path]) -> List[Tuple[Path, int, s
return violations return violations
def check_command_palette_caret_tint(repo_root: Path) -> List[str]:
"""Ensure command palette text inputs keep a white caret tint."""
content_view = repo_root / "Sources" / "ContentView.swift"
if not content_view.exists():
return [f"Missing expected file: {content_view}"]
try:
content = content_view.read_text()
except Exception as e:
return [f"Could not read {content_view}: {e}"]
checks = [
(
"search input",
r"TextField\(commandPaletteSearchPlaceholder, text: \$commandPaletteQuery\)(?P<body>.*?)"
r"\.focused\(\$isCommandPaletteSearchFocused\)",
),
(
"rename input",
r"TextField\(target\.placeholder, text: \$commandPaletteRenameDraft\)(?P<body>.*?)"
r"\.focused\(\$isCommandPaletteRenameFocused\)",
),
]
violations: List[str] = []
for label, pattern in checks:
match = re.search(pattern, content, flags=re.DOTALL)
if not match:
violations.append(
f"Could not locate command palette {label} TextField block in Sources/ContentView.swift"
)
continue
body = match.group("body")
if ".tint(.white)" not in body:
violations.append(
f"Command palette {label} TextField must use `.tint(.white)` in Sources/ContentView.swift"
)
return violations
def main(): def main():
"""Run the lint checks.""" """Run the lint checks."""
repo_root = get_repo_root() repo_root = get_repo_root()
@ -102,15 +145,18 @@ def main():
print(f"Checking {len(swift_files)} Swift files for performance issues...") print(f"Checking {len(swift_files)} Swift files for performance issues...")
# Check for auto-updating Text styles # Check for auto-updating Text styles
violations = check_autoupdating_text_styles(swift_files) style_violations = check_autoupdating_text_styles(swift_files)
tint_violations = check_command_palette_caret_tint(repo_root)
has_failures = False
if violations: if style_violations:
has_failures = True
print("\n❌ LINT FAILURES: Auto-updating Text styles found") print("\n❌ LINT FAILURES: Auto-updating Text styles found")
print("=" * 60) print("=" * 60)
print("These patterns cause continuous SwiftUI view updates and high CPU usage:") print("These patterns cause continuous SwiftUI view updates and high CPU usage:")
print() print()
for file_path, line_num, line in violations: for file_path, line_num, line in style_violations:
rel_path = file_path.relative_to(repo_root) rel_path = file_path.relative_to(repo_root)
print(f" {rel_path}:{line_num}") print(f" {rel_path}:{line_num}")
print(f" {line}") print(f" {line}")
@ -120,9 +166,23 @@ def main():
print(" Instead of: Text(date, style: .time)") print(" Instead of: Text(date, style: .time)")
print(" Use: Text(date.formatted(date: .omitted, time: .shortened))") print(" Use: Text(date.formatted(date: .omitted, time: .shortened))")
print() print()
if tint_violations:
has_failures = True
print("\n❌ LINT FAILURES: Command palette caret tint drifted")
print("=" * 60)
print("The command palette search and rename text fields must keep a white caret:")
print()
for message in tint_violations:
print(f" {message}")
print()
print("FIX: Set command palette TextField tint modifiers to `.white`.")
print()
if has_failures:
return 1 return 1
print("✅ No auto-updating Text style patterns found") print("✅ No linted SwiftUI pattern regressions found")
return 0 return 0

View file

@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""
Regression test: app shortcuts must apply to the focused window only.
Covers:
- Cmd+B (toggle sidebar) should only affect the active window.
- Cmd+T (new terminal tab/surface) should only affect the active window.
"""
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 _wait_until(predicate, timeout_s: float = 4.0, interval_s: float = 0.05, message: str = "timeout") -> None:
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return
time.sleep(interval_s)
raise cmuxError(message)
def _sidebar_visible(client: cmux, window_id: str) -> bool:
payload = client._call("debug.sidebar.visible", {"window_id": window_id}) or {}
return bool(payload.get("visible"))
def _surface_count(client: cmux, workspace_id: str) -> int:
payload = client._call("surface.list", {"workspace_id": workspace_id}) or {}
return len(payload.get("surfaces") or [])
def main() -> int:
with cmux(SOCKET_PATH) as client:
client.activate_app()
time.sleep(0.2)
window_a = client.current_window()
window_b = client.new_window()
time.sleep(0.25)
workspace_a = client.new_workspace(window_id=window_a)
workspace_b = client.new_workspace(window_id=window_b)
time.sleep(0.25)
client.focus_window(window_a)
client.activate_app()
time.sleep(0.2)
a_before = _sidebar_visible(client, window_a)
b_before = _sidebar_visible(client, window_b)
client.simulate_shortcut("cmd+b")
_wait_until(
lambda: _sidebar_visible(client, window_a) != a_before,
message="Cmd+B did not toggle sidebar in active window A",
)
a_after = _sidebar_visible(client, window_a)
b_after = _sidebar_visible(client, window_b)
if b_after != b_before:
raise cmuxError("Cmd+B in window A incorrectly toggled sidebar in window B")
client.focus_window(window_b)
client.activate_app()
time.sleep(0.2)
client.simulate_shortcut("cmd+b")
_wait_until(
lambda: _sidebar_visible(client, window_b) != b_after,
message="Cmd+B did not toggle sidebar in active window B",
)
if _sidebar_visible(client, window_a) != a_after:
raise cmuxError("Cmd+B in window B incorrectly toggled sidebar in window A")
client.focus_window(window_a)
client.activate_app()
time.sleep(0.2)
client.select_workspace(workspace_a)
time.sleep(0.1)
count_a_before = _surface_count(client, workspace_a)
count_b_before = _surface_count(client, workspace_b)
client.simulate_shortcut("cmd+t")
_wait_until(
lambda: _surface_count(client, workspace_a) == count_a_before + 1,
message="Cmd+T did not create a new surface in active window A",
)
count_b_after = _surface_count(client, workspace_b)
if count_b_after != count_b_before:
raise cmuxError("Cmd+T in window A incorrectly created a surface in window B")
print("PASS: window-scoped shortcuts stay in the active window (Cmd+B, Cmd+T)")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""
Fuzz regression: rapid Cmd+D / Ctrl+D churn must not shift the outer bonsplit container frame.
This targets the user-reported visual shift/flash while spamming split + close.
We treat any drift in x/y/width/height of the outer container frame as a failure.
"""
from collections import deque
import os
import random
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")
FUZZ_SEED = int(os.environ.get("CMUX_SPLIT_FUZZ_SEED", "424242"))
FUZZ_STEPS = int(os.environ.get("CMUX_SPLIT_FUZZ_STEPS", "1400"))
SAMPLES_PER_STEP = int(os.environ.get("CMUX_SPLIT_FUZZ_SAMPLES", "4"))
SAMPLE_INTERVAL_S = float(os.environ.get("CMUX_SPLIT_FUZZ_SAMPLE_INTERVAL_S", "0.0015"))
ACTION_JITTER_MAX_S = float(os.environ.get("CMUX_SPLIT_FUZZ_ACTION_JITTER_MAX_S", "0.0035"))
BURST_MAX = int(os.environ.get("CMUX_SPLIT_FUZZ_BURST_MAX", "3"))
MAX_PANES = int(os.environ.get("CMUX_SPLIT_FUZZ_MAX_PANES", "10"))
EPSILON = float(os.environ.get("CMUX_SPLIT_FUZZ_EPSILON", "0.0"))
TRACE_TAIL = int(os.environ.get("CMUX_SPLIT_FUZZ_TRACE_TAIL", "40"))
ASSERT_NO_UNDERFLOW = os.environ.get("CMUX_SPLIT_FUZZ_ASSERT_NO_UNDERFLOW", "0") == "1"
ASSERT_NO_EMPTY_PANEL = os.environ.get("CMUX_SPLIT_FUZZ_ASSERT_NO_EMPTY_PANEL", "0") == "1"
def _pane_count(layout_payload: dict) -> int:
layout = layout_payload.get("layout") or {}
panes = layout.get("panes") or []
return len(panes)
def _largest_split_frame(layout_payload: dict) -> dict:
selected = layout_payload.get("selectedPanels") or []
best = None
best_area = -1.0
for row in selected:
for split in row.get("splitViews") or []:
frame = split.get("frame")
if not frame:
continue
try:
x = float(frame.get("x", 0.0))
y = float(frame.get("y", 0.0))
width = float(frame.get("width", 0.0))
height = float(frame.get("height", 0.0))
except (TypeError, ValueError):
continue
if width <= 0.0 or height <= 0.0:
continue
area = width * height
if area > best_area:
best_area = area
best = {"x": x, "y": y, "width": width, "height": height}
if best is None:
raise cmuxError(f"layout_debug contains no usable split-view frame: {layout_payload}")
return best
def _container_frame(layout_payload: dict) -> dict:
container = (layout_payload.get("layout") or {}).get("containerFrame")
if container:
try:
return {
"x": float(container.get("x", 0.0)),
"y": float(container.get("y", 0.0)),
"width": float(container.get("width", 0.0)),
"height": float(container.get("height", 0.0)),
}
except (TypeError, ValueError):
pass
# Back-compat fallback for older payloads that don't expose containerFrame.
return _largest_split_frame(layout_payload)
def _assert_same_frame(
current: dict,
baseline: dict,
*,
step: int,
sample: int,
action: str,
seed: int,
action_index: int,
trace: list[str],
) -> None:
deltas = {
key: abs(float(current[key]) - float(baseline[key]))
for key in ("x", "y", "width", "height")
}
shifted = {k: v for k, v in deltas.items() if v > EPSILON}
if shifted:
raise cmuxError(
"Outer split container shifted during fuzz churn "
f"(step={step}, sample={sample}, action={action}, action_index={action_index}, seed={seed}, "
f"baseline={baseline}, current={current}, deltas={deltas}, epsilon={EPSILON})"
f"; recent_actions={trace}"
)
def _warm_start_split(c: cmux) -> dict:
# Ensure we have at least one split so the container frame exists in layout_debug.
c.simulate_shortcut("cmd+d")
deadline = time.time() + 2.0
last = None
while time.time() < deadline:
payload = c.layout_debug()
last = payload
if _pane_count(payload) >= 2:
return payload
time.sleep(0.02)
raise cmuxError(f"Timed out waiting for first split to appear: {last}")
def main() -> int:
rng = random.Random(FUZZ_SEED)
recent_actions: deque[str] = deque(maxlen=max(8, TRACE_TAIL))
total_actions = 0
with cmux(SOCKET_PATH) as c:
ws = c.new_workspace()
c.select_workspace(ws)
c.activate_app()
time.sleep(0.2)
c.reset_bonsplit_underflow_count()
c.reset_empty_panel_count()
initial = _warm_start_split(c)
baseline = _container_frame(initial)
if _pane_count(initial) < 2:
raise cmuxError("Expected at least 2 panes after warm start split")
for step in range(1, FUZZ_STEPS + 1):
burst = rng.randint(1, max(1, BURST_MAX))
for burst_index in range(1, burst + 1):
before = c.layout_debug()
pane_count = _pane_count(before)
if pane_count <= 2:
action = "cmd+d"
elif pane_count >= MAX_PANES:
action = "ctrl+d"
else:
# Bias toward split to keep churn dense while still frequently collapsing via ctrl+d.
action = "cmd+d" if rng.random() < 0.60 else "ctrl+d"
if action == "cmd+d":
c.simulate_shortcut("cmd+d")
else:
# Ctrl+D equivalent sent directly to the focused terminal surface.
c.send_ctrl_d()
total_actions += 1
recent_actions.append(
f"step={step}/burst={burst_index}/{burst} panes_before={pane_count} action={action}"
)
# Random micro-jitter to emulate uneven key-repeat timing while keeping churn fast.
if ACTION_JITTER_MAX_S > 0:
time.sleep(rng.uniform(0.0, ACTION_JITTER_MAX_S))
# Sample repeatedly after each burst to catch transient shifts.
for sample in range(0, SAMPLES_PER_STEP + 1):
payload = c.layout_debug()
current = _container_frame(payload)
_assert_same_frame(
current,
baseline,
step=step,
sample=sample,
action="burst",
seed=FUZZ_SEED,
action_index=total_actions,
trace=list(recent_actions),
)
if SAMPLE_INTERVAL_S > 0:
time.sleep(rng.uniform(0.0, SAMPLE_INTERVAL_S))
underflows = c.bonsplit_underflow_count()
if ASSERT_NO_UNDERFLOW and underflows != 0:
raise cmuxError(f"bonsplit arranged-subview underflow observed during fuzz run: {underflows}")
flashes = c.empty_panel_count()
if ASSERT_NO_EMPTY_PANEL and flashes != 0:
raise cmuxError(f"EmptyPanelView appeared during fuzz run (count={flashes})")
print(
"PASS: cmd+d/ctrl+d fuzz geometry invariant "
f"(seed={FUZZ_SEED}, steps={FUZZ_STEPS}, samples={SAMPLES_PER_STEP}, burst_max={BURST_MAX}, "
f"actions={total_actions}, epsilon={EPSILON}, underflows={underflows}, empty_panel={flashes})"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,487 @@
#!/usr/bin/env python3
"""
Focused fuzz regression for rapid Cmd+D / Ctrl+D churn in a strict 1<->2 pane loop.
Intent:
- Keep topology limited to one pane or two left/right panes only.
- Run across multiple fresh workspaces.
- Sample layout as fast as the debug socket allows during transitions/holds.
- Fail immediately if outer container x/y/width/height drifts at any sampled frame.
"""
from collections import deque
import os
import random
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")
FUZZ_SEED = int(os.environ.get("CMUX_SPLIT_2PANE_SEED", "20260223"))
WORKSPACES = int(os.environ.get("CMUX_SPLIT_2PANE_WORKSPACES", "3"))
CYCLES_PER_WORKSPACE = int(os.environ.get("CMUX_SPLIT_2PANE_CYCLES", "220"))
TRANSITION_TIMEOUT_S = float(os.environ.get("CMUX_SPLIT_2PANE_TIMEOUT_S", "2.0"))
HOLD_MIN_S = float(os.environ.get("CMUX_SPLIT_2PANE_HOLD_MIN_S", "0.003"))
HOLD_MAX_S = float(os.environ.get("CMUX_SPLIT_2PANE_HOLD_MAX_S", "0.018"))
PRE_ACTION_JITTER_MAX_S = float(os.environ.get("CMUX_SPLIT_2PANE_ACTION_JITTER_MAX_S", "0.002"))
EPSILON = float(os.environ.get("CMUX_SPLIT_2PANE_EPSILON", "0.0"))
TRACE_TAIL = int(os.environ.get("CMUX_SPLIT_2PANE_TRACE_TAIL", "64"))
LAYOUT_POLL_SLEEP_S = float(os.environ.get("CMUX_SPLIT_2PANE_POLL_SLEEP_S", "0.0008"))
LAYOUT_TIMEOUT_RETRIES = int(os.environ.get("CMUX_SPLIT_2PANE_LAYOUT_TIMEOUT_RETRIES", "4"))
LAYOUT_TIMEOUT_RETRY_SLEEP_S = float(os.environ.get("CMUX_SPLIT_2PANE_LAYOUT_TIMEOUT_RETRY_SLEEP_S", "0.0015"))
MAX_LAYOUT_TIMEOUTS = int(os.environ.get("CMUX_SPLIT_2PANE_MAX_LAYOUT_TIMEOUTS", "80"))
CTRL_D_RETRY_INTERVAL_S = float(os.environ.get("CMUX_SPLIT_2PANE_CTRL_D_RETRY_INTERVAL_S", "0.18"))
CTRL_D_MAX_EXTRA = int(os.environ.get("CMUX_SPLIT_2PANE_CTRL_D_MAX_EXTRA", "6"))
def _pane_count(layout_payload: dict) -> int:
layout = layout_payload.get("layout") or {}
return len(layout.get("panes") or [])
def _largest_split_frame(layout_payload: dict) -> dict:
selected = layout_payload.get("selectedPanels") or []
best = None
best_area = -1.0
for row in selected:
for split in row.get("splitViews") or []:
frame = split.get("frame")
if not frame:
continue
try:
x = float(frame.get("x", 0.0))
y = float(frame.get("y", 0.0))
width = float(frame.get("width", 0.0))
height = float(frame.get("height", 0.0))
except (TypeError, ValueError):
continue
if width <= 0.0 or height <= 0.0:
continue
area = width * height
if area > best_area:
best_area = area
best = {"x": x, "y": y, "width": width, "height": height}
if best is None:
raise cmuxError(f"layout_debug contains no usable split-view frame: {layout_payload}")
return best
def _container_frame(layout_payload: dict) -> dict:
container = (layout_payload.get("layout") or {}).get("containerFrame")
if container:
try:
return {
"x": float(container.get("x", 0.0)),
"y": float(container.get("y", 0.0)),
"width": float(container.get("width", 0.0)),
"height": float(container.get("height", 0.0)),
}
except (TypeError, ValueError):
pass
return _largest_split_frame(layout_payload)
def _pane_frames_sorted_x(layout_payload: dict) -> list[dict]:
layout = layout_payload.get("layout") or {}
panes = layout.get("panes") or []
frames: list[dict] = []
for pane in panes:
frame = pane.get("frame") or {}
try:
frames.append(
{
"pane_id": str(pane.get("paneId") or ""),
"x": float(frame.get("x", 0.0)),
"y": float(frame.get("y", 0.0)),
"width": float(frame.get("width", 0.0)),
"height": float(frame.get("height", 0.0)),
}
)
except (TypeError, ValueError):
continue
return sorted(frames, key=lambda p: (p["x"], p["y"]))
def _assert_same_frame(
*,
current: dict,
baseline: dict,
workspace_index: int,
cycle: int,
phase: str,
sample: int,
trace: list[str],
) -> None:
deltas = {
key: abs(float(current[key]) - float(baseline[key]))
for key in ("x", "y", "width", "height")
}
shifted = {k: v for k, v in deltas.items() if v > EPSILON}
if shifted:
raise cmuxError(
"Container frame shifted "
f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, sample={sample}, "
f"baseline={baseline}, current={current}, deltas={deltas}, epsilon={EPSILON}); "
f"recent_actions={trace}"
)
def _assert_two_panes_left_right(layout_payload: dict, *, workspace_index: int, cycle: int, trace: list[str]) -> None:
panes = _pane_frames_sorted_x(layout_payload)
if len(panes) != 2:
raise cmuxError(
f"Expected exactly 2 panes in two-pane phase, got {len(panes)} "
f"(workspace={workspace_index}, cycle={cycle}); panes={panes}; recent_actions={trace}"
)
left, right = panes[0], panes[1]
if left["width"] <= 0.0 or left["height"] <= 0.0 or right["width"] <= 0.0 or right["height"] <= 0.0:
raise cmuxError(
f"Collapsed pane in two-pane phase (workspace={workspace_index}, cycle={cycle}): "
f"left={left} right={right}; recent_actions={trace}"
)
if left["x"] >= right["x"]:
raise cmuxError(
f"Two-pane geometry is not left/right (workspace={workspace_index}, cycle={cycle}): "
f"left={left} right={right}; recent_actions={trace}"
)
def _selected_panel_by_pane(layout_payload: dict) -> dict[str, str]:
out: dict[str, str] = {}
for row in layout_payload.get("selectedPanels") or []:
pane_id = str(row.get("paneId") or "")
panel_id = str(row.get("panelId") or "")
if pane_id and panel_id:
out[pane_id] = panel_id
return out
def _rightmost_pane_id(layout_payload: dict) -> str:
panes = _pane_frames_sorted_x(layout_payload)
if len(panes) < 2:
raise cmuxError(f"Expected at least 2 panes to resolve rightmost pane: {panes}")
pane_id = str(panes[-1].get("pane_id") or "")
if not pane_id:
raise cmuxError(f"Rightmost pane is missing pane_id: {panes[-1]}")
return pane_id
def _rightmost_panel_id(layout_payload: dict) -> str:
pane_id = _rightmost_pane_id(layout_payload)
selected = _selected_panel_by_pane(layout_payload)
panel_id = str(selected.get(pane_id) or "")
if not panel_id:
raise cmuxError(f"Missing selected panel for rightmost pane: pane_id={pane_id}, selected={selected}")
return panel_id
def _safe_layout_debug(c: cmux, *, timeout_state: dict[str, int], context: str) -> dict:
for attempt in range(0, max(0, LAYOUT_TIMEOUT_RETRIES) + 1):
try:
return c.layout_debug()
except cmuxError as exc:
if "timed out waiting for response" not in str(exc).lower():
raise
timeout_state["count"] = timeout_state.get("count", 0) + 1
count = timeout_state["count"]
if count > max(0, MAX_LAYOUT_TIMEOUTS):
raise cmuxError(
f"Exceeded layout_debug timeout budget (count={count}, max={MAX_LAYOUT_TIMEOUTS}, context={context})"
) from exc
if attempt >= max(0, LAYOUT_TIMEOUT_RETRIES):
raise cmuxError(
f"layout_debug timed out after retries (attempts={attempt + 1}, count={count}, context={context})"
) from exc
if LAYOUT_TIMEOUT_RETRY_SLEEP_S > 0:
time.sleep(LAYOUT_TIMEOUT_RETRY_SLEEP_S)
raise cmuxError(f"layout_debug retry loop exhausted unexpectedly (context={context})")
def _sample_while(
c: cmux,
*,
baseline: dict,
deadline: float,
workspace_index: int,
cycle: int,
phase: str,
trace: list[str],
timeout_state: dict[str, int],
) -> int:
sampled = 0
while time.time() < deadline:
payload = _safe_layout_debug(
c,
timeout_state=timeout_state,
context=f"sample workspace={workspace_index} cycle={cycle} phase={phase} sample={sampled}",
)
current = _container_frame(payload)
_assert_same_frame(
current=current,
baseline=baseline,
workspace_index=workspace_index,
cycle=cycle,
phase=phase,
sample=sampled,
trace=trace,
)
panes_now = _pane_count(payload)
if panes_now > 2:
raise cmuxError(
f"Observed >2 panes in strict two-pane fuzz "
f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, panes={panes_now}); "
f"recent_actions={trace}"
)
sampled += 1
if LAYOUT_POLL_SLEEP_S > 0:
time.sleep(LAYOUT_POLL_SLEEP_S)
return sampled
def _wait_for_panes(
c: cmux,
*,
target_panes: int,
baseline: dict,
workspace_index: int,
cycle: int,
phase: str,
timeout_s: float,
trace: list[str],
timeout_state: dict[str, int],
) -> tuple[dict, int]:
deadline = time.time() + timeout_s
sampled = 0
last = None
while time.time() < deadline:
payload = _safe_layout_debug(
c,
timeout_state=timeout_state,
context=f"wait workspace={workspace_index} cycle={cycle} phase={phase} sample={sampled}",
)
last = payload
current = _container_frame(payload)
_assert_same_frame(
current=current,
baseline=baseline,
workspace_index=workspace_index,
cycle=cycle,
phase=phase,
sample=sampled,
trace=trace,
)
panes_now = _pane_count(payload)
if panes_now > 2:
raise cmuxError(
f"Observed >2 panes in strict two-pane fuzz while waiting "
f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, panes={panes_now}); "
f"recent_actions={trace}"
)
if panes_now == target_panes:
return payload, sampled + 1
sampled += 1
if LAYOUT_POLL_SLEEP_S > 0:
time.sleep(LAYOUT_POLL_SLEEP_S)
raise cmuxError(
f"Timed out waiting for {target_panes} panes "
f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, sampled={sampled}, "
f"last_panes={_pane_count(last or {})}, timeout_s={timeout_s}); recent_actions={trace}"
)
def _wait_for_single_pane_after_ctrl_d(
c: cmux,
*,
baseline: dict,
workspace_index: int,
cycle: int,
phase: str,
timeout_s: float,
recent_actions: deque[str],
timeout_state: dict[str, int],
) -> tuple[dict, int, int]:
deadline = time.time() + timeout_s
sampled = 0
extra_ctrl_d = 0
last = None
next_retry_at = time.time() + max(0.0, CTRL_D_RETRY_INTERVAL_S)
while time.time() < deadline:
payload = _safe_layout_debug(
c,
timeout_state=timeout_state,
context=f"wait workspace={workspace_index} cycle={cycle} phase={phase} sample={sampled}",
)
last = payload
current = _container_frame(payload)
trace = list(recent_actions)
_assert_same_frame(
current=current,
baseline=baseline,
workspace_index=workspace_index,
cycle=cycle,
phase=phase,
sample=sampled,
trace=trace,
)
panes_now = _pane_count(payload)
if panes_now > 2:
raise cmuxError(
f"Observed >2 panes in strict two-pane fuzz while waiting "
f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, panes={panes_now}); "
f"recent_actions={trace}"
)
if panes_now == 1:
return payload, sampled + 1, extra_ctrl_d
now = time.time()
if panes_now == 2 and extra_ctrl_d < max(0, CTRL_D_MAX_EXTRA) and now >= next_retry_at:
retry_right_panel_id = _rightmost_panel_id(payload)
try:
c.send_key_surface(retry_right_panel_id, "ctrl-d")
except cmuxError as exc:
# Pane/surface can disappear between layout sample and send call under heavy churn.
# Skip this retry tick and re-sample.
if "not_found" in str(exc).lower():
next_retry_at = now + max(0.0, CTRL_D_RETRY_INTERVAL_S)
sampled += 1
if LAYOUT_POLL_SLEEP_S > 0:
time.sleep(LAYOUT_POLL_SLEEP_S)
continue
raise
extra_ctrl_d += 1
recent_actions.append(
f"ws={workspace_index} cycle={cycle} action=ctrl+d(extra:{extra_ctrl_d}/{CTRL_D_MAX_EXTRA},surface={retry_right_panel_id})"
)
next_retry_at = now + max(0.0, CTRL_D_RETRY_INTERVAL_S)
sampled += 1
if LAYOUT_POLL_SLEEP_S > 0:
time.sleep(LAYOUT_POLL_SLEEP_S)
raise cmuxError(
f"Timed out waiting for 1 pane after ctrl+d "
f"(workspace={workspace_index}, cycle={cycle}, phase={phase}, sampled={sampled}, "
f"extra_ctrl_d={extra_ctrl_d}, last_panes={_pane_count(last or {})}, timeout_s={timeout_s}); "
f"recent_actions={list(recent_actions)}"
)
def main() -> int:
rng = random.Random(FUZZ_SEED)
recent_actions: deque[str] = deque(maxlen=max(8, TRACE_TAIL))
total_samples = 0
total_cycles = 0
total_extra_ctrl_d = 0
timeout_state: dict[str, int] = {"count": 0}
with cmux(SOCKET_PATH) as c:
c.activate_app()
for workspace_index in range(1, WORKSPACES + 1):
ws = c.new_workspace()
c.select_workspace(ws)
c.activate_app()
time.sleep(0.08)
start = _safe_layout_debug(c, timeout_state=timeout_state, context=f"workspace={workspace_index} start")
baseline = _container_frame(start)
start_panes = _pane_count(start)
if start_panes != 1:
raise cmuxError(f"New workspace did not start as single pane (workspace={workspace_index}, panes={start_panes})")
for cycle in range(1, CYCLES_PER_WORKSPACE + 1):
total_cycles += 1
if PRE_ACTION_JITTER_MAX_S > 0:
time.sleep(rng.uniform(0.0, PRE_ACTION_JITTER_MAX_S))
recent_actions.append(f"ws={workspace_index} cycle={cycle} action=cmd+d")
c.simulate_shortcut("cmd+d")
after_split, sampled = _wait_for_panes(
c,
target_panes=2,
baseline=baseline,
workspace_index=workspace_index,
cycle=cycle,
phase="after_cmd+d",
timeout_s=TRANSITION_TIMEOUT_S,
trace=list(recent_actions),
timeout_state=timeout_state,
)
total_samples += sampled
_assert_two_panes_left_right(after_split, workspace_index=workspace_index, cycle=cycle, trace=list(recent_actions))
hold_split = rng.uniform(HOLD_MIN_S, HOLD_MAX_S)
total_samples += _sample_while(
c,
baseline=baseline,
deadline=time.time() + hold_split,
workspace_index=workspace_index,
cycle=cycle,
phase="hold_2pane",
trace=list(recent_actions),
timeout_state=timeout_state,
)
if PRE_ACTION_JITTER_MAX_S > 0:
time.sleep(rng.uniform(0.0, PRE_ACTION_JITTER_MAX_S))
right_panel_id = _rightmost_panel_id(after_split)
recent_actions.append(f"ws={workspace_index} cycle={cycle} action=ctrl+d(surface={right_panel_id})")
c.send_key_surface(right_panel_id, "ctrl-d")
_, sampled, extra_ctrl_d = _wait_for_single_pane_after_ctrl_d(
c,
baseline=baseline,
workspace_index=workspace_index,
cycle=cycle,
phase="after_ctrl+d",
timeout_s=TRANSITION_TIMEOUT_S,
recent_actions=recent_actions,
timeout_state=timeout_state,
)
total_samples += sampled
total_extra_ctrl_d += extra_ctrl_d
hold_single = rng.uniform(HOLD_MIN_S, HOLD_MAX_S)
total_samples += _sample_while(
c,
baseline=baseline,
deadline=time.time() + hold_single,
workspace_index=workspace_index,
cycle=cycle,
phase="hold_1pane",
trace=list(recent_actions),
timeout_state=timeout_state,
)
c.close_workspace(ws)
time.sleep(0.05)
print(
"PASS: strict two-pane cmd+d/ctrl+d frame guard "
f"(seed={FUZZ_SEED}, workspaces={WORKSPACES}, cycles={total_cycles}, samples={total_samples}, "
f"extra_ctrl_d={total_extra_ctrl_d}, epsilon={EPSILON}, layout_timeouts={timeout_state.get('count', 0)})"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit 0dd965a75f02f7a358f87fd607a9e2034450a79c Subproject commit 21db26f8a6a0c7707af10da672c0d7cf07076c66

View file

@ -48,11 +48,21 @@ export async function CodeBlock({
</div> </div>
)} )}
<pre <pre
className={`bg-code-bg border border-border px-4 py-3 overflow-x-auto text-[13px] ${lineHeightClass} font-mono ${ className={`bg-code-bg border border-border px-4 py-3 overflow-x-auto text-[13px] ${lineHeightClass} ${
title ? "rounded-b-lg" : "rounded-lg" variant === "ascii" ? "" : "font-mono "
}`} }${title ? "rounded-b-lg" : "rounded-lg"}`}
style={
variant === "ascii"
? {
fontFamily:
"Menlo, Monaco, Consolas, 'Courier New', monospace",
}
: undefined
}
> >
<code>{children}</code> <code style={variant === "ascii" ? { fontFamily: "inherit" } : undefined}>
{children}
</code>
</pre> </pre>
</div> </div>
); );

View file

@ -22,7 +22,7 @@ export function SiteHeader({
return ( return (
<> <>
<header className="sticky top-0 z-30 w-full bg-background/80 backdrop-blur-sm"> <header className="sticky top-0 z-30 w-full bg-background">
<div className="w-full max-w-6xl mx-auto flex items-center px-6 h-12"> <div className="w-full max-w-6xl mx-auto flex items-center px-6 h-12">
{/* Left: logo + section */} {/* Left: logo + section */}
<div className="flex flex-1 items-center gap-3 min-w-0"> <div className="flex flex-1 items-center gap-3 min-w-0">

View file

@ -11,7 +11,7 @@ export function DocsNav({ children }: { children: React.ReactNode }) {
const { open, toggle, close, drawerRef, buttonRef } = useMobileDrawer(); const { open, toggle, close, drawerRef, buttonRef } = useMobileDrawer();
return ( return (
<div className="max-w-6xl mx-auto flex px-4"> <div className="max-w-6xl mx-auto flex px-0 md:px-4">
{/* Mobile menu button */} {/* Mobile menu button */}
<button <button
ref={buttonRef} ref={buttonRef}
@ -62,8 +62,8 @@ export function DocsNav({ children }: { children: React.ReactNode }) {
</aside> </aside>
{/* Content */} {/* Content */}
<main className="flex-1 min-w-0"> <main className="flex-1 min-w-0 overflow-x-hidden">
<div className="max-w-3xl px-6 pb-10 ml-0" data-dev="docs-content" style={{ paddingTop: 8 }}> <div className="max-w-full px-6 pb-10 ml-0" data-dev="docs-content" style={{ paddingTop: 16 }}>
<div className="docs-content text-[15px]">{children}</div> <div className="docs-content text-[15px]">{children}</div>
<DocsPager /> <DocsPager />
</div> </div>

View file

@ -159,10 +159,16 @@ body {
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.4); box-shadow: 0 1px 0 rgba(0, 0, 0, 0.4);
} }
.docs-content pre {
overflow-x: auto;
max-width: 100%;
}
.docs-content pre code { .docs-content pre code {
background: none; background: none;
padding: 0; padding: 0;
font-size: 1em; font-size: 1em;
font-family: inherit;
} }
/* Shiki dual theme */ /* Shiki dual theme */

View file

@ -225,6 +225,9 @@ export default function Home() {
<span className="text-muted group-hover:text-foreground transition-colors"> <span className="text-muted group-hover:text-foreground transition-colors">
&quot;{t.text}&quot; &quot;{t.text}&quot;
</span> </span>
{"translation" in t && t.translation && (
<span className="text-muted/60 text-xs italic"> {t.translation}</span>
)}
</a> </a>
{" "} {" "}
<a <a
@ -256,6 +259,14 @@ export default function Home() {
<DownloadButton location="bottom" /> <DownloadButton location="bottom" />
<GitHubButton /> <GitHubButton />
</div> </div>
<div className="flex justify-center mt-6">
<a
href="/docs"
className="text-sm text-muted hover:text-foreground transition-colors underline underline-offset-2 decoration-border hover:decoration-foreground"
>
Read the Docs
</a>
</div>
</main> </main>

View file

@ -17,6 +17,15 @@ export const testimonials = [
url: "https://x.com/schrockn/status/2025182278637207857", url: "https://x.com/schrockn/status/2025182278637207857",
platform: "x" as const, platform: "x" as const,
}, },
{
name: "あさざ",
handle: "@asaza_0928",
avatar: "/avatars/asaza_0928.jpg",
text: "cmux 良さそうすぎてついにバイバイ VSCode するときなのかもしれない",
translation: "cmux looks so good it might finally be time to say goodbye to VSCode",
url: "https://x.com/asaza_0928/status/2026057269075698015",
platform: "x" as const,
},
{ {
name: "johnthedebs", name: "johnthedebs",
handle: "johnthedebs", handle: "johnthedebs",
@ -49,6 +58,32 @@ export const testimonials = [
url: "https://www.reddit.com/r/ClaudeCode/comments/1r9g45u/comment/o6sxbr3/", url: "https://www.reddit.com/r/ClaudeCode/comments/1r9g45u/comment/o6sxbr3/",
platform: "reddit" as const, platform: "reddit" as const,
}, },
{
name: "Norihiro Narayama",
handle: "@northprint",
avatar: "/avatars/northprint.jpg",
text: "cmux良さそうなので入れてみたけれど、良い",
translation: "Tried cmux since it looked good — it's good",
url: "https://x.com/northprint/status/2025740286677434581",
platform: "x" as const,
},
{
name: "Kishore Neelamegam",
handle: "@indykish",
avatar: "/avatars/indykish.jpg",
text: "cmux is pretty good.",
url: "https://x.com/indykish/status/2025318347970412673",
platform: "x" as const,
},
{
name: "かたりん",
handle: "@kataring",
avatar: "/avatars/kataring.jpg",
text: "cmux.dev に乗り換えた",
translation: "Switched to cmux.dev",
url: "https://x.com/kataring/status/2026189035056832718",
platform: "x" as const,
},
]; ];
export type Testimonial = (typeof testimonials)[number]; export type Testimonial = (typeof testimonials)[number];
@ -159,6 +194,11 @@ export function TestimonialCard({
<p className="text-[15px] leading-relaxed text-muted group-hover:text-foreground transition-colors"> <p className="text-[15px] leading-relaxed text-muted group-hover:text-foreground transition-colors">
{testimonial.text} {testimonial.text}
</p> </p>
{"translation" in testimonial && testimonial.translation && (
<p className="text-xs text-muted/60 mt-1.5 italic">
{testimonial.translation}
</p>
)}
</a> </a>
); );
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB