Merge origin/main into issue-151-ssh-remote-port-proxying

This commit is contained in:
Lawrence Chen 2026-02-28 17:17:30 -08:00
commit c179ee74ea
202 changed files with 54781 additions and 2314 deletions

View file

@ -9,19 +9,29 @@ Full end-to-end release built locally. Bumps version, updates changelog, tags, t
- Get the current version from `GhosttyTabs.xcodeproj/project.pbxproj` (look for `MARKETING_VERSION`)
- Bump the minor version unless the user specifies otherwise (e.g., 0.54.0 → 0.55.0)
### 2. Gather changes since the last release
### 2. Gather changes and contributors since the last release
- Find the most recent git tag: `git describe --tags --abbrev=0`
- Get commits since that tag: `git log --oneline <last-tag>..HEAD --no-merges`
- **Filter for end-user visible changes only** — ignore developer tooling, CI, docs, tests
- Categorize changes into: Added, Changed, Fixed, Removed
- If there are no user-facing changes, ask the user if they still want to release
- **Collect contributors:** For each PR referenced in the commits, get the author:
```bash
gh pr view <N> --repo manaflow-ai/cmux --json author --jq '.author.login'
```
- Also check for linked issue reporters (the person who filed the bug):
```bash
gh issue view <N> --repo manaflow-ai/cmux --json author --jq '.author.login'
```
- Build a deduplicated list of all contributor `@handle`s for the release
### 3. Update the changelog
- Add a new section at the top of `CHANGELOG.md` with the new version and today's date
- **Only include changes that affect the end-user experience**
- Write clear, user-facing descriptions (not raw commit messages)
- **Credit contributors inline** (see Contributor Credits below)
- Also update `docs-site/content/docs/changelog.mdx` if it exists
### 4. Bump the version
@ -72,3 +82,25 @@ If the script fails, run `say "cmux release failed"`.
- Group by category: Added, Changed, Fixed, Removed
- Be concise but descriptive
- Focus on what the user experiences, not how it was implemented
## Contributor Credits
Credit the people who made each release happen. This builds community and encourages contributions.
**Per-entry attribution** — append contributor credit after each changelog bullet:
- For code contributions (PR author): `— thanks @user!`
- For bug reports (issue reporter, if different from PR author): `— thanks @reporter for the report!`
- Core team (`lawrencecchen`, `austinywang`) contributions get no per-entry callout — core work is the baseline
**Summary section** — add a "Thanks to N contributors!" section at the bottom of each release:
```markdown
### Thanks to N contributors!
- [@user1](https://github.com/user1)
- [@user2](https://github.com/user2)
```
- List all contributors alphabetically by GitHub handle (including core team)
- Link each handle to their GitHub profile
- Include everyone: PR authors, issue reporters, anyone whose work is in the release
**GitHub Release body** — when the release is published, the GitHub Release should also include the "Thanks to N contributors!" section with linked handles.

View file

@ -13,16 +13,26 @@ End-to-end release via PR flow: bump version, update changelog, create PR, merge
2. **Create a release branch**
- Create branch: `git checkout -b release/vX.Y.Z`
3. **Gather changes since the last release**
3. **Gather changes and contributors since the last release**
- Find the most recent git tag: `git describe --tags --abbrev=0`
- Get commits since that tag: `git log --oneline <last-tag>..HEAD --no-merges`
- **Filter for end-user visible changes only** - ignore developer tooling, CI, docs, tests
- Categorize changes into: Added, Changed, Fixed, Removed
- **Collect contributors:** For each PR referenced in the commits, get the author:
```bash
gh pr view <N> --repo manaflow-ai/cmux --json author --jq '.author.login'
```
- Also check for linked issue reporters (the person who filed the bug):
```bash
gh issue view <N> --repo manaflow-ai/cmux --json author --jq '.author.login'
```
- Build a deduplicated list of all contributor `@handle`s for the release
4. **Update the changelog**
- Add a new section at the top of `CHANGELOG.md` with the new version and today's date
- **Only include changes that affect the end-user experience**
- Write clear, user-facing descriptions (not raw commit messages)
- **Credit contributors inline** (see Contributor Credits below)
- Also update `docs-site/content/docs/changelog.mdx` if it exists
- If there are no user-facing changes, ask the user if they still want to release
@ -73,3 +83,25 @@ If the script fails, run `say "cmux release failed"`.
- Test additions or fixes
- Internal refactoring with no user-visible effect
- Dependency updates (unless they fix a user-facing bug)
## Contributor Credits
Credit the people who made each release happen. This builds community and encourages contributions.
**Per-entry attribution** — append contributor credit after each changelog bullet:
- For code contributions (PR author): `— thanks @user!`
- For bug reports (issue reporter, if different from PR author): `— thanks @reporter for the report!`
- Core team (`lawrencecchen`, `austinywang`) contributions get no per-entry callout — core work is the baseline
**Summary section** — add a "Thanks to N contributors!" section at the bottom of each release:
```markdown
### Thanks to N contributors!
- [@user1](https://github.com/user1)
- [@user2](https://github.com/user2)
```
- List all contributors alphabetically by GitHub handle (including core team)
- Link each handle to their GitHub profile
- Include everyone: PR authors, issue reporters, anyone whose work is in the release
**GitHub Release body** — when the release is published, the GitHub Release should also include the "Thanks to N contributors!" section with linked handles.

View file

@ -11,16 +11,26 @@ Prepare a new release for cmux. This command updates the changelog, bumps the ve
2. **Create a release branch**
- Create branch: `git checkout -b release/vX.Y.Z`
3. **Gather changes since the last release**
3. **Gather changes and contributors since the last release**
- Find the most recent git tag: `git describe --tags --abbrev=0`
- Get commits since that tag: `git log --oneline <last-tag>..HEAD --no-merges`
- **Filter for end-user visible changes only** - ignore developer tooling, CI, docs, tests
- Categorize changes into: Added, Changed, Fixed, Removed
- **Collect contributors:** For each PR referenced in the commits, get the author:
```bash
gh pr view <N> --repo manaflow-ai/cmux --json author --jq '.author.login'
```
- Also check for linked issue reporters (the person who filed the bug):
```bash
gh issue view <N> --repo manaflow-ai/cmux --json author --jq '.author.login'
```
- Build a deduplicated list of all contributor `@handle`s for the release
4. **Update the changelog**
- Add a new section at the top of `CHANGELOG.md` with the new version and today's date
- **Only include changes that affect the end-user experience** - things users will see, feel, or interact with
- Write clear, user-facing descriptions (not raw commit messages)
- **Credit contributors inline** (see Contributor Credits below)
- Also update `docs-site/content/docs/changelog.mdx` with the same content
- If there are no user-facing changes, ask the user if they still want to release
@ -89,18 +99,47 @@ Prepare a new release for cmux. This command updates the changelog, bumps the ve
- Focus on what the user experiences, not how it was implemented
- Link to issues/PRs if relevant
## Contributor Credits
Credit the people who made each release happen. This builds community and encourages contributions.
**Per-entry attribution** — append contributor credit after each changelog bullet:
- For code contributions (PR author): `— thanks @user!`
- For bug reports (issue reporter, if different from PR author): `— thanks @reporter for the report!`
- Core team (`lawrencecchen`, `austinywang`) contributions get no per-entry callout — core work is the baseline
**Summary section** — add a "Thanks to N contributors!" section at the bottom of each release:
```markdown
### Thanks to N contributors!
- [@user1](https://github.com/user1)
- [@user2](https://github.com/user2)
```
- List all contributors alphabetically by GitHub handle (including core team)
- Link each handle to their GitHub profile
- Include everyone: PR authors, issue reporters, anyone whose work is in the release
**GitHub Release body** — when the release is published, the GitHub Release should also include the "Thanks to N contributors!" section with linked handles.
## Example Changelog Entry
```markdown
## [0.13.0] - 2025-01-30
### Added
- New keyboard shortcut for quick tab switching
- New keyboard shortcut for quick tab switching ([#42](https://github.com/manaflow-ai/cmux/pull/42)) — thanks @contributor!
### Fixed
- Memory leak when closing split panes
- Notification badges not clearing properly
- Memory leak when closing split panes ([#38](https://github.com/manaflow-ai/cmux/pull/38)) — thanks @fixer!
- Notification badges not clearing properly ([#35](https://github.com/manaflow-ai/cmux/pull/35)) — thanks @reporter for the report!
### Changed
- Improved terminal rendering performance
- Improved terminal rendering performance ([#40](https://github.com/manaflow-ai/cmux/pull/40))
### Thanks to 4 contributors!
- [@contributor](https://github.com/contributor)
- [@fixer](https://github.com/fixer)
- [@lawrencechen](https://github.com/lawrencechen)
- [@reporter](https://github.com/reporter)
```

97
.github/workflows/build-ghosttykit.yml vendored Normal file
View file

@ -0,0 +1,97 @@
name: Build GhosttyKit
on:
push:
branches:
- main
pull_request:
jobs:
build-ghosttykit:
# 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
concurrency:
group: self-hosted-build
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
- name: Get ghostty SHA
id: ghostty-sha
run: |
SHA=$(git -C ghostty rev-parse HEAD)
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "Ghostty SHA: $SHA"
- name: Check if xcframework release already exists
id: check-release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="xcframework-${{ steps.ghostty-sha.outputs.sha }}"
if gh release view "$TAG" --repo manaflow-ai/ghostty >/dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Release $TAG already exists, skipping build"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "Release $TAG not found, will build"
fi
- name: Select Xcode
if: steps.check-release.outputs.exists == 'false'
run: |
set -euo pipefail
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
XCODE_DIR="/Applications/Xcode.app/Contents/Developer"
else
XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -n 1 || true)"
if [ -n "$XCODE_APP" ]; then
XCODE_DIR="$XCODE_APP/Contents/Developer"
else
echo "No Xcode.app found under /Applications" >&2
exit 1
fi
fi
echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV"
export DEVELOPER_DIR="$XCODE_DIR"
xcodebuild -version
- name: Build GhosttyKit.xcframework
if: steps.check-release.outputs.exists == 'false'
run: |
set -euo pipefail
if ! command -v zig >/dev/null 2>&1; then
if command -v brew >/dev/null 2>&1; then
brew install zig
else
echo "zig is required to build GhosttyKit.xcframework. Install zig and retry." >&2
exit 1
fi
fi
cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false -Doptimize=ReleaseFast
- name: Package xcframework
if: steps.check-release.outputs.exists == 'false'
run: |
set -euo pipefail
rm -rf GhosttyKit.xcframework
cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework
tar czf GhosttyKit.xcframework.tar.gz GhosttyKit.xcframework
- name: Upload xcframework release
if: steps.check-release.outputs.exists == 'false'
env:
GH_TOKEN: ${{ secrets.GHOSTTY_RELEASE_TOKEN }}
run: |
set -euo pipefail
TAG="xcframework-${{ steps.ghostty-sha.outputs.sha }}"
gh release create "$TAG" \
--repo manaflow-ai/ghostty \
--title "GhosttyKit xcframework (${{ steps.ghostty-sha.outputs.sha }})" \
--notes "Pre-built GhosttyKit.xcframework for commit ${{ steps.ghostty-sha.outputs.sha }}" \
GhosttyKit.xcframework.tar.gz
echo "Published release $TAG"

View file

@ -7,6 +7,24 @@ on:
pull_request:
jobs:
workflow-guard-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Validate self-hosted runner guards
run: ./tests/test_ci_self_hosted_guard.sh
- name: Validate create-dmg version pinning
run: ./tests/test_ci_create_dmg_pinned.sh
- name: Validate unit-test SwiftPM retry guard
run: ./tests/test_ci_unit_test_spm_retry.sh
- name: Validate cmux scheme test configuration
run: ./tests/test_ci_scheme_testaction_debug.sh
web-typecheck:
runs-on: ubuntu-latest
defaults:
@ -14,10 +32,10 @@ jobs:
working-directory: web
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2
- name: Install dependencies
run: bun install --frozen-lockfile
@ -25,14 +43,16 @@ jobs:
- name: Typecheck
run: bun tsc --noEmit
ui-tests:
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
concurrency:
group: self-hosted-build
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
@ -79,8 +99,75 @@ jobs:
# Remove stale build cache to avoid incremental build errors
rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
- name: Resolve Swift packages
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
rm -rf "$SOURCE_PACKAGES_DIR"
mkdir -p "$SOURCE_PACKAGES_DIR"
for attempt in 1 2 3; do
if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-resolvePackageDependencies; then
exit 0
fi
if [ "$attempt" -eq 3 ]; then
echo "Failed to resolve Swift packages after 3 attempts" >&2
exit 1
fi
echo "Package resolution failed on attempt $attempt, retrying..."
sleep $((attempt * 5))
done
- name: Run unit tests
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
run_unit_tests() {
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" test 2>&1
}
# xcodebuild exits 65 even for expected failures (XCTExpectFailure).
# Capture output and fail only if there are unexpected failures.
set +e
OUTPUT=$(run_unit_tests)
EXIT_CODE=$?
set -e
# SwiftPM binary artifact resolution can occasionally fail on self-hosted
# runners with "Could not resolve package dependencies". Retry once after
# clearing SwiftPM/DerivedData caches to recover from transient corruption.
if [ "$EXIT_CODE" -ne 0 ] && echo "$OUTPUT" | grep -q "Could not resolve package dependencies"; then
echo "SwiftPM package resolution failed, clearing caches and retrying once"
rm -rf ~/Library/Caches/org.swift.swiftpm
rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
set +e
OUTPUT=$(run_unit_tests)
EXIT_CODE=$?
set -e
fi
echo "$OUTPUT"
if [ "$EXIT_CODE" -ne 0 ]; then
SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1)
if echo "$SUMMARY" | grep -q "(0 unexpected)"; then
echo "All failures are expected, treating as pass"
else
echo "Unexpected test failures detected"
exit 1
fi
fi
- name: Run UI tests
run: |
set -euo pipefail
# Run directly on the self-hosted macOS runner.
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
-only-testing:cmuxUITests/UpdatePillUITests test

View file

@ -15,6 +15,9 @@ on:
permissions:
contents: write
env:
CREATE_DMG_VERSION: 8.0.0
jobs:
decide:
runs-on: ubuntu-latest
@ -25,7 +28,7 @@ jobs:
steps:
- name: Decide whether a nightly build is needed
id: decide
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
env:
FORCE_BUILD: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force == 'true' && 'true' || 'false' }}
with:
@ -84,7 +87,7 @@ jobs:
cancel-in-progress: false
steps:
- name: Checkout main
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ needs.decide.outputs.head_sha }}
submodules: recursive
@ -112,7 +115,7 @@ jobs:
run: |
brew update
brew install zig
npm install --global create-dmg
npm install --global "create-dmg@${CREATE_DMG_VERSION}"
- name: Build GhosttyKit.xcframework
run: |
@ -190,6 +193,12 @@ jobs:
fi
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${NIGHTLY_BUILD}" "$APP_PLIST"
# Use an immutable DMG filename in appcast URLs so old appcasts keep
# pointing at matching archives while nightly assets roll forward.
NIGHTLY_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg"
echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV"
echo "NIGHTLY_DMG_IMMUTABLE=${NIGHTLY_DMG_IMMUTABLE}" >> "$GITHUB_ENV"
# Embed commit SHA for bug reports
/usr/libexec/PlistBuddy -c "Delete :CMUXCommit" "$APP_PLIST" >/dev/null 2>&1 || true
/usr/libexec/PlistBuddy -c "Add :CMUXCommit string ${SHORT_SHA}" "$APP_PLIST"
@ -198,6 +207,7 @@ jobs:
echo "Nightly bundle ID: com.cmuxterm.app.nightly"
echo "Nightly marketing version: ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}"
echo "Nightly build number: ${NIGHTLY_BUILD}"
echo "Nightly immutable DMG: ${NIGHTLY_DMG_IMMUTABLE}"
echo "Commit SHA: ${SHORT_SHA}"
- name: Import signing cert
@ -283,6 +293,23 @@ jobs:
xcrun stapler staple "$DMG_RELEASE"
xcrun stapler validate "$DMG_RELEASE"
# Keep a stable filename for humans and an immutable filename used
# by appcast URLs to prevent signature/asset mismatch races.
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)
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
@ -291,7 +318,7 @@ jobs:
echo "Missing SPARKLE_PRIVATE_KEY secret" >&2
exit 1
fi
./scripts/sparkle_generate_appcast.sh cmux-nightly-macos.dmg nightly appcast.xml
./scripts/sparkle_generate_appcast.sh "$NIGHTLY_DMG_IMMUTABLE" nightly appcast.xml
- name: Move nightly tag to built commit
run: |
@ -302,7 +329,7 @@ jobs:
git push origin refs/tags/nightly --force
- name: Publish nightly release assets
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
with:
tag_name: nightly
name: Nightly
@ -315,6 +342,7 @@ jobs:
[Download cmux-nightly-macos.dmg](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
files: |
cmux-nightly-macos-${{ github.run_id }}*.dmg
cmux-nightly-macos.dmg
appcast.xml
overwrite_files: true

View file

@ -9,6 +9,9 @@ on:
permissions:
contents: write
env:
CREATE_DMG_VERSION: 8.0.0
jobs:
build-sign-notarize:
runs-on: self-hosted
@ -17,11 +20,67 @@ jobs:
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
- name: Guard immutable release assets
id: guard_release_assets
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
script: |
const { evaluateReleaseAssetGuard } = require('./scripts/release_asset_guard');
const tag = context.ref.replace('refs/tags/', '');
core.setOutput('skip_all', 'false');
core.setOutput('skip_upload', 'false');
core.setOutput('release_state', 'clear');
try {
const release = await github.rest.repos.getReleaseByTag({
owner: context.repo.owner,
repo: context.repo.repo,
tag,
});
const existingAssetNames = (release.data.assets || []).map((asset) => asset.name);
const {
conflicts,
missingImmutableAssets,
guardState,
hasPartialConflict,
shouldSkipBuildAndUpload,
} = evaluateReleaseAssetGuard({ existingAssetNames });
core.setOutput('release_state', guardState);
if (hasPartialConflict) {
core.setFailed(
`Release ${tag} has a partial immutable asset state. Existing immutable assets: ` +
`${conflicts.join(', ')}. Missing immutable assets: ${missingImmutableAssets.join(', ')}. ` +
'Resolve release assets manually before rerunning.'
);
return;
}
if (shouldSkipBuildAndUpload) {
core.notice(
`Release ${tag} already contains immutable assets (${conflicts.join(', ')}). ` +
'Skipping build, notarization, and upload to preserve existing signed artifacts.'
);
core.setOutput('skip_all', 'true');
core.setOutput('skip_upload', 'true');
return;
}
core.notice(`Release ${tag} exists but has no immutable release assets yet; continuing.`);
} catch (error) {
if (error.status === 404) {
core.notice(`Release ${tag} does not exist yet; safe to build and publish assets.`);
return;
}
throw error;
}
- name: Select Xcode
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: |
set -euo pipefail
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
@ -41,15 +100,18 @@ jobs:
xcrun --sdk macosx --show-sdk-path
- name: Install build deps
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: |
brew update
brew install zig
npm install --global create-dmg
npm install --global "create-dmg@${CREATE_DMG_VERSION}"
- name: Download Metal Toolchain
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: xcodebuild -downloadComponent MetalToolchain
- name: Build GhosttyKit.xcframework
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: |
cd ghostty
zig build -Demit-xcframework=true -Demit-macos-app=false -Doptimize=ReleaseFast
@ -58,11 +120,13 @@ jobs:
cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework
- name: Clear SPM cache
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: |
rm -rf ~/Library/Caches/org.swift.swiftpm
rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
- name: Configure SwiftPM cache
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: |
set -euo pipefail
CACHE_DIR="${RUNNER_TEMP}/swiftpm-cache/${GITHUB_RUN_ID}"
@ -71,6 +135,7 @@ jobs:
echo "SWIFTPM_CACHE_PATH=$CACHE_DIR" >> "$GITHUB_ENV"
- name: Derive Sparkle public key from private key
if: steps.guard_release_assets.outputs.skip_all != 'true'
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: |
@ -83,10 +148,12 @@ jobs:
echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV"
- name: Build app (Release)
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: |
xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
- name: Inject Sparkle keys into Info.plist
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: |
APP_PLIST="build/Build/Products/Release/cmux.app/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$APP_PLIST" >/dev/null 2>&1 || true
@ -100,6 +167,7 @@ jobs:
/usr/libexec/PlistBuddy -c "Print :SUFeedURL" "$APP_PLIST"
- name: Import signing cert
if: steps.guard_release_assets.outputs.skip_all != 'true'
env:
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@ -123,6 +191,7 @@ jobs:
security list-keychains -d user -s build.keychain
- name: Codesign app
if: steps.guard_release_assets.outputs.skip_all != 'true'
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: |
@ -140,6 +209,7 @@ jobs:
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
- name: Notarize app
if: steps.guard_release_assets.outputs.skip_all != 'true'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
@ -183,7 +253,22 @@ jobs:
xcrun stapler staple "$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
if: steps.guard_release_assets.outputs.skip_all != 'true'
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: |
@ -194,12 +279,14 @@ jobs:
./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.xml
- name: Upload release asset
uses: softprops/action-gh-release@v2
if: steps.guard_release_assets.outputs.skip_upload != 'true'
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
with:
files: |
cmux-macos.dmg
appcast.xml
generate_release_notes: true
overwrite_files: false
- name: Cleanup keychain
if: always()

View file

@ -65,7 +65,7 @@ jobs:
echo "DMG SHA256: $SHA256"
- name: Checkout homebrew-cmux
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
repository: manaflow-ai/homebrew-cmux
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}

1
.gitignore vendored
View file

@ -38,6 +38,7 @@ zig-out/
# Node
node_modules/
.next/
# Test outputs
tests/visual_output/

0
.gitkeep Normal file
View file

View file

@ -2,6 +2,101 @@
All notable changes to cmux are documented here.
## [0.61.0] - 2026-02-25
### Added
- Command palette (Cmd+Shift+P) with update actions and all-window switcher results ([#358](https://github.com/manaflow-ai/cmux/pull/358), [#361](https://github.com/manaflow-ai/cmux/pull/361))
- Split actions and shortcut hints in terminal context menus
- Cross-window tab and workspace move UI with improved destination focus behavior
- Sidebar pull request metadata rows and workspace PR open actions
- Workspace color schemes and left-rail workspace indicator settings ([#324](https://github.com/manaflow-ai/cmux/pull/324), [#329](https://github.com/manaflow-ai/cmux/pull/329), [#332](https://github.com/manaflow-ai/cmux/pull/332))
- URL open-wrapper routing into the embedded browser ([#332](https://github.com/manaflow-ai/cmux/pull/332))
- Cmd+Q quit warning with suppression toggle ([#295](https://github.com/manaflow-ai/cmux/pull/295))
- `cmux --version` output now includes commit metadata
### Changed
- Added light mode and unified theme refresh across app surfaces ([#258](https://github.com/manaflow-ai/cmux/pull/258)) — thanks @ijpatricio for the report!
- Browser link middle-click handling now uses native WebKit behavior ([#416](https://github.com/manaflow-ai/cmux/pull/416))
- Settings-window actions now route through a single command-palette/settings flow
- Sentry upgraded with tracing, breadcrumbs, and dSYM upload support ([#366](https://github.com/manaflow-ai/cmux/pull/366))
- Session restore scope clarification: cmux restores layout, working directory, scrollback, and browser history, but does not resume live terminal process state yet
### Fixed
- Startup split hang when pressing Cmd+D then Ctrl+D early after launch ([#364](https://github.com/manaflow-ai/cmux/pull/364))
- Browser focus handoff and click-to-focus regressions in mixed terminal/browser workspaces ([#381](https://github.com/manaflow-ai/cmux/pull/381), [#355](https://github.com/manaflow-ai/cmux/pull/355))
- Caps Lock handling in browser omnibar keyboard paths ([#382](https://github.com/manaflow-ai/cmux/pull/382))
- Embedded browser deeplink URL scheme handling ([#392](https://github.com/manaflow-ai/cmux/pull/392))
- Sidebar resize cap regression ([#393](https://github.com/manaflow-ai/cmux/pull/393))
- Terminal zoom inheritance for new splits, surfaces, and workspaces ([#384](https://github.com/manaflow-ai/cmux/pull/384))
- Terminal find overlay layering across split and portal-hosted layouts
- Titlebar drag and double-click zoom handling on browser-side panes
- Stale browser favicon and window-title updates after navigation
### Thanks to 7 contributors!
- [@austinywang](https://github.com/austinywang)
- [@avisser](https://github.com/avisser)
- [@gnguralnick](https://github.com/gnguralnick)
- [@ijpatricio](https://github.com/ijpatricio)
- [@jperkin](https://github.com/jperkin)
- [@jungcome7](https://github.com/jungcome7)
- [@lawrencecchen](https://github.com/lawrencecchen)
## [0.60.0] - 2026-02-21
### Added
- Tab context menu with rename, close, unread, and workspace actions ([#225](https://github.com/manaflow-ai/cmux/pull/225))
- Cmd+Shift+T reopens closed browser panels ([#253](https://github.com/manaflow-ai/cmux/pull/253))
- Vertical sidebar branch layout setting showing git branch and directory per pane
- JavaScript alert/confirm/prompt dialogs in browser panel ([#237](https://github.com/manaflow-ai/cmux/pull/237))
- File drag-and-drop and file input in browser panel ([#214](https://github.com/manaflow-ai/cmux/pull/214))
- tmux-compatible command set with matrix tests ([#221](https://github.com/manaflow-ai/cmux/pull/221))
- Pane resize divider control via CLI ([#223](https://github.com/manaflow-ai/cmux/pull/223))
- Production read-screen capture APIs ([#219](https://github.com/manaflow-ai/cmux/pull/219))
- Notification rings on terminal panes ([#132](https://github.com/manaflow-ai/cmux/pull/132))
- Claude Code integration enabled by default ([#247](https://github.com/manaflow-ai/cmux/pull/247))
- HTTP host allowlist for embedded browser with save and proceed flow ([#206](https://github.com/manaflow-ai/cmux/pull/206), [#203](https://github.com/manaflow-ai/cmux/pull/203))
- Setting to disable workspace auto-reorder on notification ([#215](https://github.com/manaflow-ai/cmux/issues/205))
- Browser panel mouse back/forward buttons and middle-click close ([#139](https://github.com/manaflow-ai/cmux/pull/139))
- Browser DevTools shortcut wiring and persistence ([#117](https://github.com/manaflow-ai/cmux/pull/117))
- CJK IME input support for Korean, Chinese, and Japanese ([#125](https://github.com/manaflow-ai/cmux/pull/125))
- `--help` flag on CLI subcommands ([#128](https://github.com/manaflow-ai/cmux/pull/128))
- `--command` flag for `new-workspace` CLI command ([#121](https://github.com/manaflow-ai/cmux/pull/121))
- `rename-tab` socket command ([#260](https://github.com/manaflow-ai/cmux/pull/260))
- Remap-aware bonsplit tooltips and browser split shortcuts ([#200](https://github.com/manaflow-ai/cmux/pull/200))
### Fixed
- IME preedit anchor sizing ([#266](https://github.com/manaflow-ai/cmux/pull/266))
- Cmd+Shift+T focus against deferred stale callbacks ([#267](https://github.com/manaflow-ai/cmux/pull/267))
- Unknown Bonsplit tab context actions causing crash ([#264](https://github.com/manaflow-ai/cmux/pull/264))
- Socket CLI commands stealing macOS app focus ([#260](https://github.com/manaflow-ai/cmux/pull/260))
- CLI unix socket lag from main-thread blocking ([#259](https://github.com/manaflow-ai/cmux/pull/259))
- Main-thread notification cascade causing hangs ([#232](https://github.com/manaflow-ai/cmux/pull/232))
- Favicon out-of-sync during back/forward navigation ([#233](https://github.com/manaflow-ai/cmux/pull/233))
- Stale sidebar git branch after closing a split
- Browser download UX and crash path ([#235](https://github.com/manaflow-ai/cmux/pull/235))
- Browser reopen focus across workspace switches ([#257](https://github.com/manaflow-ai/cmux/pull/257))
- Mark Tab as Unread no-op on focused tab ([#249](https://github.com/manaflow-ai/cmux/pull/249))
- Split dividers disappearing in tiny panes ([#250](https://github.com/manaflow-ai/cmux/pull/250))
- Flaky browser download activity accounting ([#246](https://github.com/manaflow-ai/cmux/pull/246))
- Drag overlay routing and terminal overlay regressions ([#218](https://github.com/manaflow-ai/cmux/pull/218))
- Initial bonsplit split animation flicker
- Window top inset on new window creation ([#224](https://github.com/manaflow-ai/cmux/pull/224))
- Cmd+Enter being routed as browser reload ([#213](https://github.com/manaflow-ai/cmux/pull/213))
- Child-exit close for last-terminal workspaces ([#254](https://github.com/manaflow-ai/cmux/pull/254))
- Sidebar resizer hitbox and cursor across portals ([#255](https://github.com/manaflow-ai/cmux/pull/255))
- Workspace-scoped tab action resolution
- IDN host allowlist normalization
- `setup.sh` cache rebuild and stale lock timeout ([#217](https://github.com/manaflow-ai/cmux/pull/217))
- Inconsistent Tab/Workspace terminology in settings and menus ([#187](https://github.com/manaflow-ai/cmux/pull/187))
### Changed
- CLI workspace commands now run off the main thread for better responsiveness ([#270](https://github.com/manaflow-ai/cmux/pull/270))
- Remove border below titlebar ([#242](https://github.com/manaflow-ai/cmux/pull/242))
- Slimmer browser omnibar with button hover/press states ([#271](https://github.com/manaflow-ai/cmux/pull/271))
- Browser under-page background refreshes on theme updates ([#272](https://github.com/manaflow-ai/cmux/pull/272))
- Command shortcut hints scoped to active window ([#226](https://github.com/manaflow-ai/cmux/pull/226))
- Nightly and release assets are now immutable (no accidental overwrite) ([#268](https://github.com/manaflow-ai/cmux/pull/268), [#269](https://github.com/manaflow-ai/cmux/pull/269))
## [0.59.0] - 2026-02-19
### Fixed

View file

@ -95,8 +95,25 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug
- **Custom UTTypes** for drag-and-drop must be declared in `Resources/Info.plist` under `UTExportedTypeDeclarations` (e.g. `com.splittabbar.tabtransfer`, `com.cmux.sidebar-tab-reorder`).
- Do not add an app-level display link or manual `ghostty_surface_draw` loop; rely on Ghostty wakeups/renderer to avoid typing lag.
- **Terminal find layering contract:** `SurfaceSearchOverlay` must be mounted from `GhosttySurfaceScrollView` in `Sources/GhosttyTerminalView.swift` (AppKit portal layer), not from SwiftUI panel containers such as `Sources/Panels/TerminalPanelView.swift`. Portal-hosted terminal views can sit above SwiftUI during split/workspace churn.
- **Submodule safety:** When modifying a submodule (ghostty, vendor/bonsplit, etc.), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd <submodule> && git merge-base --is-ancestor HEAD origin/main`.
## Socket command threading policy
- Do not use `DispatchQueue.main.sync` for high-frequency socket telemetry commands (`report_*`, `ports_kick`, status/progress/log metadata updates).
- For telemetry hot paths:
- Parse and validate arguments off-main.
- Dedupe/coalesce off-main first.
- Schedule minimal UI/model mutation with `DispatchQueue.main.async` only when needed.
- Commands that directly manipulate AppKit/Ghostty UI state (focus/select/open/close/send key/input, list/current queries requiring exact synchronous snapshot) are allowed to run on main actor.
- If adding a new socket command, default to off-main handling; require an explicit reason in code comments when main-thread execution is necessary.
## Socket focus policy
- Socket/CLI commands must not steal macOS app focus (no app activation/window raising side effects).
- Only explicit focus-intent commands may mutate in-app focus/selection (`window.focus`, `workspace.select/next/previous/last`, `surface.focus`, `pane.focus/last`, browser focus commands, and v1 focus equivalents).
- All non-focus commands should preserve current user focus context while still applying data/model changes.
## E2E mac UI tests
Run UI tests on the UTM macOS VM (never on the host machine). Always run e2e UI tests via `ssh cmux-vm`:
@ -150,7 +167,7 @@ git commit -m "Update ghostty submodule"
Use the `/release` command to prepare a new release. This will:
1. Determine the new version (bumps minor by default)
2. Gather commits since the last tag and update the changelog
3. Update `CHANGELOG.md` and `docs-site/content/docs/changelog.mdx`
3. Update `CHANGELOG.md` (the docs changelog page at `web/app/docs/changelog/page.tsx` reads from it)
4. Run `./scripts/bump-version.sh` to update both versions
5. Commit, tag, and push
@ -179,4 +196,4 @@ Notes:
- The release asset is `cmux-macos.dmg` attached to the tag.
- README download button points to `releases/latest/download/cmux-macos.dmg`.
- Versioning: bump the minor version for updates unless explicitly asked otherwise.
- Changelog: always update both `CHANGELOG.md` and the docs-site version.
- Changelog: update `CHANGELOG.md`; docs changelog is rendered from it.

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,7 @@
A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; };
A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.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 */; };
A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; };
A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; };
@ -34,10 +35,12 @@
A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; };
A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; };
A5001250 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; };
B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; };
A5001270 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = A5001271 /* PostHog */; };
A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; };
A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; };
A50012F3 /* KeyboardShortcutSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F2 /* KeyboardShortcutSettings.swift */; };
A50012F5 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F4 /* KeyboardLayout.swift */; };
A5001521 /* PostHogAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001520 /* PostHogAnalytics.swift */; };
A5001201 /* UpdateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001211 /* UpdateController.swift */; };
A5001202 /* UpdateDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001212 /* UpdateDelegate.swift */; };
@ -54,11 +57,13 @@
A5001208 /* UpdateTitlebarAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001218 /* UpdateTitlebarAccessory.swift */; };
A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.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 */; };
A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; };
B9000002A1B2C3D4E5F60719 /* cmux.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000001A1B2C3D4E5F60719 /* cmux.swift */; };
B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */ = {isa = PBXBuildFile; fileRef = B9000004A1B2C3D4E5F60719 /* cmux */; };
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 */; };
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 */; };
@ -71,11 +76,15 @@
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; };
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; };
E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; };
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; };
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; };
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; };
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; };
/* End PBXBuildFile section */
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; };
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; };
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; };
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; };
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 */; };
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
A5001020 /* Embed Frameworks */ = {
@ -96,6 +105,7 @@
files = (
B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */,
C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */,
D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */,
);
name = "Copy CLI";
runOnlyForDeploymentPostprocessing = 0;
@ -144,6 +154,7 @@
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>"; };
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>"; };
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>"; };
@ -162,6 +173,7 @@
A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = "<group>"; };
A50012F0 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
A50012F2 /* KeyboardShortcutSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettings.swift; sourceTree = "<group>"; };
A50012F4 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = "<group>"; };
A5001211 /* UpdateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateController.swift; sourceTree = "<group>"; };
A5001212 /* UpdateDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateDelegate.swift; sourceTree = "<group>"; };
A5001213 /* UpdateDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateDriver.swift; sourceTree = "<group>"; };
@ -177,11 +189,13 @@
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>"; };
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>"; };
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>"; };
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; };
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; };
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; };
@ -190,14 +204,18 @@
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>"; };
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>"; };
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>"; };
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>"; };
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>"; };
/* End PBXFileReference section */
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>"; };
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>"; };
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>"; };
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
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>"; };
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
A5001030 /* Frameworks */ = {
@ -229,6 +247,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -307,6 +326,7 @@
B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */,
A50012F0 /* Backport.swift */,
A50012F2 /* KeyboardShortcutSettings.swift */,
A50012F4 /* KeyboardLayout.swift */,
A5001013 /* TabManager.swift */,
A5001511 /* UITestRecorder.swift */,
A5001520 /* PostHogAnalytics.swift */,
@ -319,6 +339,7 @@
A5001019 /* TerminalController.swift */,
A5001541 /* PortScanner.swift */,
A5001225 /* SocketControlSettings.swift */,
A5001600 /* SentryHelper.swift */,
A5001090 /* AppDelegate.swift */,
A5001091 /* NotificationsPage.swift */,
A5001092 /* TerminalNotificationStore.swift */,
@ -345,6 +366,7 @@
A5001219 /* WindowToolbarController.swift */,
A5001241 /* WindowDecorationsController.swift */,
A5001222 /* WindowAccessor.swift */,
A5001611 /* SessionPersistence.swift */,
);
path = Sources;
sourceTree = "<group>";
@ -395,17 +417,21 @@
path = cmuxUITests;
sourceTree = "<group>";
};
F1000003A1B2C3D4E5F60718 /* cmuxTests */ = {
isa = PBXGroup;
children = (
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */,
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */,
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
);
path = cmuxTests;
sourceTree = "<group>";
};
F1000003A1B2C3D4E5F60718 /* cmuxTests */ = {
isa = PBXGroup;
children = (
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */,
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */,
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */,
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */,
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
);
path = cmuxTests;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -447,6 +473,9 @@
);
dependencies = (
);
packageProductDependencies = (
A5001251 /* Sentry */,
);
name = "cmux-cli";
productName = cmux;
productReference = B9000004A1B2C3D4E5F60719 /* cmux */;
@ -536,6 +565,7 @@
B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */,
A50012F1 /* Backport.swift in Sources */,
A50012F3 /* KeyboardShortcutSettings.swift in Sources */,
A50012F5 /* KeyboardLayout.swift in Sources */,
A5001003 /* TabManager.swift in Sources */,
A5001501 /* UITestRecorder.swift in Sources */,
A5001521 /* PostHogAnalytics.swift in Sources */,
@ -548,6 +578,7 @@
A5001007 /* TerminalController.swift in Sources */,
A5001540 /* PortScanner.swift in Sources */,
A5001226 /* SocketControlSettings.swift in Sources */,
A5001601 /* SentryHelper.swift in Sources */,
A5001093 /* AppDelegate.swift in Sources */,
A5001094 /* NotificationsPage.swift in Sources */,
A5001095 /* TerminalNotificationStore.swift in Sources */,
@ -574,6 +605,7 @@
A5001209 /* WindowToolbarController.swift in Sources */,
A5001240 /* WindowDecorationsController.swift in Sources */,
A500120C /* WindowAccessor.swift in Sources */,
A5001610 /* SessionPersistence.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -594,18 +626,22 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
F1000005A1B2C3D4E5F60718 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */,
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */,
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
B9000006A1B2C3D4E5F60719 /* Sources */ = {
F1000005A1B2C3D4E5F60718 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */,
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */,
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */,
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */,
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
B9000006A1B2C3D4E5F60719 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
@ -702,7 +738,7 @@
CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 71;
CURRENT_PROJECT_VERSION = 73;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = NO;
GENERATE_INFOPLIST_FILE = NO;
@ -711,7 +747,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.59.0;
MARKETING_VERSION = 0.61.0;
OTHER_LDFLAGS = (
"-lc++",
"-framework",
@ -741,7 +777,7 @@
CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 71;
CURRENT_PROJECT_VERSION = 73;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = NO;
GENERATE_INFOPLIST_FILE = NO;
@ -750,7 +786,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.59.0;
MARKETING_VERSION = 0.61.0;
OTHER_LDFLAGS = (
"-lc++",
"-framework",
@ -778,6 +814,12 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path",
"@executable_path/../Frameworks",
"@executable_path/../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
PRODUCT_NAME = cmux;
PRODUCT_MODULE_NAME = cmux_cli;
@ -791,6 +833,12 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path",
"@executable_path/../Frameworks",
"@executable_path/../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
PRODUCT_NAME = cmux;
PRODUCT_MODULE_NAME = cmux_cli;
@ -804,10 +852,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71;
CURRENT_PROJECT_VERSION = 73;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.59.0;
MARKETING_VERSION = 0.61.0;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -821,10 +869,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71;
CURRENT_PROJECT_VERSION = 73;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.59.0;
MARKETING_VERSION = 0.61.0;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -838,10 +886,10 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71;
CURRENT_PROJECT_VERSION = 73;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.59.0;
MARKETING_VERSION = 0.61.0;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -857,10 +905,10 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71;
CURRENT_PROJECT_VERSION = 73;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.59.0;
MARKETING_VERSION = 0.61.0;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests;
PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion="1500" version="1.7">
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
<BuildActionEntries>
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5001050" BuildableName="cmux.app" BlueprintName="GhosttyTabs" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
</BuildActionEntry>
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F1000004A1B2C3D4E5F60718" BuildableName="cmuxTests.xctest" BlueprintName="cmuxTests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
<Testables>
<TestableReference skipped="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F1000004A1B2C3D4E5F60718" BuildableName="cmuxTests.xctest" BlueprintName="cmuxTests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
</TestableReference>
<TestableReference skipped="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="CB450DF0F0B3839599082C4D" BuildableName="cmuxUITests.xctest" BlueprintName="cmuxUITests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
</TestableReference>
</Testables>
<MacroExpansion>
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5001050" BuildableName="cmux.app" BlueprintName="GhosttyTabs" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
</MacroExpansion>
</TestAction>
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="YES" debugDocumentVersioning="YES" allowLocationSimulation="YES">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5001050" BuildableName="cmux.app" BlueprintName="GhosttyTabs" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction buildConfiguration="Debug" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
<BuildableProductRunnable runnableDebuggingMode="0">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5001050" BuildableName="cmux.app" BlueprintName="GhosttyTabs" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction buildConfiguration="Debug"/>
<ArchiveAction buildConfiguration="Debug" revealArchiveInOrganizer="YES"/>
</Scheme>

View file

@ -7,7 +7,7 @@
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction buildConfiguration="Release" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
<Testables>
<TestableReference skipped="NO">
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="CB450DF0F0B3839599082C4D" BuildableName="cmuxUITests.xctest" BlueprintName="cmuxUITests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>

View file

@ -11,12 +11,17 @@
English | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a>
</p>
<p align="center">
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
</p>
<p align="center">
<img src="./docs/assets/main-first-image.png" alt="cmux screenshot" width="900" />
</p>
<p align="center">
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Demo video</a>
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Demo video</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a>
</p>
## Features
@ -52,7 +57,7 @@ Split a browser alongside your terminal with a scriptable API ported from <a hre
<tr>
<td width="40%" valign="middle">
<h3>Vertical + horizontal tabs</h3>
Sidebar shows git branch, working directory, listening ports, and latest notification text. Split horizontally and vertically.
Sidebar shows git branch, linked PR status/number, working directory, listening ports, and latest notification text. Split horizontally and vertically.
</td>
<td width="60%">
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Vertical tabs and split panes" width="100%" />
@ -96,12 +101,22 @@ I run a lot of Claude Code and Codex sessions in parallel. I was using Ghostty w
I tried a few coding orchestrators but most of them were Electron/Tauri apps and the performance bugged me. I also just prefer the terminal since GUI orchestrators lock you into their workflow. So I built cmux as a native macOS app in Swift/AppKit. It uses libghostty for terminal rendering and reads your existing Ghostty config for themes, fonts, and colors.
The main additions are the sidebar and notification system. The sidebar has vertical tabs that show git branch, working directory, listening ports, and the latest notification text for each workspace. The notification system picks up terminal sequences (OSC 9/99/777) and has a CLI (`cmux notify`) you can wire into agent hooks for Claude Code, OpenCode, etc. When an agent is waiting, its pane gets a blue ring and the tab lights up in the sidebar, so I can tell which one needs me across splits and tabs. Cmd+Shift+U jumps to the most recent unread.
The main additions are the sidebar and notification system. The sidebar has vertical tabs that show git branch, linked PR status/number, working directory, listening ports, and the latest notification text for each workspace. The notification system picks up terminal sequences (OSC 9/99/777) and has a CLI (`cmux notify`) you can wire into agent hooks for Claude Code, OpenCode, etc. When an agent is waiting, its pane gets a blue ring and the tab lights up in the sidebar, so I can tell which one needs me across splits and tabs. Cmd+Shift+U jumps to the most recent unread.
The in-app browser has a scriptable API ported from [agent-browser](https://github.com/vercel-labs/agent-browser). Agents can snapshot the accessibility tree, get element refs, click, fill forms, and evaluate JS. You can split a browser pane next to your terminal and have Claude Code interact with your dev server directly.
Everything is scriptable through the CLI and socket API — create workspaces/tabs, split panes, send keystrokes, open URLs in the browser.
## The Zen of cmux
cmux is not prescriptive about how developers hold their tools. It's a terminal and browser with a CLI, and the rest is up to you.
cmux is a primitive, not a solution. It gives you a terminal, a browser, notifications, workspaces, splits, tabs, and a CLI to control all of it. cmux doesn't force you into an opinionated way to use coding agents. What you build with the primitives is yours.
The best developers have always built their own tools. Nobody has figured out the best way to work with agents yet, and the teams building closed products definitely haven't either. The developers closest to their own codebases will figure it out first.
Give a million developers composable primitives and they'll collectively find the most efficient workflows faster than any product team could design top-down.
## Keyboard Shortcuts
### Workspaces
@ -114,6 +129,7 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta
| ⌃ ⌘ ] | Next workspace |
| ⌃ ⌘ [ | Previous workspace |
| ⌘ ⇧ W | Close workspace |
| ⌘ ⇧ R | Rename workspace |
| ⌘ B | Toggle sidebar |
### Surfaces
@ -193,14 +209,56 @@ 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.
## Session restore (current behavior)
On relaunch, cmux currently restores app layout and metadata only:
- Window/workspace/pane layout
- Working directories
- Terminal scrollback (best effort)
- Browser URL and navigation history
cmux does **not** restore live process state inside terminal apps. For example, active Claude Code/tmux/vim sessions are not resumed after restart yet.
## 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>
## Contributing
Ways to get involved:
- Follow us on X for updates [@manaflowai](https://x.com/manaflowai) or [@lawrencecchen](https://x.com/lawrencecchen)
- Join the conversation on [Discord](https://discord.gg/xsgFEVrWCZ)
- Create and participate in [GitHub issues](https://github.com/manaflow-ai/cmux/issues) and [discussions](https://github.com/manaflow-ai/cmux/discussions)
- Let me know what you're building with cmux
## Community
- [Discord](https://discord.com/invite/QRxkhZgY)
- [Discord](https://discord.gg/xsgFEVrWCZ)
- [GitHub](https://github.com/manaflow-ai/cmux)
- [X / Twitter](https://twitter.com/manaflowai)
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
## Founder's Edition
cmux is free, open source, and always will be. If you'd like to support development and get early access to what's coming next:
**[Get Founder's Edition](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
- **Prioritized feature requests/bug fixes**
- **Early access: cmux AI that gives you context on every workspace, tab and panel**
- **Early access: iOS app with terminals synced between desktop and phone**
- **Early access: Cloud VMs**
- **Early access: Voice mode**
- **My personal iMessage/WhatsApp**
## License
This project is licensed under the GNU Affero General Public License v3.0 or later (`AGPL-3.0-or-later`).

View file

@ -26,6 +26,8 @@
<string></string>
<key>NSMainStoryboardFile</key>
<string></string>
<key>NSMicrophoneUsageDescription</key>
<string>A program running within cmux would like to use your microphone.</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSServices</key>

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

@ -3,9 +3,9 @@
_cmux_send() {
local payload="$1"
if command -v ncat >/dev/null 2>&1; then
printf '%s\n' "$payload" | ncat -U "$CMUX_SOCKET_PATH" --send-only
printf '%s\n' "$payload" | ncat -w 1 -U "$CMUX_SOCKET_PATH" --send-only
elif command -v socat >/dev/null 2>&1; then
printf '%s\n' "$payload" | socat - "UNIX-CONNECT:$CMUX_SOCKET_PATH"
printf '%s\n' "$payload" | socat -T 1 - "UNIX-CONNECT:$CMUX_SOCKET_PATH"
elif command -v nc >/dev/null 2>&1; then
# Some nc builds don't support unix sockets, but keep as a last-ditch fallback.
#
@ -23,11 +23,29 @@ _cmux_send() {
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.
_CMUX_PWD_LAST_PWD="${_CMUX_PWD_LAST_PWD:-}"
_CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}"
_CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}"
_CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}"
_CMUX_GIT_JOB_STARTED_AT="${_CMUX_GIT_JOB_STARTED_AT:-0}"
_CMUX_PR_LAST_PWD="${_CMUX_PR_LAST_PWD:-}"
_CMUX_PR_LAST_RUN="${_CMUX_PR_LAST_RUN:-0}"
_CMUX_PR_JOB_PID="${_CMUX_PR_JOB_PID:-}"
_CMUX_PR_JOB_STARTED_AT="${_CMUX_PR_JOB_STARTED_AT:-0}"
_CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}"
_CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
_CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}"
@ -67,6 +85,28 @@ _cmux_prompt_command() {
local now=$SECONDS
local pwd="$PWD"
# Post-wake socket writes can occasionally leave a probe process wedged.
# If one probe is stale, clear the guard so fresh async probes can resume.
if [[ -n "$_CMUX_GIT_JOB_PID" ]]; then
if ! kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then
_CMUX_GIT_JOB_PID=""
_CMUX_GIT_JOB_STARTED_AT=0
elif (( _CMUX_GIT_JOB_STARTED_AT > 0 )) && (( now - _CMUX_GIT_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT )); then
_CMUX_GIT_JOB_PID=""
_CMUX_GIT_JOB_STARTED_AT=0
fi
fi
if [[ -n "$_CMUX_PR_JOB_PID" ]]; then
if ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
_CMUX_PR_JOB_PID=""
_CMUX_PR_JOB_STARTED_AT=0
elif (( _CMUX_PR_JOB_STARTED_AT > 0 )) && (( now - _CMUX_PR_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT )); then
_CMUX_PR_JOB_PID=""
_CMUX_PR_JOB_STARTED_AT=0
fi
fi
# Resolve TTY name once.
if [[ -z "$_CMUX_TTY_NAME" ]]; then
local t
@ -94,6 +134,7 @@ _cmux_prompt_command() {
if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]]; then
kill "$_CMUX_GIT_JOB_PID" >/dev/null 2>&1 || true
_CMUX_GIT_JOB_PID=""
_CMUX_GIT_JOB_STARTED_AT=0
fi
fi
@ -107,12 +148,57 @@ _cmux_prompt_command() {
local first
first=$(git status --porcelain -uno 2>/dev/null | head -1)
[[ -n "$first" ]] && dirty_opt="--status=dirty"
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID"
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
else
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID"
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
fi
} >/dev/null 2>&1 &
_CMUX_GIT_JOB_PID=$!
_CMUX_GIT_JOB_STARTED_AT=$now
fi
# Pull request metadata (number/state/url):
# refresh on cwd change and periodically to avoid stale status.
if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then
kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true
_CMUX_PR_JOB_PID=""
_CMUX_PR_JOB_STARTED_AT=0
fi
fi
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then
if [[ -z "$_CMUX_PR_JOB_PID" ]] || ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
_CMUX_PR_LAST_PWD="$pwd"
_CMUX_PR_LAST_RUN=$now
{
local branch pr_tsv number state url status_opt=""
branch=$(git branch --show-current 2>/dev/null)
if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
else
pr_tsv="$(gh pr view --json number,state,url --jq '[.number, .state, .url] | @tsv' 2>/dev/null || true)"
if [[ -z "$pr_tsv" ]]; then
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
else
IFS=$'\t' read -r number state url <<< "$pr_tsv"
if [[ -z "$number" || -z "$url" ]]; then
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
else
case "$state" in
MERGED) status_opt="--state=merged" ;;
OPEN) status_opt="--state=open" ;;
CLOSED) status_opt="--state=closed" ;;
*) status_opt="" ;;
esac
_cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
fi
fi
fi
} >/dev/null 2>&1 &
_CMUX_PR_JOB_PID=$!
_CMUX_PR_JOB_STARTED_AT=$now
fi
fi
# Ports: lightweight kick to the app's batched scanner every ~10s.
@ -150,15 +236,17 @@ _cmux_install_prompt_command() {
fi
}
# Ensure Resources/bin is at the front of PATH. Shell init (.bashrc/.bash_profile)
# may prepend other dirs that push our wrapper behind the system claude binary.
# Ensure Resources/bin is at the front of PATH, and remove the app's
# Contents/MacOS entry so the GUI cmux binary cannot shadow the CLI cmux.
# Shell init (.bashrc/.bash_profile) may prepend other dirs after launch.
_cmux_fix_path() {
if [[ -n "${GHOSTTY_BIN_DIR:-}" ]]; then
local bin_dir="${GHOSTTY_BIN_DIR%/MacOS}"
bin_dir="${bin_dir}/Resources/bin"
local gui_dir="${GHOSTTY_BIN_DIR%/}"
local bin_dir="${gui_dir%/MacOS}/Resources/bin"
if [[ -d "$bin_dir" ]]; then
local new_path=":${PATH}:"
new_path="${new_path//:${bin_dir}:/:}"
new_path="${new_path//:${gui_dir}:/:}"
new_path="${new_path#:}"
new_path="${new_path%:}"
PATH="${bin_dir}:${new_path}"

View file

@ -4,9 +4,9 @@
_cmux_send() {
local payload="$1"
if command -v ncat >/dev/null 2>&1; then
print -r -- "$payload" | ncat -U "$CMUX_SOCKET_PATH" --send-only
print -r -- "$payload" | ncat -w 1 -U "$CMUX_SOCKET_PATH" --send-only
elif command -v socat >/dev/null 2>&1; then
print -r -- "$payload" | socat - "UNIX-CONNECT:$CMUX_SOCKET_PATH"
print -r -- "$payload" | socat -T 1 - "UNIX-CONNECT:$CMUX_SOCKET_PATH"
elif command -v nc >/dev/null 2>&1; then
# Some nc builds don't support unix sockets, but keep as a last-ditch fallback.
#
@ -24,16 +24,35 @@ _cmux_send() {
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.
typeset -g _CMUX_PWD_LAST_PWD=""
typeset -g _CMUX_GIT_LAST_PWD=""
typeset -g _CMUX_GIT_LAST_RUN=0
typeset -g _CMUX_GIT_JOB_PID=""
typeset -g _CMUX_GIT_JOB_STARTED_AT=0
typeset -g _CMUX_GIT_FORCE=0
typeset -g _CMUX_GIT_HEAD_LAST_PWD=""
typeset -g _CMUX_GIT_HEAD_PATH=""
typeset -g _CMUX_GIT_HEAD_MTIME=0
typeset -g _CMUX_HAVE_ZSTAT=0
typeset -g _CMUX_PR_LAST_PWD=""
typeset -g _CMUX_PR_LAST_RUN=0
typeset -g _CMUX_PR_JOB_PID=""
typeset -g _CMUX_PR_JOB_STARTED_AT=0
typeset -g _CMUX_PR_FORCE=0
typeset -g _CMUX_ASYNC_JOB_TIMEOUT=20
typeset -g _CMUX_PORTS_LAST_RUN=0
typeset -g _CMUX_CMD_START=0
@ -143,7 +162,8 @@ _cmux_preexec() {
local cmd="${1## }"
case "$cmd" in
git\ *|git|gh\ *|lazygit|lazygit\ *|tig|tig\ *|gitui|gitui\ *|stg\ *|jj\ *)
_CMUX_GIT_FORCE=1 ;;
_CMUX_GIT_FORCE=1
_CMUX_PR_FORCE=1 ;;
esac
# Register TTY + kick batched port scan for foreground commands (servers).
@ -171,6 +191,30 @@ _cmux_precmd() {
local cmd_start="$_CMUX_CMD_START"
_CMUX_CMD_START=0
# Post-wake socket writes can occasionally leave a probe process wedged.
# If one probe is stale, clear the guard so fresh async probes can resume.
if [[ -n "$_CMUX_GIT_JOB_PID" ]]; then
if ! kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then
_CMUX_GIT_JOB_PID=""
_CMUX_GIT_JOB_STARTED_AT=0
elif (( _CMUX_GIT_JOB_STARTED_AT > 0 )) && (( now - _CMUX_GIT_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT )); then
_CMUX_GIT_JOB_PID=""
_CMUX_GIT_JOB_STARTED_AT=0
_CMUX_GIT_FORCE=1
fi
fi
if [[ -n "$_CMUX_PR_JOB_PID" ]]; then
if ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
_CMUX_PR_JOB_PID=""
_CMUX_PR_JOB_STARTED_AT=0
elif (( _CMUX_PR_JOB_STARTED_AT > 0 )) && (( now - _CMUX_PR_JOB_STARTED_AT >= _CMUX_ASYNC_JOB_TIMEOUT )); then
_CMUX_PR_JOB_PID=""
_CMUX_PR_JOB_STARTED_AT=0
_CMUX_PR_FORCE=1
fi
fi
# CWD: keep the app in sync with the actual shell directory.
# This is also the simplest way to test sidebar directory behavior end-to-end.
if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then
@ -200,6 +244,7 @@ _cmux_precmd() {
# Treat HEAD file change like a git command — force-replace any
# running probe so the sidebar picks up the new branch immediately.
_CMUX_GIT_FORCE=1
_CMUX_PR_FORCE=1
should_git=1
fi
fi
@ -224,6 +269,7 @@ _cmux_precmd() {
if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]] || (( _CMUX_GIT_FORCE )); then
kill "$_CMUX_GIT_JOB_PID" >/dev/null 2>&1 || true
_CMUX_GIT_JOB_PID=""
_CMUX_GIT_JOB_STARTED_AT=0
else
can_launch_git=0
fi
@ -240,12 +286,72 @@ _cmux_precmd() {
local first
first=$(git status --porcelain -uno 2>/dev/null | head -1)
[[ -n "$first" ]] && dirty_opt="--status=dirty"
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID"
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
else
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID"
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
fi
} >/dev/null 2>&1 &!
_CMUX_GIT_JOB_PID=$!
_CMUX_GIT_JOB_STARTED_AT=$now
fi
fi
# Pull request metadata (number/state/url):
# - refresh on cwd change, explicit git/gh commands, and occasionally for status drift
# - keep this independent from the git probe cadence to avoid hitting GitHub too often
local should_pr=0
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then
should_pr=1
elif (( _CMUX_PR_FORCE )); then
should_pr=1
elif (( now - _CMUX_PR_LAST_RUN >= 60 )); then
should_pr=1
fi
if (( should_pr )); then
local can_launch_pr=1
if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( _CMUX_PR_FORCE )); then
kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true
_CMUX_PR_JOB_PID=""
_CMUX_PR_JOB_STARTED_AT=0
else
can_launch_pr=0
fi
fi
if (( can_launch_pr )); then
_CMUX_PR_FORCE=0
_CMUX_PR_LAST_PWD="$pwd"
_CMUX_PR_LAST_RUN=$now
{
local branch pr_tsv number state url status_opt=""
branch=$(git branch --show-current 2>/dev/null)
if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
else
pr_tsv="$(gh pr view --json number,state,url --jq '[.number, .state, .url] | @tsv' 2>/dev/null || true)"
if [[ -z "$pr_tsv" ]]; then
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
else
local IFS=$'\t'
read -r number state url <<< "$pr_tsv"
if [[ -z "$number" ]] || [[ -z "$url" ]]; then
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
else
case "$state" in
MERGED) status_opt="--state=merged" ;;
OPEN) status_opt="--state=open" ;;
CLOSED) status_opt="--state=closed" ;;
*) status_opt="" ;;
esac
_cmux_send "report_pr $number $url $status_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
fi
fi
fi
} >/dev/null 2>&1 &!
_CMUX_PR_JOB_PID=$!
_CMUX_PR_JOB_STARTED_AT=$now
fi
fi
@ -262,17 +368,19 @@ _cmux_precmd() {
fi
}
# Ensure Resources/bin is at the front of PATH. Shell init (.zprofile/.zshrc)
# may prepend other dirs that push our wrapper behind the system claude binary.
# Ensure Resources/bin is at the front of PATH, and remove the app's
# Contents/MacOS entry so the GUI cmux binary cannot shadow the CLI cmux.
# Shell init (.zprofile/.zshrc) may prepend other dirs after launch.
# We fix this once on first prompt (after all init files have run).
_cmux_fix_path() {
if [[ -n "${GHOSTTY_BIN_DIR:-}" ]]; then
local bin_dir="${GHOSTTY_BIN_DIR%/MacOS}"
bin_dir="${bin_dir}/Resources/bin"
local gui_dir="${GHOSTTY_BIN_DIR%/}"
local bin_dir="${gui_dir%/MacOS}/Resources/bin"
if [[ -d "$bin_dir" ]]; then
# Remove existing entry and re-prepend.
# Remove existing entries and re-prepend the CLI bin dir.
local -a parts=("${(@s/:/)PATH}")
parts=("${(@)parts:#$bin_dir}")
parts=("${(@)parts:#$gui_dir}")
PATH="${bin_dir}:${(j/:/)parts}"
fi
fi

File diff suppressed because it is too large Load diff

View file

@ -21,9 +21,108 @@ private func browserPortalDebugFrame(_ rect: NSRect) -> String {
#endif
final class WindowBrowserHostView: NSView {
private struct DividerRegion {
let rectInWindow: NSRect
let isVertical: Bool
}
private enum DividerCursorKind: Equatable {
case vertical
case horizontal
var cursor: NSCursor {
switch self {
case .vertical: return .resizeLeftRight
case .horizontal: return .resizeUpDown
}
}
}
override var isOpaque: Bool { false }
private static let sidebarLeadingEdgeEpsilon: CGFloat = 1
private static let minimumVisibleLeadingContentWidth: CGFloat = 24
private var cachedSidebarDividerX: CGFloat?
private var sidebarDividerMissCount = 0
private var trackingArea: NSTrackingArea?
private var activeDividerCursorKind: DividerCursorKind?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
clearActiveDividerCursor(restoreArrow: false)
}
window?.invalidateCursorRects(for: self)
}
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
window?.invalidateCursorRects(for: self)
}
override func setFrameOrigin(_ newOrigin: NSPoint) {
super.setFrameOrigin(newOrigin)
window?.invalidateCursorRects(for: self)
}
override func resetCursorRects() {
super.resetCursorRects()
guard let window, let rootView = window.contentView else { return }
var regions: [DividerRegion] = []
Self.collectSplitDividerRegions(in: rootView, into: &regions)
let expansion: CGFloat = 4
for region in regions {
var rectInHost = convert(region.rectInWindow, from: nil)
rectInHost = rectInHost.insetBy(
dx: region.isVertical ? -expansion : 0,
dy: region.isVertical ? 0 : -expansion
)
let clipped = rectInHost.intersection(bounds)
guard !clipped.isNull, clipped.width > 0, clipped.height > 0 else { continue }
addCursorRect(clipped, cursor: region.isVertical ? .resizeLeftRight : .resizeUpDown)
}
}
override func updateTrackingAreas() {
if let trackingArea {
removeTrackingArea(trackingArea)
}
let options: NSTrackingArea.Options = [
.inVisibleRect,
.activeAlways,
.cursorUpdate,
.mouseMoved,
.mouseEnteredAndExited,
.enabledDuringMouseDrag,
]
let next = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
addTrackingArea(next)
trackingArea = next
super.updateTrackingAreas()
}
override func cursorUpdate(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
updateDividerCursor(at: point)
}
override func mouseMoved(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
updateDividerCursor(at: point)
}
override func mouseExited(with event: NSEvent) {
clearActiveDividerCursor(restoreArrow: true)
}
override func hitTest(_ point: NSPoint) -> NSView? {
updateDividerCursor(at: point)
if shouldPassThroughToTitlebar(at: point) {
return nil
}
if shouldPassThroughToSidebarResizer(at: point) {
return nil
}
if shouldPassThroughToSplitDivider(at: point) {
return nil
}
@ -31,15 +130,105 @@ final class WindowBrowserHostView: NSView {
return hitView === self ? nil : hitView
}
private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool {
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)
guard let rootView = window.contentView else { return false }
return Self.containsSplitDivider(at: windowPoint, in: rootView)
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 static func containsSplitDivider(at windowPoint: NSPoint, in view: NSView) -> Bool {
guard !view.isHidden else { return false }
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
// Browser portal host sits above SwiftUI content. Allow pointer/mouse events
// to reach the SwiftUI sidebar divider resizer zone.
let visibleSlots = subviews.compactMap { $0 as? WindowBrowserSlotView }
.filter { !$0.isHidden && $0.window != nil && $0.frame.width > 1 && $0.frame.height > 1 }
// If content is flush to the leading edge, sidebar is effectively hidden.
// In that state, treating any internal split edge as a sidebar divider
// steals split-divider cursor/drag behavior.
let hasLeadingContent = visibleSlots.contains {
$0.frame.minX <= Self.sidebarLeadingEdgeEpsilon
&& $0.frame.maxX > Self.minimumVisibleLeadingContentWidth
}
if hasLeadingContent {
if cachedSidebarDividerX != nil {
sidebarDividerMissCount += 1
if sidebarDividerMissCount >= 2 {
cachedSidebarDividerX = nil
sidebarDividerMissCount = 0
}
}
return false
}
// Ignore transient 0-origin slots during layout churn and preserve the last
// known-good divider edge.
let dividerCandidates = visibleSlots
.map(\.frame.minX)
.filter { $0 > Self.sidebarLeadingEdgeEpsilon }
if let leftMostEdge = dividerCandidates.min() {
cachedSidebarDividerX = leftMostEdge
sidebarDividerMissCount = 0
} else if cachedSidebarDividerX != nil {
// Keep cache briefly for layout churn, but clear if we miss repeatedly
// so stale divider positions don't steal pointer routing.
sidebarDividerMissCount += 1
if sidebarDividerMissCount >= 4 {
cachedSidebarDividerX = nil
sidebarDividerMissCount = 0
}
}
guard let dividerX = cachedSidebarDividerX else {
return false
}
let regionMinX = dividerX - SidebarResizeInteraction.hitWidthPerSide
let regionMaxX = dividerX + SidebarResizeInteraction.hitWidthPerSide
return point.x >= regionMinX && point.x <= regionMaxX
}
private func updateDividerCursor(at point: NSPoint) {
if shouldPassThroughToSidebarResizer(at: point) {
clearActiveDividerCursor(restoreArrow: false)
return
}
guard let nextKind = splitDividerCursorKind(at: point) else {
clearActiveDividerCursor(restoreArrow: true)
return
}
activeDividerCursorKind = nextKind
nextKind.cursor.set()
}
private func clearActiveDividerCursor(restoreArrow: Bool) {
guard activeDividerCursorKind != nil else { return }
window?.invalidateCursorRects(for: self)
activeDividerCursorKind = nil
if restoreArrow {
NSCursor.arrow.set()
}
}
private func splitDividerCursorKind(at point: NSPoint) -> DividerCursorKind? {
guard let window else { return nil }
let windowPoint = convert(point, to: nil)
guard let rootView = window.contentView else { return nil }
return Self.dividerCursorKind(at: windowPoint, in: rootView)
}
private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool {
splitDividerCursorKind(at: point) != nil
}
private static func dividerCursorKind(at windowPoint: NSPoint, in view: NSView) -> DividerCursorKind? {
guard !view.isHidden else { return nil }
if let splitView = view as? NSSplitView {
let pointInSplit = splitView.convert(windowPoint, from: nil)
@ -52,7 +241,10 @@ final class WindowBrowserHostView: NSView {
let thickness = splitView.dividerThickness
let dividerRect: NSRect
if splitView.isVertical {
guard first.width > 1, second.width > 1 else { continue }
// Keep divider hit-testing active even when one side is nearly collapsed,
// so users can drag the divider back out from the border.
// But ignore transient states where both panes are effectively 0-width.
guard first.width > 1 || second.width > 1 else { continue }
let x = max(0, first.maxX)
dividerRect = NSRect(
x: x,
@ -61,7 +253,8 @@ final class WindowBrowserHostView: NSView {
height: splitView.bounds.height
)
} else {
guard first.height > 1, second.height > 1 else { continue }
// Same behavior for horizontal splits with a near-zero-height pane.
guard first.height > 1 || second.height > 1 else { continue }
let y = max(0, first.maxY)
dividerRect = NSRect(
x: 0,
@ -72,20 +265,56 @@ final class WindowBrowserHostView: NSView {
}
let expanded = dividerRect.insetBy(dx: -expansion, dy: -expansion)
if expanded.contains(pointInSplit) {
return true
return splitView.isVertical ? .vertical : .horizontal
}
}
}
}
for subview in view.subviews.reversed() {
if containsSplitDivider(at: windowPoint, in: subview) {
return true
if let kind = dividerCursorKind(at: windowPoint, in: subview) {
return kind
}
}
return false
return nil
}
private static func collectSplitDividerRegions(in view: NSView, into result: inout [DividerRegion]) {
guard !view.isHidden else { return }
if let splitView = view as? NSSplitView {
let dividerCount = max(0, splitView.arrangedSubviews.count - 1)
for dividerIndex in 0..<dividerCount {
let first = splitView.arrangedSubviews[dividerIndex].frame
let second = splitView.arrangedSubviews[dividerIndex + 1].frame
let thickness = splitView.dividerThickness
let dividerRect: NSRect
if splitView.isVertical {
guard first.width > 1 || second.width > 1 else { continue }
let x = max(0, first.maxX)
dividerRect = NSRect(x: x, y: 0, width: thickness, height: splitView.bounds.height)
} else {
guard first.height > 1 || second.height > 1 else { continue }
let y = max(0, first.maxY)
dividerRect = NSRect(x: 0, y: y, width: splitView.bounds.width, height: thickness)
}
let dividerRectInWindow = splitView.convert(dividerRect, to: nil)
guard dividerRectInWindow.width > 0, dividerRectInWindow.height > 0 else { continue }
result.append(
DividerRegion(
rectInWindow: dividerRectInWindow,
isVertical: splitView.isVertical
)
)
}
}
for subview in view.subviews {
collectSplitDividerRegions(in: subview, into: &result)
}
}
}
final class WindowBrowserSlotView: NSView {
@ -112,6 +341,8 @@ final class WindowBrowserPortal: NSObject {
private weak var installedContainerView: NSView?
private weak var installedReferenceView: NSView?
private var hasDeferredFullSyncScheduled = false
private var hasExternalGeometrySyncScheduled = false
private var geometryObservers: [NSObjectProtocol] = []
private struct Entry {
weak var webView: WKWebView?
@ -131,9 +362,73 @@ final class WindowBrowserPortal: NSObject {
hostView.layer?.masksToBounds = true
hostView.translatesAutoresizingMaskIntoConstraints = true
hostView.autoresizingMask = []
installGeometryObservers(for: window)
_ = 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
private func ensureInstalled() -> Bool {
guard let window else { return false }
@ -205,13 +500,32 @@ final class WindowBrowserPortal: NSObject {
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.y - rhs.origin.y) <= epsilon &&
abs(lhs.size.width - rhs.size.width) <= 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 {
frame.minX < bounds.minX - epsilon ||
frame.minY < bounds.minY - epsilon ||
@ -551,7 +865,8 @@ final class WindowBrowserPortal: NSObject {
_ = synchronizeHostFrameToReference()
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 hasFiniteHostBounds =
hostBounds.origin.x.isFinite &&
@ -624,6 +939,8 @@ final class WindowBrowserPortal: NSObject {
CATransaction.setDisableActions(true)
containerView.frame = targetFrame
CATransaction.commit()
webView.needsLayout = true
webView.layoutSubtreeIfNeeded()
}
let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size)
@ -738,6 +1055,7 @@ final class WindowBrowserPortal: NSObject {
}
func tearDown() {
removeGeometryObservers()
for webViewId in Array(entriesByWebViewId.keys) {
detachWebView(withId: webViewId)
}

File diff suppressed because it is too large Load diff

View file

@ -2,8 +2,11 @@ import Bonsplit
import SwiftUI
struct SurfaceSearchOverlay: View {
let surface: TerminalSurface
let tabId: UUID
let surfaceId: UUID
@ObservedObject var searchState: TerminalSurface.SearchState
let onMoveFocusToTerminal: () -> Void
let onNavigateSearch: (_ action: String) -> Void
let onClose: () -> Void
@State private var corner: Corner = .topRight
@State private var dragOffset: CGSize = .zero
@ -44,22 +47,22 @@ struct SurfaceSearchOverlay: View {
if searchState.needle.isEmpty {
onClose()
} else {
surface.hostedView.moveFocus()
onMoveFocusToTerminal()
}
}
.backport.onKeyPress(.return) { modifiers in
let action = modifiers.contains(.shift)
? "navigate_search:previous"
: "navigate_search:next"
_ = surface.performBindingAction(action)
onNavigateSearch(action)
return .handled
}
Button(action: {
#if DEBUG
dlog("findbar.next surface=\(surface.id.uuidString.prefix(5))")
dlog("findbar.next surface=\(surfaceId.uuidString.prefix(5))")
#endif
_ = surface.performBindingAction("navigate_search:next")
onNavigateSearch("navigate_search:next")
}) {
Image(systemName: "chevron.up")
}
@ -68,9 +71,9 @@ struct SurfaceSearchOverlay: View {
Button(action: {
#if DEBUG
dlog("findbar.prev surface=\(surface.id.uuidString.prefix(5))")
dlog("findbar.prev surface=\(surfaceId.uuidString.prefix(5))")
#endif
_ = surface.performBindingAction("navigate_search:previous")
onNavigateSearch("navigate_search:previous")
}) {
Image(systemName: "chevron.down")
}
@ -79,7 +82,7 @@ struct SurfaceSearchOverlay: View {
Button(action: {
#if DEBUG
dlog("findbar.close surface=\(surface.id.uuidString.prefix(5))")
dlog("findbar.close surface=\(surfaceId.uuidString.prefix(5))")
#endif
onClose()
}) {
@ -93,12 +96,13 @@ struct SurfaceSearchOverlay: View {
.clipShape(clipShape)
.shadow(radius: 4)
.onAppear {
NSLog("Find: overlay appear tab=%@ surface=%@", surface.tabId.uuidString, surface.id.uuidString)
NSLog("Find: overlay appear tab=%@ surface=%@", tabId.uuidString, surfaceId.uuidString)
isSearchFieldFocused = true
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in
guard notification.object as? TerminalSurface === surface else { return }
NSLog("Find: overlay focus tab=%@ surface=%@", surface.tabId.uuidString, surface.id.uuidString)
guard let focusedSurface = notification.object as? TerminalSurface,
focusedSurface.id == surfaceId else { return }
NSLog("Find: overlay focus tab=%@ surface=%@", tabId.uuidString, surfaceId.uuidString)
DispatchQueue.main.async {
isSearchFieldFocused = true
}

View file

@ -2,11 +2,14 @@ import Foundation
import AppKit
struct GhosttyConfig {
enum ColorSchemePreference {
enum ColorSchemePreference: Hashable {
case light
case dark
}
private static let loadCacheLock = NSLock()
private static var cachedConfigsByColorScheme: [ColorSchemePreference: GhosttyConfig] = [:]
var fontFamily: String = "Menlo"
var fontSize: CGFloat = 12
var theme: String?
@ -45,7 +48,45 @@ struct GhosttyConfig {
return backgroundColor.darken(by: isLightBackground ? 0.08 : 0.4)
}
static func load() -> GhosttyConfig {
static func load(
preferredColorScheme: ColorSchemePreference? = nil,
useCache: Bool = true,
loadFromDisk: (_ preferredColorScheme: ColorSchemePreference) -> GhosttyConfig = Self.loadFromDisk
) -> GhosttyConfig {
let resolvedColorScheme = preferredColorScheme ?? currentColorSchemePreference()
if useCache, let cached = cachedLoad(for: resolvedColorScheme) {
return cached
}
let loaded = loadFromDisk(resolvedColorScheme)
if useCache {
storeCachedLoad(loaded, for: resolvedColorScheme)
}
return loaded
}
static func invalidateLoadCache() {
loadCacheLock.lock()
cachedConfigsByColorScheme.removeAll()
loadCacheLock.unlock()
}
private static func cachedLoad(for colorScheme: ColorSchemePreference) -> GhosttyConfig? {
loadCacheLock.lock()
defer { loadCacheLock.unlock() }
return cachedConfigsByColorScheme[colorScheme]
}
private static func storeCachedLoad(
_ config: GhosttyConfig,
for colorScheme: ColorSchemePreference
) {
loadCacheLock.lock()
cachedConfigsByColorScheme[colorScheme] = config
loadCacheLock.unlock()
}
private static func loadFromDisk(preferredColorScheme: ColorSchemePreference) -> GhosttyConfig {
var config = GhosttyConfig()
// Match Ghostty's default load order on macOS.
@ -64,7 +105,12 @@ struct GhosttyConfig {
// Load theme if specified
if let themeName = config.theme {
config.loadTheme(themeName)
config.loadTheme(
themeName,
environment: ProcessInfo.processInfo.environment,
bundleResourceURL: Bundle.main.resourceURL,
preferredColorScheme: preferredColorScheme
)
}
return config

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,46 @@
import Carbon
class KeyboardLayout {
/// Return a string ID of the current keyboard input source.
static var id: String? {
if let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(),
let sourceIdPointer = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) {
let sourceId = Unmanaged<CFString>.fromOpaque(sourceIdPointer).takeUnretainedValue()
return sourceId as String
}
return nil
}
/// Translate a physical keyCode to the unmodified character under the current keyboard layout.
static func character(forKeyCode keyCode: UInt16) -> String? {
guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(),
let layoutDataPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else {
return nil
}
let layoutData = unsafeBitCast(layoutDataPointer, to: CFData.self)
guard let bytes = CFDataGetBytePtr(layoutData) else { return nil }
let keyboardLayout = UnsafeRawPointer(bytes).assumingMemoryBound(to: UCKeyboardLayout.self)
var deadKeyState: UInt32 = 0
var chars = [UniChar](repeating: 0, count: 4)
var length = 0
let status = UCKeyTranslate(
keyboardLayout,
keyCode,
UInt16(kUCKeyActionDisplay),
0,
UInt32(LMGetKbdType()),
UInt32(kUCKeyTranslateNoDeadKeysBit),
&deadKeyState,
chars.count,
&length,
&chars
)
guard status == noErr, length > 0 else { return nil }
return String(utf16CodeUnits: chars, count: length).lowercased()
}
}

View file

@ -8,6 +8,8 @@ enum KeyboardShortcutSettings {
case toggleSidebar
case newTab
case newWindow
case closeWindow
case openFolder
case showNotifications
case jumpToUnread
case triggerFlash
@ -17,6 +19,9 @@ enum KeyboardShortcutSettings {
case prevSurface
case nextSidebarTab
case prevSidebarTab
case renameTab
case renameWorkspace
case closeWorkspace
case newSurface
// Panes / splits
@ -26,6 +31,7 @@ enum KeyboardShortcutSettings {
case focusDown
case splitRight
case splitDown
case toggleSplitZoom
case splitBrowserRight
case splitBrowserDown
@ -41,6 +47,8 @@ enum KeyboardShortcutSettings {
case .toggleSidebar: return "Toggle Sidebar"
case .newTab: return "New Workspace"
case .newWindow: return "New Window"
case .closeWindow: return "Close Window"
case .openFolder: return "Open Folder"
case .showNotifications: return "Show Notifications"
case .jumpToUnread: return "Jump to Latest Unread"
case .triggerFlash: return "Flash Focused Panel"
@ -48,6 +56,9 @@ enum KeyboardShortcutSettings {
case .prevSurface: return "Previous Surface"
case .nextSidebarTab: return "Next Workspace"
case .prevSidebarTab: return "Previous Workspace"
case .renameTab: return "Rename Tab"
case .renameWorkspace: return "Rename Workspace"
case .closeWorkspace: return "Close Workspace"
case .newSurface: return "New Surface"
case .focusLeft: return "Focus Pane Left"
case .focusRight: return "Focus Pane Right"
@ -55,6 +66,7 @@ enum KeyboardShortcutSettings {
case .focusDown: return "Focus Pane Down"
case .splitRight: return "Split Right"
case .splitDown: return "Split Down"
case .toggleSplitZoom: return "Toggle Pane Zoom"
case .splitBrowserRight: return "Split Browser Right"
case .splitBrowserDown: return "Split Browser Down"
case .openBrowser: return "Open Browser"
@ -68,17 +80,23 @@ enum KeyboardShortcutSettings {
case .toggleSidebar: return "shortcut.toggleSidebar"
case .newTab: return "shortcut.newTab"
case .newWindow: return "shortcut.newWindow"
case .closeWindow: return "shortcut.closeWindow"
case .openFolder: return "shortcut.openFolder"
case .showNotifications: return "shortcut.showNotifications"
case .jumpToUnread: return "shortcut.jumpToUnread"
case .triggerFlash: return "shortcut.triggerFlash"
case .nextSidebarTab: return "shortcut.nextSidebarTab"
case .prevSidebarTab: return "shortcut.prevSidebarTab"
case .renameTab: return "shortcut.renameTab"
case .renameWorkspace: return "shortcut.renameWorkspace"
case .closeWorkspace: return "shortcut.closeWorkspace"
case .focusLeft: return "shortcut.focusLeft"
case .focusRight: return "shortcut.focusRight"
case .focusUp: return "shortcut.focusUp"
case .focusDown: return "shortcut.focusDown"
case .splitRight: return "shortcut.splitRight"
case .splitDown: return "shortcut.splitDown"
case .toggleSplitZoom: return "shortcut.toggleSplitZoom"
case .splitBrowserRight: return "shortcut.splitBrowserRight"
case .splitBrowserDown: return "shortcut.splitBrowserDown"
case .nextSurface: return "shortcut.nextSurface"
@ -98,6 +116,10 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false)
case .newWindow:
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 .openFolder:
return StoredShortcut(key: "o", command: true, shift: false, option: false, control: false)
case .showNotifications:
return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false)
case .jumpToUnread:
@ -108,6 +130,12 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "]", command: true, shift: false, option: false, control: true)
case .prevSidebarTab:
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:
return StoredShortcut(key: "r", command: true, shift: true, option: false, control: false)
case .closeWorkspace:
return StoredShortcut(key: "w", command: true, shift: true, option: false, control: false)
case .focusLeft:
return StoredShortcut(key: "", command: true, shift: false, option: true, control: false)
case .focusRight:
@ -120,6 +148,8 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "d", command: true, shift: false, option: false, control: false)
case .splitDown:
return StoredShortcut(key: "d", command: true, shift: true, option: false, control: false)
case .toggleSplitZoom:
return StoredShortcut(key: "\r", command: true, shift: true, option: false, control: false)
case .splitBrowserRight:
return StoredShortcut(key: "d", command: true, shift: false, option: true, control: false)
case .splitBrowserDown:
@ -190,6 +220,8 @@ enum KeyboardShortcutSettings {
static func nextSidebarTabShortcut() -> StoredShortcut { shortcut(for: .nextSidebarTab) }
static func prevSidebarTabShortcut() -> StoredShortcut { shortcut(for: .prevSidebarTab) }
static func renameWorkspaceShortcut() -> StoredShortcut { shortcut(for: .renameWorkspace) }
static func closeWorkspaceShortcut() -> StoredShortcut { shortcut(for: .closeWorkspace) }
static func focusLeftShortcut() -> StoredShortcut { shortcut(for: .focusLeft) }
static func focusRightShortcut() -> StoredShortcut { shortcut(for: .focusRight) }
@ -198,6 +230,7 @@ enum KeyboardShortcutSettings {
static func splitRightShortcut() -> StoredShortcut { shortcut(for: .splitRight) }
static func splitDownShortcut() -> StoredShortcut { shortcut(for: .splitDown) }
static func toggleSplitZoomShortcut() -> StoredShortcut { shortcut(for: .toggleSplitZoom) }
static func splitBrowserRightShortcut() -> StoredShortcut { shortcut(for: .splitBrowserRight) }
static func splitBrowserDownShortcut() -> StoredShortcut { shortcut(for: .splitBrowserDown) }
@ -228,6 +261,8 @@ struct StoredShortcut: Codable, Equatable {
switch key {
case "\t":
keyText = "TAB"
case "\r":
keyText = ""
default:
keyText = key.uppercased()
}
@ -244,6 +279,69 @@ struct StoredShortcut: Codable, Equatable {
return flags
}
var keyEquivalent: KeyEquivalent? {
switch key {
case "":
return .leftArrow
case "":
return .rightArrow
case "":
return .upArrow
case "":
return .downArrow
case "\t":
return .tab
case "\r":
return KeyEquivalent(Character("\r"))
default:
let lowered = key.lowercased()
guard lowered.count == 1, let character = lowered.first else { return nil }
return KeyEquivalent(character)
}
}
var eventModifiers: EventModifiers {
var modifiers: EventModifiers = []
if command {
modifiers.insert(.command)
}
if shift {
modifiers.insert(.shift)
}
if option {
modifiers.insert(.option)
}
if control {
modifiers.insert(.control)
}
return modifiers
}
var menuItemKeyEquivalent: String? {
switch key {
case "":
guard let scalar = UnicodeScalar(NSLeftArrowFunctionKey) else { return nil }
return String(Character(scalar))
case "":
guard let scalar = UnicodeScalar(NSRightArrowFunctionKey) else { return nil }
return String(Character(scalar))
case "":
guard let scalar = UnicodeScalar(NSUpArrowFunctionKey) else { return nil }
return String(Character(scalar))
case "":
guard let scalar = UnicodeScalar(NSDownArrowFunctionKey) else { return nil }
return String(Character(scalar))
case "\t":
return "\t"
case "\r":
return "\r"
default:
let lowered = key.lowercased()
guard lowered.count == 1 else { return nil }
return lowered
}
}
static func from(event: NSEvent) -> StoredShortcut? {
guard let key = storedKey(from: event) else { return nil }
@ -274,6 +372,7 @@ struct StoredShortcut: Codable, Equatable {
case 125: return "" // down arrow
case 126: return "" // up arrow
case 48: return "\t" // tab
case 36, 76: return "\r" // return, keypad enter
case 33: return "[" // kVK_ANSI_LeftBracket
case 30: return "]" // kVK_ANSI_RightBracket
case 27: return "-" // kVK_ANSI_Minus

View file

@ -5,6 +5,7 @@ struct NotificationsPage: View {
@EnvironmentObject var tabManager: TabManager
@Binding var selection: SidebarSelection
@FocusState private var focusedNotificationId: UUID?
@AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data()
var body: some View {
VStack(spacing: 0) {
@ -73,6 +74,8 @@ struct NotificationsPage: View {
Spacer()
if !notificationStore.notifications.isEmpty {
jumpToUnreadButton
Button("Clear All") {
notificationStore.clearAll()
}
@ -97,11 +100,76 @@ struct NotificationsPage: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var jumpToUnreadButton: some View {
if let key = jumpToUnreadShortcut.keyEquivalent {
Button(action: {
AppDelegate.shared?.jumpToLatestUnread()
}) {
HStack(spacing: 6) {
Text("Jump to Latest Unread")
ShortcutAnnotation(text: jumpToUnreadShortcut.displayString)
}
}
.buttonStyle(.bordered)
.keyboardShortcut(key, modifiers: jumpToUnreadShortcut.eventModifiers)
.help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip("Jump to Latest Unread"))
.disabled(!hasUnreadNotifications)
} else {
Button(action: {
AppDelegate.shared?.jumpToLatestUnread()
}) {
HStack(spacing: 6) {
Text("Jump to Latest Unread")
ShortcutAnnotation(text: jumpToUnreadShortcut.displayString)
}
}
.buttonStyle(.bordered)
.help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip("Jump to Latest Unread"))
.disabled(!hasUnreadNotifications)
}
}
private var jumpToUnreadShortcut: StoredShortcut {
decodeShortcut(
from: jumpToUnreadShortcutData,
fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut
)
}
private var hasUnreadNotifications: Bool {
notificationStore.notifications.contains(where: { !$0.isRead })
}
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
guard !data.isEmpty,
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
return fallback
}
return shortcut
}
private func tabTitle(for tabId: UUID) -> String? {
AppDelegate.shared?.tabTitle(for: tabId) ?? tabManager.tabs.first(where: { $0.id == tabId })?.title
}
}
private struct ShortcutAnnotation: View {
let text: String
var body: some View {
Text(text)
.font(.system(size: 10, weight: .semibold, design: .rounded))
.foregroundStyle(.primary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(Color(nsColor: .controlBackgroundColor))
)
}
}
private struct NotificationRow: View {
let notification: TerminalNotification
let tabTitle: String?
@ -114,11 +182,11 @@ private struct NotificationRow: View {
Button(action: onOpen) {
HStack(alignment: .top, spacing: 12) {
Circle()
.fill(notification.isRead ? Color.clear : Color.accentColor)
.fill(notification.isRead ? Color.clear : cmuxAccentColor())
.frame(width: 8, height: 8)
.overlay(
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)

File diff suppressed because it is too large Load diff

View file

@ -71,7 +71,7 @@ enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable {
// Matches Bonsplit tab icon tint for active tabs.
return Color(nsColor: .labelColor)
case .accent:
return .accentColor
return cmuxAccentColor()
case .tertiary:
return Color(nsColor: .tertiaryLabelColor)
}
@ -122,6 +122,87 @@ struct OmnibarInlineCompletion: Equatable {
}
}
private struct OmnibarAddressButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
OmnibarAddressButtonStyleBody(configuration: configuration)
}
}
private struct OmnibarAddressButtonStyleBody: View {
let configuration: OmnibarAddressButtonStyle.Configuration
@Environment(\.isEnabled) private var isEnabled
@State private var isHovered = false
private var backgroundOpacity: Double {
guard isEnabled else { return 0.0 }
if configuration.isPressed { return 0.16 }
if isHovered { return 0.08 }
return 0.0
}
var body: some View {
configuration.label
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color.primary.opacity(backgroundOpacity))
)
.onHover { hovering in
isHovered = hovering
}
.animation(.easeOut(duration: 0.12), value: isHovered)
.animation(.easeOut(duration: 0.08), value: configuration.isPressed)
}
}
private extension View {
func cmuxFlatSymbolColorRendering() -> some View {
// `symbolColorRenderingMode(.flat)` is not available in the current SDK
// used by CI/local builds. Keep this modifier as a compatibility no-op.
self
}
}
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
struct BrowserPanelView: View {
@ObservedObject var panel: BrowserPanel
@ -129,12 +210,14 @@ struct BrowserPanelView: View {
let isVisibleInUI: Bool
let portalPriority: Int
let onRequestPanelFocus: () -> Void
@Environment(\.colorScheme) private var colorScheme
@State private var omnibarState = OmnibarState()
@State private var addressBarFocused: Bool = false
@AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabledStorage = BrowserSearchSettings.defaultSearchSuggestionsEnabled
@AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
@AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
@AppStorage(BrowserThemeSettings.modeKey) private var browserThemeModeRaw = BrowserThemeSettings.defaultMode.rawValue
@State private var suggestionTask: Task<Void, Never>?
@State private var isLoadingRemoteSuggestions: Bool = false
@State private var latestRemoteSuggestionQuery: String = ""
@ -144,12 +227,16 @@ struct BrowserPanelView: View {
@State private var omnibarHasMarkedText: Bool = false
@State private var suppressNextFocusLostRevert: Bool = false
@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 lastHandledAddressBarFocusRequestId: UUID?
private let omnibarPillCornerRadius: CGFloat = 12
@State private var isBrowserThemeMenuPresented = false
// Keep this below half of the compact omnibar height so it reads as a squircle,
// not a capsule.
private let omnibarPillCornerRadius: CGFloat = 10
private let addressBarButtonSize: CGFloat = 22
private let addressBarButtonHitSize: CGFloat = 32
private let addressBarButtonHitSize: CGFloat = 26
private let addressBarVerticalPadding: CGFloat = 4
private let devToolsButtonIconSize: CGFloat = 11
private var searchEngine: BrowserSearchEngine {
@ -184,16 +271,41 @@ struct BrowserPanelView: View {
BrowserDevToolsIconColorOption(rawValue: devToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor
}
private var browserThemeMode: BrowserThemeMode {
BrowserThemeSettings.mode(for: browserThemeModeRaw)
}
private var browserChromeBackgroundColor: NSColor {
resolvedBrowserChromeBackgroundColor(
for: colorScheme,
themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor
)
}
private var browserChromeColorScheme: ColorScheme {
resolvedBrowserChromeColorScheme(
for: colorScheme,
themeBackgroundColor: GhosttyApp.shared.defaultBackgroundColor
)
}
private var omnibarPillBackgroundColor: NSColor {
resolvedBrowserOmnibarPillBackgroundColor(
for: browserChromeColorScheme,
themeBackgroundColor: browserChromeBackgroundColor
)
}
var body: some View {
VStack(spacing: 0) {
addressBar
webView
}
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(Color.accentColor.opacity(focusFlashOpacity), lineWidth: 3)
.shadow(color: Color.accentColor.opacity(focusFlashOpacity * 0.35), radius: 10)
.padding(6)
RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius)
.stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3)
.shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10)
.padding(FocusFlashPattern.ringInset)
.allowsHitTesting(false)
}
.overlay(alignment: .topLeading) {
@ -213,8 +325,9 @@ struct BrowserPanelView: View {
}
)
.frame(width: omnibarPillFrame.width)
.offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 6)
.offset(x: omnibarPillFrame.minX, y: omnibarPillFrame.maxY + 3)
.zIndex(1000)
.environment(\.colorScheme, browserChromeColorScheme)
}
}
.coordinateSpace(name: "BrowserPanelViewSpace")
@ -226,25 +339,32 @@ struct BrowserPanelView: View {
guard let webView = note.object as? CmuxWebView else { return false }
return webView === panel?.webView
}) { _ in
#if DEBUG
dlog(
"browser.focus.clickIntent panel=\(panel.id.uuidString.prefix(5)) " +
"isFocused=\(isFocused ? 1 : 0) " +
"addressFocused=\(addressBarFocused ? 1 : 0)"
)
#endif
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 {
UserDefaults.standard.register(defaults: [
BrowserSearchSettings.searchEngineKey: BrowserSearchSettings.defaultSearchEngine.rawValue,
BrowserSearchSettings.searchSuggestionsEnabledKey: BrowserSearchSettings.defaultSearchSuggestionsEnabled,
BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue,
])
let resolvedThemeMode = BrowserThemeSettings.mode(defaults: .standard)
if browserThemeModeRaw != resolvedThemeMode.rawValue {
browserThemeModeRaw = resolvedThemeMode.rawValue
}
panel.refreshAppearanceDrivenColors()
panel.setBrowserThemeMode(browserThemeMode)
applyPendingAddressBarFocusRequestIfNeeded()
syncURLFromPanel()
// If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar.
autoFocusOmnibarIfBlank()
syncWebViewResponderPolicyWithViewState(reason: "onAppear")
BrowserHistoryStore.shared.loadIfNeeded()
}
.onChange(of: panel.focusFlashToken) { _ in
@ -262,6 +382,16 @@ struct BrowserPanelView: View {
addressBarFocused = false
}
}
.onChange(of: browserThemeModeRaw) { _ in
let normalizedMode = BrowserThemeSettings.mode(for: browserThemeModeRaw)
if browserThemeModeRaw != normalizedMode.rawValue {
browserThemeModeRaw = normalizedMode.rawValue
}
panel.setBrowserThemeMode(normalizedMode)
}
.onChange(of: colorScheme) { _ in
panel.refreshAppearanceDrivenColors()
}
.onChange(of: panel.pendingAddressBarFocusRequestId) { _ in
applyPendingAddressBarFocusRequestIfNeeded()
}
@ -274,6 +404,7 @@ struct BrowserPanelView: View {
hideSuggestions()
addressBarFocused = false
}
syncWebViewResponderPolicyWithViewState(reason: "panelFocusChanged")
}
.onChange(of: addressBarFocused) { focused in
let urlString = panel.preferredURLStringForOmnibar() ?? ""
@ -301,6 +432,7 @@ struct BrowserPanelView: View {
}
inlineCompletion = nil
}
syncWebViewResponderPolicyWithViewState(reason: "addressBarFocusChanged")
}
.onReceive(NotificationCenter.default.publisher(for: .browserMoveOmnibarSelection)) { notification in
guard let panelId = notification.object as? UUID, panelId == panel.id else { return }
@ -332,13 +464,17 @@ struct BrowserPanelView: View {
.accessibilityIdentifier("BrowserOmnibarPill")
.accessibilityLabel("Browser omnibar")
developerToolsButton
if !panel.isShowingNewTabPage {
browserThemeModeButton
developerToolsButton
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(Color(nsColor: .windowBackgroundColor))
.padding(.vertical, addressBarVerticalPadding)
.background(Color(nsColor: browserChromeBackgroundColor))
// Keep the omnibar stack above WKWebView so the suggestions popup is visible.
.zIndex(1)
.environment(\.colorScheme, browserChromeColorScheme)
}
private var addressBarButtonBar: some View {
@ -354,7 +490,7 @@ struct BrowserPanelView: View {
.frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(OmnibarAddressButtonStyle())
.disabled(!panel.canGoBack)
.opacity(panel.canGoBack ? 1.0 : 0.4)
.help("Go Back")
@ -370,7 +506,7 @@ struct BrowserPanelView: View {
.frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(OmnibarAddressButtonStyle())
.disabled(!panel.canGoForward)
.opacity(panel.canGoForward ? 1.0 : 0.4)
.help("Go Forward")
@ -393,8 +529,20 @@ struct BrowserPanelView: View {
.frame(width: addressBarButtonHitSize, height: addressBarButtonHitSize, alignment: .center)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.buttonStyle(OmnibarAddressButtonStyle())
.help(panel.isLoading ? "Stop" : "Reload")
if panel.isDownloading {
HStack(spacing: 4) {
ProgressView()
.controlSize(.small)
Text("Downloading...")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.padding(.leading, 6)
.help("Download in progress")
}
}
}
@ -403,16 +551,74 @@ struct BrowserPanelView: View {
openDevTools()
}) {
Image(systemName: devToolsIconOption.rawValue)
.symbolRenderingMode(.monochrome)
.cmuxFlatSymbolColorRendering()
.font(.system(size: devToolsButtonIconSize, weight: .medium))
.foregroundStyle(devToolsColorOption.color)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
}
.buttonStyle(.plain)
.buttonStyle(OmnibarAddressButtonStyle())
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.help("Toggle Developer Tools")
.help(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.tooltip("Toggle Developer Tools"))
.accessibilityIdentifier("BrowserToggleDevToolsButton")
}
private var browserThemeModeButton: some View {
Button(action: {
isBrowserThemeMenuPresented.toggle()
}) {
Image(systemName: browserThemeMode.iconName)
.symbolRenderingMode(.monochrome)
.cmuxFlatSymbolColorRendering()
.font(.system(size: devToolsButtonIconSize, weight: .medium))
.foregroundStyle(browserThemeModeIconColor)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
}
.buttonStyle(OmnibarAddressButtonStyle())
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.popover(isPresented: $isBrowserThemeMenuPresented, arrowEdge: .bottom) {
browserThemeModePopover
}
.help("Browser Theme: \(browserThemeMode.displayName)")
.accessibilityIdentifier("BrowserThemeModeButton")
}
private var browserThemeModePopover: some View {
VStack(alignment: .leading, spacing: 2) {
ForEach(BrowserThemeMode.allCases) { mode in
Button {
applyBrowserThemeModeSelection(mode)
isBrowserThemeMenuPresented = false
} label: {
HStack(spacing: 8) {
Image(systemName: mode == browserThemeMode ? "checkmark" : "circle")
.font(.system(size: 10, weight: .semibold))
.opacity(mode == browserThemeMode ? 1.0 : 0.0)
.frame(width: 12, alignment: .center)
Text(mode.displayName)
.font(.system(size: 12))
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.frame(height: 24)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(mode == browserThemeMode ? Color.primary.opacity(0.12) : Color.clear)
)
}
.buttonStyle(.plain)
.accessibilityIdentifier("BrowserThemeModeOption\(mode.rawValue.capitalized)")
}
}
.padding(8)
.frame(minWidth: 128)
}
private var browserThemeModeIconColor: Color {
devToolsColorOption.color
}
private var omnibarField: some View {
let showSecureBadge = panel.currentURL?.scheme == "https"
@ -483,11 +689,11 @@ struct BrowserPanelView: View {
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)
.fill(Color(nsColor: .textBackgroundColor))
.fill(Color(nsColor: omnibarPillBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)
.stroke(addressBarFocused ? Color.accentColor : Color.clear, lineWidth: 1)
.stroke(addressBarFocused ? cmuxAccentColor() : Color.clear, lineWidth: 1)
)
.accessibilityElement(children: .contain)
.background {
@ -502,42 +708,77 @@ struct BrowserPanelView: View {
}
private var webView: some View {
WebViewRepresentable(
panel: panel,
shouldAttachWebView: isVisibleInUI,
shouldFocusWebView: isFocused && !addressBarFocused,
isPanelFocused: isFocused,
portalZPriority: portalPriority
)
// Keep the representable identity stable across bonsplit structural updates.
// This reduces WKWebView reparenting churn (and the associated WebKit crashes).
.id(panel.id)
.contentShape(Rectangle())
.simultaneousGesture(TapGesture().onEnded {
// Chrome-like behavior: clicking web content while editing the
// omnibar should commit blur and revert transient edits.
if addressBarFocused {
addressBarFocused = false
}
})
.zIndex(0)
Group {
if panel.shouldRenderWebView {
WebViewRepresentable(
panel: panel,
shouldAttachWebView: isVisibleInUI,
shouldFocusWebView: isFocused && !addressBarFocused,
isPanelFocused: isFocused,
portalZPriority: portalPriority
)
// Keep the representable identity stable across bonsplit structural updates.
// This reduces WKWebView reparenting churn (and the associated WebKit crashes).
.id(panel.id)
.contentShape(Rectangle())
.simultaneousGesture(TapGesture().onEnded {
// Chrome-like behavior: clicking web content while editing the
// omnibar should commit blur and revert transient edits.
if addressBarFocused {
addressBarFocused = false
}
})
} else {
Color(nsColor: browserChromeBackgroundColor)
.contentShape(Rectangle())
.onTapGesture {
onRequestPanelFocus()
if addressBarFocused {
addressBarFocused = false
}
}
}
}
.zIndex(0)
}
private func triggerFocusFlashAnimation() {
focusFlashFadeWorkItem?.cancel()
focusFlashFadeWorkItem = nil
focusFlashAnimationGeneration &+= 1
let generation = focusFlashAnimationGeneration
focusFlashOpacity = FocusFlashPattern.values.first ?? 0
withAnimation(.easeOut(duration: 0.08)) {
focusFlashOpacity = 1.0
}
let item = DispatchWorkItem {
withAnimation(.easeOut(duration: 0.35)) {
focusFlashOpacity = 0.0
for segment in FocusFlashPattern.segments {
DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) {
guard focusFlashAnimationGeneration == generation else { return }
withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) {
focusFlashOpacity = segment.targetOpacity
}
}
}
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() {
@ -546,8 +787,32 @@ struct BrowserPanelView: View {
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() {
guard let requestId = panel.pendingAddressBarFocusRequestId else { return }
guard !isCommandPaletteVisibleForPanelWindow() else { return }
guard lastHandledAddressBarFocusRequestId != requestId else { return }
lastHandledAddressBarFocusRequestId = requestId
panel.beginSuppressWebViewFocusForAddressBar()
@ -575,6 +840,7 @@ struct BrowserPanelView: View {
private func autoFocusOmnibarIfBlank() {
guard isFocused else { return }
guard !addressBarFocused else { return }
guard !isCommandPaletteVisibleForPanelWindow() else { return }
// If a test/automation explicitly focused WebKit, don't steal focus back.
guard !panel.shouldSuppressOmnibarAutofocus() else { return }
// If a real navigation is underway (e.g. open_browser https://...), don't steal focus.
@ -592,6 +858,13 @@ struct BrowserPanelView: View {
}
}
private func applyBrowserThemeModeSelection(_ mode: BrowserThemeMode) {
if browserThemeModeRaw != mode.rawValue {
browserThemeModeRaw = mode.rawValue
}
panel.setBrowserThemeMode(mode)
}
private func handleOmnibarTap() {
onRequestPanelFocus()
guard !addressBarFocused else { return }
@ -1942,6 +2215,13 @@ struct OmnibarSuggestion: Identifiable, Hashable {
}
}
func browserOmnibarShouldReacquireFocusAfterEndEditing(
suppressWebViewFocus: Bool,
nextResponderIsOtherTextField: Bool
) -> Bool {
suppressWebViewFocus && !nextResponderIsOtherTextField
}
private final class OmnibarNativeTextField: NSTextField {
var onPointerDown: (() -> Void)?
var onHandleKeyEvent: ((NSEvent, NSTextView?) -> Bool)?
@ -2054,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) {
if !parent.isFocused {
DispatchQueue.main.async {
@ -2066,15 +2369,18 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
func controlTextDidEndEditing(_ obj: Notification) {
if parent.isFocused {
if parent.shouldSuppressWebViewFocus() {
if shouldReacquireFocusAfterEndEditing(window: parentField?.window) {
guard pendingFocusRequest != true else { return }
pendingFocusRequest = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.pendingFocusRequest = nil
guard self.parent.isFocused else { return }
guard self.parent.shouldSuppressWebViewFocus() 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
// the actual first responder when the text field is being edited).
let fr = window.firstResponder
@ -2387,11 +2693,12 @@ private struct OmnibarSuggestionsView: View {
let searchSuggestionsEnabled: Bool
let onCommit: (OmnibarSuggestion) -> Void
let onHighlight: (Int) -> Void
@Environment(\.colorScheme) private var colorScheme
// Keep radii below the smallest rendered heights so corners don't get
// auto-clamped and visually change as popup height changes.
private let popupCornerRadius: CGFloat = 16
private let rowHighlightCornerRadius: CGFloat = 12
// Keep radii below half of the smallest rendered heights so this keeps a
// squircle silhouette instead of auto-clamping into a capsule.
private let popupCornerRadius: CGFloat = 12
private let rowHighlightCornerRadius: CGFloat = 9
private let singleLineRowHeight: CGFloat = 24
private let rowSpacing: CGFloat = 1
private let topInset: CGFloat = 3
@ -2444,6 +2751,101 @@ private struct OmnibarSuggestionsView: View {
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
private var rowsView: some View {
VStack(spacing: rowSpacing) {
@ -2457,18 +2859,18 @@ private struct OmnibarSuggestionsView: View {
HStack(spacing: 6) {
Text(item.listText)
.font(.system(size: 11))
.foregroundStyle(Color.white.opacity(0.9))
.foregroundStyle(listTextColor)
.lineLimit(1)
.truncationMode(.tail)
if let badge = item.trailingBadgeText {
Text(badge)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(Color.white.opacity(0.72))
.foregroundStyle(badgeTextColor)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(Color.white.opacity(0.08))
.fill(badgeBackgroundColor)
)
}
Spacer(minLength: 0)
@ -2484,7 +2886,7 @@ private struct OmnibarSuggestionsView: View {
RoundedRectangle(cornerRadius: rowHighlightCornerRadius, style: .continuous)
.fill(
idx == selectedIndex
? Color.white.opacity(0.12)
? rowHighlightColor
: Color.clear
)
)
@ -2539,10 +2941,7 @@ private struct OmnibarSuggestionsView: View {
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
.fill(
LinearGradient(
colors: [
Color.black.opacity(0.26),
Color.black.opacity(0.14),
],
colors: popupOverlayGradientColors,
startPoint: .top,
endPoint: .bottom
)
@ -2553,18 +2952,16 @@ private struct OmnibarSuggestionsView: View {
RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous)
.stroke(
LinearGradient(
colors: [
Color.white.opacity(0.22),
Color.white.opacity(0.06),
],
colors: popupBorderGradientColors,
startPoint: .top,
endPoint: .bottom
),
lineWidth: 1
)
)
.shadow(color: Color.black.opacity(0.45), radius: 20, y: 10)
.contentShape(Rectangle())
.clipShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous))
.shadow(color: popupShadowColor, radius: 20, y: 10)
.contentShape(RoundedRectangle(cornerRadius: popupCornerRadius, style: .continuous))
.accessibilityElement(children: .contain)
.accessibilityRespondsToUserInteraction(true)
.accessibilityIdentifier("BrowserOmnibarSuggestions")
@ -2621,6 +3018,27 @@ struct WebViewRepresentable: NSViewRepresentable {
super.setFrameSize(newSize)
onGeometryChanged?()
}
override func hitTest(_ point: NSPoint) -> NSView? {
if shouldPassThroughToSidebarResizer(at: point) {
return nil
}
return super.hitTest(point)
}
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
// Pass through a narrow leading-edge band so the shared sidebar divider
// handle can receive hover/click even when WKWebView is attached here.
// Keeping this deterministic avoids flicker from dynamic left-edge scans.
guard point.x >= 0, point.x <= SidebarResizeInteraction.hitWidthPerSide else {
return false
}
guard let window, let contentView = window.contentView else {
return false
}
let hostRectInContent = contentView.convert(bounds, from: self)
return hostRectInContent.minX > 1
}
}
#if DEBUG
@ -2842,6 +3260,7 @@ struct WebViewRepresentable: NSViewRepresentable {
coordinator: Coordinator,
generation: Int
) {
let retryInterval: TimeInterval = 1.0 / 60.0
// Don't schedule multiple overlapping retries.
guard coordinator.attachRetryWorkItem == nil else { return }
@ -2874,7 +3293,7 @@ struct WebViewRepresentable: NSViewRepresentable {
// Be generous here: bonsplit structural updates can keep a representable
// container off-window longer than a few seconds under load.
if coordinator.attachRetryCount < 400 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval) {
scheduleAttachRetry(
webView,
panel: panel,
@ -2911,13 +3330,18 @@ struct WebViewRepresentable: NSViewRepresentable {
}
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) {
let webView = panel.webView
context.coordinator.panel = panel
context.coordinator.webView = webView
Self.applyWebViewFirstResponderPolicy(
panel: panel,
webView: webView,
isPanelFocused: isPanelFocused
)
let shouldUseWindowPortal = panel.shouldPreserveWebViewAttachmentDuringTransientHide()
if shouldUseWindowPortal {
@ -3165,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) {
coordinator.attachRetryWorkItem?.cancel()
coordinator.attachRetryWorkItem = nil

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,41 @@ public enum PanelType: String, Codable, Sendable {
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.)
@MainActor
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
func unfocus()
/// Trigger a focus flash animation for this panel.
func triggerFlash()
}
/// Extension providing default implementations

View file

@ -85,15 +85,20 @@ final class TerminalPanel: Panel, ObservableObject {
workingDirectory: String? = nil,
portOrdinal: Int = 0,
initialCommand: String? = nil,
initialEnvironmentOverrides: [String: String] = [:]
initialEnvironmentOverrides: [String: String] = [:],
additionalEnvironment: [String: String] = [:]
) {
var mergedEnvironment = initialEnvironmentOverrides
for (key, value) in additionalEnvironment {
mergedEnvironment[key] = value
}
let surface = TerminalSurface(
tabId: workspaceId,
context: context,
configTemplate: configTemplate,
workingDirectory: workingDirectory,
initialCommand: initialCommand,
initialEnvironmentOverrides: initialEnvironmentOverrides
initialEnvironmentOverrides: mergedEnvironment
)
surface.portOrdinal = portOrdinal
self.init(workspaceId: workspaceId, surface: surface)
@ -139,8 +144,11 @@ final class TerminalPanel: Panel, ObservableObject {
func close() {
// The surface will be cleaned up by its deinit
// Just unfocus before closing
// Detach from the window portal on real close so stale hosted views
// cannot remain above browser panes after split close.
unfocus()
hostedView.setVisibleInUI(false)
TerminalWindowPortalRegistry.detach(hostedView: hostedView)
}
func requestViewReattach() {

View file

@ -15,37 +15,26 @@ struct TerminalPanelView: View {
let onTriggerFlash: () -> Void
var body: some View {
ZStack(alignment: .topLeading) {
GhosttyTerminalView(
terminalSurface: panel.surface,
isActive: isFocused,
isVisibleInUI: isVisibleInUI,
portalZPriority: portalPriority,
showsInactiveOverlay: isSplit && !isFocused,
showsUnreadNotificationRing: hasUnreadNotification,
inactiveOverlayColor: appearance.unfocusedOverlayNSColor,
inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity,
reattachToken: panel.viewReattachToken,
onFocus: { _ in onFocus() },
onTriggerFlash: onTriggerFlash
)
// Keep the NSViewRepresentable identity stable across bonsplit structural updates.
// This prevents transient teardown/recreate that can momentarily detach the hosted terminal view.
.id(panel.id)
.background(Color.clear)
// Search overlay
if let searchState = panel.searchState {
SurfaceSearchOverlay(
surface: panel.surface,
searchState: searchState,
onClose: {
panel.searchState = nil
panel.hostedView.moveFocus()
}
)
}
}
// Layering contract: terminal find UI is mounted in GhosttySurfaceScrollView (AppKit portal layer)
// via `searchState`. Rendering `SurfaceSearchOverlay` in this SwiftUI container can hide it.
GhosttyTerminalView(
terminalSurface: panel.surface,
isActive: isFocused,
isVisibleInUI: isVisibleInUI,
portalZPriority: portalPriority,
showsInactiveOverlay: isSplit && !isFocused,
showsUnreadNotificationRing: hasUnreadNotification,
inactiveOverlayColor: appearance.unfocusedOverlayNSColor,
inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity,
searchState: panel.searchState,
reattachToken: panel.viewReattachToken,
onFocus: { _ in onFocus() },
onTriggerFlash: onTriggerFlash
)
// Keep the NSViewRepresentable identity stable across bonsplit structural updates.
// This prevents transient teardown/recreate that can momentarily detach the hosted terminal view.
.id(panel.id)
.background(Color.clear)
}
}

View file

@ -49,6 +49,7 @@ final class PortScanner: @unchecked Sendable {
func registerTTY(workspaceId: UUID, panelId: UUID, ttyName: String) {
queue.async { [self] in
let key = PanelKey(workspaceId: workspaceId, panelId: panelId)
guard ttyNames[key] != ttyName else { return }
ttyNames[key] = ttyName
}
}

View file

@ -13,11 +13,13 @@ final class PostHogAnalytics {
private let host = "https://us.i.posthog.com"
private let lastActiveDayUTCKey = "posthog.lastActiveDayUTC"
private let lastActiveHourUTCKey = "posthog.lastActiveHourUTC"
private var didStart = false
private var activeCheckTimer: Timer?
private var isEnabled: Bool {
guard TelemetrySettings.enabledForCurrentLaunch else { return false }
#if DEBUG
// Avoid polluting production analytics while iterating locally.
return ProcessInfo.processInfo.environment["CMUX_POSTHOG_ENABLE"] == "1"
@ -39,8 +41,9 @@ final class PostHogAnalytics {
PostHogSDK.shared.setup(config)
// Tag every event so PostHog can distinguish desktop from web.
PostHogSDK.shared.register(["platform": "cmuxterm"])
// Tag every event so PostHog can distinguish desktop from web and
// break events down by released app version/build.
PostHogSDK.shared.register(Self.superProperties(infoDictionary: Bundle.main.infoDictionary ?? [:]))
// The SDK automatically generates and persists an anonymous distinct ID.
@ -53,6 +56,7 @@ final class PostHogAnalytics {
guard let self else { return }
guard NSApp.isActive else { return }
self.trackDailyActive(reason: "activeTimer")
self.trackHourlyActive(reason: "activeTimer")
}
}
@ -68,20 +72,55 @@ final class PostHogAnalytics {
defaults.set(today, forKey: lastActiveDayUTCKey)
PostHogSDK.shared.capture("cmux_daily_active", properties: [
"day_utc": today,
"reason": reason,
])
PostHogSDK.shared.capture(
"cmux_daily_active",
properties: Self.dailyActiveProperties(
dayUTC: today,
reason: reason,
infoDictionary: Bundle.main.infoDictionary ?? [:]
)
)
// For DAU we care more about delivery than batching.
PostHogSDK.shared.flush()
}
func trackHourlyActive(reason: String) {
startIfNeeded()
guard didStart else { return }
let hour = utcHourString(Date())
let defaults = UserDefaults.standard
if defaults.string(forKey: lastActiveHourUTCKey) == hour {
return
}
defaults.set(hour, forKey: lastActiveHourUTCKey)
PostHogSDK.shared.capture(
"cmux_hourly_active",
properties: Self.hourlyActiveProperties(
hourUTC: hour,
reason: reason,
infoDictionary: Bundle.main.infoDictionary ?? [:]
)
)
}
func flush() {
guard didStart else { return }
PostHogSDK.shared.flush()
}
private func utcHourString(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .iso8601)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd'T'HH"
return formatter.string(from: date)
}
private func utcDayString(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .iso8601)
@ -90,4 +129,47 @@ final class PostHogAnalytics {
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
nonisolated static func superProperties(infoDictionary: [String: Any]) -> [String: Any] {
var properties: [String: Any] = ["platform": "cmuxterm"]
properties.merge(versionProperties(infoDictionary: infoDictionary)) { _, new in new }
return properties
}
nonisolated static func dailyActiveProperties(
dayUTC: String,
reason: String,
infoDictionary: [String: Any]
) -> [String: Any] {
var properties: [String: Any] = [
"day_utc": dayUTC,
"reason": reason,
]
properties.merge(versionProperties(infoDictionary: infoDictionary)) { _, new in new }
return properties
}
nonisolated static func hourlyActiveProperties(
hourUTC: String,
reason: String,
infoDictionary: [String: Any]
) -> [String: Any] {
var properties: [String: Any] = [
"hour_utc": hourUTC,
"reason": reason,
]
properties.merge(versionProperties(infoDictionary: infoDictionary)) { _, new in new }
return properties
}
nonisolated private static func versionProperties(infoDictionary: [String: Any]) -> [String: Any] {
var properties: [String: Any] = [:]
if let value = infoDictionary["CFBundleShortVersionString"] as? String, !value.isEmpty {
properties["app_version"] = value
}
if let value = infoDictionary["CFBundleVersion"] as? String, !value.isEmpty {
properties["app_build"] = value
}
return properties
}
}

View file

@ -0,0 +1,45 @@
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) {
guard TelemetrySettings.enabledForCurrentLaunch else { return }
let crumb = Breadcrumb(level: .info, category: category)
crumb.message = message
crumb.data = data
SentrySDK.addBreadcrumb(crumb)
}
private func sentryCaptureMessage(
_ message: String,
level: SentryLevel,
category: String,
data: [String: Any]?,
contextKey: String?
) {
guard TelemetrySettings.enabledForCurrentLaunch else { return }
_ = SentrySDK.capture(message: message) { scope in
scope.setLevel(level)
scope.setTag(value: category, key: "category")
if let data {
scope.setContext(value: data, key: contextKey ?? category)
}
}
}
func sentryCaptureWarning(
_ message: String,
category: String = "ui",
data: [String: Any]? = nil,
contextKey: String? = nil
) {
sentryCaptureMessage(message, level: .warning, category: category, data: data, contextKey: contextKey)
}
func sentryCaptureError(
_ message: String,
category: String = "ui",
data: [String: Any]? = nil,
contextKey: String? = nil
) {
sentryCaptureMessage(message, level: .error, category: category, data: data, contextKey: contextKey)
}

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
final class SidebarSelectionState: ObservableObject {
@Published var selection: SidebarSelection = .tabs
}
@Published var selection: SidebarSelection
init(selection: SidebarSelection = .tabs) {
self.selection = selection
}
}

View file

@ -1,16 +1,19 @@
import Foundation
#if canImport(Security)
import Security
#endif
enum SocketControlMode: String, CaseIterable, Identifiable {
case off
case cmuxOnly
/// Allow any local process to connect (no ancestry check).
/// Only accessible via CMUX_SOCKET_MODE=allowAll env var not shown in the UI.
case automation
case password
/// Full open access (all local users/processes) with no ancestry or password gate.
case allowAll
var id: String { rawValue }
/// Cases shown in the Settings UI. `allowAll` is intentionally excluded.
static var uiCases: [SocketControlMode] { [.off, .cmuxOnly] }
static var uiCases: [SocketControlMode] { [.off, .cmuxOnly, .automation, .password, .allowAll] }
var displayName: String {
switch self {
@ -18,8 +21,12 @@ enum SocketControlMode: String, CaseIterable, Identifiable {
return "Off"
case .cmuxOnly:
return "cmux processes only"
case .automation:
return "Automation mode"
case .password:
return "Password mode"
case .allowAll:
return "Allow all processes"
return "Full open access"
}
}
@ -29,45 +36,444 @@ enum SocketControlMode: String, CaseIterable, Identifiable {
return "Disable the local control socket."
case .cmuxOnly:
return "Only processes started inside cmux terminals can send commands."
case .automation:
return "Allow external local automation clients from this macOS user (no ancestry check)."
case .password:
return "Require socket authentication with a password stored in a local file."
case .allowAll:
return "Allow any local process to connect (no ancestry check)."
return "Allow any local process and user to connect with no auth. Unsafe."
}
}
var socketFilePermissions: UInt16 {
switch self {
case .allowAll:
return 0o666
case .off, .cmuxOnly, .automation, .password:
return 0o600
}
}
var requiresPasswordAuth: Bool {
self == .password
}
}
enum SocketControlPasswordStore {
static let directoryName = "cmux"
static let fileName = "socket-control-password"
private static let keychainMigrationDefaultsKey = "socketControlPasswordMigrationVersion"
private static let keychainMigrationVersion = 1
private static let legacyKeychainService = "com.cmuxterm.app.socket-control"
private static let legacyKeychainAccount = "local-socket-password"
private struct LazyKeychainFallbackCache {
var hasLoaded = false
var password: String?
}
private static let lazyKeychainFallbackLock = NSLock()
private static var lazyKeychainFallbackCache = LazyKeychainFallbackCache()
static func configuredPassword(
environment: [String: String] = ProcessInfo.processInfo.environment,
fileURL: URL? = nil,
allowLazyKeychainFallback: Bool = false,
loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() }
) -> String? {
if let envPassword = normalized(environment[SocketControlSettings.socketPasswordEnvKey]) {
return envPassword
}
let filePassword: String?
do {
filePassword = try loadPassword(fileURL: fileURL)
} catch {
filePassword = nil
}
if let filePassword {
return filePassword
}
guard allowLazyKeychainFallback else {
return nil
}
return cachedLazyKeychainFallbackPassword(loadKeychainPassword: loadKeychainPassword)
}
static func hasConfiguredPassword(
environment: [String: String] = ProcessInfo.processInfo.environment,
fileURL: URL? = nil,
allowLazyKeychainFallback: Bool = false,
loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() }
) -> Bool {
guard let configured = configuredPassword(
environment: environment,
fileURL: fileURL,
allowLazyKeychainFallback: allowLazyKeychainFallback,
loadKeychainPassword: loadKeychainPassword
) else { return false }
return !configured.isEmpty
}
static func verify(
password candidate: String,
environment: [String: String] = ProcessInfo.processInfo.environment,
fileURL: URL? = nil,
allowLazyKeychainFallback: Bool = false,
loadKeychainPassword: () -> String? = { loadLegacyPasswordFromKeychain() }
) -> Bool {
guard let expected = configuredPassword(
environment: environment,
fileURL: fileURL,
allowLazyKeychainFallback: allowLazyKeychainFallback,
loadKeychainPassword: loadKeychainPassword
), !expected.isEmpty else {
return false
}
return expected == candidate
}
static func migrateLegacyKeychainPasswordIfNeeded(
defaults: UserDefaults = .standard,
fileURL: URL? = nil,
loadLegacyPassword: () -> String? = { loadLegacyPasswordFromKeychain() },
deleteLegacyPassword: () -> Bool = { deleteLegacyPasswordFromKeychain() }
) {
guard defaults.integer(forKey: keychainMigrationDefaultsKey) < keychainMigrationVersion else {
return
}
guard let legacyPassword = normalized(loadLegacyPassword()) else {
defaults.set(keychainMigrationVersion, forKey: keychainMigrationDefaultsKey)
return
}
do {
if try loadPassword(fileURL: fileURL) == nil {
try savePassword(legacyPassword, fileURL: fileURL)
}
guard deleteLegacyPassword() else {
return
}
defaults.set(keychainMigrationVersion, forKey: keychainMigrationDefaultsKey)
} catch {
// Leave migration unset so it retries on next launch.
}
}
static func loadPassword(fileURL: URL? = nil) throws -> String? {
guard let fileURL = fileURL ?? defaultPasswordFileURL() else {
return nil
}
guard FileManager.default.fileExists(atPath: fileURL.path) else {
return nil
}
let data = try Data(contentsOf: fileURL)
guard let password = String(data: data, encoding: .utf8) else {
return nil
}
return normalized(password)
}
static func savePassword(_ password: String, fileURL: URL? = nil) throws {
let normalized = password.trimmingCharacters(in: .newlines)
if normalized.isEmpty {
try clearPassword(fileURL: fileURL)
return
}
guard let fileURL = fileURL ?? defaultPasswordFileURL() else {
throw NSError(
domain: NSCocoaErrorDomain,
code: NSFileNoSuchFileError,
userInfo: [NSLocalizedDescriptionKey: "Unable to resolve socket password file path."]
)
}
let directory = fileURL.deletingLastPathComponent()
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
let data = Data(normalized.utf8)
try data.write(to: fileURL, options: .atomic)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: fileURL.path)
}
static func clearPassword(fileURL: URL? = nil) throws {
guard let fileURL = fileURL ?? defaultPasswordFileURL() else {
return
}
guard FileManager.default.fileExists(atPath: fileURL.path) else {
return
}
try FileManager.default.removeItem(at: fileURL)
}
static func resetLazyKeychainFallbackCacheForTests() {
lazyKeychainFallbackLock.lock()
lazyKeychainFallbackCache = LazyKeychainFallbackCache()
lazyKeychainFallbackLock.unlock()
}
static func defaultPasswordFileURL(
appSupportDirectory: URL? = nil,
fileManager: FileManager = .default
) -> URL? {
let resolvedAppSupport: URL
if let appSupportDirectory {
resolvedAppSupport = appSupportDirectory
} else if let discovered = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
resolvedAppSupport = discovered
} else {
return nil
}
return resolvedAppSupport
.appendingPathComponent(directoryName, isDirectory: true)
.appendingPathComponent(fileName, isDirectory: false)
}
private static func loadLegacyPasswordFromKeychain() -> String? {
#if canImport(Security)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: legacyKeychainService,
kSecAttrAccount: legacyKeychainAccount,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
#else
return nil
#endif
}
private static func deleteLegacyPasswordFromKeychain() -> Bool {
#if canImport(Security)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: legacyKeychainService,
kSecAttrAccount: legacyKeychainAccount,
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
#else
return false
#endif
}
private static func cachedLazyKeychainFallbackPassword(
loadKeychainPassword: () -> String?
) -> String? {
lazyKeychainFallbackLock.lock()
defer { lazyKeychainFallbackLock.unlock() }
if lazyKeychainFallbackCache.hasLoaded {
return lazyKeychainFallbackCache.password
}
lazyKeychainFallbackCache.hasLoaded = true
lazyKeychainFallbackCache.password = normalized(loadKeychainPassword())
return lazyKeychainFallbackCache.password
}
private static func normalized(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .newlines)
return trimmed.isEmpty ? nil : trimmed
}
}
struct SocketControlSettings {
static let appStorageKey = "socketControlMode"
static let legacyEnabledKey = "socketControlEnabled"
static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE"
static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD"
static let launchTagEnvKey = "CMUX_TAG"
static let baseDebugBundleIdentifier = "com.cmuxterm.app.debug"
/// Map old persisted rawValues to the new enum.
static func migrateMode(_ raw: String) -> SocketControlMode {
switch raw {
case "off": return .off
case "cmuxOnly": return .cmuxOnly
case "allowAll": return .allowAll
// Legacy values:
case "notifications", "full": return .cmuxOnly
default: return defaultMode
private static func normalizeMode(_ raw: String) -> String {
raw
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.replacingOccurrences(of: "_", with: "")
.replacingOccurrences(of: "-", with: "")
}
private static func parseMode(_ raw: String) -> SocketControlMode? {
switch normalizeMode(raw) {
case "off":
return .off
case "cmuxonly":
return .cmuxOnly
case "automation":
return .automation
case "password":
return .password
case "allowall", "openaccess", "fullopenaccess":
return .allowAll
// Legacy values from the old socket mode model.
case "notifications":
return .automation
case "full":
return .allowAll
default:
return nil
}
}
/// Map persisted values to the current enum values.
static func migrateMode(_ raw: String) -> SocketControlMode {
parseMode(raw) ?? defaultMode
}
static var defaultMode: SocketControlMode {
return .cmuxOnly
}
static func socketPath() -> String {
if let override = ProcessInfo.processInfo.environment["CMUX_SOCKET_PATH"], !override.isEmpty {
return override
}
private static var isDebugBuild: Bool {
#if DEBUG
return "/tmp/cmux-debug.sock"
true
#else
return "/tmp/cmux.sock"
false
#endif
}
static func envOverrideEnabled() -> Bool? {
guard let raw = ProcessInfo.processInfo.environment["CMUX_SOCKET_ENABLE"], !raw.isEmpty else {
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
}
// XCUITest launches the app as a separate process without XCTest env vars,
// so isRunningUnderXCTest() misses it. Check for any CMUX_UI_TEST_ env var.
if environment.keys.contains(where: { $0.hasPrefix("CMUX_UI_TEST_") }) {
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",
"XCInjectBundle",
"XCInjectBundleInto",
]
if indicators.contains(where: { key in
guard let value = environment[key] else { return false }
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}) {
return true
}
if environment["DYLD_INSERT_LIBRARIES"]?.contains("libXCTest") == true {
return true
}
return false
}
static func socketPath(
environment: [String: String] = ProcessInfo.processInfo.environment,
bundleIdentifier: String? = Bundle.main.bundleIdentifier,
isDebugBuild: Bool = SocketControlSettings.isDebugBuild
) -> String {
let fallback = defaultSocketPath(bundleIdentifier: bundleIdentifier, isDebugBuild: isDebugBuild)
guard let override = environment["CMUX_SOCKET_PATH"], !override.isEmpty else {
return fallback
}
if shouldHonorSocketPathOverride(
environment: environment,
bundleIdentifier: bundleIdentifier,
isDebugBuild: isDebugBuild
) {
return override
}
return fallback
}
static func defaultSocketPath(bundleIdentifier: String?, isDebugBuild: Bool) -> String {
if bundleIdentifier == "com.cmuxterm.app.nightly" {
return "/tmp/cmux-nightly.sock"
}
if isDebugLikeBundleIdentifier(bundleIdentifier) || isDebugBuild {
return "/tmp/cmux-debug.sock"
}
if isStagingBundleIdentifier(bundleIdentifier) {
return "/tmp/cmux-staging.sock"
}
return "/tmp/cmux.sock"
}
static func shouldHonorSocketPathOverride(
environment: [String: String],
bundleIdentifier: String?,
isDebugBuild: Bool
) -> Bool {
if isTruthy(environment[allowSocketPathOverrideKey]) {
return true
}
if isDebugLikeBundleIdentifier(bundleIdentifier) || isStagingBundleIdentifier(bundleIdentifier) {
return true
}
return isDebugBuild
}
static func isDebugLikeBundleIdentifier(_ bundleIdentifier: String?) -> Bool {
guard let bundleIdentifier else { return false }
return bundleIdentifier == "com.cmuxterm.app.debug"
|| bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.")
}
static func isStagingBundleIdentifier(_ bundleIdentifier: String?) -> Bool {
guard let bundleIdentifier else { return false }
return bundleIdentifier == "com.cmuxterm.app.staging"
|| bundleIdentifier.hasPrefix("com.cmuxterm.app.staging.")
}
static func isTruthy(_ raw: String?) -> Bool {
guard let raw else { return false }
switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
static func envOverrideEnabled(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> Bool? {
guard let raw = environment["CMUX_SOCKET_ENABLE"], !raw.isEmpty else {
return nil
}
@ -81,33 +487,30 @@ struct SocketControlSettings {
}
}
static func envOverrideMode() -> SocketControlMode? {
guard let raw = ProcessInfo.processInfo.environment["CMUX_SOCKET_MODE"], !raw.isEmpty else {
static func envOverrideMode(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> SocketControlMode? {
guard let raw = environment["CMUX_SOCKET_MODE"], !raw.isEmpty else {
return nil
}
let cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
switch cleaned {
case "off": return .off
case "cmuxonly", "cmux_only", "cmux-only": return .cmuxOnly
case "allowall", "allow_all", "allow-all": return .allowAll
// Legacy env var values map to allowAll so existing test scripts keep working
case "notifications", "full": return .allowAll
default: return SocketControlMode(rawValue: cleaned)
}
return parseMode(raw)
}
static func effectiveMode(userMode: SocketControlMode) -> SocketControlMode {
if let overrideEnabled = envOverrideEnabled() {
static func effectiveMode(
userMode: SocketControlMode,
environment: [String: String] = ProcessInfo.processInfo.environment
) -> SocketControlMode {
if let overrideEnabled = envOverrideEnabled(environment: environment) {
if !overrideEnabled {
return .off
}
if let overrideMode = envOverrideMode() {
if let overrideMode = envOverrideMode(environment: environment) {
return overrideMode
}
return userMode == .off ? .cmuxOnly : userMode
}
if let overrideMode = envOverrideMode() {
if let overrideMode = envOverrideMode(environment: environment) {
return overrideMode
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -71,6 +71,19 @@ struct TerminalNotification: Identifiable, Hashable {
@MainActor
final class TerminalNotificationStore: ObservableObject {
private struct TabSurfaceKey: Hashable {
let tabId: UUID
let surfaceId: UUID?
}
private struct NotificationIndexes {
var unreadCount = 0
var unreadCountByTabId: [UUID: Int] = [:]
var unreadByTabSurface = Set<TabSurfaceKey>()
var latestUnreadByTabId: [UUID: TerminalNotification] = [:]
var latestByTabId: [UUID: TerminalNotification] = [:]
}
static let shared = TerminalNotificationStore()
static let categoryIdentifier = "com.cmuxterm.app.userNotification"
@ -78,6 +91,7 @@ final class TerminalNotificationStore: ObservableObject {
@Published private(set) var notifications: [TerminalNotification] = [] {
didSet {
indexes = Self.buildIndexes(for: notifications)
refreshDockBadge()
}
}
@ -86,8 +100,28 @@ final class TerminalNotificationStore: ObservableObject {
private var hasRequestedAuthorization = false
private var hasPromptedForSettings = false
private var userDefaultsObserver: NSObjectProtocol?
private let settingsPromptWindowRetryDelay: TimeInterval = 0.5
private let settingsPromptWindowRetryLimit = 20
private var notificationSettingsWindowProvider: () -> NSWindow? = {
NSApp.keyWindow ?? NSApp.mainWindow
}
private var notificationSettingsAlertFactory: () -> NSAlert = {
NSAlert()
}
private var notificationSettingsScheduler: (_ delay: TimeInterval, _ block: @escaping () -> Void) -> Void = {
delay,
block in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
block()
}
}
private var notificationSettingsURLOpener: (URL) -> Void = { url in
NSWorkspace.shared.open(url)
}
private var indexes = NotificationIndexes()
private init() {
indexes = Self.buildIndexes(for: notifications)
userDefaultsObserver = NotificationCenter.default.addObserver(
forName: UserDefaults.didChangeNotification,
object: nil,
@ -124,26 +158,29 @@ final class TerminalNotificationStore: ObservableObject {
}
var unreadCount: Int {
notifications.filter { !$0.isRead }.count
indexes.unreadCount
}
func unreadCount(forTabId tabId: UUID) -> Int {
notifications.filter { $0.tabId == tabId && !$0.isRead }.count
indexes.unreadCountByTabId[tabId] ?? 0
}
func hasUnreadNotification(forTabId tabId: UUID, surfaceId: UUID?) -> Bool {
notifications.contains { $0.tabId == tabId && $0.surfaceId == surfaceId && !$0.isRead }
indexes.unreadByTabSurface.contains(TabSurfaceKey(tabId: tabId, surfaceId: surfaceId))
}
func latestNotification(forTabId tabId: UUID) -> TerminalNotification? {
if let unread = notifications.first(where: { $0.tabId == tabId && !$0.isRead }) {
return unread
}
return notifications.first(where: { $0.tabId == tabId })
indexes.latestUnreadByTabId[tabId] ?? indexes.latestByTabId[tabId]
}
func addNotification(tabId: UUID, surfaceId: UUID?, title: String, subtitle: String, body: String) {
clearNotifications(forTabId: tabId, surfaceId: surfaceId)
var updated = notifications
var idsToClear: [String] = []
updated.removeAll { existing in
guard existing.tabId == tabId, existing.surfaceId == surfaceId else { return false }
idsToClear.append(existing.id.uuidString)
return true
}
let isActiveTab = AppDelegate.shared?.tabManager?.selectedTabId == tabId
let focusedSurfaceId = AppDelegate.shared?.tabManager?.focusedSurfaceId(for: tabId)
@ -151,6 +188,11 @@ final class TerminalNotificationStore: ObservableObject {
let isFocusedPanel = isActiveTab && isFocusedSurface
let isAppFocused = AppFocusState.isAppFocused()
if isAppFocused && isFocusedPanel {
if !idsToClear.isEmpty {
notifications = updated
center.removeDeliveredNotifications(withIdentifiers: idsToClear)
center.removePendingNotificationRequests(withIdentifiers: idsToClear)
}
return
}
@ -168,101 +210,136 @@ final class TerminalNotificationStore: ObservableObject {
createdAt: Date(),
isRead: false
)
notifications.insert(notification, at: 0)
updated.insert(notification, at: 0)
notifications = updated
if !idsToClear.isEmpty {
center.removeDeliveredNotifications(withIdentifiers: idsToClear)
center.removePendingNotificationRequests(withIdentifiers: idsToClear)
}
scheduleUserNotification(notification)
}
func markRead(id: UUID) {
guard let index = notifications.firstIndex(where: { $0.id == id }) else { return }
if notifications[index].isRead { return }
notifications[index].isRead = true
var updated = notifications
guard let index = updated.firstIndex(where: { $0.id == id }) else { return }
guard !updated[index].isRead else { return }
updated[index].isRead = true
notifications = updated
center.removeDeliveredNotifications(withIdentifiers: [id.uuidString])
}
func markRead(forTabId tabId: UUID) {
var updated = notifications
var idsToClear: [String] = []
for index in notifications.indices {
if notifications[index].tabId == tabId && !notifications[index].isRead {
notifications[index].isRead = true
idsToClear.append(notifications[index].id.uuidString)
for index in updated.indices {
if updated[index].tabId == tabId && !updated[index].isRead {
updated[index].isRead = true
idsToClear.append(updated[index].id.uuidString)
}
}
if !idsToClear.isEmpty {
notifications = updated
center.removeDeliveredNotifications(withIdentifiers: idsToClear)
}
}
func markRead(forTabId tabId: UUID, surfaceId: UUID?) {
var updated = notifications
var idsToClear: [String] = []
for index in notifications.indices {
if notifications[index].tabId == tabId,
notifications[index].surfaceId == surfaceId,
!notifications[index].isRead {
notifications[index].isRead = true
idsToClear.append(notifications[index].id.uuidString)
for index in updated.indices {
if updated[index].tabId == tabId,
updated[index].surfaceId == surfaceId,
!updated[index].isRead {
updated[index].isRead = true
idsToClear.append(updated[index].id.uuidString)
}
}
if !idsToClear.isEmpty {
notifications = updated
center.removeDeliveredNotifications(withIdentifiers: idsToClear)
center.removePendingNotificationRequests(withIdentifiers: idsToClear)
}
}
func markUnread(forTabId tabId: UUID) {
for index in notifications.indices {
if notifications[index].tabId == tabId {
notifications[index].isRead = false
var updated = notifications
var didChange = false
for index in updated.indices {
if updated[index].tabId == tabId, updated[index].isRead {
updated[index].isRead = false
didChange = true
}
}
if didChange {
notifications = updated
}
}
func markAllRead() {
var updated = notifications
var idsToClear: [String] = []
for index in notifications.indices {
if !notifications[index].isRead {
notifications[index].isRead = true
idsToClear.append(notifications[index].id.uuidString)
for index in updated.indices {
if !updated[index].isRead {
updated[index].isRead = true
idsToClear.append(updated[index].id.uuidString)
}
}
if !idsToClear.isEmpty {
notifications = updated
center.removeDeliveredNotifications(withIdentifiers: idsToClear)
center.removePendingNotificationRequests(withIdentifiers: idsToClear)
}
}
func remove(id: UUID) {
notifications.removeAll { $0.id == id }
var updated = notifications
let originalCount = updated.count
updated.removeAll { $0.id == id }
guard updated.count != originalCount else { return }
notifications = updated
center.removeDeliveredNotifications(withIdentifiers: [id.uuidString])
}
func clearAll() {
guard !notifications.isEmpty else { return }
let ids = notifications.map { $0.id.uuidString }
notifications.removeAll()
if !ids.isEmpty {
center.removeDeliveredNotifications(withIdentifiers: ids)
}
center.removeDeliveredNotifications(withIdentifiers: ids)
center.removePendingNotificationRequests(withIdentifiers: ids)
}
func clearNotifications(forTabId tabId: UUID, surfaceId: UUID?) {
let ids = notifications
.filter { $0.tabId == tabId && $0.surfaceId == surfaceId }
.map { $0.id.uuidString }
notifications.removeAll { $0.tabId == tabId && $0.surfaceId == surfaceId }
if !ids.isEmpty {
center.removeDeliveredNotifications(withIdentifiers: ids)
center.removePendingNotificationRequests(withIdentifiers: ids)
var updated: [TerminalNotification] = []
updated.reserveCapacity(notifications.count)
var idsToClear: [String] = []
for notification in notifications {
if notification.tabId == tabId, notification.surfaceId == surfaceId {
idsToClear.append(notification.id.uuidString)
} else {
updated.append(notification)
}
}
guard !idsToClear.isEmpty else { return }
notifications = updated
center.removeDeliveredNotifications(withIdentifiers: idsToClear)
center.removePendingNotificationRequests(withIdentifiers: idsToClear)
}
func clearNotifications(forTabId tabId: UUID) {
let ids = notifications
.filter { $0.tabId == tabId }
.map { $0.id.uuidString }
notifications.removeAll { $0.tabId == tabId }
if !ids.isEmpty {
center.removeDeliveredNotifications(withIdentifiers: ids)
center.removePendingNotificationRequests(withIdentifiers: ids)
var updated: [TerminalNotification] = []
updated.reserveCapacity(notifications.count)
var idsToClear: [String] = []
for notification in notifications {
if notification.tabId == tabId {
idsToClear.append(notification.id.uuidString)
} else {
updated.append(notification)
}
}
guard !idsToClear.isEmpty else { return }
notifications = updated
center.removeDeliveredNotifications(withIdentifiers: idsToClear)
center.removePendingNotificationRequests(withIdentifiers: idsToClear)
}
private func scheduleUserNotification(_ notification: TerminalNotification) {
@ -336,20 +413,94 @@ final class TerminalNotificationStore: ObservableObject {
DispatchQueue.main.async { [weak self] in
guard let self, !self.hasPromptedForSettings else { return }
self.hasPromptedForSettings = true
let alert = NSAlert()
alert.messageText = "Enable Notifications for cmux"
alert.informativeText = "Notifications are disabled for cmux. Enable them in System Settings to see alerts."
alert.addButton(withTitle: "Open Settings")
alert.addButton(withTitle: "Not Now")
let response = alert.runModal()
guard response == .alertFirstButtonReturn else { return }
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") {
NSWorkspace.shared.open(url)
}
self.presentNotificationSettingsPrompt(attempt: 0)
}
}
private func presentNotificationSettingsPrompt(attempt: Int) {
guard let window = notificationSettingsWindowProvider() else {
guard attempt < settingsPromptWindowRetryLimit else {
// If no window is available after retries, allow a future denied callback
// to prompt again when the app has a key/main window.
hasPromptedForSettings = false
return
}
notificationSettingsScheduler(settingsPromptWindowRetryDelay) { [weak self] in
self?.presentNotificationSettingsPrompt(attempt: attempt + 1)
}
return
}
let alert = notificationSettingsAlertFactory()
alert.messageText = "Enable Notifications for cmux"
alert.informativeText = "Notifications are disabled for cmux. Enable them in System Settings to see alerts."
alert.addButton(withTitle: "Open Settings")
alert.addButton(withTitle: "Not Now")
alert.beginSheetModal(for: window) { [weak self] response in
guard response == .alertFirstButtonReturn,
let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") else {
return
}
self?.notificationSettingsURLOpener(url)
}
}
private static func buildIndexes(for notifications: [TerminalNotification]) -> NotificationIndexes {
var indexes = NotificationIndexes()
for notification in notifications {
if indexes.latestByTabId[notification.tabId] == nil {
indexes.latestByTabId[notification.tabId] = notification
}
guard !notification.isRead else { continue }
indexes.unreadCount += 1
indexes.unreadCountByTabId[notification.tabId, default: 0] += 1
indexes.unreadByTabSurface.insert(
TabSurfaceKey(tabId: notification.tabId, surfaceId: notification.surfaceId)
)
if indexes.latestUnreadByTabId[notification.tabId] == nil {
indexes.latestUnreadByTabId[notification.tabId] = notification
}
}
return indexes
}
#if DEBUG
func configureNotificationSettingsPromptHooksForTesting(
windowProvider: @escaping () -> NSWindow?,
alertFactory: @escaping () -> NSAlert,
scheduler: @escaping (_ delay: TimeInterval, _ block: @escaping () -> Void) -> Void,
urlOpener: @escaping (URL) -> Void
) {
notificationSettingsWindowProvider = windowProvider
notificationSettingsAlertFactory = alertFactory
notificationSettingsScheduler = scheduler
notificationSettingsURLOpener = urlOpener
hasPromptedForSettings = false
}
func resetNotificationSettingsPromptHooksForTesting() {
notificationSettingsWindowProvider = { NSApp.keyWindow ?? NSApp.mainWindow }
notificationSettingsAlertFactory = { NSAlert() }
notificationSettingsScheduler = { delay, block in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
block()
}
}
notificationSettingsURLOpener = { url in
NSWorkspace.shared.open(url)
}
hasPromptedForSettings = false
}
func promptToEnableNotificationsForTesting() {
promptToEnableNotifications()
}
func replaceNotificationsForTesting(_ notifications: [TerminalNotification]) {
self.notifications = notifications
}
#endif
private func refreshDockBadge() {
let label = Self.dockBadgeLabel(
unreadCount: unreadCount,

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,8 @@ class UpdateController {
private(set) var updater: SPUUpdater
private let userDriver: UpdateDriver
private var installCancellable: AnyCancellable?
private var attemptInstallCancellable: AnyCancellable?
private var didObserveAttemptUpdateProgress: Bool = false
private var noUpdateDismissCancellable: AnyCancellable?
private var noUpdateDismissWorkItem: DispatchWorkItem?
private var readyCheckWorkItem: DispatchWorkItem?
@ -46,6 +48,7 @@ class UpdateController {
deinit {
installCancellable?.cancel()
attemptInstallCancellable?.cancel()
noUpdateDismissCancellable?.cancel()
noUpdateDismissWorkItem?.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).
@objc func checkForUpdates() {
UpdateLogStore.shared.append("checkForUpdates invoked (state=\(viewModel.state.isIdle ? "idle" : "busy"))")
@ -175,6 +207,12 @@ class UpdateController {
return true
}
private func stopAttemptUpdateMonitoring() {
attemptInstallCancellable?.cancel()
attemptInstallCancellable = nil
didObserveAttemptUpdateProgress = false
}
private func installNoUpdateDismissObserver() {
noUpdateDismissCancellable = Publishers.CombineLatest(viewModel.$state, viewModel.$overrideState)
.receive(on: DispatchQueue.main)

View file

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

View file

@ -200,7 +200,7 @@ struct TitlebarControlButton<Content: View>: View {
@State private var isHovering = false
var body: some View {
Button(action: action) {
let baseButton = Button(action: action) {
content()
.frame(width: config.buttonSize, height: config.buttonSize)
.contentShape(Rectangle())
@ -209,7 +209,12 @@ struct TitlebarControlButton<Content: View>: View {
.frame(width: config.buttonSize, height: config.buttonSize)
.contentShape(Rectangle())
.background(hoverBackground)
.onHover { isHovering = $0 }
if titlebarControlsShouldTrackButtonHover(config: config) {
baseButton.onHover { isHovering = $0 }
} else {
baseButton
}
}
@ViewBuilder
@ -333,7 +338,7 @@ struct TitlebarControlsView: View {
.foregroundColor(.white)
.frame(width: config.badgeSize, height: config.badgeSize)
.background(
Circle().fill(Color.accentColor)
Circle().fill(cmuxAccentColor())
)
.offset(x: config.badgeOffset.width, y: config.badgeOffset.height)
}
@ -341,7 +346,7 @@ struct TitlebarControlsView: View {
.frame(width: config.buttonSize, height: config.buttonSize)
}
.accessibilityIdentifier("titlebarControl.showNotifications")
.overlay(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 }.allowsHitTesting(false))
.background(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 })
.accessibilityLabel("Notifications")
.help(KeyboardShortcutSettings.Action.showNotifications.tooltip("Show notifications"))
@ -657,12 +662,49 @@ private final class TitlebarCommandKeyMonitor: ObservableObject {
}
}
struct TitlebarControlsLayoutSnapshot: Equatable {
let contentSize: NSSize
let containerHeight: CGFloat
let yOffset: CGFloat
}
func titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyleConfig) -> Bool {
config.hoverBackground
}
func titlebarControlsShouldScheduleForViewSizeChange(
previous: NSSize,
current: NSSize,
tolerance: CGFloat = 0.5
) -> Bool {
guard current.width > 0, current.height > 0 else { return false }
guard previous.width > 0, previous.height > 0 else { return true }
return abs(previous.width - current.width) > tolerance
|| abs(previous.height - current.height) > tolerance
}
func titlebarControlsShouldApplyLayout(
previous: TitlebarControlsLayoutSnapshot?,
next: TitlebarControlsLayoutSnapshot,
tolerance: CGFloat = 0.5
) -> Bool {
guard let previous else { return true }
return abs(previous.contentSize.width - next.contentSize.width) > tolerance
|| abs(previous.contentSize.height - next.contentSize.height) > tolerance
|| abs(previous.containerHeight - next.containerHeight) > tolerance
|| abs(previous.yOffset - next.yOffset) > tolerance
}
final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewController, NSPopoverDelegate {
private let hostingView: NonDraggableHostingView<TitlebarControlsView>
private let containerView = NSView()
private let notificationStore: TerminalNotificationStore
private lazy var notificationsPopover: NSPopover = makeNotificationsPopover()
private var pendingSizeUpdate = false
private var fittingSizeNeedsRefresh = true
private var cachedFittingSize: NSSize?
private var lastObservedViewSize: NSSize = .zero
private var lastAppliedLayoutSnapshot: TitlebarControlsLayoutSnapshot?
private let viewModel = TitlebarControlsViewModel()
private var userDefaultsObserver: NSObjectProtocol?
var popoverIsShownForTesting: Bool { notificationsPopover.isShown }
@ -696,10 +738,10 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
object: nil,
queue: .main
) { [weak self] _ in
self?.scheduleSizeUpdate()
self?.scheduleSizeUpdate(invalidateFittingSize: true)
}
scheduleSizeUpdate()
scheduleSizeUpdate(invalidateFittingSize: true)
}
required init?(coder: NSCoder) {
@ -714,15 +756,26 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
override func viewDidAppear() {
super.viewDidAppear()
scheduleSizeUpdate()
scheduleSizeUpdate(invalidateFittingSize: true)
}
override func viewDidLayout() {
super.viewDidLayout()
scheduleSizeUpdate()
let currentViewSize = view.bounds.size
guard titlebarControlsShouldScheduleForViewSizeChange(
previous: lastObservedViewSize,
current: currentViewSize
) else {
return
}
lastObservedViewSize = currentViewSize
scheduleSizeUpdate(invalidateFittingSize: true)
}
private func scheduleSizeUpdate() {
private func scheduleSizeUpdate(invalidateFittingSize: Bool = false) {
if invalidateFittingSize {
fittingSizeNeedsRefresh = true
}
guard !pendingSizeUpdate else { return }
pendingSizeUpdate = true
DispatchQueue.main.async { [weak self] in
@ -732,14 +785,33 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
}
private func updateSize() {
hostingView.invalidateIntrinsicContentSize()
hostingView.layoutSubtreeIfNeeded()
let contentSize = hostingView.fittingSize
let contentSize: NSSize
if fittingSizeNeedsRefresh || cachedFittingSize == nil {
hostingView.invalidateIntrinsicContentSize()
hostingView.layoutSubtreeIfNeeded()
cachedFittingSize = hostingView.fittingSize
fittingSizeNeedsRefresh = false
}
contentSize = cachedFittingSize ?? .zero
guard contentSize.width > 0, contentSize.height > 0 else { return }
let titlebarHeight = view.window.map { window in
window.frame.height - window.contentLayoutRect.height
} ?? contentSize.height
let containerHeight = max(contentSize.height, titlebarHeight)
let yOffset = max(0, (containerHeight - contentSize.height) / 2.0)
let nextLayoutSnapshot = TitlebarControlsLayoutSnapshot(
contentSize: contentSize,
containerHeight: containerHeight,
yOffset: yOffset
)
guard titlebarControlsShouldApplyLayout(
previous: lastAppliedLayoutSnapshot,
next: nextLayoutSnapshot
) else {
return
}
lastAppliedLayoutSnapshot = nextLayoutSnapshot
preferredContentSize = NSSize(width: contentSize.width, height: containerHeight)
containerView.frame = NSRect(x: 0, y: 0, width: contentSize.width, height: containerHeight)
hostingView.frame = NSRect(x: 0, y: yOffset, width: contentSize.width, height: contentSize.height)
@ -905,11 +977,11 @@ private struct NotificationPopoverRow: View {
Button(action: onOpen) {
HStack(alignment: .top, spacing: 10) {
Circle()
.fill(notification.isRead ? Color.clear : Color.accentColor)
.fill(notification.isRead ? Color.clear : cmuxAccentColor())
.frame(width: 8, height: 8)
.overlay(
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)

View file

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

View file

@ -1,6 +1,347 @@
import AppKit
import Bonsplit
import SwiftUI
private func windowDragHandleFormatPoint(_ point: NSPoint) -> String {
String(format: "(%.1f,%.1f)", point.x, point.y)
}
private func windowDragHandleEventTypeDescription(_ eventType: NSEvent.EventType?) -> String {
eventType.map { String(describing: $0) } ?? "nil"
}
private enum WindowDragHandleBreadcrumbLimiter {
private static let lock = NSLock()
private static var lastEmissionByKey: [String: CFAbsoluteTime] = [:]
static func shouldEmit(key: String, minInterval: CFTimeInterval) -> Bool {
lock.lock()
defer { lock.unlock() }
let now = CFAbsoluteTimeGetCurrent()
if let previous = lastEmissionByKey[key], (now - previous) < minInterval {
return false
}
lastEmissionByKey[key] = now
if lastEmissionByKey.count > 128 {
let staleThreshold = now - max(minInterval * 4, 60)
lastEmissionByKey = lastEmissionByKey.filter { _, timestamp in
timestamp >= staleThreshold
}
}
return true
}
}
private func windowDragHandleEmitBreadcrumb(
_ message: String,
window: NSWindow?,
eventType: NSEvent.EventType?,
point: NSPoint,
minInterval: CFTimeInterval = 10,
extraData: [String: Any] = [:]
) {
let windowNumber = window?.windowNumber ?? -1
let key = "\(message):\(windowNumber)"
guard WindowDragHandleBreadcrumbLimiter.shouldEmit(key: key, minInterval: minInterval) else {
return
}
var data: [String: Any] = [
"event_type": windowDragHandleEventTypeDescription(eventType),
"point": windowDragHandleFormatPoint(point),
"window_number": windowNumber,
"window_present": window != nil
]
for (name, value) in extraData {
data[name] = value
}
sentryBreadcrumb(message, category: "titlebar.drag", data: data)
}
private func windowDragHandleShouldResolveActiveHitCapture(
for eventType: NSEvent.EventType?,
eventWindow: NSWindow?,
dragHandleWindow: NSWindow?
) -> Bool {
// We only need active hit resolution for titlebar mouse-down handling.
// During launch, NSApp.currentEvent can transiently point at a stale
// leftMouseDown from outside this window (for example Finder/Dock
// activation). Treat those as passive events so we never walk SwiftUI/
// AppKit hierarchy while initial layout is mutating it.
guard eventType == .leftMouseDown else {
return false
}
guard let dragHandleWindow else {
// Test-only views may not be attached to a window.
return true
}
guard let eventWindow else {
return false
}
return eventWindow === dragHandleWindow
}
/// 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 enum WindowDragHandleAssociatedObjectKeys {
private static let suppressionDepthToken = NSObject()
static let suppressionDepth = UnsafeRawPointer(Unmanaged.passUnretained(suppressionDepthToken).toOpaque())
}
func beginWindowDragSuppression(window: NSWindow?) -> Int? {
guard let window else { return nil }
let current = windowDragSuppressionDepth(window: window)
let next = current + 1
objc_setAssociatedObject(
window,
WindowDragHandleAssociatedObjectKeys.suppressionDepth,
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,
WindowDragHandleAssociatedObjectKeys.suppressionDepth,
nil,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
} else {
objc_setAssociatedObject(
window,
WindowDragHandleAssociatedObjectKeys.suppressionDepth,
NSNumber(value: next),
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
return next
}
func windowDragSuppressionDepth(window: NSWindow?) -> Int {
guard let window,
let value = objc_getAssociatedObject(window, WindowDragHandleAssociatedObjectKeys.suppressionDepth) 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
}
/// 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,
eventType: NSEvent.EventType? = NSApp.currentEvent?.type,
eventWindow: NSWindow? = NSApp.currentEvent?.window
) -> Bool {
let dragHandleWindow = dragHandleView.window
// Suppression recovery runs first so stale depth is cleared even for
// passive events the associated-object reads/writes here are pure ObjC
// runtime calls and cannot trigger Swift exclusive-access violations.
if isWindowDragSuppressed(window: dragHandleWindow) {
// 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: dragHandleWindow)
windowDragHandleEmitBreadcrumb(
"titlebar.dragHandle.suppression.recovered",
window: dragHandleWindow,
eventType: eventType,
point: point,
minInterval: 20,
extraData: [
"cleared_depth": clearedDepth
]
)
#if DEBUG
dlog(
"titlebar.dragHandle.hitTest suppressionRecovered clearedDepth=\(clearedDepth) point=\(windowDragHandleFormatPoint(point))"
)
#endif
} else {
#if DEBUG
let depth = windowDragSuppressionDepth(window: dragHandleWindow)
dlog(
"titlebar.dragHandle.hitTest capture=false reason=suppressed depth=\(depth) point=\(windowDragHandleFormatPoint(point))"
)
#endif
return false
}
}
// Bail out before the view-hierarchy walk so we never re-enter SwiftUI
// views during a layout pass which causes exclusive-access crashes (#490).
if !windowDragHandleShouldResolveActiveHitCapture(
for: eventType,
eventWindow: eventWindow,
dragHandleWindow: dragHandleWindow
) {
#if DEBUG
let eventTypeDescription = eventType.map { String(describing: $0) } ?? "nil"
let eventWindowNumber = eventWindow?.windowNumber ?? -1
let dragWindowNumber = dragHandleWindow?.windowNumber ?? -1
dlog(
"titlebar.dragHandle.hitTest capture=false reason=passiveEvent eventType=\(eventTypeDescription) eventWindow=\(eventWindowNumber) dragWindow=\(dragWindowNumber) 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
}
let siblingSnapshot = Array(superview.subviews.reversed())
#if DEBUG
let siblingCount = siblingSnapshot.count
#endif
for sibling in siblingSnapshot {
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
windowDragHandleEmitBreadcrumb(
"titlebar.dragHandle.hitTest.blockedBySiblingHit",
window: dragHandleWindow,
eventType: eventType,
point: point,
minInterval: 8,
extraData: [
"sibling_type": String(describing: type(of: sibling)),
"hit_type": String(describing: type(of: hitView))
]
)
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.
/// This lets us keep `window.isMovableByWindowBackground = false` so drags in the app content
/// (e.g. sidebar tab reordering) don't move the whole window.
@ -14,8 +355,61 @@ struct WindowDragHandleView: NSViewRepresentable {
}
private final class DraggableView: NSView {
override var mouseDownCanMoveWindow: Bool { true }
override func hitTest(_ point: NSPoint) -> NSView? { self }
override var mouseDownCanMoveWindow: Bool { false }
override func hitTest(_ point: NSPoint) -> NSView? {
let currentEvent = NSApp.currentEvent
let shouldCapture = windowDragHandleShouldCaptureHit(
point,
in: self,
eventType: currentEvent?.type,
eventWindow: currentEvent?.window
)
#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 isWorkspaceInputActive: Bool
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
@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 {
let appearance = PanelAppearance.fromConfig(config)
let isSplit = workspace.bonsplitController.allPaneIds.count > 1 ||
@ -41,8 +58,15 @@ struct WorkspaceContentView: View {
if let panel = workspace.panel(for: tab.id) {
let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
let isVisibleInUI = isWorkspaceVisible && isSelectedInPane
let hasUnreadNotification = notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id)
let isVisibleInUI = Self.panelVisibleInUI(
isWorkspaceVisible: isWorkspaceVisible,
isSelectedInPane: isSelectedInPane,
isFocused: isFocused
)
let hasUnreadNotification = Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id),
isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id)
)
PanelContentView(
panel: panel,
isFocused: isFocused,
@ -58,7 +82,7 @@ struct WorkspaceContentView: View {
// indicator and where keyboard input/flash-focus actually lands.
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id)
workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)
},
onRequestPanelFocus: {
guard isWorkspaceInputActive else { return }
@ -84,7 +108,7 @@ struct WorkspaceContentView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
syncBonsplitNotificationBadges()
workspace.applyGhosttyChrome(backgroundColor: GhosttyApp.shared.defaultBackgroundColor)
refreshGhosttyAppearanceConfig(reason: "onAppear")
}
.onChange(of: notificationStore.notifications) { _, _ in
syncBonsplitNotificationBadges()
@ -93,18 +117,29 @@ struct WorkspaceContentView: View {
syncBonsplitNotificationBadges()
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in
refreshGhosttyAppearanceConfig()
GhosttyConfig.invalidateLoadCache()
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.
refreshGhosttyAppearanceConfig()
refreshGhosttyAppearanceConfig(reason: "colorSchemeChanged:\(oldValue)->\(newValue)")
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { notification in
if let backgroundColor = notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor {
workspace.applyGhosttyChrome(backgroundColor: backgroundColor)
} else {
workspace.applyGhosttyChrome(backgroundColor: GhosttyApp.shared.defaultBackgroundColor)
}
let payloadHex = (notification.userInfo?[GhosttyNotificationKey.backgroundColor] as? NSColor)?.hexString() ?? "nil"
let eventId = (notification.userInfo?[GhosttyNotificationKey.backgroundEventId] as? NSNumber)?.uint64Value
let source = (notification.userInfo?[GhosttyNotificationKey.backgroundSource] as? String) ?? "nil"
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
)
}
}
@ -138,10 +173,95 @@ struct WorkspaceContentView: View {
}
}
private func refreshGhosttyAppearanceConfig() {
let next = GhosttyConfig.load()
config = next
workspace.applyGhosttyChrome(from: next)
static func resolveGhosttyAppearanceConfig(
reason: String = "unspecified",
backgroundOverride: NSColor? = nil,
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)
}
}
@ -174,6 +294,8 @@ extension WorkspaceContentView {
struct EmptyPanelView: View {
@ObservedObject var workspace: Workspace
let paneId: PaneID
@AppStorage(KeyboardShortcutSettings.Action.newSurface.defaultsKey) private var newSurfaceShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.openBrowser.defaultsKey) private var openBrowserShortcutData = Data()
private struct ShortcutHint: View {
let text: String
@ -208,6 +330,49 @@ struct EmptyPanelView: View {
_ = workspace.newBrowserSurface(inPane: paneId)
}
private var newSurfaceShortcut: StoredShortcut {
decodeShortcut(from: newSurfaceShortcutData, fallback: KeyboardShortcutSettings.Action.newSurface.defaultShortcut)
}
private var openBrowserShortcut: StoredShortcut {
decodeShortcut(from: openBrowserShortcutData, fallback: KeyboardShortcutSettings.Action.openBrowser.defaultShortcut)
}
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
guard !data.isEmpty,
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
return fallback
}
return shortcut
}
@ViewBuilder
private func emptyPaneActionButton(
title: String,
systemImage: String,
shortcut: StoredShortcut,
action: @escaping () -> Void
) -> some View {
if let key = shortcut.keyEquivalent {
Button(action: action) {
HStack(spacing: 10) {
Label(title, systemImage: systemImage)
ShortcutHint(text: shortcut.displayString)
}
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(key, modifiers: shortcut.eventModifiers)
} else {
Button(action: action) {
HStack(spacing: 10) {
Label(title, systemImage: systemImage)
ShortcutHint(text: shortcut.displayString)
}
}
.buttonStyle(.borderedProminent)
}
}
var body: some View {
VStack(spacing: 16) {
Image(systemName: "terminal.fill")
@ -219,27 +384,19 @@ struct EmptyPanelView: View {
.foregroundStyle(.secondary)
HStack(spacing: 12) {
Button {
createTerminal()
} label: {
HStack(spacing: 10) {
Label("Terminal", systemImage: "terminal.fill")
ShortcutHint(text: "⌘T")
}
}
.buttonStyle(.borderedProminent)
.keyboardShortcut("t", modifiers: [.command])
emptyPaneActionButton(
title: "Terminal",
systemImage: "terminal.fill",
shortcut: newSurfaceShortcut,
action: createTerminal
)
Button {
createBrowser()
} label: {
HStack(spacing: 10) {
Label("Browser", systemImage: "globe")
ShortcutHint(text: "⌘⇧L")
}
}
.buttonStyle(.borderedProminent)
.keyboardShortcut("l", modifiers: [.command, .shift])
emptyPaneActionButton(
title: "Browser",
systemImage: "globe",
shortcut: openBrowserShortcut,
action: createBrowser
)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)

File diff suppressed because it is too large Load diff

48
ci_scripts/ci_post_clone.sh Executable file
View file

@ -0,0 +1,48 @@
#!/bin/bash
set -euo pipefail
echo "=== ci_post_clone.sh ==="
# Initialize submodules (needed for vendor/bonsplit SPM package)
echo "Initializing submodules..."
git submodule update --init --recursive
# Get ghostty submodule SHA
GHOSTTY_SHA=$(git -C "$CI_PRIMARY_REPOSITORY_PATH/ghostty" rev-parse HEAD)
echo "Ghostty SHA: $GHOSTTY_SHA"
# Download pre-built xcframework from manaflow-ai/ghostty releases
TAG="xcframework-$GHOSTTY_SHA"
URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz"
echo "Downloading xcframework from $URL"
MAX_RETRIES=30
RETRY_DELAY=20
for i in $(seq 1 $MAX_RETRIES); do
if curl -fSL -o "$CI_PRIMARY_REPOSITORY_PATH/GhosttyKit.xcframework.tar.gz" "$URL"; then
echo "Download succeeded on attempt $i"
break
fi
if [ "$i" -eq "$MAX_RETRIES" ]; then
echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2
exit 1
fi
echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..."
sleep $RETRY_DELAY
done
# Extract xcframework to project root
echo "Extracting xcframework..."
cd "$CI_PRIMARY_REPOSITORY_PATH"
tar xzf GhosttyKit.xcframework.tar.gz
rm GhosttyKit.xcframework.tar.gz
test -d GhosttyKit.xcframework
echo "GhosttyKit.xcframework extracted successfully"
# Download Metal toolchain (required for shader compilation)
echo "Downloading Metal toolchain..."
xcodebuild -downloadComponent MetalToolchain
echo "=== ci_post_clone.sh done ==="

43
ci_scripts/ci_pre_xcodebuild.sh Executable file
View file

@ -0,0 +1,43 @@
#!/bin/bash
set -euo pipefail
ROOT="${CI_PRIMARY_REPOSITORY_PATH:-$PWD}"
cd "$ROOT"
echo "ci_pre_xcodebuild: repository root is $ROOT"
if [ -f "vendor/bonsplit/Package.swift" ]; then
echo "ci_pre_xcodebuild: vendor/bonsplit already present"
exit 0
fi
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "ci_pre_xcodebuild: attempting submodule init for vendor/bonsplit"
git submodule sync --recursive || true
git submodule update --init --recursive vendor/bonsplit || true
fi
if [ ! -f "vendor/bonsplit/Package.swift" ]; then
echo "ci_pre_xcodebuild: submodule not present, cloning fallback"
rm -rf vendor/bonsplit
mkdir -p vendor
git clone --depth 1 https://github.com/manaflow-ai/bonsplit.git vendor/bonsplit
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
expected_sha="$(git ls-tree HEAD vendor/bonsplit | awk '{print $3}')"
if [ -n "${expected_sha:-}" ]; then
(
cd vendor/bonsplit
git fetch --depth 1 origin "$expected_sha" || true
git checkout "$expected_sha" || true
)
fi
fi
fi
if [ ! -f "vendor/bonsplit/Package.swift" ]; then
echo "ci_pre_xcodebuild: missing vendor/bonsplit/Package.swift after recovery" >&2
exit 1
fi
echo "ci_pre_xcodebuild: vendor/bonsplit is ready"

View file

@ -8,6 +8,8 @@
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>

View file

@ -0,0 +1,552 @@
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 testCmdShiftRRequestsRenameWorkspaceInCommandPalette() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer {
closeWindow(withId: windowId)
}
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
let workspaceExpectation = expectation(description: "Expected command palette rename workspace notification")
var observedWorkspaceWindow: NSWindow?
let workspaceToken = NotificationCenter.default.addObserver(
forName: .commandPaletteRenameWorkspaceRequested,
object: nil,
queue: nil
) { notification in
observedWorkspaceWindow = notification.object as? NSWindow
workspaceExpectation.fulfill()
}
defer { NotificationCenter.default.removeObserver(workspaceToken) }
let renameTabExpectation = expectation(description: "Rename tab notification should not fire for Cmd+Shift+R")
renameTabExpectation.isInverted = true
let renameTabToken = NotificationCenter.default.addObserver(
forName: .commandPaletteRenameTabRequested,
object: nil,
queue: nil
) { _ in
renameTabExpectation.fulfill()
}
defer { NotificationCenter.default.removeObserver(renameTabToken) }
guard let event = makeKeyDownEvent(
key: "r",
modifiers: [.command, .shift],
keyCode: 15, // kVK_ANSI_R
windowNumber: window.windowNumber
) else {
XCTFail("Failed to construct Cmd+Shift+R event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
wait(for: [workspaceExpectation, renameTabExpectation], timeout: 1.0)
XCTAssertEqual(observedWorkspaceWindow?.windowNumber, window.windowNumber)
}
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")
}
func testPresentPreferencesWindowShowsCustomSettingsWindowAndActivates() {
var showFallbackSettingsWindowCallCount = 0
var activateApplicationCallCount = 0
AppDelegate.presentPreferencesWindow(
showFallbackSettingsWindow: {
showFallbackSettingsWindowCallCount += 1
},
activateApplication: {
activateApplicationCallCount += 1
}
)
XCTAssertEqual(showFallbackSettingsWindowCallCount, 1)
XCTAssertEqual(activateApplicationCallCount, 1)
}
func testPresentPreferencesWindowSupportsRepeatedCalls() {
var showFallbackSettingsWindowCallCount = 0
var activateApplicationCallCount = 0
AppDelegate.presentPreferencesWindow(
showFallbackSettingsWindow: {
showFallbackSettingsWindowCallCount += 1
},
activateApplication: {
activateApplicationCallCount += 1
}
)
AppDelegate.presentPreferencesWindow(
showFallbackSettingsWindow: {
showFallbackSettingsWindowCallCount += 1
},
activateApplication: {
activateApplicationCallCount += 1
}
)
XCTAssertEqual(showFallbackSettingsWindowCallCount, 2)
XCTAssertEqual(activateApplicationCallCount, 2)
}
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))
}
}

View file

@ -642,6 +642,73 @@ final class CJKIMECompositionSequenceTests: XCTestCase {
}
}
// MARK: - IME firstRect placement and sizing
/// Regression tests for IME candidate/preedit anchor rectangle reporting.
/// If width/height are discarded here, macOS can place preedit UI incorrectly.
final class CJKIMEFirstRectTests: XCTestCase {
func testFirstRectUsesIMEProvidedWidthAndHeight() {
let frame = NSRect(x: 0, y: 0, width: 800, height: 600)
let view = GhosttyNSView(frame: frame)
view.cellSize = CGSize(width: 10, height: 20)
view.setIMEPointForTesting(x: 120, y: 240, width: 64, height: 26)
let window = NSWindow(
contentRect: NSRect(x: 100, y: 100, width: 800, height: 600),
styleMask: [.titled],
backing: .buffered,
defer: false
)
let content = NSView(frame: frame)
window.contentView = content
content.addSubview(view)
view.frame = frame
defer {
view.clearIMEPointForTesting()
window.orderOut(nil)
}
let rect = view.firstRect(forCharacterRange: NSRange(location: 0, length: 1), actualRange: nil)
let expectedViewRect = NSRect(x: 120, y: frame.height - 240, width: 64, height: 26)
let expectedScreenRect = window.convertToScreen(view.convert(expectedViewRect, to: nil))
XCTAssertEqual(rect.origin.x, expectedScreenRect.origin.x, accuracy: 0.001)
XCTAssertEqual(rect.origin.y, expectedScreenRect.origin.y, accuracy: 0.001)
XCTAssertEqual(rect.width, 64, accuracy: 0.001)
XCTAssertEqual(rect.height, 26, accuracy: 0.001)
}
func testFirstRectFallsBackToCellHeightWhenIMEHeightIsZero() {
let frame = NSRect(x: 0, y: 0, width: 640, height: 480)
let view = GhosttyNSView(frame: frame)
view.cellSize = CGSize(width: 9, height: 18)
view.setIMEPointForTesting(x: 80, y: 120, width: 36, height: 0)
let window = NSWindow(
contentRect: NSRect(x: 40, y: 40, width: 640, height: 480),
styleMask: [.titled],
backing: .buffered,
defer: false
)
let content = NSView(frame: frame)
window.contentView = content
content.addSubview(view)
view.frame = frame
defer {
view.clearIMEPointForTesting()
window.orderOut(nil)
}
let rect = view.firstRect(forCharacterRange: NSRange(location: 0, length: 1), actualRange: nil)
XCTAssertEqual(rect.width, 36, accuracy: 0.001)
XCTAssertEqual(rect.height, 18, accuracy: 0.001)
}
}
// MARK: - Key text accumulator during CJK IME composition
/// Tests that the keyTextAccumulator correctly manages text during the keyDown
@ -694,3 +761,72 @@ final class CJKIMEKeyTextAccumulatorTests: XCTestCase {
XCTAssertNil(view.keyTextAccumulatorForTesting)
}
}
// MARK: - Space release regression (Codex hold-to-talk in cmux)
@MainActor
final class GhosttySpaceReleaseRegressionTests: XCTestCase {
func testSyntheticSpaceReleaseCarriesUnshiftedCodepoint() {
_ = NSApplication.shared
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let hostedView = surface.hostedView
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer {
GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil
window.orderOut(nil)
}
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
hostedView.frame = contentView.bounds
hostedView.autoresizingMask = [.width, .height]
contentView.addSubview(hostedView)
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
contentView.layoutSubtreeIfNeeded()
hostedView.setVisibleInUI(true)
hostedView.setActive(true)
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
var releaseEvent: ghostty_input_key_s?
GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in
if keyEvent.action == GHOSTTY_ACTION_RELEASE, keyEvent.keycode == 49 {
releaseEvent = keyEvent
}
}
let sent = hostedView.debugSendSyntheticKeyPressAndReleaseForUITest(
characters: " ",
charactersIgnoringModifiers: " ",
keyCode: 49
)
XCTAssertTrue(sent, "Expected synthetic Space key press/release to be dispatched")
guard let releaseEvent else {
XCTFail("Expected to capture synthetic Space key release event")
return
}
XCTAssertEqual(releaseEvent.action, GHOSTTY_ACTION_RELEASE)
XCTAssertEqual(releaseEvent.keycode, 49)
XCTAssertEqual(releaseEvent.unshifted_codepoint, " ".unicodeScalars.first!.value)
XCTAssertEqual(releaseEvent.consumed_mods.rawValue, GHOSTTY_MODS_NONE.rawValue)
XCTAssertFalse(releaseEvent.composing)
XCTAssertNil(releaseEvent.text)
}
}

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,38 @@ import AppKit
@testable import cmux
#endif
final class SidebarPathFormatterTests: XCTestCase {
func testShortenedPathReplacesExactHomeDirectory() {
XCTAssertEqual(
SidebarPathFormatter.shortenedPath(
"/Users/example",
homeDirectoryPath: "/Users/example"
),
"~"
)
}
func testShortenedPathReplacesHomeDirectoryPrefix() {
XCTAssertEqual(
SidebarPathFormatter.shortenedPath(
"/Users/example/projects/cmux",
homeDirectoryPath: "/Users/example"
),
"~/projects/cmux"
)
}
func testShortenedPathLeavesExternalPathUnchanged() {
XCTAssertEqual(
SidebarPathFormatter.shortenedPath(
"/tmp/cmux",
homeDirectoryPath: "/Users/example"
),
"/tmp/cmux"
)
}
}
final class GhosttyConfigTests: XCTestCase {
private struct RGB: Equatable {
let red: Int
@ -126,6 +158,64 @@ final class GhosttyConfigTests: XCTestCase {
XCTAssertEqual(rgb255(config.backgroundColor), RGB(red: 253, green: 246, blue: 227))
}
func testLoadCachesPerColorScheme() {
GhosttyConfig.invalidateLoadCache()
defer { GhosttyConfig.invalidateLoadCache() }
var loadCount = 0
let loadFromDisk: (GhosttyConfig.ColorSchemePreference) -> GhosttyConfig = { scheme in
loadCount += 1
var config = GhosttyConfig()
config.fontFamily = "\(scheme)-\(loadCount)"
return config
}
let lightFirst = GhosttyConfig.load(
preferredColorScheme: .light,
loadFromDisk: loadFromDisk
)
let lightSecond = GhosttyConfig.load(
preferredColorScheme: .light,
loadFromDisk: loadFromDisk
)
let darkFirst = GhosttyConfig.load(
preferredColorScheme: .dark,
loadFromDisk: loadFromDisk
)
XCTAssertEqual(loadCount, 2)
XCTAssertEqual(lightFirst.fontFamily, "light-1")
XCTAssertEqual(lightSecond.fontFamily, "light-1")
XCTAssertEqual(darkFirst.fontFamily, "dark-2")
}
func testLoadCacheInvalidationForcesReload() {
GhosttyConfig.invalidateLoadCache()
defer { GhosttyConfig.invalidateLoadCache() }
var loadCount = 0
let loadFromDisk: (GhosttyConfig.ColorSchemePreference) -> GhosttyConfig = { _ in
loadCount += 1
var config = GhosttyConfig()
config.fontFamily = "reload-\(loadCount)"
return config
}
let first = GhosttyConfig.load(
preferredColorScheme: .dark,
loadFromDisk: loadFromDisk
)
GhosttyConfig.invalidateLoadCache()
let second = GhosttyConfig.load(
preferredColorScheme: .dark,
loadFromDisk: loadFromDisk
)
XCTAssertEqual(loadCount, 2)
XCTAssertEqual(first.fontFamily, "reload-1")
XCTAssertEqual(second.fontFamily, "reload-2")
}
func testLegacyConfigFallbackUsesLegacyFileWhenConfigGhosttyIsEmpty() {
XCTAssertTrue(
GhosttyApp.shouldLoadLegacyGhosttyConfig(
@ -162,7 +252,138 @@ final class GhosttyConfigTests: XCTestCase {
)
}
func testClaudeCodeIntegrationDefaultsToDisabledWhenUnset() {
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 testScrollLagCaptureRequiresSustainedLag() {
XCTAssertFalse(
GhosttyApp.shouldCaptureScrollLagEvent(
samples: 4,
averageMs: 18,
maxMs: 85,
thresholdMs: 40,
nowUptime: 1000,
lastReportedUptime: nil
)
)
XCTAssertFalse(
GhosttyApp.shouldCaptureScrollLagEvent(
samples: 10,
averageMs: 6,
maxMs: 85,
thresholdMs: 40,
nowUptime: 1000,
lastReportedUptime: nil
)
)
XCTAssertFalse(
GhosttyApp.shouldCaptureScrollLagEvent(
samples: 10,
averageMs: 18,
maxMs: 35,
thresholdMs: 40,
nowUptime: 1000,
lastReportedUptime: nil
)
)
XCTAssertTrue(
GhosttyApp.shouldCaptureScrollLagEvent(
samples: 10,
averageMs: 18,
maxMs: 85,
thresholdMs: 40,
nowUptime: 1000,
lastReportedUptime: nil
)
)
}
func testScrollLagCaptureRespectsCooldownWindow() {
XCTAssertFalse(
GhosttyApp.shouldCaptureScrollLagEvent(
samples: 12,
averageMs: 22,
maxMs: 90,
thresholdMs: 40,
nowUptime: 1200,
lastReportedUptime: 1005,
cooldown: 300
)
)
XCTAssertTrue(
GhosttyApp.shouldCaptureScrollLagEvent(
samples: 12,
averageMs: 22,
maxMs: 90,
thresholdMs: 40,
nowUptime: 1406,
lastReportedUptime: 1005,
cooldown: 300
)
)
}
func testClaudeCodeIntegrationDefaultsToEnabledWhenUnset() {
let suiteName = "cmux.tests.claude-hooks.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated user defaults suite")
@ -173,7 +394,7 @@ final class GhosttyConfigTests: XCTestCase {
}
defaults.removeObject(forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey)
XCTAssertFalse(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
XCTAssertTrue(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
}
func testClaudeCodeIntegrationRespectsStoredPreference() {
@ -193,6 +414,37 @@ final class GhosttyConfigTests: XCTestCase {
XCTAssertFalse(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
}
func testTelemetryDefaultsToEnabledWhenUnset() {
let suiteName = "cmux.tests.telemetry.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated user defaults suite")
return
}
defer {
defaults.removePersistentDomain(forName: suiteName)
}
defaults.removeObject(forKey: TelemetrySettings.sendAnonymousTelemetryKey)
XCTAssertTrue(TelemetrySettings.isEnabled(defaults: defaults))
}
func testTelemetryRespectsStoredPreference() {
let suiteName = "cmux.tests.telemetry.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated user defaults suite")
return
}
defer {
defaults.removePersistentDomain(forName: suiteName)
}
defaults.set(true, forKey: TelemetrySettings.sendAnonymousTelemetryKey)
XCTAssertTrue(TelemetrySettings.isEnabled(defaults: defaults))
defaults.set(false, forKey: TelemetrySettings.sendAnonymousTelemetryKey)
XCTAssertFalse(TelemetrySettings.isEnabled(defaults: defaults))
}
private func rgb255(_ color: NSColor) -> RGB {
let srgb = color.usingColorSpace(.sRGB)!
var red: CGFloat = 0
@ -208,6 +460,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 {
func testSignalsInSameBurstFlushOnce() {
let coalescer = NotificationBurstCoalescer(delay: 0.01)
@ -271,6 +592,133 @@ 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 {
func testPopReturnsEntriesInLIFOOrder() {
var stack = RecentlyClosedBrowserStack(capacity: 20)
stack.push(makeSnapshot(index: 1))
stack.push(makeSnapshot(index: 2))
stack.push(makeSnapshot(index: 3))
XCTAssertEqual(stack.pop()?.originalTabIndex, 3)
XCTAssertEqual(stack.pop()?.originalTabIndex, 2)
XCTAssertEqual(stack.pop()?.originalTabIndex, 1)
XCTAssertNil(stack.pop())
}
func testPushDropsOldestEntriesWhenCapacityExceeded() {
var stack = RecentlyClosedBrowserStack(capacity: 3)
for index in 1...5 {
stack.push(makeSnapshot(index: index))
}
XCTAssertEqual(stack.pop()?.originalTabIndex, 5)
XCTAssertEqual(stack.pop()?.originalTabIndex, 4)
XCTAssertEqual(stack.pop()?.originalTabIndex, 3)
XCTAssertNil(stack.pop())
}
private func makeSnapshot(index: Int) -> ClosedBrowserPanelRestoreSnapshot {
ClosedBrowserPanelRestoreSnapshot(
workspaceId: UUID(),
url: URL(string: "https://example.com/\(index)"),
originalPaneId: UUID(),
originalTabIndex: index,
fallbackSplitOrientation: .horizontal,
fallbackSplitInsertFirst: false,
fallbackAnchorPaneId: UUID()
)
}
}
final class TabManagerNotificationOrderingSourceTests: XCTestCase {
func testGhosttyDidSetTitleObserverDoesNotHopThroughTask() throws {
let projectRoot = findProjectRoot()
@ -316,3 +764,353 @@ final class TabManagerNotificationOrderingSourceTests: XCTestCase {
return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
}
}
final class SocketControlSettingsTests: XCTestCase {
func testMigrateModeSupportsExpandedSocketModes() {
XCTAssertEqual(SocketControlSettings.migrateMode("off"), .off)
XCTAssertEqual(SocketControlSettings.migrateMode("cmuxOnly"), .cmuxOnly)
XCTAssertEqual(SocketControlSettings.migrateMode("automation"), .automation)
XCTAssertEqual(SocketControlSettings.migrateMode("password"), .password)
XCTAssertEqual(SocketControlSettings.migrateMode("allow-all"), .allowAll)
// Legacy aliases
XCTAssertEqual(SocketControlSettings.migrateMode("notifications"), .automation)
XCTAssertEqual(SocketControlSettings.migrateMode("full"), .allowAll)
}
func testSocketModePermissions() {
XCTAssertEqual(SocketControlMode.off.socketFilePermissions, 0o600)
XCTAssertEqual(SocketControlMode.cmuxOnly.socketFilePermissions, 0o600)
XCTAssertEqual(SocketControlMode.automation.socketFilePermissions, 0o600)
XCTAssertEqual(SocketControlMode.password.socketFilePermissions, 0o600)
XCTAssertEqual(SocketControlMode.allowAll.socketFilePermissions, 0o666)
}
func testInvalidEnvSocketModeDoesNotOverrideUserMode() {
XCTAssertNil(
SocketControlSettings.envOverrideMode(
environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"]
)
)
XCTAssertEqual(
SocketControlSettings.effectiveMode(
userMode: .password,
environment: ["CMUX_SOCKET_MODE": "definitely-not-a-mode"]
),
.password
)
}
func testStableReleaseIgnoresAmbientSocketOverrideByDefault() {
let path = SocketControlSettings.socketPath(
environment: [
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock",
],
bundleIdentifier: "com.cmuxterm.app",
isDebugBuild: false
)
XCTAssertEqual(path, "/tmp/cmux.sock")
}
func testNightlyReleaseUsesDedicatedDefaultAndIgnoresAmbientSocketOverride() {
let path = SocketControlSettings.socketPath(
environment: [
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-issue-153-tmux-compat.sock",
],
bundleIdentifier: "com.cmuxterm.app.nightly",
isDebugBuild: false
)
XCTAssertEqual(path, "/tmp/cmux-nightly.sock")
}
func testDebugBundleHonorsSocketOverrideWithoutOptInFlag() {
let path = SocketControlSettings.socketPath(
environment: [
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-my-tag.sock",
],
bundleIdentifier: "com.cmuxterm.app.debug.my-tag",
isDebugBuild: false
)
XCTAssertEqual(path, "/tmp/cmux-debug-my-tag.sock")
}
func testStagingBundleHonorsSocketOverrideWithoutOptInFlag() {
let path = SocketControlSettings.socketPath(
environment: [
"CMUX_SOCKET_PATH": "/tmp/cmux-staging-my-tag.sock",
],
bundleIdentifier: "com.cmuxterm.app.staging.my-tag",
isDebugBuild: false
)
XCTAssertEqual(path, "/tmp/cmux-staging-my-tag.sock")
}
func testStableReleaseCanOptInToSocketOverride() {
let path = SocketControlSettings.socketPath(
environment: [
"CMUX_SOCKET_PATH": "/tmp/cmux-debug-forced.sock",
"CMUX_ALLOW_SOCKET_OVERRIDE": "1",
],
bundleIdentifier: "com.cmuxterm.app",
isDebugBuild: false
)
XCTAssertEqual(path, "/tmp/cmux-debug-forced.sock")
}
func testDefaultSocketPathByChannel() {
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app", isDebugBuild: false),
"/tmp/cmux.sock"
)
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.nightly", isDebugBuild: false),
"/tmp/cmux-nightly.sock"
)
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.debug.tag", isDebugBuild: false),
"/tmp/cmux-debug.sock"
)
XCTAssertEqual(
SocketControlSettings.defaultSocketPath(bundleIdentifier: "com.cmuxterm.app.staging.tag", isDebugBuild: false),
"/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
)
)
}
func testXCTestInjectBundleLaunchIgnoresLaunchTagGate() {
XCTAssertFalse(
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
environment: ["XCInjectBundle": "/tmp/fake.xctest"],
bundleIdentifier: "com.cmuxterm.app.debug",
isDebugBuild: true
)
)
}
func testXCTestDyldLaunchIgnoresLaunchTagGate() {
XCTAssertFalse(
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
environment: ["DYLD_INSERT_LIBRARIES": "/usr/lib/libXCTestBundleInject.dylib"],
bundleIdentifier: "com.cmuxterm.app.debug",
isDebugBuild: true
)
)
}
func testXCUITestLaunchEnvironmentIgnoresLaunchTagGate() {
// XCUITest launches the app as a separate process without XCTest env vars.
// The app receives CMUX_UI_TEST_* vars via XCUIApplication.launchEnvironment.
XCTAssertFalse(
SocketControlSettings.shouldBlockUntaggedDebugLaunch(
environment: ["CMUX_UI_TEST_MODE": "1"],
bundleIdentifier: "com.cmuxterm.app.debug",
isDebugBuild: true
)
)
}
}
final class PostHogAnalyticsPropertiesTests: XCTestCase {
func testDailyActivePropertiesIncludeVersionAndBuild() {
let properties = PostHogAnalytics.dailyActiveProperties(
dayUTC: "2026-02-21",
reason: "didBecomeActive",
infoDictionary: [
"CFBundleShortVersionString": "0.31.0",
"CFBundleVersion": "230",
]
)
XCTAssertEqual(properties["day_utc"] as? String, "2026-02-21")
XCTAssertEqual(properties["reason"] as? String, "didBecomeActive")
XCTAssertEqual(properties["app_version"] as? String, "0.31.0")
XCTAssertEqual(properties["app_build"] as? String, "230")
}
func testSuperPropertiesIncludePlatformVersionAndBuild() {
let properties = PostHogAnalytics.superProperties(
infoDictionary: [
"CFBundleShortVersionString": "0.31.0",
"CFBundleVersion": "230",
]
)
XCTAssertEqual(properties["platform"] as? String, "cmuxterm")
XCTAssertEqual(properties["app_version"] as? String, "0.31.0")
XCTAssertEqual(properties["app_build"] as? String, "230")
}
func testHourlyActivePropertiesIncludeVersionAndBuild() {
let properties = PostHogAnalytics.hourlyActiveProperties(
hourUTC: "2026-02-21T14",
reason: "didBecomeActive",
infoDictionary: [
"CFBundleShortVersionString": "0.31.0",
"CFBundleVersion": "230",
]
)
XCTAssertEqual(properties["hour_utc"] as? String, "2026-02-21T14")
XCTAssertEqual(properties["reason"] as? String, "didBecomeActive")
XCTAssertEqual(properties["app_version"] as? String, "0.31.0")
XCTAssertEqual(properties["app_build"] as? String, "230")
}
func testHourlyPropertiesOmitVersionFieldsWhenUnavailable() {
let properties = PostHogAnalytics.hourlyActiveProperties(
hourUTC: "2026-02-21T14",
reason: "activeTimer",
infoDictionary: [:]
)
XCTAssertEqual(properties["hour_utc"] as? String, "2026-02-21T14")
XCTAssertEqual(properties["reason"] as? String, "activeTimer")
XCTAssertNil(properties["app_version"])
XCTAssertNil(properties["app_build"])
}
func testPropertiesOmitVersionFieldsWhenUnavailable() {
let superProperties = PostHogAnalytics.superProperties(infoDictionary: [:])
XCTAssertEqual(superProperties["platform"] as? String, "cmuxterm")
XCTAssertNil(superProperties["app_version"])
XCTAssertNil(superProperties["app_build"])
let dailyProperties = PostHogAnalytics.dailyActiveProperties(
dayUTC: "2026-02-21",
reason: "activeTimer",
infoDictionary: [:]
)
XCTAssertEqual(dailyProperties["day_utc"] as? String, "2026-02-21")
XCTAssertEqual(dailyProperties["reason"] as? String, "activeTimer")
XCTAssertNil(dailyProperties["app_version"])
XCTAssertNil(dailyProperties["app_build"])
}
}
final class GhosttyMouseFocusTests: XCTestCase {
func testShouldRequestFirstResponderForMouseFocusWhenEnabledAndWindowIsActive() {
XCTAssertTrue(
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
focusFollowsMouseEnabled: true,
pressedMouseButtons: 0,
appIsActive: true,
windowIsKey: true,
alreadyFirstResponder: false,
visibleInUI: true,
hasUsableGeometry: true,
hiddenInHierarchy: false
)
)
}
func testShouldNotRequestFirstResponderWhenFocusFollowsMouseDisabled() {
XCTAssertFalse(
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
focusFollowsMouseEnabled: false,
pressedMouseButtons: 0,
appIsActive: true,
windowIsKey: true,
alreadyFirstResponder: false,
visibleInUI: true,
hasUsableGeometry: true,
hiddenInHierarchy: false
)
)
}
func testShouldNotRequestFirstResponderDuringMouseDrag() {
XCTAssertFalse(
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
focusFollowsMouseEnabled: true,
pressedMouseButtons: 1,
appIsActive: true,
windowIsKey: true,
alreadyFirstResponder: false,
visibleInUI: true,
hasUsableGeometry: true,
hiddenInHierarchy: false
)
)
}
func testShouldNotRequestFirstResponderWhenViewCannotSafelyReceiveFocus() {
XCTAssertFalse(
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
focusFollowsMouseEnabled: true,
pressedMouseButtons: 0,
appIsActive: true,
windowIsKey: true,
alreadyFirstResponder: false,
visibleInUI: true,
hasUsableGeometry: false,
hiddenInHierarchy: false
)
)
XCTAssertFalse(
GhosttyNSView.shouldRequestFirstResponderForMouseFocus(
focusFollowsMouseEnabled: true,
pressedMouseButtons: 0,
appIsActive: true,
windowIsKey: true,
alreadyFirstResponder: false,
visibleInUI: true,
hasUsableGeometry: true,
hiddenInHierarchy: true
)
)
}
}

View file

@ -0,0 +1,735 @@
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 testSessionAutosaveTickPolicySkipsWhenTerminating() {
XCTAssertTrue(
AppDelegate.shouldRunSessionAutosaveTick(isTerminatingApp: false)
)
XCTAssertFalse(
AppDelegate.shouldRunSessionAutosaveTick(isTerminatingApp: true)
)
}
func testSessionSnapshotSynchronousWritePolicy() {
XCTAssertFalse(
AppDelegate.shouldWriteSessionSnapshotSynchronously(
isTerminatingApp: false,
includeScrollback: false
)
)
XCTAssertFalse(
AppDelegate.shouldWriteSessionSnapshotSynchronously(
isTerminatingApp: false,
includeScrollback: true
)
)
XCTAssertFalse(
AppDelegate.shouldWriteSessionSnapshotSynchronously(
isTerminatingApp: true,
includeScrollback: false
)
)
XCTAssertTrue(
AppDelegate.shouldWriteSessionSnapshotSynchronously(
isTerminatingApp: true,
includeScrollback: true
)
)
}
func testUnchangedAutosaveFingerprintSkipsWithinStalenessWindow() {
let now = Date()
XCTAssertTrue(
AppDelegate.shouldSkipSessionAutosaveForUnchangedFingerprint(
isTerminatingApp: false,
includeScrollback: false,
previousFingerprint: 1234,
currentFingerprint: 1234,
lastPersistedAt: now.addingTimeInterval(-5),
now: now,
maximumAutosaveSkippableInterval: 60
)
)
}
func testUnchangedAutosaveFingerprintDoesNotSkipAfterStalenessWindow() {
let now = Date()
XCTAssertFalse(
AppDelegate.shouldSkipSessionAutosaveForUnchangedFingerprint(
isTerminatingApp: false,
includeScrollback: false,
previousFingerprint: 1234,
currentFingerprint: 1234,
lastPersistedAt: now.addingTimeInterval(-120),
now: now,
maximumAutosaveSkippableInterval: 60
)
)
}
func testUnchangedAutosaveFingerprintNeverSkipsTerminatingOrScrollbackWrites() {
let now = Date()
XCTAssertFalse(
AppDelegate.shouldSkipSessionAutosaveForUnchangedFingerprint(
isTerminatingApp: true,
includeScrollback: false,
previousFingerprint: 1234,
currentFingerprint: 1234,
lastPersistedAt: now.addingTimeInterval(-1),
now: now
)
)
XCTAssertFalse(
AppDelegate.shouldSkipSessionAutosaveForUnchangedFingerprint(
isTerminatingApp: false,
includeScrollback: true,
previousFingerprint: 1234,
currentFingerprint: 1234,
lastPersistedAt: now.addingTimeInterval(-1),
now: now
)
)
}
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,333 @@
import XCTest
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
final class SocketControlPasswordStoreTests: XCTestCase {
override func setUp() {
super.setUp()
SocketControlPasswordStore.resetLazyKeychainFallbackCacheForTests()
}
override func tearDown() {
SocketControlPasswordStore.resetLazyKeychainFallbackCacheForTests()
super.tearDown()
}
func testSaveLoadAndClearRoundTripUsesFileStorage() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false)
XCTAssertFalse(SocketControlPasswordStore.hasConfiguredPassword(environment: [:], fileURL: fileURL))
try SocketControlPasswordStore.savePassword("hunter2", fileURL: fileURL)
XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "hunter2")
XCTAssertTrue(SocketControlPasswordStore.hasConfiguredPassword(environment: [:], fileURL: fileURL))
try SocketControlPasswordStore.clearPassword(fileURL: fileURL)
XCTAssertNil(try SocketControlPasswordStore.loadPassword(fileURL: fileURL))
XCTAssertFalse(SocketControlPasswordStore.hasConfiguredPassword(environment: [:], fileURL: fileURL))
}
func testConfiguredPasswordPrefersEnvironmentOverStoredFile() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false)
try SocketControlPasswordStore.savePassword("stored-secret", fileURL: fileURL)
let environment = [SocketControlSettings.socketPasswordEnvKey: "env-secret"]
let configured = SocketControlPasswordStore.configuredPassword(
environment: environment,
fileURL: fileURL
)
XCTAssertEqual(configured, "env-secret")
}
func testConfiguredPasswordLazyKeychainFallbackReadsOnlyOnceAndCaches() {
var readCount = 0
let withoutFallback = SocketControlPasswordStore.configuredPassword(
environment: [:],
fileURL: nil,
allowLazyKeychainFallback: false,
loadKeychainPassword: {
readCount += 1
return "legacy-secret"
}
)
XCTAssertNil(withoutFallback)
XCTAssertEqual(readCount, 0)
let firstWithFallback = SocketControlPasswordStore.configuredPassword(
environment: [:],
fileURL: nil,
allowLazyKeychainFallback: true,
loadKeychainPassword: {
readCount += 1
return "legacy-secret"
}
)
XCTAssertEqual(firstWithFallback, "legacy-secret")
XCTAssertEqual(readCount, 1)
let secondWithFallback = SocketControlPasswordStore.configuredPassword(
environment: [:],
fileURL: nil,
allowLazyKeychainFallback: true,
loadKeychainPassword: {
readCount += 1
return "new-secret"
}
)
XCTAssertEqual(secondWithFallback, "legacy-secret")
XCTAssertEqual(readCount, 1)
}
func testConfiguredPasswordLazyKeychainFallbackCachesMissingValue() {
var readCount = 0
let first = SocketControlPasswordStore.configuredPassword(
environment: [:],
fileURL: nil,
allowLazyKeychainFallback: true,
loadKeychainPassword: {
readCount += 1
return nil
}
)
XCTAssertNil(first)
XCTAssertEqual(readCount, 1)
let second = SocketControlPasswordStore.configuredPassword(
environment: [:],
fileURL: nil,
allowLazyKeychainFallback: true,
loadKeychainPassword: {
readCount += 1
return "should-not-be-read"
}
)
XCTAssertNil(second)
XCTAssertEqual(readCount, 1)
}
func testConfiguredPasswordPrefersStoredFileOverLazyKeychainFallback() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false)
try SocketControlPasswordStore.savePassword("stored-secret", fileURL: fileURL)
var readCount = 0
let configured = SocketControlPasswordStore.configuredPassword(
environment: [:],
fileURL: fileURL,
allowLazyKeychainFallback: true,
loadKeychainPassword: {
readCount += 1
return "legacy-secret"
}
)
XCTAssertEqual(configured, "stored-secret")
XCTAssertEqual(readCount, 0)
}
func testHasConfiguredAndVerifyReuseSingleLazyKeychainRead() {
var readCount = 0
let loader = {
readCount += 1
return "legacy-secret"
}
XCTAssertTrue(
SocketControlPasswordStore.hasConfiguredPassword(
environment: [:],
fileURL: nil,
allowLazyKeychainFallback: true,
loadKeychainPassword: loader
)
)
XCTAssertEqual(readCount, 1)
XCTAssertTrue(
SocketControlPasswordStore.verify(
password: "legacy-secret",
environment: [:],
fileURL: nil,
allowLazyKeychainFallback: true,
loadKeychainPassword: loader
)
)
XCTAssertEqual(readCount, 1)
}
func testDefaultPasswordFileURLUsesCmuxAppSupportPath() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let resolved = SocketControlPasswordStore.defaultPasswordFileURL(appSupportDirectory: tempDir)
XCTAssertEqual(
resolved?.path,
tempDir.appendingPathComponent("cmux", isDirectory: true)
.appendingPathComponent("socket-control-password", isDirectory: false).path
)
}
func testLegacyKeychainMigrationCopiesPasswordDeletesLegacyAndRunsOnlyOnce() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-socket-password-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let fileURL = tempDir.appendingPathComponent("socket-password.txt", isDirectory: false)
let defaultsSuiteName = "cmux-socket-password-migration-tests-\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: defaultsSuiteName) else {
XCTFail("Expected isolated UserDefaults suite for migration test")
return
}
defer { defaults.removePersistentDomain(forName: defaultsSuiteName) }
var lookupCount = 0
var deleteCount = 0
SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded(
defaults: defaults,
fileURL: fileURL,
loadLegacyPassword: {
lookupCount += 1
return "legacy-secret"
},
deleteLegacyPassword: {
deleteCount += 1
return true
}
)
XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "legacy-secret")
XCTAssertEqual(lookupCount, 1)
XCTAssertEqual(deleteCount, 1)
SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded(
defaults: defaults,
fileURL: fileURL,
loadLegacyPassword: {
lookupCount += 1
return "new-value"
},
deleteLegacyPassword: {
deleteCount += 1
return true
}
)
XCTAssertEqual(lookupCount, 1)
XCTAssertEqual(deleteCount, 1)
XCTAssertEqual(try SocketControlPasswordStore.loadPassword(fileURL: fileURL), "legacy-secret")
}
}
final class CmuxCLIPathInstallerTests: XCTestCase {
func testInstallAndUninstallRoundTripWithoutAdministratorPrivileges() throws {
let fileManager = FileManager.default
let root = fileManager.temporaryDirectory
.appendingPathComponent("cmux-cli-installer-tests-\(UUID().uuidString)", isDirectory: true)
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: root) }
let bundledCLIURL = root
.appendingPathComponent("cmux.app/Contents/Resources/bin/cmux", isDirectory: false)
try fileManager.createDirectory(
at: bundledCLIURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try "#!/bin/sh\necho cmux\n".write(to: bundledCLIURL, atomically: true, encoding: .utf8)
let destinationURL = root.appendingPathComponent("usr/local/bin/cmux", isDirectory: false)
var privilegedInstallCallCount = 0
var privilegedUninstallCallCount = 0
let installer = CmuxCLIPathInstaller(
fileManager: fileManager,
destinationURL: destinationURL,
bundledCLIURLProvider: { bundledCLIURL },
expectedBundledCLIPath: bundledCLIURL.path,
privilegedInstaller: { _, _ in privilegedInstallCallCount += 1 },
privilegedUninstaller: { _ in privilegedUninstallCallCount += 1 }
)
let installOutcome = try installer.install()
XCTAssertFalse(installOutcome.usedAdministratorPrivileges)
XCTAssertEqual(privilegedInstallCallCount, 0)
XCTAssertTrue(installer.isInstalled())
XCTAssertEqual(
try fileManager.destinationOfSymbolicLink(atPath: destinationURL.path),
bundledCLIURL.path
)
let uninstallOutcome = try installer.uninstall()
XCTAssertFalse(uninstallOutcome.usedAdministratorPrivileges)
XCTAssertTrue(uninstallOutcome.removedExistingEntry)
XCTAssertEqual(privilegedUninstallCallCount, 0)
XCTAssertFalse(fileManager.fileExists(atPath: destinationURL.path))
XCTAssertFalse(installer.isInstalled())
}
func testInstallFallsBackToAdministratorFlowWhenDestinationIsNotWritable() throws {
let fileManager = FileManager.default
let root = fileManager.temporaryDirectory
.appendingPathComponent("cmux-cli-installer-tests-\(UUID().uuidString)", isDirectory: true)
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: root) }
let bundledCLIURL = root
.appendingPathComponent("cmux.app/Contents/Resources/bin/cmux", isDirectory: false)
try fileManager.createDirectory(
at: bundledCLIURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try "#!/bin/sh\necho cmux\n".write(to: bundledCLIURL, atomically: true, encoding: .utf8)
let destinationURL = root.appendingPathComponent("usr/local/bin/cmux", isDirectory: false)
let destinationDir = destinationURL.deletingLastPathComponent()
try fileManager.createDirectory(at: destinationDir, withIntermediateDirectories: true)
try fileManager.setAttributes([.posixPermissions: 0o555], ofItemAtPath: destinationDir.path)
defer {
try? fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destinationDir.path)
}
var privilegedInstallCallCount = 0
let installer = CmuxCLIPathInstaller(
fileManager: fileManager,
destinationURL: destinationURL,
bundledCLIURLProvider: { bundledCLIURL },
expectedBundledCLIPath: bundledCLIURL.path,
privilegedInstaller: { sourceURL, privilegedDestinationURL in
privilegedInstallCallCount += 1
XCTAssertEqual(sourceURL.standardizedFileURL, bundledCLIURL.standardizedFileURL)
XCTAssertEqual(privilegedDestinationURL.standardizedFileURL, destinationURL.standardizedFileURL)
try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destinationDir.path)
try fileManager.createSymbolicLink(at: privilegedDestinationURL, withDestinationURL: sourceURL)
}
)
let installOutcome = try installer.install()
XCTAssertTrue(installOutcome.usedAdministratorPrivileges)
XCTAssertEqual(privilegedInstallCallCount, 1)
XCTAssertTrue(installer.isInstalled())
}
}

View file

@ -1,7 +1,12 @@
import XCTest
import Foundation
import AppKit
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
/// Regression test: ensures UpdatePill is never gated behind #if DEBUG in production code paths.
/// This prevents accidentally hiding the update UI in Release builds.
@ -144,6 +149,23 @@ final class BrowserInsecureHTTPSettingsTests: XCTestCase {
XCTAssertFalse(browserShouldBlockInsecureHTTPURL(httpsURL, rawAllowlist: nil))
}
func testPreparedNavigationRequestPreservesOriginalMethodBodyAndHeaders() throws {
let url = try XCTUnwrap(URL(string: "http://localtest.me:3000/submit"))
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = Data("token=abc123".utf8)
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
let prepared = browserPreparedNavigationRequest(request)
XCTAssertEqual(prepared.url, url)
XCTAssertEqual(prepared.httpMethod, "POST")
XCTAssertEqual(prepared.httpBody, Data("token=abc123".utf8))
XCTAssertEqual(prepared.value(forHTTPHeaderField: "Content-Type"), "application/x-www-form-urlencoded")
XCTAssertEqual(prepared.cachePolicy, .useProtocolCachePolicy)
}
func testOneTimeBypassIsConsumedAfterFirstNavigation() throws {
let insecureURL = try XCTUnwrap(URL(string: "http://neverssl.com"))
var bypassHostOnce: String? = "neverssl.com"
@ -200,6 +222,57 @@ final class BrowserInsecureHTTPSettingsTests: XCTestCase {
}
}
final class TitlebarControlsSizingPolicyTests: XCTestCase {
func testSchedulePolicyRequiresMeaningfulViewSizeChange() {
XCTAssertFalse(titlebarControlsShouldScheduleForViewSizeChange(previous: .zero, current: .zero))
XCTAssertTrue(
titlebarControlsShouldScheduleForViewSizeChange(
previous: .zero,
current: NSSize(width: 240, height: 38)
)
)
XCTAssertFalse(
titlebarControlsShouldScheduleForViewSizeChange(
previous: NSSize(width: 240, height: 38),
current: NSSize(width: 240.2, height: 38.1)
)
)
XCTAssertTrue(
titlebarControlsShouldScheduleForViewSizeChange(
previous: NSSize(width: 240, height: 38),
current: NSSize(width: 247, height: 38)
)
)
}
func testLayoutApplyPolicySkipsEquivalentSnapshots() {
let baseline = TitlebarControlsLayoutSnapshot(
contentSize: NSSize(width: 128, height: 22),
containerHeight: 28,
yOffset: 3
)
XCTAssertTrue(titlebarControlsShouldApplyLayout(previous: nil, next: baseline))
XCTAssertFalse(titlebarControlsShouldApplyLayout(previous: baseline, next: baseline))
let changed = TitlebarControlsLayoutSnapshot(
contentSize: NSSize(width: 132, height: 22),
containerHeight: 28,
yOffset: 3
)
XCTAssertTrue(titlebarControlsShouldApplyLayout(previous: baseline, next: changed))
}
}
final class TitlebarControlsHoverPolicyTests: XCTestCase {
func testHoverTrackingOnlyEnabledForHoverBackgroundStyles() {
XCTAssertFalse(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.classic.config))
XCTAssertFalse(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.compact.config))
XCTAssertFalse(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.roomy.config))
XCTAssertTrue(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.pillGroup.config))
XCTAssertFalse(titlebarControlsShouldTrackButtonHover(config: TitlebarControlsStyle.softButtons.config))
}
}
/// Regression test: ensure new terminal windows are born in full-size content mode so
/// titlebar/content offsets are correct before the first resize.
final class MainWindowLayoutStyleTests: XCTestCase {

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

@ -0,0 +1,439 @@
import XCTest
import AppKit
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
final class WorkspaceManualUnreadTests: XCTestCase {
func testShouldClearManualUnreadWhenFocusMovesToDifferentPanel() {
let previousFocusedPanelId = UUID()
let nextFocusedPanelId = UUID()
XCTAssertTrue(
Workspace.shouldClearManualUnread(
previousFocusedPanelId: previousFocusedPanelId,
nextFocusedPanelId: nextFocusedPanelId,
isManuallyUnread: true,
markedAt: Date()
)
)
}
func testShouldNotClearManualUnreadWhenFocusStaysOnSamePanelWithinGrace() {
let panelId = UUID()
let now = Date()
XCTAssertFalse(
Workspace.shouldClearManualUnread(
previousFocusedPanelId: panelId,
nextFocusedPanelId: panelId,
isManuallyUnread: true,
markedAt: now.addingTimeInterval(-0.05),
now: now,
sameTabGraceInterval: 0.2
)
)
}
func testShouldClearManualUnreadWhenFocusStaysOnSamePanelAfterGrace() {
let panelId = UUID()
let now = Date()
XCTAssertTrue(
Workspace.shouldClearManualUnread(
previousFocusedPanelId: panelId,
nextFocusedPanelId: panelId,
isManuallyUnread: true,
markedAt: now.addingTimeInterval(-0.25),
now: now,
sameTabGraceInterval: 0.2
)
)
}
func testShouldNotClearManualUnreadWhenNotManuallyUnread() {
XCTAssertFalse(
Workspace.shouldClearManualUnread(
previousFocusedPanelId: UUID(),
nextFocusedPanelId: UUID(),
isManuallyUnread: false,
markedAt: Date()
)
)
}
func testShouldNotClearManualUnreadWhenNoPreviousFocusAndWithinGrace() {
let now = Date()
XCTAssertFalse(
Workspace.shouldClearManualUnread(
previousFocusedPanelId: nil,
nextFocusedPanelId: UUID(),
isManuallyUnread: true,
markedAt: now.addingTimeInterval(-0.05),
now: now,
sameTabGraceInterval: 0.2
)
)
}
func testShouldShowUnreadIndicatorWhenNotificationIsUnread() {
XCTAssertTrue(
Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: true,
isManuallyUnread: false
)
)
}
func testShouldShowUnreadIndicatorWhenManualUnreadIsSet() {
XCTAssertTrue(
Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: false,
isManuallyUnread: true
)
)
}
func testShouldHideUnreadIndicatorWhenNeitherNotificationNorManualUnreadExists() {
XCTAssertFalse(
Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: false,
isManuallyUnread: false
)
)
}
}
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

@ -6,6 +6,7 @@ final class AutomationSocketUITests: XCTestCase {
private let defaultsDomain = "com.cmuxterm.app.debug"
private let modeKey = "socketControlMode"
private let legacyKey = "socketControlEnabled"
private let launchTag = "ui-tests-automation-socket"
override func setUp() {
super.setUp()
@ -16,11 +17,12 @@ final class AutomationSocketUITests: XCTestCase {
}
func testSocketToggleDisablesAndEnables() {
let app = XCUIApplication()
app.launchArguments += ["-\(modeKey)", "cmuxOnly"]
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
let app = configuredApp(mode: "cmuxOnly")
app.launch()
app.activate()
XCTAssertTrue(
ensureForegroundAfterLaunch(app, timeout: 12.0),
"Expected app to launch for socket toggle test. state=\(app.state.rawValue)"
)
guard let resolvedPath = resolveSocketPath(timeout: 5.0) else {
XCTFail("Expected control socket to exist")
@ -32,16 +34,40 @@ final class AutomationSocketUITests: XCTestCase {
}
func testSocketDisabledWhenSettingOff() {
let app = XCUIApplication()
app.launchArguments += ["-\(modeKey)", "off"]
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
let app = configuredApp(mode: "off")
app.launch()
app.activate()
XCTAssertTrue(
ensureForegroundAfterLaunch(app, timeout: 12.0),
"Expected app to launch for socket off test. state=\(app.state.rawValue)"
)
XCTAssertTrue(waitForSocket(exists: false, timeout: 3.0))
app.terminate()
}
private func configuredApp(mode: String) -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments += ["-\(modeKey)", mode]
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1"
// Debug launches require a tag outside reload.sh; provide one in UITests so CI
// does not fail with "Application ... does not have a process ID".
app.launchEnvironment["CMUX_TAG"] = launchTag
return app
}
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
if app.wait(for: .runningForeground, timeout: timeout) {
return true
}
// On busy UI runners the app can launch backgrounded; activate once before failing.
if app.state == .runningBackground {
app.activate()
return app.wait(for: .runningForeground, timeout: 6.0)
}
return false
}
private func waitForSocket(exists: Bool, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {

View file

@ -18,7 +18,11 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
}
func testOmnibarSuggestionsAlignToPillAndCmdNP() {
seedBrowserHistoryForTest()
seedBrowserHistoryForTest(seedEntries: [
SeedEntry(url: "https://example.com/", title: "Example Domain", visitCount: 12, typedCount: 4),
SeedEntry(url: "https://example.org/", title: "Example Organization", visitCount: 9, typedCount: 3),
SeedEntry(url: "https://go.dev/", title: "The Go Programming Language", visitCount: 6, typedCount: 1),
])
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
@ -26,8 +30,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
// Keep suggestions deterministic for the keyboard-nav assertions.
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
app.launch()
app.activate()
launchAndEnsureForeground(app)
// Focus omnibar.
app.typeKey("l", modifierFlags: [.command])
@ -39,7 +42,10 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
// Type a query that matches the seeded URL.
omnibar.typeText("exam")
XCTAssertTrue(
typeQueryAndWaitForSuggestions(app: app, omnibar: omnibar, query: "exam", timeout: 6.0),
"Expected omnibar suggestions to appear for 'exam'"
)
// SwiftUI's accessibility typing for ScrollView can vary; match by identifier regardless of element type.
let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
@ -75,10 +81,16 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
XCTAssertTrue(row1.waitForExistence(timeout: 6.0))
app.typeKey("n", modifierFlags: [.command])
XCTAssertTrue(waitForRowSelected(row1, timeout: 2.0), "Expected Cmd+N to select row 1. value=\(String(describing: row1.value))")
XCTAssertTrue(
waitForSuggestionRowToBeSelected(row1, timeout: 3.0),
"Expected Cmd+N to move selection to row 1. row1Value=\(String(describing: row1.value))"
)
app.typeKey("p", modifierFlags: [.command])
XCTAssertTrue(waitForRowSelected(row0, timeout: 2.0), "Expected Cmd+P to return to row 0. value=\(String(describing: row0.value))")
XCTAssertTrue(
waitForSuggestionRowToBeSelected(row0, timeout: 3.0),
"Expected Cmd+P to move selection back to row 0. row0Value=\(String(describing: row0.value))"
)
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
@ -104,14 +116,16 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
// Keep suggestions deterministic.
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
app.launch()
app.activate()
launchAndEnsureForeground(app)
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
XCTAssertTrue(
focusOmnibarWithCmdL(app: app, omnibar: omnibar, timeout: 4.0),
"Expected Cmd+L to place keyboard focus in omnibar before typing"
)
// Focus omnibar and navigate to example.com via autocompletion (row 0).
app.typeKey("l", modifierFlags: [.command])
omnibar.typeText("exam")
let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
@ -189,14 +203,14 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
app.launchEnvironment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] = #"["go tutorial","go json","go fmt"]"#
app.launch()
app.activate()
app.typeKey("l", modifierFlags: [.command])
launchAndEnsureForeground(app)
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
omnibar.typeText("go")
XCTAssertTrue(
typeQueryAndWaitForSuggestions(app: app, omnibar: omnibar, query: "go", timeout: 6.0),
"Expected omnibar suggestions to appear for 'go'"
)
let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
XCTAssertTrue(suggestionsElement.waitForExistence(timeout: 6.0))
@ -207,13 +221,22 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
XCTAssertTrue(row2.waitForExistence(timeout: 6.0))
app.typeKey("n", modifierFlags: [.command])
XCTAssertTrue(waitForRowSelected(row1, timeout: 2.0), "Expected Cmd+N to select row 1")
XCTAssertTrue(
waitForSuggestionRowToBeSelected(row1, timeout: 3.0),
"Expected Cmd+N to move selection to row 1. row1Value=\(String(describing: row1.value))"
)
app.typeKey("n", modifierFlags: [.command])
XCTAssertTrue(waitForRowSelected(row2, timeout: 2.0), "Expected repeated Cmd+N to keep moving selection")
XCTAssertTrue(
waitForSuggestionRowToBeSelected(row2, timeout: 3.0),
"Expected repeated Cmd+N to move selection to row 2. row2Value=\(String(describing: row2.value))"
)
app.typeKey("p", modifierFlags: [.command])
XCTAssertTrue(waitForRowSelected(row1, timeout: 2.0), "Expected Cmd+P to move selection up")
XCTAssertTrue(
waitForSuggestionRowToBeSelected(row1, timeout: 3.0),
"Expected Cmd+P to move selection back to row 1. row1Value=\(String(describing: row1.value))"
)
}
func testOmnibarShowsMultipleRowsWithoutClipping() {
@ -225,8 +248,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
app.launchEnvironment["CMUX_UI_TEST_REMOTE_SUGGESTIONS_JSON"] = #"["go tutorial","go json","go fmt"]"#
app.launch()
app.activate()
launchAndEnsureForeground(app)
app.typeKey("l", modifierFlags: [.command])
@ -253,8 +275,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
app.launch()
app.activate()
launchAndEnsureForeground(app)
app.typeKey("l", modifierFlags: [.command])
@ -315,8 +336,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
app.launch()
app.activate()
launchAndEnsureForeground(app)
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
@ -342,7 +362,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
app.typeKey("l", modifierFlags: [.command])
app.typeText("lo")
let typedDeadline = Date().addingTimeInterval(4.0)
let typedDeadline = Date().addingTimeInterval(7.0)
var observedValue = ""
var startsWithTypedPrefix = false
while Date() < typedDeadline {
@ -373,8 +393,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
app.launch()
app.activate()
launchAndEnsureForeground(app)
app.typeKey("l", modifierFlags: [.command])
@ -385,14 +404,46 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
let suggestionsElement = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
XCTAssertTrue(suggestionsElement.waitForExistence(timeout: 6.0))
let row0 = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.0").firstMatch
XCTAssertTrue(row0.waitForExistence(timeout: 4.0))
let row0Value = (row0.value as? String) ?? ""
XCTAssertTrue(
row0Value.localizedCaseInsensitiveContains("gmail"),
"Expected autocomplete candidate to be first row. row0Value=\(row0Value)"
)
let rows: [XCUIElement] = (0...4).map {
app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions.Row.\($0)").firstMatch
}
XCTAssertTrue(rows[0].waitForExistence(timeout: 4.0))
var gmailRowIndex: Int?
let gmailDeadline = Date().addingTimeInterval(4.0)
while Date() < gmailDeadline {
for (index, row) in rows.enumerated() where row.exists {
let rowValue = (row.value as? String) ?? ""
if rowValue.localizedCaseInsensitiveContains("gmail") {
gmailRowIndex = index
break
}
}
if gmailRowIndex != nil {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
guard let gmailRowIndex else {
let rowValues = rows.enumerated().compactMap { index, row -> String? in
guard row.exists else { return nil }
return "row\(index)=\((row.value as? String) ?? "<nil>")"
}.joined(separator: ", ")
XCTFail("Expected a Gmail suggestion row. rows=\(rowValues)")
return
}
if gmailRowIndex > 0 {
let gmailRow = rows[gmailRowIndex]
for _ in 0..<gmailRowIndex {
app.typeKey("n", modifierFlags: [.command])
}
XCTAssertTrue(
waitForSuggestionRowToBeSelected(gmailRow, timeout: 3.0),
"Expected Cmd+N to select Gmail row \(gmailRowIndex). value=\(String(describing: gmailRow.value))"
)
}
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
@ -415,8 +466,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
app.launch()
app.activate()
launchAndEnsureForeground(app)
app.typeKey("l", modifierFlags: [.command])
@ -461,8 +511,7 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
app.launch()
app.activate()
launchAndEnsureForeground(app)
app.typeKey("l", modifierFlags: [.command])
@ -499,26 +548,28 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_DISABLE_REMOTE_SUGGESTIONS"] = "1"
app.launch()
app.activate()
launchAndEnsureForeground(app)
app.typeKey("l", modifierFlags: [.command])
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0))
omnibar.typeText("go")
omnibar.typeText("exam")
let typedPrefix = "exam"
let inlineDeadline = Date().addingTimeInterval(3.0)
var valueBeforeCmdA = ""
while Date() < inlineDeadline {
let value = (omnibar.value as? String) ?? ""
if value.contains("google.com") {
valueBeforeCmdA = (omnibar.value as? String) ?? ""
let normalized = valueBeforeCmdA.lowercased()
if normalized.hasPrefix(typedPrefix), valueBeforeCmdA.utf16.count > typedPrefix.utf16.count {
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
XCTAssertTrue(
((omnibar.value as? String) ?? "").contains("google.com"),
"Expected inline completion to show google.com before Cmd+A."
valueBeforeCmdA.lowercased().hasPrefix(typedPrefix) && valueBeforeCmdA.utf16.count > typedPrefix.utf16.count,
"Expected inline completion to extend typed prefix before Cmd+A. value=\(valueBeforeCmdA)"
)
app.typeKey("a", modifierFlags: [.command])
@ -526,11 +577,30 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
let afterCmdA = (omnibar.value as? String) ?? ""
XCTAssertTrue(
afterCmdA.contains("google.com"),
"Expected Cmd+A to preserve inline completion display instead of collapsing to typed prefix. value=\(afterCmdA)"
afterCmdA.lowercased().hasPrefix(typedPrefix) && afterCmdA.utf16.count > typedPrefix.utf16.count,
"Expected Cmd+A to preserve inline completion display instead of collapsing to typed prefix. before=\(valueBeforeCmdA) after=\(afterCmdA)"
)
}
private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) {
app.launch()
XCTAssertTrue(
ensureForegroundAfterLaunch(app, timeout: timeout),
"Expected app to launch in foreground. state=\(app.state.rawValue)"
)
}
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
if app.wait(for: .runningForeground, timeout: timeout) {
return true
}
if app.state == .runningBackground {
app.activate()
return app.wait(for: .runningForeground, timeout: 6.0)
}
return false
}
private struct SeedEntry {
let url: String
let title: String
@ -617,14 +687,81 @@ final class BrowserOmnibarSuggestionsUITests: XCTestCase {
add(attachment)
}
private func waitForRowSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool {
private func waitForSuggestionRowToBeSelected(_ row: XCUIElement, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if ((row.value as? String) ?? "").contains("selected") {
if isSuggestionRowSelected(row) {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return ((row.value as? String) ?? "").contains("selected")
return isSuggestionRowSelected(row)
}
private func isSuggestionRowSelected(_ row: XCUIElement) -> Bool {
guard row.exists else { return false }
guard let rawValue = row.value as? String else { return false }
return rawValue.localizedCaseInsensitiveContains("selected")
}
private func typeQueryAndWaitForSuggestions(
app: XCUIApplication,
omnibar: XCUIElement,
query: String,
timeout: TimeInterval,
attempts: Int = 3
) -> Bool {
let suggestions = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarSuggestions").firstMatch
for _ in 0..<attempts {
if app.state == .runningBackground {
app.activate()
_ = app.wait(for: .runningForeground, timeout: 2.0)
}
app.typeKey("l", modifierFlags: [.command])
guard omnibar.waitForExistence(timeout: 6.0) else { continue }
omnibar.click()
app.typeKey("a", modifierFlags: [.command])
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
omnibar.click()
omnibar.typeText(query)
if suggestions.waitForExistence(timeout: timeout) {
return true
}
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
return suggestions.exists
}
private func focusOmnibarWithCmdL(app: XCUIApplication, omnibar: XCUIElement, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
app.typeKey("l", modifierFlags: [.command])
guard omnibar.waitForExistence(timeout: 1.0) else { continue }
let before = (omnibar.value as? String) ?? ""
omnibar.typeText("z")
let probeDeadline = Date().addingTimeInterval(0.5)
var acceptedProbe = false
while Date() < probeDeadline {
let value = (omnibar.value as? String) ?? ""
if value != before {
acceptedProbe = true
break
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
if acceptedProbe {
app.typeKey("a", modifierFlags: [.command])
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
return true
}
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
}
return false
}
}

View file

@ -18,9 +18,9 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1"
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launch()
app.activate()
launchAndEnsureForeground(app)
XCTAssertTrue(
waitForData(keys: ["terminalPaneId", "browserPaneId", "webViewFocused"], timeout: 10.0),
@ -93,10 +93,10 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1"
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_USE_GHOSTTY_CONFIG"] = "1"
app.launch()
app.activate()
launchAndEnsureForeground(app)
XCTAssertTrue(
waitForData(keys: ["terminalPaneId", "browserPaneId", "webViewFocused", "ghosttyGotoSplitLeftShortcut"], timeout: 10.0),
@ -109,7 +109,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
}
XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test")
XCTAssertEqual(setup["ghosttyGotoSplitLeftShortcut"], "⌃⌘H", "Expected Ghostty config trigger to be Cmd+Ctrl+H")
XCTAssertFalse((setup["ghosttyGotoSplitLeftShortcut"] ?? "").isEmpty, "Expected Ghostty trigger metadata to be present")
guard let expectedTerminalPaneId = setup["terminalPaneId"] else {
XCTFail("Missing terminalPaneId in goto_split setup data")
@ -132,8 +132,8 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launch()
app.activate()
app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1"
launchAndEnsureForeground(app)
XCTAssertTrue(
waitForData(keys: ["browserPanelId", "webViewFocused"], timeout: 10.0),
@ -176,8 +176,8 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launch()
app.activate()
app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1"
launchAndEnsureForeground(app)
XCTAssertTrue(
waitForData(keys: ["browserPanelId", "terminalPaneId", "webViewFocused"], timeout: 10.0),
@ -225,8 +225,8 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launch()
app.activate()
app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1"
launchAndEnsureForeground(app)
XCTAssertTrue(
waitForData(keys: ["browserPanelId", "terminalPaneId", "webViewFocused"], timeout: 10.0),
@ -280,8 +280,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launch()
app.activate()
launchAndEnsureForeground(app)
XCTAssertTrue(
waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0),
@ -314,8 +313,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launch()
app.activate()
launchAndEnsureForeground(app)
XCTAssertTrue(
waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0),
@ -348,8 +346,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launch()
app.activate()
launchAndEnsureForeground(app)
XCTAssertTrue(
waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0),
@ -390,8 +387,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
app.launch()
app.activate()
launchAndEnsureForeground(app)
XCTAssertTrue(
waitForData(keys: ["webViewFocused", "initialPaneCount"], timeout: 10.0),
@ -427,6 +423,25 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
)
}
private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) {
app.launch()
XCTAssertTrue(
ensureForegroundAfterLaunch(app, timeout: timeout),
"Expected app to launch in foreground. state=\(app.state.rawValue)"
)
}
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
if app.wait(for: .runningForeground, timeout: timeout) {
return true
}
if app.state == .runningBackground {
app.activate()
return app.wait(for: .runningForeground, timeout: 6.0)
}
return false
}
private func waitForData(keys: [String], timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {

View file

@ -134,8 +134,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
}
let rightPanelId = ready["rightPanelId"] ?? ""
XCTAssertEqual(ready["focusedPanelBefore"], rightPanelId, "Expected right split to be the focused panel before Ctrl+D. data=\(ready)")
XCTAssertEqual(ready["firstResponderPanelBefore"], rightPanelId, "Expected AppKit first responder to match right split before Ctrl+D. data=\(ready)")
guard !rightPanelId.isEmpty else {
XCTFail("Missing rightPanelId in setup data. data=\(ready)")
return
}
assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: rightPanelId, context: "Horizontal split")
// Exercise the real keyboard path (same path as user typing Ctrl+D), not an in-process helper.
app.activate()
@ -191,8 +194,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
}
let rightPanelId = ready["rightPanelId"] ?? ""
XCTAssertEqual(ready["focusedPanelBefore"], rightPanelId, "Expected right split to be focused before Ctrl+D. data=\(ready)")
XCTAssertEqual(ready["firstResponderPanelBefore"], rightPanelId, "Expected first responder to match right split before Ctrl+D. data=\(ready)")
guard !rightPanelId.isEmpty else {
XCTFail("Missing rightPanelId in setup data. data=\(ready)")
return
}
assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: rightPanelId, context: "Three-pane layout")
guard let done = waitForJSONKey("done", equals: "1", atPath: dataPath, timeout: 10.0) else {
XCTFail("Timed out waiting for done=1 after Ctrl+D. data=\(loadJSON(atPath: dataPath) ?? [:])")
return
@ -257,16 +263,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
2,
"Attempt \(attempt): expected two panels before Ctrl+D in 2x2-right-close repro. data=\(ready)"
)
XCTAssertEqual(
ready["focusedPanelBefore"],
exitPanelId,
"Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)"
)
XCTAssertEqual(
ready["firstResponderPanelBefore"],
exitPanelId,
"Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)"
)
guard !exitPanelId.isEmpty else {
XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)")
return
}
assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): 2x2-right-close")
app.typeKey("d", modifierFlags: [.control])
@ -335,16 +336,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
2,
"Attempt \(attempt): expected two panels before Ctrl+D in 2x2-bottom-close repro. data=\(ready)"
)
XCTAssertEqual(
ready["focusedPanelBefore"],
exitPanelId,
"Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)"
)
XCTAssertEqual(
ready["firstResponderPanelBefore"],
exitPanelId,
"Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)"
)
guard !exitPanelId.isEmpty else {
XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)")
return
}
assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): 2x2-bottom-close")
app.typeKey("d", modifierFlags: [.control])
@ -412,16 +408,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
2,
"Attempt \(attempt): expected two panels before Ctrl+D in 2x2-right-close repro. data=\(ready)"
)
XCTAssertEqual(
ready["focusedPanelBefore"],
exitPanelId,
"Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)"
)
XCTAssertEqual(
ready["firstResponderPanelBefore"],
exitPanelId,
"Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)"
)
guard !exitPanelId.isEmpty else {
XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)")
return
}
assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): 2x2-right-close real key")
app.typeKey("d", modifierFlags: [.control])
@ -497,16 +488,11 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
2,
"Attempt \(attempt): expected two panels before Ctrl+D in left/right repro. data=\(ready)"
)
XCTAssertEqual(
ready["focusedPanelBefore"],
exitPanelId,
"Attempt \(attempt): expected target exit pane to be focused before Ctrl+D. data=\(ready)"
)
XCTAssertEqual(
ready["firstResponderPanelBefore"],
exitPanelId,
"Attempt \(attempt): expected first responder to match target pane before Ctrl+D. data=\(ready)"
)
guard !exitPanelId.isEmpty else {
XCTFail("Attempt \(attempt): missing exitPanelId in setup data. data=\(ready)")
return
}
assertCtrlDPreconditionsBeforeTrigger(ready, expectedExitPanelId: exitPanelId, context: "Attempt \(attempt): left/right real key")
app.typeKey("d", modifierFlags: [.control])
@ -546,6 +532,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 {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
@ -619,6 +667,26 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
return nil
}
private func assertCtrlDPreconditionsBeforeTrigger(
_ data: [String: String],
expectedExitPanelId: String,
context: String
) {
XCTAssertEqual(
data["focusedPanelBefore"],
expectedExitPanelId,
"\(context): expected target exit pane to be focused before Ctrl+D. data=\(data)"
)
let firstResponderPanelBefore = data["firstResponderPanelBefore"] ?? ""
if !firstResponderPanelBefore.isEmpty {
XCTAssertEqual(
firstResponderPanelBefore,
expectedExitPanelId,
"\(context): expected first responder to match target pane before Ctrl+D when present. data=\(data)"
)
}
}
private func loadJSON(atPath path: String) -> [String: String]? {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {

View file

@ -5,12 +5,14 @@ import CoreGraphics
final class MultiWindowNotificationsUITests: XCTestCase {
private var dataPath = ""
private var socketPath = ""
private var launchTag = ""
override func setUp() {
super.setUp()
continueAfterFailure = false
dataPath = "/tmp/cmux-ui-test-multi-window-notifs-\(UUID().uuidString).json"
socketPath = "/tmp/cmux-ui-test-socket-\(UUID().uuidString).sock"
launchTag = "ui-tests-multi-window-notifs-\(UUID().uuidString.prefix(8))"
try? FileManager.default.removeItem(atPath: dataPath)
try? FileManager.default.removeItem(atPath: socketPath)
}
@ -25,8 +27,12 @@ final class MultiWindowNotificationsUITests: XCTestCase {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath
app.launchEnvironment["CMUX_TAG"] = launchTag
app.launch()
app.activate()
XCTAssertTrue(
ensureForegroundAfterLaunch(app, timeout: 12.0),
"Expected app to launch for multi-window routing test. state=\(app.state.rawValue)"
)
XCTAssertTrue(
waitForData(keys: [
@ -108,8 +114,12 @@ final class MultiWindowNotificationsUITests: XCTestCase {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1"
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath
app.launchEnvironment["CMUX_TAG"] = launchTag
app.launch()
app.activate()
XCTAssertTrue(
ensureForegroundAfterLaunch(app, timeout: 12.0),
"Expected app to launch for notifications popover shortcut test. state=\(app.state.rawValue)"
)
XCTAssertTrue(
waitForData(keys: ["notifId1"], timeout: 15.0),
@ -137,14 +147,29 @@ final class MultiWindowNotificationsUITests: XCTestCase {
XCTAssertTrue(waitForElementToDisappear(targetButton, timeout: 3.0), "Expected popover to close on Escape")
}
func testEmptyNotificationsPopoverBlocksTerminalTyping() {
func testEmptyNotificationsPopoverBlocksTerminalTyping() throws {
let app = XCUIApplication()
app.launchArguments += ["-socketControlMode", "allowAll"]
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_SOCKET_MODE"] = "allowAll"
app.launchEnvironment["CMUX_SOCKET_ENABLE"] = "1"
app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1"
app.launchEnvironment["CMUX_TAG"] = launchTag
app.launch()
app.activate()
XCTAssertTrue(
ensureForegroundAfterLaunch(app, timeout: 12.0),
"Expected app to launch for empty popover blocking test. state=\(app.state.rawValue)"
)
XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 8.0))
XCTAssertTrue(waitForSocketPong(timeout: 8.0), "Expected control socket to respond")
guard let resolvedPath = resolveSocketPath(timeout: 8.0) else {
throw XCTSkip("Control socket unavailable in this test environment. requested=\(socketPath)")
}
socketPath = resolvedPath
let pingResponse = waitForSocketPong(timeout: 8.0)
guard pingResponse == "PONG" else {
throw XCTSkip("Control socket did not respond in time. path=\(socketPath) response=\(pingResponse ?? "<nil>")")
}
_ = socketCommand("clear_notifications")
@ -198,6 +223,17 @@ final class MultiWindowNotificationsUITests: XCTestCase {
return app.windows.count >= count
}
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
if app.wait(for: .runningForeground, timeout: timeout) {
return true
}
if app.state == .runningBackground {
app.activate()
return app.wait(for: .runningForeground, timeout: 6.0)
}
return false
}
private func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
let predicate = NSPredicate(format: "exists == false")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
@ -238,31 +274,73 @@ final class MultiWindowNotificationsUITests: XCTestCase {
return false
}
private func waitForSocketPong(timeout: TimeInterval) -> Bool {
private func waitForSocketPong(timeout: TimeInterval) -> String? {
let deadline = Date().addingTimeInterval(timeout)
var lastResponse: String?
while Date() < deadline {
if socketCommand("ping") == "PONG" {
return true
lastResponse = socketCommand("ping")
if lastResponse == "PONG" {
return "PONG"
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return socketCommand("ping") ?? lastResponse
}
private func resolveSocketPath(timeout: TimeInterval) -> String? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
for candidate in expectedSocketCandidates() {
guard FileManager.default.fileExists(atPath: candidate) else { continue }
if socketRespondsToPing(at: candidate) {
return candidate
}
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
for candidate in expectedSocketCandidates() {
guard FileManager.default.fileExists(atPath: candidate) else { continue }
if socketRespondsToPing(at: candidate) {
return candidate
}
}
return nil
}
private func expectedSocketCandidates() -> [String] {
var candidates = [socketPath]
let taggedDebugSocket = "/tmp/cmux-debug-\(launchTag).sock"
if taggedDebugSocket != socketPath {
candidates.append(taggedDebugSocket)
}
return candidates
}
private func socketRespondsToPing(at path: String) -> Bool {
let originalPath = socketPath
socketPath = path
defer { socketPath = originalPath }
return socketCommand("ping") == "PONG"
}
private func socketCommand(_ cmd: String) -> String? {
if let response = ControlSocketClient(path: socketPath).sendLine(cmd) {
return response
}
return socketCommandViaNetcat(cmd)
}
private func socketCommandViaNetcat(_ cmd: String) -> String? {
let nc = "/usr/bin/nc"
guard FileManager.default.isExecutableFile(atPath: nc) else { return nil }
let proc = Process()
proc.executableURL = URL(fileURLWithPath: nc)
proc.arguments = ["-U", socketPath, "-w", "2"]
proc.executableURL = URL(fileURLWithPath: "/bin/sh")
let script = "printf '%s\\n' \(shellSingleQuote(cmd)) | \(nc) -U \(shellSingleQuote(socketPath)) -w 2 2>/dev/null"
proc.arguments = ["-lc", script]
let inPipe = Pipe()
let outPipe = Pipe()
let errPipe = Pipe()
proc.standardInput = inPipe
proc.standardOutput = outPipe
proc.standardError = errPipe
do {
try proc.run()
@ -270,11 +348,6 @@ final class MultiWindowNotificationsUITests: XCTestCase {
return nil
}
if let data = (cmd + "\n").data(using: .utf8) {
inPipe.fileHandleForWriting.write(data)
}
inPipe.fileHandleForWriting.closeFile()
proc.waitUntilExit()
let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
@ -286,6 +359,94 @@ final class MultiWindowNotificationsUITests: XCTestCase {
return trimmed.isEmpty ? nil : trimmed
}
private func shellSingleQuote(_ value: String) -> String {
if value.isEmpty { return "''" }
return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
}
private final class ControlSocketClient {
private let path: String
init(path: String) {
self.path = path
}
func sendLine(_ line: String) -> String? {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return nil }
defer { close(fd) }
#if os(macOS)
var noSigPipe: Int32 = 1
_ = withUnsafePointer(to: &noSigPipe) { ptr in
setsockopt(
fd,
SOL_SOCKET,
SO_NOSIGPIPE,
ptr,
socklen_t(MemoryLayout<Int32>.size)
)
}
#endif
var addr = sockaddr_un()
memset(&addr, 0, MemoryLayout<sockaddr_un>.size)
addr.sun_family = sa_family_t(AF_UNIX)
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
let bytes = Array(path.utf8CString)
guard bytes.count <= maxLen else { return nil }
withUnsafeMutablePointer(to: &addr.sun_path) { p in
let raw = UnsafeMutableRawPointer(p).assumingMemoryBound(to: CChar.self)
memset(raw, 0, maxLen)
for i in 0..<bytes.count {
raw[i] = bytes[i]
}
}
let pathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0
let addrLen = socklen_t(pathOffset + bytes.count)
#if os(macOS)
addr.sun_len = UInt8(min(Int(addrLen), 255))
#endif
let connected = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
connect(fd, sa, addrLen)
}
}
guard connected == 0 else { return nil }
let payload = line + "\n"
let wrote: Bool = payload.withCString { cstr in
var remaining = strlen(cstr)
var p = UnsafeRawPointer(cstr)
while remaining > 0 {
let n = write(fd, p, remaining)
if n <= 0 { return false }
remaining -= n
p = p.advanced(by: n)
}
return true
}
guard wrote else { return nil }
var buf = [UInt8](repeating: 0, count: 4096)
var accum = ""
while true {
let n = read(fd, &buf, buf.count)
if n <= 0 { break }
if let chunk = String(bytes: buf[0..<n], encoding: .utf8) {
accum.append(chunk)
if let idx = accum.firstIndex(of: "\n") {
return String(accum[..<idx])
}
}
}
return accum.isEmpty ? nil : accum.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
private func readCurrentTerminalText() -> String? {
guard let response = socketCommand("read_terminal_text"), response.hasPrefix("OK ") else {
return nil

View file

@ -13,6 +13,7 @@ final class SidebarResizeUITests: XCTestCase {
let elements = app.descendants(matching: .any)
let resizer = elements["SidebarResizer"]
XCTAssertTrue(resizer.waitForExistence(timeout: 5.0))
XCTAssertTrue(waitForElementHittable(resizer, timeout: 5.0), "Expected sidebar resizer to become hittable")
let initialX = resizer.frame.minX
@ -35,4 +36,46 @@ final class SidebarResizeUITests: XCTestCase {
XCTAssertLessThanOrEqual(leftDelta, -40, "Expected drag-left to move resizer left")
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))
XCTAssertTrue(waitForElementHittable(resizer, timeout: 5.0), "Expected sidebar resizer to become hittable")
let start = resizer.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
let farRight = start.withOffset(CGVector(dx: max(1200, window.frame.width * 2.0), 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)"
)
}
private func waitForElementHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if element.exists, element.isHittable {
let frame = element.frame
if frame.width > 1, frame.height > 1 {
return true
}
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return false
}
}

View file

@ -1,6 +1,24 @@
import XCTest
import Foundation
// UI runners can adjust wall clock time mid-test; use monotonic uptime for polling deadlines.
private func pollUntil(
timeout: TimeInterval,
pollInterval: TimeInterval = 0.05,
condition: () -> Bool
) -> Bool {
let start = ProcessInfo.processInfo.systemUptime
while true {
if condition() {
return true
}
if (ProcessInfo.processInfo.systemUptime - start) >= timeout {
return false
}
RunLoop.current.run(until: Date().addingTimeInterval(pollInterval))
}
}
final class UpdatePillUITests: XCTestCase {
override func setUp() {
super.setUp()
@ -131,25 +149,28 @@ final class UpdatePillUITests: XCTestCase {
}
private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if app.windows.count >= count { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
pollUntil(timeout: timeout) {
app.windows.count >= count
}
return app.windows.count >= count
}
private func assertVisibleSize(_ element: XCUIElement, timeout: TimeInterval = 2.0) {
let deadline = Date().addingTimeInterval(timeout)
let pollInterval: TimeInterval = 0.05
var size = element.frame.size
while Date() < deadline {
var exists = element.exists
var hittable = element.isHittable
let visible = pollUntil(timeout: timeout, pollInterval: pollInterval) {
size = element.frame.size
if size.width > 20 && size.height > 10 {
return
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
exists = element.exists
hittable = element.isHittable
return size.width > 20 && size.height > 10
}
if !visible {
XCTFail(
"Expected UpdatePill to have visible size, got \(size), exists=\(exists), hittable=\(hittable)"
)
}
XCTFail("Expected UpdatePill to have visible size, got \(size)")
}
private func attachScreenshot(name: String, screenshot: XCUIScreenshot = XCUIScreen.main.screenshot()) {
@ -197,12 +218,14 @@ final class UpdatePillUITests: XCTestCase {
private func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) {
app.launch()
let deadline = Date().addingTimeInterval(activateTimeout)
while Date() < deadline, app.state != .runningForeground {
let activated = pollUntil(timeout: activateTimeout) {
guard app.state != .runningForeground else {
return true
}
app.activate()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
return app.state == .runningForeground
}
if app.state != .runningForeground {
if !activated {
app.activate()
}
}
@ -274,7 +297,7 @@ final class TitlebarShortcutHintsUITests: XCTestCase {
XCTAssertEqual(sidebarHintFrame.minY, notificationsHintFrame.minY, accuracy: 1.0)
XCTAssertEqual(notificationsHintFrame.minY, newTabHintFrame.minY, accuracy: 1.0)
// Keep the sidebar hint lane to the right of the sidebar icon so it cannot clip into the traffic-light backdrop.
XCTAssertGreaterThanOrEqual(sidebarHintFrame.minX, hintedToggleFrame.minX - 1.0)
XCTAssertGreaterThanOrEqual(sidebarHintFrame.minX, hintedToggleFrame.minX - 4.0)
let sortedHintFrames = [sidebarHintFrame, notificationsHintFrame, newTabHintFrame]
.sorted { $0.minX < $1.minX }
@ -293,40 +316,32 @@ final class TitlebarShortcutHintsUITests: XCTestCase {
app.launchArguments += ["-shortcutHintTitlebarYOffset", "0"]
app.launch()
let deadline = Date().addingTimeInterval(2.0)
while Date() < deadline, app.state != .runningForeground {
_ = pollUntil(timeout: 2.0) {
guard app.state != .runningForeground else {
return true
}
app.activate()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
return app.state == .runningForeground
}
return app
}
private func waitForWindowCount(atLeast count: Int, app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if app.windows.count >= count { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
pollUntil(timeout: timeout) {
app.windows.count >= count
}
return app.windows.count >= count
}
private func waitForElementVisible(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
pollUntil(timeout: timeout) {
if element.exists {
let frame = element.frame
if frame.width > 1, frame.height > 1 {
return true
}
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
return false
}
if element.exists {
let frame = element.frame
return frame.width > 1 && frame.height > 1
}
return false
}
}

View file

@ -0,0 +1,76 @@
# Socket/CLI No-Focus-Steal Todo
## Goal
Ensure commands run through the cmux Unix socket/CLI do not steal user focus from the current UI workflow.
Policy target:
- App activation/window raising from socket commands: **never**.
- In-app focus mutation from socket commands: only for explicit focus-intent commands.
- Non-focus commands must not move workspace/pane/surface focus as a side effect.
## Task Checklist
- [x] Inventory all v1 + v2 socket command entrypoints.
- [x] Add socket-command focus policy context in `TerminalController`.
- [x] Suppress app activation for socket command path in `AppDelegate` (`focusMainWindow`, `createMainWindow`).
- [x] Gate in-app focus mutation side-effects in v2 handlers.
- [x] Gate in-app focus mutation side-effects in legacy v1 handlers.
- [x] Add explicit CLI `rename-tab` command with env-default targeting.
- [x] Update CLI help/usage/subcommand docs for `rename-tab`.
- [x] Add regression tests for rename-tab and no-unintended-focus-side-effects.
- [x] Run build + targeted tests.
- [x] Open PR.
## Explicit Focus-Intent Allowlist
These may mutate in-app focus/selection state:
v1:
- `focus_window`
- `select_workspace`
- `focus_surface`
- `focus_pane`
- `focus_surface_by_panel`
- `focus_webview`
- `focus_notification` (debug)
- `activate_app` (debug)
v2:
- `window.focus`
- `workspace.select`
- `workspace.next`
- `workspace.previous`
- `workspace.last`
- `surface.focus`
- `pane.focus`
- `pane.last`
- `browser.focus_webview`
- `browser.focus`
- `browser.tab.switch`
- `debug.notification.focus`
- `debug.app.activate`
All other commands should preserve current user focus context.
## Command Coverage Matrix (All Command Families)
- [x] v1 `ping`, `help`
- [x] v1 window commands (`list_windows`, `current_window`, `focus_window`, `new_window`, `close_window`)
- [x] v1 workspace commands (`move_workspace_to_window`, `list_workspaces`, `new_workspace`, `close_workspace`, `select_workspace`, `current_workspace`)
- [x] v1 surface/pane commands (`new_split`, `list_surfaces`, `focus_surface`, `list_panes`, `list_pane_surfaces`, `focus_pane`, `focus_surface_by_panel`, `drag_surface_to_split`, `new_pane`, `new_surface`, `close_surface`, `refresh_surfaces`, `surface_health`)
- [x] v1 input commands (`send`, `send_key`, `send_surface`, `send_key_surface`, `read_screen`)
- [x] v1 notification/status/log/report commands (`notify*`, `list_notifications`, `clear_notifications`, `set_status`, `clear_status`, `list_status`, `log`, `clear_log`, `list_log`, `set_progress`, `clear_progress`, `report_*`, `ports_kick`, `sidebar_state`, `reset_sidebar`)
- [x] v1 browser commands (`open_browser`, `navigate`, `browser_back`, `browser_forward`, `browser_reload`, `get_url`, `focus_webview`, `is_webview_focused`)
- [x] v1 debug/test commands (shortcut, type, drop/pasteboard, overlay probes, focus checks, screenshots, render/layout/flash/panel snapshot)
- [x] v2 system methods (`system.*`)
- [x] v2 window methods (`window.*`)
- [x] v2 workspace methods (`workspace.*`)
- [x] v2 surface methods (`surface.*`, `tab.action`)
- [x] v2 pane methods (`pane.*`)
- [x] v2 notification methods (`notification.*`)
- [x] v2 app methods (`app.*`)
- [x] v2 browser methods (full `browser.*` set including tab/network/trace/input)
- [x] v2 debug methods (`debug.*`)
## CLI Coverage
- [x] Ensure every top-level CLI command routes to non-focus-stealing socket behavior.
- [x] Add/verify `rename-workspace` + `rename-window` behavior remains intact.
- [x] Add explicit `rename-tab` command (defaults to `CMUX_TAB_ID` / `CMUX_SURFACE_ID` / `CMUX_WORKSPACE_ID` when flags omitted).

View file

@ -2,10 +2,50 @@
set -euo pipefail
# Build, sign, notarize, create DMG, generate appcast, and upload to GitHub release.
# Usage: ./scripts/build-sign-upload.sh <tag>
# Usage: ./scripts/build-sign-upload.sh <tag> [--allow-overwrite]
# Requires: source ~/.secrets/cmuxterm.env && export SPARKLE_PRIVATE_KEY
TAG="${1:?Usage: $0 <tag>}"
usage() {
cat <<'EOF'
Usage: ./scripts/build-sign-upload.sh <tag> [--allow-overwrite]
Options:
--allow-overwrite Permit replacing existing release assets for the same tag.
Use only for emergency rerolls.
EOF
}
ALLOW_OVERWRITE="false"
POSITIONAL=()
while [[ $# -gt 0 ]]; do
case "$1" in
--allow-overwrite)
ALLOW_OVERWRITE="true"
shift
;;
-h|--help)
usage
exit 0
;;
-*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
*)
POSITIONAL+=("$1")
shift
;;
esac
done
set -- "${POSITIONAL[@]}"
if [[ $# -ne 1 ]]; then
usage >&2
exit 1
fi
TAG="$1"
SIGN_HASH="A050CC7E193C8221BDBA204E731B046CDCCC1B30"
ENTITLEMENTS="cmux.entitlements"
APP_PATH="build/Build/Products/Release/cmux.app"
@ -81,8 +121,29 @@ echo "Generating appcast..."
# --- Create GitHub release (if needed) and upload ---
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Uploading to existing release $TAG..."
gh release upload "$TAG" cmux-macos.dmg appcast.xml --clobber
echo "Release $TAG already exists"
EXISTING_ASSETS="$(gh release view "$TAG" --json assets --jq '.assets[].name' || true)"
HAS_CONFLICTING_ASSET="false"
for asset in cmux-macos.dmg appcast.xml; do
if printf '%s\n' "$EXISTING_ASSETS" | grep -Fxq "$asset"; then
HAS_CONFLICTING_ASSET="true"
break
fi
done
if [[ "$HAS_CONFLICTING_ASSET" == "true" && "$ALLOW_OVERWRITE" != "true" ]]; then
echo "ERROR: Refusing to overwrite signed release assets for existing tag $TAG." >&2
echo "Use a new tag, or rerun with --allow-overwrite for an emergency reroll." >&2
exit 1
fi
if [[ "$ALLOW_OVERWRITE" == "true" ]]; then
echo "Uploading with overwrite enabled for existing release $TAG..."
gh release upload "$TAG" cmux-macos.dmg appcast.xml --clobber
else
echo "Uploading to existing release $TAG..."
gh release upload "$TAG" cmux-macos.dmg appcast.xml
fi
else
echo "Creating release $TAG and uploading..."
gh release create "$TAG" cmux-macos.dmg appcast.xml --title "$TAG" --notes "See CHANGELOG.md for details"

View file

@ -0,0 +1,37 @@
"use strict";
const IMMUTABLE_RELEASE_ASSETS = ["cmux-macos.dmg", "appcast.xml"];
const RELEASE_ASSET_GUARD_STATE = Object.freeze({
CLEAR: "clear",
PARTIAL: "partial",
COMPLETE: "complete",
});
function evaluateReleaseAssetGuard({ existingAssetNames, immutableAssetNames = IMMUTABLE_RELEASE_ASSETS }) {
const immutableAssets = immutableAssetNames || IMMUTABLE_RELEASE_ASSETS;
const existing = new Set(existingAssetNames || []);
const conflicts = immutableAssets.filter((assetName) => existing.has(assetName));
const missingImmutableAssets = immutableAssets.filter((assetName) => !existing.has(assetName));
let guardState = RELEASE_ASSET_GUARD_STATE.CLEAR;
if (conflicts.length === immutableAssets.length && immutableAssets.length > 0) {
guardState = RELEASE_ASSET_GUARD_STATE.COMPLETE;
} else if (conflicts.length > 0) {
guardState = RELEASE_ASSET_GUARD_STATE.PARTIAL;
}
return {
conflicts,
missingImmutableAssets,
guardState,
hasPartialConflict: guardState === RELEASE_ASSET_GUARD_STATE.PARTIAL,
shouldSkipBuildAndUpload: guardState === RELEASE_ASSET_GUARD_STATE.COMPLETE,
shouldSkipUpload: guardState === RELEASE_ASSET_GUARD_STATE.COMPLETE,
};
}
module.exports = {
IMMUTABLE_RELEASE_ASSETS,
RELEASE_ASSET_GUARD_STATE,
evaluateReleaseAssetGuard,
};

View file

@ -0,0 +1,49 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
IMMUTABLE_RELEASE_ASSETS,
RELEASE_ASSET_GUARD_STATE,
evaluateReleaseAssetGuard,
} = require("./release_asset_guard");
test("marks guard as complete and skips build/upload when all immutable assets already exist", () => {
const result = evaluateReleaseAssetGuard({
existingAssetNames: ["cmux-macos.dmg", "appcast.xml", "notes.txt"],
});
assert.deepEqual(result.conflicts, IMMUTABLE_RELEASE_ASSETS);
assert.deepEqual(result.missingImmutableAssets, []);
assert.equal(result.guardState, RELEASE_ASSET_GUARD_STATE.COMPLETE);
assert.equal(result.hasPartialConflict, false);
assert.equal(result.shouldSkipBuildAndUpload, true);
assert.equal(result.shouldSkipUpload, true);
});
test("marks guard as clear when immutable assets are not present", () => {
const result = evaluateReleaseAssetGuard({
existingAssetNames: ["notes.txt", "checksums.txt"],
});
assert.deepEqual(result.conflicts, []);
assert.deepEqual(result.missingImmutableAssets, IMMUTABLE_RELEASE_ASSETS);
assert.equal(result.guardState, RELEASE_ASSET_GUARD_STATE.CLEAR);
assert.equal(result.hasPartialConflict, false);
assert.equal(result.shouldSkipBuildAndUpload, false);
assert.equal(result.shouldSkipUpload, false);
});
test("marks guard as partial when only some immutable assets exist", () => {
const result = evaluateReleaseAssetGuard({
existingAssetNames: ["appcast.xml"],
});
assert.deepEqual(result.conflicts, ["appcast.xml"]);
assert.deepEqual(result.missingImmutableAssets, ["cmux-macos.dmg"]);
assert.equal(result.guardState, RELEASE_ASSET_GUARD_STATE.PARTIAL);
assert.equal(result.hasPartialConflict, true);
assert.equal(result.shouldSkipBuildAndUpload, false);
assert.equal(result.shouldSkipUpload, false);
});

View file

@ -13,6 +13,7 @@ cd "$(dirname "$0")/.."
DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v1"
APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app"
RUN_TAG="tests-v1"
echo "== build =="
# 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
# 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=""
for _ in {1..120}; do
@ -70,7 +71,7 @@ launch_and_wait() {
export CMUX_SOCKET="$SOCK"
# 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
echo "== wait ready =="

View file

@ -13,6 +13,7 @@ cd "$(dirname "$0")/.."
DERIVED_DATA_PATH="$HOME/Library/Developer/Xcode/DerivedData/cmux-tests-v2"
APP="$DERIVED_DATA_PATH/Build/Products/Debug/cmux DEV.app"
RUN_TAG="tests-v2"
echo "== build =="
# 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
# 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=""
for _ in {1..120}; do
@ -70,7 +71,7 @@ launch_and_wait() {
export CMUX_SOCKET="$SOCK"
# 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
echo "== wait ready =="

View file

@ -93,6 +93,7 @@ sidebarBlurOpacity="$(format_number "$(read_value sidebarBlurOpacity 0.79)" 2)"
sidebarTintHex="$(read_value sidebarTintHex '#101010')"
sidebarTintOpacity="$(format_number "$(read_value sidebarTintOpacity 0.54)" 2)"
sidebarCornerRadius="$(format_number "$(read_value sidebarCornerRadius 0.0)" 1)"
sidebarActiveTabIndicatorStyle="$(read_value sidebarActiveTabIndicatorStyle solidFill)"
shortcutHintSidebarXOffset="$(format_number "$(read_value shortcutHintSidebarXOffset 0.0)" 1)"
shortcutHintSidebarYOffset="$(format_number "$(read_value shortcutHintSidebarYOffset 0.0)" 1)"
shortcutHintTitlebarXOffset="$(format_number "$(read_value shortcutHintTitlebarXOffset 4.0)" 1)"
@ -141,6 +142,7 @@ sidebarBlurOpacity=$sidebarBlurOpacity
sidebarTintHex=$sidebarTintHex
sidebarTintOpacity=$sidebarTintOpacity
sidebarCornerRadius=$sidebarCornerRadius
sidebarActiveTabIndicatorStyle=$sidebarActiveTabIndicatorStyle
shortcutHintSidebarXOffset=$shortcutHintSidebarXOffset
shortcutHintSidebarYOffset=$shortcutHintSidebarYOffset
shortcutHintTitlebarXOffset=$shortcutHintTitlebarXOffset

View file

@ -1,6 +1,6 @@
---
name: release
description: Prepare and ship a cmux release end-to-end: choose the next version, curate user-facing changelog entries, bump versions, open and monitor a release PR, merge, tag, and verify published artifacts. Use when asked to cut, prepare, publish, or tag a new release.
description: "Prepare and ship a cmux release end-to-end: choose the next version, curate user-facing changelog entries, bump versions, open and monitor a release PR, merge, tag, and verify published artifacts. Use when asked to cut, prepare, publish, or tag a new release."
---
# Release
@ -16,15 +16,18 @@ Run this workflow to prepare and publish a cmux release.
2. Create a release branch:
- `git checkout -b release/vX.Y.Z`
3. Gather user-facing changes since the last tag:
3. Gather user-facing changes and contributors since the last tag:
- `git describe --tags --abbrev=0`
- `git log --oneline <last-tag>..HEAD --no-merges`
- Keep only end-user visible changes (features, bug fixes, UX/perf behavior).
- **Collect contributors:** For each PR, get the author with `gh pr view <N> --repo manaflow-ai/cmux --json author --jq '.author.login'`. Also check linked issue reporters with `gh issue view <N> --json author --jq '.author.login'`.
- Build a deduplicated list of all contributor `@handle`s.
4. Update changelogs:
- Update `CHANGELOG.md`.
- Update `docs-site/content/docs/changelog.mdx`.
- Do not edit a separate docs changelog file; `web/app/docs/changelog/page.tsx` renders from `CHANGELOG.md`.
- Use categories `Added`, `Changed`, `Fixed`, `Removed`.
- **Credit contributors inline** (see Contributor Credits below).
- If no user-facing changes exist, confirm with the user before continuing.
5. Bump app version metadata:
@ -64,3 +67,10 @@ Run this workflow to prepare and publish a cmux release.
- Exclude internal-only changes (CI, tests, docs-only edits, refactors without behavior changes).
- Write concise user-facing bullets in present tense.
## Contributor Credits
Credit the people who made each release happen:
- **Per-entry:** Append `— thanks @user!` for community code contributions. Use `— thanks @user for the report!` for bug reporters (when different from PR author). No callout for core team (`lawrencecchen`, `austinywang`) — core work is the baseline.
- **Summary:** Add a `### Thanks to N contributors!` section at the bottom of each release with an alphabetical list of all `[@handle](https://github.com/handle)` links (including core team).
- **GitHub Release body:** Include the same "Thanks to N contributors!" section with linked handles.

View file

@ -500,7 +500,17 @@ class cmux:
if not response.startswith("OK"):
raise cmuxError(response)
def set_status(self, key: str, value: str, icon: str = None, color: str = None, tab: str = None) -> None:
def set_status(
self,
key: str,
value: str,
icon: str = None,
color: str = None,
url: str = None,
priority: int = None,
format: str = None,
tab: str = None,
) -> None:
"""Set a sidebar status entry."""
# Put options before `--` so value can contain arbitrary tokens like `--tab`.
cmd = f"set_status {key}"
@ -508,6 +518,12 @@ class cmux:
cmd += f" --icon={icon}"
if color:
cmd += f" --color={color}"
if url:
cmd += f" --url={_quote_option_value(url)}"
if priority is not None:
cmd += f" --priority={priority}"
if format:
cmd += f" --format={format}"
if tab:
cmd += f" --tab={tab}"
cmd += f" -- {_quote_option_value(value)}"
@ -524,6 +540,86 @@ class cmux:
if not response.startswith("OK"):
raise cmuxError(response)
def report_meta(
self,
key: str,
value: str,
icon: str = None,
color: str = None,
url: str = None,
priority: int = None,
format: str = None,
tab: str = None,
) -> None:
"""Report a sidebar metadata entry."""
cmd = f"report_meta {key}"
if icon:
cmd += f" --icon={icon}"
if color:
cmd += f" --color={color}"
if url:
cmd += f" --url={_quote_option_value(url)}"
if priority is not None:
cmd += f" --priority={priority}"
if format:
cmd += f" --format={format}"
if tab:
cmd += f" --tab={tab}"
cmd += f" -- {_quote_option_value(value)}"
response = self._send_command(cmd)
if not response.startswith("OK"):
raise cmuxError(response)
def clear_meta(self, key: str, tab: str = None) -> None:
"""Remove a sidebar metadata entry."""
cmd = f"clear_meta {key}"
if tab:
cmd += f" --tab={tab}"
response = self._send_command(cmd)
if not response.startswith("OK"):
raise cmuxError(response)
def list_meta(self, tab: str = None) -> str:
"""List sidebar metadata entries."""
cmd = "list_meta"
if tab:
cmd += f" --tab={tab}"
response = self._send_command(cmd)
if response.startswith("ERROR"):
raise cmuxError(response)
return response
def report_meta_block(self, key: str, markdown: str, priority: int = None, tab: str = None) -> None:
"""Report a freeform sidebar markdown metadata block."""
cmd = f"report_meta_block {key}"
if priority is not None:
cmd += f" --priority={priority}"
if tab:
cmd += f" --tab={tab}"
cmd += f" -- {_quote_option_value(markdown)}"
response = self._send_command(cmd)
if not response.startswith("OK"):
raise cmuxError(response)
def clear_meta_block(self, key: str, tab: str = None) -> None:
"""Remove a sidebar markdown metadata block."""
cmd = f"clear_meta_block {key}"
if tab:
cmd += f" --tab={tab}"
response = self._send_command(cmd)
if not response.startswith("OK"):
raise cmuxError(response)
def list_meta_blocks(self, tab: str = None) -> str:
"""List sidebar markdown metadata blocks."""
cmd = "list_meta_blocks"
if tab:
cmd += f" --tab={tab}"
response = self._send_command(cmd)
if response.startswith("ERROR"):
raise cmuxError(response)
return response
def log(self, message: str, level: str = None, source: str = None, tab: str = None) -> None:
"""Append a sidebar log entry."""
# TerminalController.parseOptions treats any --* token as an option until
@ -572,6 +668,63 @@ class cmux:
if not response.startswith("OK"):
raise cmuxError(response)
def report_pr(
self,
number: int,
url: str,
label: str = None,
state: str = None,
tab: str = None,
panel: str = None,
) -> None:
"""Report pull-request metadata for sidebar display."""
cmd = f"report_pr {number} {url}"
if label:
cmd += f" --label={_quote_option_value(label)}"
if state:
cmd += f" --state={state}"
if tab:
cmd += f" --tab={tab}"
if panel:
cmd += f" --panel={panel}"
response = self._send_command(cmd)
if not response.startswith("OK"):
raise cmuxError(response)
def report_review(
self,
number: int,
url: str,
label: str = None,
state: str = None,
tab: str = None,
panel: str = None,
) -> None:
"""Report provider-specific review metadata (GitLab MR, Bitbucket PR, etc.)."""
cmd = f"report_review {number} {url}"
if label:
cmd += f" --label={_quote_option_value(label)}"
if state:
cmd += f" --state={state}"
if tab:
cmd += f" --tab={tab}"
if panel:
cmd += f" --panel={panel}"
response = self._send_command(cmd)
if not response.startswith("OK"):
raise cmuxError(response)
def clear_pr(self, tab: str = None, panel: str = None) -> None:
"""Clear pull-request metadata for sidebar display."""
cmd = "clear_pr"
if tab:
cmd += f" --tab={tab}"
if panel:
cmd += f" --panel={panel}"
response = self._send_command(cmd)
if not response.startswith("OK"):
raise cmuxError(response)
def report_ports(self, *ports: int, tab: str = None) -> None:
"""Report listening ports for sidebar display."""
port_str = " ".join(str(p) for p in ports)

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,128 @@
#!/usr/bin/env python3
"""Static regression guard for browser eval async wrapping + telemetry injection."""
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 extract_span(source: str, start_marker: str, end_marker: str) -> str:
start = source.find(start_marker)
if start < 0:
raise ValueError(f"Missing start marker: {start_marker}")
end = source.find(end_marker, start)
if end < 0:
raise ValueError(f"Missing end marker: {end_marker}")
return source[start:end]
def main() -> int:
root = repo_root()
failures: list[str] = []
terminal_path = root / "Sources" / "TerminalController.swift"
panel_path = root / "Sources" / "Panels" / "BrowserPanel.swift"
terminal_source = terminal_path.read_text(encoding="utf-8")
panel_source = panel_path.read_text(encoding="utf-8")
if "preferAsync: Bool = false" not in terminal_source:
failures.append("v2RunJavaScript() no longer exposes preferAsync toggle")
run_js_block = extract_block(terminal_source, "private func v2RunJavaScript(")
if "callAsyncJavaScript" not in run_js_block:
failures.append("v2RunJavaScript() no longer uses callAsyncJavaScript for async JS")
run_browser_js_block = extract_block(terminal_source, "private func v2RunBrowserJavaScript(")
required_wrapper_tokens = [
"let asyncFunctionBody =",
"__cmuxMaybeAwait",
"__cmux_t",
"__cmux_v",
"return await __cmuxEvalInFrame();",
"preferAsync: true",
]
for token in required_wrapper_tokens:
if token not in run_browser_js_block:
failures.append(f"v2RunBrowserJavaScript() missing async eval wrapper token: {token}")
if "v2BrowserUndefinedSentinel" not in terminal_source:
failures.append("TerminalController is missing undefined sentinel handling")
if "v2BrowserEvalEnvelopeTypeUndefined" not in terminal_source:
failures.append("TerminalController is missing undefined envelope decode constant")
hook_block = extract_block(terminal_source, "private func v2BrowserEnsureTelemetryHooks(")
if "BrowserPanel.telemetryHookBootstrapScriptSource" not in hook_block:
failures.append("v2BrowserEnsureTelemetryHooks() no longer uses shared BrowserPanel telemetry source")
if "static let telemetryHookBootstrapScriptSource" not in panel_source:
failures.append("BrowserPanel is missing telemetryHookBootstrapScriptSource")
if "static let dialogTelemetryHookBootstrapScriptSource" not in panel_source:
failures.append("BrowserPanel is missing dialogTelemetryHookBootstrapScriptSource")
base_script_span = extract_span(
panel_source,
"static let telemetryHookBootstrapScriptSource =",
"static let dialogTelemetryHookBootstrapScriptSource =",
)
if "window.alert = function(message)" in base_script_span:
failures.append("Document-start telemetry script should not override alert dialogs")
if "window.confirm = function(message)" in base_script_span:
failures.append("Document-start telemetry script should not override confirm dialogs")
if "window.prompt = function(message, defaultValue)" in base_script_span:
failures.append("Document-start telemetry script should not override prompt dialogs")
panel_init_block = extract_block(
panel_source,
"init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil)",
)
required_init_tokens = [
"config.userContentController.addUserScript(",
"source: Self.telemetryHookBootstrapScriptSource",
"injectionTime: .atDocumentStart",
]
for token in required_init_tokens:
if token not in panel_init_block:
failures.append(f"BrowserPanel init() missing telemetry user-script token: {token}")
if failures:
print("FAIL: browser eval async wrapper / telemetry injection regression guard failed")
for item in failures:
print(f" - {item}")
return 1
print("PASS: browser eval async wrapper / telemetry injection regression guard is in place")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,89 @@
#!/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")
if 'dict["__cmux_t"] as? String' not in value_block or 'type == "undefined"' not in value_block:
failures.append("displayBrowserValue() no longer maps __cmux_t=undefined to literal 'undefined'")
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,125 @@
#!/usr/bin/env python3
"""Static regression guards for compact browser omnibar sizing."""
from __future__ import annotations
import re
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 parse_cgfloat_constant(source: str, name: str) -> float | None:
match = re.search(
rf"private let {re.escape(name)}: CGFloat = ([0-9]+(?:\.[0-9]+)?)",
source,
)
if not match:
return None
return float(match.group(1))
def main() -> int:
root = repo_root()
failures: list[str] = []
view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift"
view_source = view_path.read_text(encoding="utf-8")
hit_size = parse_cgfloat_constant(view_source, "addressBarButtonHitSize")
if hit_size is None:
failures.append("addressBarButtonHitSize constant is missing")
elif hit_size > 26:
failures.append(
f"addressBarButtonHitSize regressed to {hit_size:g}; expected <= 26 for compact omnibar height"
)
vertical_padding = parse_cgfloat_constant(view_source, "addressBarVerticalPadding")
if vertical_padding is None:
failures.append("addressBarVerticalPadding constant is missing")
elif vertical_padding > 4:
failures.append(
f"addressBarVerticalPadding regressed to {vertical_padding:g}; expected <= 4 for compact omnibar height"
)
omnibar_corner_radius = parse_cgfloat_constant(view_source, "omnibarPillCornerRadius")
if omnibar_corner_radius is None:
failures.append("omnibarPillCornerRadius constant is missing")
elif omnibar_corner_radius > 10:
failures.append(
f"omnibarPillCornerRadius regressed to {omnibar_corner_radius:g}; expected <= 10 to keep a squircle profile"
)
address_bar_block = extract_block(view_source, "private var addressBar: some View")
if ".padding(.vertical, addressBarVerticalPadding)" not in address_bar_block:
failures.append("addressBar no longer applies compact vertical padding via addressBarVerticalPadding")
omnibar_field_block = extract_block(view_source, "private var omnibarField: some View")
if omnibar_field_block.count(
"RoundedRectangle(cornerRadius: omnibarPillCornerRadius, style: .continuous)"
) < 2:
failures.append(
"omnibarField no longer uses continuous rounded-rectangle background+stroke tied to omnibarPillCornerRadius"
)
button_bar_block = extract_block(view_source, "private var addressBarButtonBar: some View")
hit_frame_uses = button_bar_block.count("addressBarButtonHitSize")
if hit_frame_uses < 3:
failures.append(
"navigation buttons no longer consistently use addressBarButtonHitSize frames (padding may be lost)"
)
extract_block(view_source, "private struct OmnibarAddressButtonStyle: ButtonStyle")
style_body_block = extract_block(view_source, "private struct OmnibarAddressButtonStyleBody: View")
if "configuration.isPressed" not in style_body_block:
failures.append("OmnibarAddressButtonStyleBody is missing pressed-state styling")
if "isHovered" not in style_body_block or ".onHover" not in style_body_block:
failures.append("OmnibarAddressButtonStyleBody is missing hover-state styling")
style_uses = view_source.count(".buttonStyle(OmnibarAddressButtonStyle())")
if style_uses < 4:
failures.append(
"address bar buttons no longer consistently use OmnibarAddressButtonStyle"
)
if failures:
print("FAIL: browser omnibar compact layout regression guards failed")
for failure in failures:
print(f" - {failure}")
return 1
print("PASS: browser omnibar compact layout regression guards are in place")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Regression test for https://github.com/manaflow-ai/cmux/issues/387.
# Ensures release workflows pin create-dmg to an explicit version.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
WORKFLOWS=(
"$ROOT_DIR/.github/workflows/release.yml"
"$ROOT_DIR/.github/workflows/nightly.yml"
)
for workflow in "${WORKFLOWS[@]}"; do
if ! grep -Eq 'npm install --global .*create-dmg@' "$workflow"; then
echo "FAIL: $workflow must install create-dmg with an explicit version"
exit 1
fi
if grep -Eq 'npm install --global[[:space:]]+create-dmg([[:space:]]|$)' "$workflow"; then
echo "FAIL: $workflow still has unpinned create-dmg install"
exit 1
fi
done
echo "PASS: create-dmg install is pinned in release workflows"

View file

@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
SCHEME_FILE="GhosttyTabs.xcodeproj/xcshareddata/xcschemes/cmux.xcscheme"
if [ ! -f "$SCHEME_FILE" ]; then
echo "FAIL: Missing scheme file at $SCHEME_FILE" >&2
exit 1
fi
if ! grep -q '<TestAction buildConfiguration="Debug"' "$SCHEME_FILE"; then
echo "FAIL: cmux scheme TestAction must use Debug build configuration for UI test setup hooks" >&2
exit 1
fi
echo "PASS: cmux scheme TestAction uses Debug"

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 '
/^ tests:/ { in_tests=1; next }
in_tests && /^ [^[:space:]]/ { in_tests=0 }
in_tests && /runs-on: self-hosted/ { saw_self_hosted=1 }
in_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: tests block must keep both self-hosted and fork guard"
exit 1
fi
echo "PASS: tests self-hosted fork guard is present"

View file

@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Regression test for CI unit-test SwiftPM dependency flake handling.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
WORKFLOW_FILE="$ROOT_DIR/.github/workflows/ci.yml"
REQUIRED_PATTERNS=(
"run_unit_tests()"
"Could not resolve package dependencies"
"rm -rf ~/Library/Caches/org.swift.swiftpm"
"OUTPUT=\$(run_unit_tests)"
)
for pattern in "${REQUIRED_PATTERNS[@]}"; do
if ! grep -Fq "$pattern" "$WORKFLOW_FILE"; then
echo "FAIL: Missing pattern in ci.yml: $pattern"
exit 1
fi
done
echo "PASS: CI unit-test SwiftPM retry guard is present"

View file

@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""
Regression test: claude-hook stop surfaces a clear socket-connect error when target socket is missing.
"""
from __future__ import annotations
import glob
import os
import shutil
import subprocess
import tempfile
def resolve_cmux_cli() -> str:
explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI")
if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK):
return explicit
candidates: list[str] = []
candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux")))
candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux"))
candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)]
if candidates:
candidates.sort(key=os.path.getmtime, reverse=True)
return candidates[0]
in_path = shutil.which("cmux")
if in_path:
return in_path
raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.")
def main() -> int:
try:
cli_path = resolve_cmux_cli()
except Exception as exc:
print(f"FAIL: {exc}")
return 1
missing_socket = os.path.join(tempfile.gettempdir(), f"cmux-missing-{os.getpid()}.sock")
try:
if os.path.exists(missing_socket):
os.remove(missing_socket)
except OSError:
pass
env = os.environ.copy()
env["CMUX_CLI_SENTRY_DISABLED"] = "1"
env["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1"
env.pop("CMUX_SOCKET_PATH", None)
proc = subprocess.run(
[cli_path, "--socket", missing_socket, "claude-hook", "stop"],
input="{}",
text=True,
capture_output=True,
env=env,
check=False,
)
if proc.returncode == 0:
print("FAIL: expected non-zero exit when socket is missing")
print(f"stdout={proc.stdout}")
print(f"stderr={proc.stderr}")
return 1
expected_prefixes = [
f"Error: Socket not found at {missing_socket}",
f"Error: Failed to connect to socket at {missing_socket}",
]
if not any(prefix in proc.stderr for prefix in expected_prefixes):
print("FAIL: missing expected socket error text")
print(f"expected one of: {expected_prefixes!r}")
print(f"stderr: {proc.stderr!r}")
return 1
print("PASS: claude-hook stop missing-socket error is explicit")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Regression test: cmux CLI should not exit with SIGPIPE on broken stdout pipes."""
from __future__ import annotations
import glob
import os
import shutil
import subprocess
def resolve_cmux_cli() -> str:
explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI")
if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK):
return explicit
candidates: list[str] = []
candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux")))
candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux"))
candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)]
if candidates:
candidates.sort(key=os.path.getmtime, reverse=True)
return candidates[0]
in_path = shutil.which("cmux")
if in_path:
return in_path
raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.")
def run_with_closed_stdout(cli_path: str, *args: str) -> tuple[int, str]:
read_fd, write_fd = os.pipe()
os.close(read_fd)
proc = subprocess.Popen(
[cli_path, *args],
stdout=write_fd,
stderr=subprocess.PIPE,
text=True,
close_fds=True,
)
os.close(write_fd)
_, stderr = proc.communicate()
return proc.returncode, (stderr or "").strip()
def require_zero_exit(cli_path: str, *args: str) -> tuple[bool, str]:
code, err = run_with_closed_stdout(cli_path, *args)
if code != 0:
cmd = " ".join(args)
return False, f"`cmux {cmd}` exited {code} with closed stdout pipe (stderr={err!r})"
return True, ""
def main() -> int:
try:
cli_path = resolve_cmux_cli()
except Exception as exc:
print(f"FAIL: {exc}")
return 1
ok_version, version_msg = require_zero_exit(cli_path, "--version")
ok_help, help_msg = require_zero_exit(cli_path, "help")
failures = [msg for msg in [version_msg, help_msg] if msg]
if failures:
print("FAIL: CLI still fails on broken stdout pipes")
for failure in failures:
print(f"- {failure}")
return 1
print("PASS: CLI ignores SIGPIPE and exits cleanly when stdout pipe is closed")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Regression test: CLI socket Sentry telemetry must apply to all commands."""
from __future__ import annotations
import subprocess
from pathlib import Path
def get_repo_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
return Path(result.stdout.strip())
return Path.cwd()
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
if needle not in content:
failures.append(message)
def reject(content: str, needle: str, message: str, failures: list[str]) -> None:
if needle in content:
failures.append(message)
def main() -> int:
repo_root = get_repo_root()
cli_path = repo_root / "CLI" / "cmux.swift"
if not cli_path.exists():
print(f"FAIL: missing expected file: {cli_path}")
return 1
content = cli_path.read_text(encoding="utf-8")
failures: list[str] = []
require(
content,
"private final class CLISocketSentryTelemetry {",
"Missing CLISocketSentryTelemetry definition",
failures,
)
require(
content,
'processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" ||',
"Missing CMUX_CLI_SENTRY_DISABLED kill switch",
failures,
)
require(
content,
'processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1"',
"Missing backwards-compatible CMUX_CLAUDE_HOOK_SENTRY_DISABLED kill switch",
failures,
)
require(
content,
"private var shouldEmit: Bool {\n !disabledByEnv\n }",
"Telemetry scope should be command-agnostic (only disabled by env kill switch)",
failures,
)
require(
content,
'let crumb = Breadcrumb(level: .info, category: "cmux.cli")',
"Telemetry breadcrumb category should be cmux.cli",
failures,
)
require(
content,
'"command": command,',
"Base telemetry context must include command name",
failures,
)
require(
content,
"let cliTelemetry = CLISocketSentryTelemetry(",
"CLI should initialize generic socket telemetry",
failures,
)
require(
content,
'cliTelemetry.breadcrumb(\n "socket.connect.attempt",',
"CLI should emit socket.connect.attempt breadcrumb for commands",
failures,
)
reject(
content,
"self.enabled = command == \"claude-hook\"",
"Telemetry regressed to claude-hook-only scope",
failures,
)
reject(
content,
"enabled && !disabledByEnv",
"Telemetry still depends on legacy enabled flag",
failures,
)
if failures:
print("FAIL: CLI socket telemetry scope regression(s) detected")
for failure in failures:
print(f"- {failure}")
return 1
print("PASS: CLI socket telemetry scope is command-agnostic")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Regression tests for CLI subcommand help coverage and accuracy."""
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,
check=False,
)
if result.returncode == 0:
return Path(result.stdout.strip())
return Path.cwd()
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
if needle not in content:
failures.append(message)
def extract_switch_commands(content: str, start_index: int = 0) -> tuple[set[str], int]:
marker = "switch command {"
marker_index = content.find(marker, start_index)
if marker_index == -1:
return set(), -1
open_brace = content.find("{", marker_index)
if open_brace == -1:
return set(), -1
depth = 1
cursor = open_brace + 1
while cursor < len(content) and depth > 0:
char = content[cursor]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
cursor += 1
block = content[open_brace + 1:cursor - 1]
commands: set[str] = set()
collecting_case = False
case_lines: list[str] = []
for line in block.splitlines():
stripped = line.strip()
if stripped.startswith("case "):
collecting_case = True
case_lines = [line]
elif collecting_case:
case_lines.append(line)
if collecting_case and ":" in line:
case_text = "\n".join(case_lines)
commands.update(re.findall(r'"([^"]+)"', case_text))
collecting_case = False
case_lines = []
return commands, cursor
def main() -> int:
repo_root = get_repo_root()
cli_path = repo_root / "CLI" / "cmux.swift"
if not cli_path.exists():
print(f"FAIL: missing expected file: {cli_path}")
return 1
content = cli_path.read_text(encoding="utf-8")
failures: list[str] = []
require(
content,
'if commandArgs.contains("--help") || commandArgs.contains("-h") {',
"Subcommand help pre-dispatch gate is missing",
failures,
)
require(
content,
'if dispatchSubcommandHelp(command: command, commandArgs: commandArgs) {',
"Subcommand help dispatch call is missing",
failures,
)
require(
content,
"print(\"Unknown command '\\(command)'. Run 'cmux help' to see available commands.\")",
"Subcommand help fallback unknown-command line is missing",
failures,
)
require(
content,
"print(\"Unknown command '\\(command)'. Run 'cmux help' to see available commands.\")\n return",
"Subcommand help fallback must return before command execution",
failures,
)
dispatch_commands, next_index = extract_switch_commands(content, 0)
subcommand_usage_commands, _ = extract_switch_commands(content, next_index if next_index != -1 else 0)
if not dispatch_commands:
failures.append("Failed to parse main dispatch switch command list")
if not subcommand_usage_commands:
failures.append("Failed to parse subcommandUsage switch command list")
missing_help_entries = sorted(dispatch_commands - subcommand_usage_commands)
if missing_help_entries:
failures.append(
"Missing subcommandUsage entries for dispatch command(s): "
+ ", ".join(missing_help_entries)
)
# Regression checks for concrete help text that previously drifted from dispatch logic.
for needle, message in [
('case "help":', "Missing subcommandUsage entry for help"),
("Usage: cmux help", "help subcommand usage text is missing"),
("Usage: cmux move-workspace-to-window --workspace <id|ref|index> --window <id|ref|index>", "move-workspace-to-window help must document index handles"),
("--tab <id|ref|index> Target tab (accepts tab:<n> or surface:<n>; default: $CMUX_TAB_ID, then $CMUX_SURFACE_ID, then focused tab)", "tab-action help must document CMUX_TAB_ID/CMUX_SURFACE_ID fallback"),
("--workspace <id|ref|index> Workspace to rename (default: current/$CMUX_WORKSPACE_ID)", "rename-workspace help must document CMUX_WORKSPACE_ID fallback"),
("text|html|value|count|box|styles|attr: [--selector <css> | <css>]", "browser get help must document --selector"),
("attr: [--attr <name> | <name>]", "browser get attr help must document --attr"),
("styles: [--property <name>]", "browser get styles help must document --property"),
("role: [--name <text>] [--exact] <role>", "browser find role help must document --name/--exact"),
("text|label|placeholder|alt|title|testid: [--exact] <text>", "browser find text-like help must document --exact"),
("nth: [--index <n> | <n>] [--selector <css> | <css>]", "browser find nth help must document --index/--selector"),
("route <pattern> [--abort] [--body <text>]", "browser network route help must document --abort/--body"),
]:
require(content, needle, message, failures)
if failures:
print("FAIL: CLI subcommand help regression(s) detected")
for failure in failures:
print(f"- {failure}")
return 1
print("PASS: CLI subcommand help coverage and flag/env documentation are present")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Regression test: `cmux tree` command wiring and output contract."""
from __future__ import annotations
import subprocess
from pathlib import Path
def get_repo_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
return Path(result.stdout.strip())
return Path.cwd()
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
if needle not in content:
failures.append(message)
def main() -> int:
repo_root = get_repo_root()
cli_path = repo_root / "CLI" / "cmux.swift"
controller_path = repo_root / "Sources" / "TerminalController.swift"
if not cli_path.exists():
print(f"FAIL: missing expected file: {cli_path}")
return 1
if not controller_path.exists():
print(f"FAIL: missing expected file: {controller_path}")
return 1
content = cli_path.read_text(encoding="utf-8")
controller_content = controller_path.read_text(encoding="utf-8")
failures: list[str] = []
require(
content,
'case "tree":\n try runTreeCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)',
"Missing `tree` command dispatch",
failures,
)
require(
content,
"tree [--all] [--workspace <id|ref|index>]",
"Top-level usage text missing tree command",
failures,
)
require(
content,
"Usage: cmux tree [flags]",
"Subcommand help for `cmux tree --help` is missing",
failures,
)
require(
content,
"Known flags: --all --workspace <id|ref|index> --json",
"Tree flag validation for --all/--workspace is missing",
failures,
)
require(
content,
"--json Structured JSON output",
"Tree help text should document --json",
failures,
)
require(
content,
'print(jsonString(formatIDs(payload, mode: idFormat)))',
"Tree command JSON output should honor --id-format conversion",
failures,
)
# Data sources needed for full hierarchy + browser URLs.
for method in [
'method: "system.tree"',
'method: "system.identify"',
'method: "window.list"',
'method: "workspace.list"',
'method: "pane.list"',
'method: "surface.list"',
'method: "browser.tab.list"',
'method: "browser.url.get"',
]:
require(
content,
method,
f"Tree command is missing expected API call: {method}",
failures,
)
# Text tree rendering contract.
for glyph in ['"├── "', '"└── "', '""']:
require(
content,
glyph,
f"Tree output missing box-drawing glyph: {glyph}",
failures,
)
for marker in ["[current]", "[selected]", "[focused]", "◀ active", "◀ here"]:
require(
content,
marker,
f"Tree output missing required marker: {marker}",
failures,
)
require(
content,
'surfaceType.lowercased() == "browser"',
"Tree surface rendering should special-case browser surfaces",
failures,
)
require(
content,
'let url = surface["url"] as? String',
"Tree surface rendering should include browser URL when available",
failures,
)
# Server-side one-shot hierarchy path for performance.
for needle, message in [
('case "system.tree":', "Socket router is missing system.tree dispatch"),
('"system.tree"', "Capabilities list should advertise system.tree"),
("private func v2SystemTree(params: [String: Any]) -> V2CallResult {", "Missing v2SystemTree implementation"),
('"active":', "system.tree payload should include focused path"),
('"caller":', "system.tree payload should include caller path"),
('"windows":', "system.tree payload should include hierarchy windows"),
]:
require(controller_content, needle, message, failures)
if failures:
print("FAIL: cmux tree command regression(s) detected")
for failure in failures:
print(f"- {failure}")
return 1
print("PASS: cmux tree command wiring and output contract are present")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Regression test: CLI version output wiring keeps commit metadata support."""
from __future__ import annotations
import subprocess
from pathlib import Path
def get_repo_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
return Path(result.stdout.strip())
return Path.cwd()
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
if needle not in content:
failures.append(message)
def main() -> int:
repo_root = get_repo_root()
cli_path = repo_root / "CLI" / "cmux.swift"
if not cli_path.exists():
print(f"FAIL: missing expected file: {cli_path}")
return 1
content = cli_path.read_text(encoding="utf-8")
failures: list[str] = []
require(
content,
'let commit = info["CMUXCommit"].flatMap { normalizedCommitHash($0) }',
"versionSummary no longer reads CMUXCommit metadata",
failures,
)
require(
content,
'return "\\(baseSummary) [\\(commit)]"',
"versionSummary no longer appends commit metadata",
failures,
)
require(
content,
'if let commit = dictionary["CMUXCommit"] as? String,',
"Info.plist parsing no longer reads CMUXCommit",
failures,
)
require(
content,
"if let commit = gitCommitHash(at: current) {",
"Project fallback no longer probes git commit hash",
failures,
)
require(
content,
'["git", "-C", directory.path, "rev-parse", "--short=9", "HEAD"]',
"Git commit probe command changed unexpectedly",
failures,
)
require(
content,
'normalizedCommitHash(ProcessInfo.processInfo.environment["CMUX_COMMIT"])',
"Environment commit fallback (CMUX_COMMIT) is missing",
failures,
)
if failures:
print("FAIL: CLI version commit metadata regression(s) detected")
for failure in failures:
print(f"- {failure}")
return 1
print("PASS: CLI version commit metadata wiring is intact")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""
Regression test: `cmux --version` should print version text without requiring a socket.
"""
from __future__ import annotations
import glob
import os
import re
import shutil
import subprocess
def resolve_cmux_cli() -> str:
explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI")
if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK):
return explicit
candidates: list[str] = []
candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux")))
candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux"))
candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)]
if candidates:
candidates.sort(key=os.path.getmtime, reverse=True)
return candidates[0]
in_path = shutil.which("cmux")
if in_path:
return in_path
raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.")
def run(cli_path: str, *args: str) -> tuple[int, str, str]:
proc = subprocess.run(
[cli_path, *args],
text=True,
capture_output=True,
check=False,
)
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
def main() -> int:
try:
cli_path = resolve_cmux_cli()
except Exception as exc:
print(f"FAIL: {exc}")
return 1
code, out, err = run(cli_path, "--version")
if code != 0:
print("FAIL: `cmux --version` exited non-zero")
print(f"exit={code}")
print(f"stdout={out}")
print(f"stderr={err}")
return 1
if not out:
print("FAIL: `cmux --version` produced empty stdout")
return 1
if not re.search(r"\b\d+\.\d+\.\d+\b", out):
print(f"FAIL: version output missing semantic version: {out!r}")
return 1
code2, out2, err2 = run(cli_path, "version")
if code2 != 0:
print("FAIL: `cmux version` exited non-zero")
print(f"exit={code2}")
print(f"stdout={out2}")
print(f"stderr={err2}")
return 1
if out2 != out:
print("FAIL: `cmux --version` and `cmux version` differ")
print(f"--version: {out!r}")
print(f"version: {out2!r}")
return 1
print(f"PASS: cmux version command works ({out})")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""
Regression test: Cmd+Option+T closes all other tabs in the focused pane
after an explicit confirmation.
Run this against an app launched with CMUX_SOCKET_MODE=allowAll.
"""
import os
import subprocess
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05) -> bool:
start = time.time()
while time.time() - start < timeout_s:
if predicate():
return True
time.sleep(interval_s)
return False
def _pane_state(client: cmux) -> list[dict]:
rows: list[dict] = []
for index, panel_id, title, selected in client.list_pane_surfaces():
rows.append(
{
"index": index,
"panel_id": panel_id,
"title": title,
"selected": selected,
}
)
return rows
def _send_shortcut_via_system_events(key: str, modifiers: str) -> None:
script = f'tell application "System Events" to keystroke "{key}" using {{{modifiers}}}'
try:
subprocess.run(["osascript", "-e", script], check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as exc:
stderr = (exc.stderr or "").strip()
raise cmuxError(
"Failed to send keyboard shortcut via System Events. "
f"Ensure macOS Accessibility automation is enabled. stderr={stderr}"
) from exc
def main() -> int:
with cmux(SOCKET_PATH) as client:
if not client.ping():
raise cmuxError(
f"Socket ping failed on {SOCKET_PATH}. "
"Launch Debug app with CMUX_SOCKET_MODE=allowAll for this test."
)
workspace_id = client.new_workspace()
try:
client.select_workspace(workspace_id)
time.sleep(0.25)
client.activate_app()
time.sleep(0.15)
# Create two additional tabs in the current focused pane.
client.new_surface()
client.new_surface()
time.sleep(0.25)
before = _pane_state(client)
if len(before) < 3:
raise cmuxError(f"Expected >=3 tabs before shortcut, got {before}")
selected_rows = [row for row in before if row["selected"]]
if len(selected_rows) != 1:
raise cmuxError(f"Expected exactly one selected tab before shortcut, got {before}")
selected_panel_id = selected_rows[0]["panel_id"]
expected_to_close = [row for row in before if row["panel_id"] != selected_panel_id]
if len(expected_to_close) < 2:
raise cmuxError(
f"Expected at least two non-selected tabs before shortcut, got {before}"
)
# Trigger shortcut via real OS key event; this should open the confirmation dialog.
_send_shortcut_via_system_events("t", "command down, option down")
time.sleep(0.25)
after_trigger = _pane_state(client)
if len(after_trigger) != len(before):
raise cmuxError(
"Cmd+Option+T should require confirmation before closing.\n"
f"before={before}\n"
f"after_trigger={after_trigger}"
)
# Confirm the dialog with Cmd+D (wired to click the destructive "Close" button).
_send_shortcut_via_system_events("d", "command down")
closed = _wait_until(lambda: len(_pane_state(client)) == 1, timeout_s=5.0, interval_s=0.05)
if not closed:
raise cmuxError(
"Timed out waiting for tabs to close after confirming Cmd+Option+T.\n"
f"before={before}\n"
f"after_trigger={after_trigger}\n"
f"after_confirm={_pane_state(client)}"
)
after_confirm = _pane_state(client)
if len(after_confirm) != 1:
raise cmuxError(
f"Expected one remaining tab after confirmation, got {after_confirm}"
)
remaining = after_confirm[0]
if remaining["panel_id"] != selected_panel_id:
raise cmuxError(
"Expected selected tab to remain after closing others.\n"
f"expected_selected={selected_panel_id}\n"
f"remaining={remaining}\n"
f"before={before}"
)
print("PASS: Cmd+Option+T closed all other tabs in focused pane.")
print(f"workspace={workspace_id}")
print(f"selected_panel={selected_panel_id}")
return 0
finally:
try:
client.close_workspace(workspace_id)
except Exception:
pass
if __name__ == "__main__":
raise SystemExit(main())

Some files were not shown because too many files have changed in this diff Show more