Merge remote-tracking branch 'origin/main' into pr-374-ssh-remote-cli-relay

# Conflicts:
#	CLI/cmux.swift
#	Sources/ContentView.swift
#	Sources/GhosttyTerminalView.swift
#	Sources/Panels/TerminalPanel.swift
#	Sources/TabManager.swift
#	Sources/TerminalController.swift
#	Sources/Workspace.swift
This commit is contained in:
Lawrence Chen 2026-02-24 17:57:15 -08:00
commit 7da2357a16
135 changed files with 30520 additions and 719 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)
```

View file

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

View file

@ -190,6 +190,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 +204,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 +290,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 +315,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: |
@ -315,6 +339,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

@ -21,7 +21,63 @@ jobs:
with:
submodules: recursive
- name: Guard immutable release assets
id: guard_release_assets
uses: actions/github-script@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 +97,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
- 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 +117,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 +132,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 +145,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 +164,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 +188,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 +206,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 +250,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 +276,14 @@ jobs:
./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.xml
- name: Upload release asset
if: steps.guard_release_assets.outputs.skip_upload != 'true'
uses: softprops/action-gh-release@v2
with:
files: |
cmux-macos.dmg
appcast.xml
generate_release_notes: true
overwrite_files: false
- name: Cleanup keychain
if: always()

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,62 @@
All notable changes to cmux are documented here.
## [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

@ -93,8 +93,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`:

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 */; };
@ -54,11 +55,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 +74,14 @@
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 */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
A5001020 /* Embed Frameworks */ = {
@ -96,6 +102,7 @@
files = (
B900000BA1B2C3D4E5F60719 /* cmux in Copy CLI */,
C1ADE00002A1B2C3D4E5F719 /* claude in Copy CLI */,
D1BEF00002A1B2C3D4E5F719 /* open in Copy CLI */,
);
name = "Copy CLI";
runOnlyForDeploymentPostprocessing = 0;
@ -144,6 +151,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>"; };
@ -177,11 +185,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 +200,17 @@
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>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
A5001030 /* Frameworks */ = {
@ -319,6 +332,7 @@
A5001019 /* TerminalController.swift */,
A5001541 /* PortScanner.swift */,
A5001225 /* SocketControlSettings.swift */,
A5001600 /* SentryHelper.swift */,
A5001090 /* AppDelegate.swift */,
A5001091 /* NotificationsPage.swift */,
A5001092 /* TerminalNotificationStore.swift */,
@ -345,6 +359,7 @@
A5001219 /* WindowToolbarController.swift */,
A5001241 /* WindowDecorationsController.swift */,
A5001222 /* WindowAccessor.swift */,
A5001611 /* SessionPersistence.swift */,
);
path = Sources;
sourceTree = "<group>";
@ -395,17 +410,20 @@
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 */,
);
path = cmuxTests;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -548,6 +566,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 +593,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 +614,21 @@
);
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 */,
);
runOnlyForDeploymentPostprocessing = 0;
};
B9000006A1B2C3D4E5F60719 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
@ -702,7 +725,7 @@
CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 71;
CURRENT_PROJECT_VERSION = 72;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = NO;
GENERATE_INFOPLIST_FILE = NO;
@ -711,7 +734,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.59.0;
MARKETING_VERSION = 0.60.0;
OTHER_LDFLAGS = (
"-lc++",
"-framework",
@ -741,7 +764,7 @@
CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 71;
CURRENT_PROJECT_VERSION = 72;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = NO;
GENERATE_INFOPLIST_FILE = NO;
@ -750,7 +773,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.59.0;
MARKETING_VERSION = 0.60.0;
OTHER_LDFLAGS = (
"-lc++",
"-framework",
@ -804,10 +827,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71;
CURRENT_PROJECT_VERSION = 72;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.59.0;
MARKETING_VERSION = 0.60.0;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -821,10 +844,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71;
CURRENT_PROJECT_VERSION = 72;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.59.0;
MARKETING_VERSION = 0.60.0;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -838,10 +861,10 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71;
CURRENT_PROJECT_VERSION = 72;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.59.0;
MARKETING_VERSION = 0.60.0;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -857,10 +880,10 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 71;
CURRENT_PROJECT_VERSION = 72;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.59.0;
MARKETING_VERSION = 0.60.0;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests;
PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -114,6 +114,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,9 +194,19 @@ 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.
## Star History
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
</picture>
</a>
## Community
- [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)

283
Resources/bin/open Executable file
View file

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

View file

@ -23,6 +23,18 @@ _cmux_send() {
fi
}
_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:-}"
@ -107,9 +119,9 @@ _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=$!

View file

@ -24,6 +24,18 @@ _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=""
@ -240,9 +252,9 @@ _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=$!

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)
}

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

@ -8,6 +8,7 @@ enum KeyboardShortcutSettings {
case toggleSidebar
case newTab
case newWindow
case closeWindow
case showNotifications
case jumpToUnread
case triggerFlash
@ -17,6 +18,9 @@ enum KeyboardShortcutSettings {
case prevSurface
case nextSidebarTab
case prevSidebarTab
case renameTab
case renameWorkspace
case closeWorkspace
case newSurface
// Panes / splits
@ -41,6 +45,7 @@ enum KeyboardShortcutSettings {
case .toggleSidebar: return "Toggle Sidebar"
case .newTab: return "New Workspace"
case .newWindow: return "New Window"
case .closeWindow: return "Close Window"
case .showNotifications: return "Show Notifications"
case .jumpToUnread: return "Jump to Latest Unread"
case .triggerFlash: return "Flash Focused Panel"
@ -48,6 +53,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"
@ -68,11 +76,15 @@ enum KeyboardShortcutSettings {
case .toggleSidebar: return "shortcut.toggleSidebar"
case .newTab: return "shortcut.newTab"
case .newWindow: return "shortcut.newWindow"
case .closeWindow: return "shortcut.closeWindow"
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"
@ -98,6 +110,8 @@ 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 .showNotifications:
return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false)
case .jumpToUnread:
@ -108,6 +122,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:
@ -190,6 +210,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) }
@ -244,6 +266,65 @@ 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
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"
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 }

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,90 @@ 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 {
@ViewBuilder
func cmuxFlatSymbolColorRendering() -> some View {
if #available(macOS 26.0, *) {
self.symbolColorRenderingMode(.flat)
} else {
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 +213,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 +230,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 +274,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 +328,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 +342,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 +385,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 +407,7 @@ struct BrowserPanelView: View {
hideSuggestions()
addressBarFocused = false
}
syncWebViewResponderPolicyWithViewState(reason: "panelFocusChanged")
}
.onChange(of: addressBarFocused) { focused in
let urlString = panel.preferredURLStringForOmnibar() ?? ""
@ -301,6 +435,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 +467,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 +493,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 +509,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 +532,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 +554,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 +692,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 +711,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 +790,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 +843,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 +861,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 +2218,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 +2337,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 +2372,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 +2696,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 +2754,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 +2862,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 +2889,7 @@ private struct OmnibarSuggestionsView: View {
RoundedRectangle(cornerRadius: rowHighlightCornerRadius, style: .continuous)
.fill(
idx == selectedIndex
? Color.white.opacity(0.12)
? rowHighlightColor
: Color.clear
)
)
@ -2539,10 +2944,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 +2955,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 +3021,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 +3263,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 +3296,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 +3333,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 +3592,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

View file

@ -1,4 +1,6 @@
import AppKit
import Bonsplit
import ObjectiveC
import WebKit
/// WKWebView tends to consume some Command-key equivalents (e.g. Cmd+N/Cmd+W),
@ -6,14 +8,124 @@ import WebKit
/// key equivalents first so app-level shortcuts continue to work when WebKit is
/// the first responder.
final class CmuxWebView: WKWebView {
override func performKeyEquivalent(with event: NSEvent) -> Bool {
// Preserve Cmd+Return/Enter for web content (e.g. editors/forms). Do not
// route it through app/menu key equivalents, which can trigger unintended actions.
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if flags.contains(.command), event.keyCode == 36 || event.keyCode == 76 {
// Some sites/WebKit paths report middle-click link activations as
// WKNavigationAction.buttonNumber=4 instead of 2. Track a recent local
// middle-click so navigation delegates can recover intent reliably.
private struct MiddleClickIntent {
let webViewID: ObjectIdentifier
let uptime: TimeInterval
}
private static var lastMiddleClickIntent: MiddleClickIntent?
private static let middleClickIntentMaxAge: TimeInterval = 0.8
static func hasRecentMiddleClickIntent(for webView: WKWebView) -> Bool {
guard let webView = webView as? CmuxWebView else { return false }
guard let intent = lastMiddleClickIntent else { return false }
let age = ProcessInfo.processInfo.systemUptime - intent.uptime
if age > middleClickIntentMaxAge {
lastMiddleClickIntent = nil
return false
}
return intent.webViewID == ObjectIdentifier(webView)
}
private static func recordMiddleClickIntent(for webView: CmuxWebView) {
lastMiddleClickIntent = MiddleClickIntent(
webViewID: ObjectIdentifier(webView),
uptime: ProcessInfo.processInfo.systemUptime
)
}
private final class ContextMenuFallbackBox: NSObject {
weak var target: AnyObject?
let action: Selector?
init(target: AnyObject?, action: Selector?) {
self.target = target
self.action = action
}
}
private static var contextMenuFallbackKey: UInt8 = 0
var onContextMenuDownloadStateChanged: ((Bool) -> Void)?
var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)?
var contextMenuDefaultBrowserOpener: ((URL) -> Bool)?
/// Guard against background panes stealing first responder (e.g. page autofocus).
/// BrowserPanelView updates this as pane focus state changes.
var allowsFirstResponderAcquisition: Bool = true
private var pointerFocusAllowanceDepth: Int = 0
var allowsFirstResponderAcquisitionEffective: Bool {
allowsFirstResponderAcquisition || pointerFocusAllowanceDepth > 0
}
var debugPointerFocusAllowanceDepth: Int { pointerFocusAllowanceDepth }
override func becomeFirstResponder() -> Bool {
guard allowsFirstResponderAcquisitionEffective else {
#if DEBUG
let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
dlog(
"browser.focus.blockedBecome web=\(ObjectIdentifier(self)) " +
"policy=\(allowsFirstResponderAcquisition ? 1 : 0) " +
"pointerDepth=\(pointerFocusAllowanceDepth) eventType=\(eventType)"
)
#endif
return false
}
let result = super.becomeFirstResponder()
if result {
NotificationCenter.default.post(name: .browserDidBecomeFirstResponderWebView, object: self)
}
#if DEBUG
let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
dlog(
"browser.focus.become web=\(ObjectIdentifier(self)) result=\(result ? 1 : 0) " +
"policy=\(allowsFirstResponderAcquisition ? 1 : 0) " +
"pointerDepth=\(pointerFocusAllowanceDepth) eventType=\(eventType)"
)
#endif
return result
}
/// Temporarily permits focus acquisition for explicit pointer-driven interactions
/// (mouse click into this webview) while keeping background autofocus blocked.
func withPointerFocusAllowance(_ body: () -> Void) {
pointerFocusAllowanceDepth += 1
#if DEBUG
dlog(
"browser.focus.pointerAllowance.enter web=\(ObjectIdentifier(self)) " +
"depth=\(pointerFocusAllowanceDepth)"
)
#endif
defer {
pointerFocusAllowanceDepth = max(0, pointerFocusAllowanceDepth - 1)
#if DEBUG
dlog(
"browser.focus.pointerAllowance.exit web=\(ObjectIdentifier(self)) " +
"depth=\(pointerFocusAllowanceDepth)"
)
#endif
}
body()
}
override func performKeyEquivalent(with event: NSEvent) -> Bool {
if event.keyCode == 36 || event.keyCode == 76 {
// Always bypass app/menu key-equivalent routing for Return/Enter so WebKit
// receives the keyDown path used by form submission handlers.
return false
}
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
// Menu/app shortcut routing is only needed for Command equivalents
// (New Tab, Close Tab, tab switching, split commands, etc).
guard flags.contains(.command) else {
return super.performKeyEquivalent(with: event)
}
// Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc).
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
return true
@ -46,20 +158,48 @@ final class CmuxWebView: WKWebView {
// NSView (WKWebView), not to sibling SwiftUI overlays. Notify the panel system so
// bonsplit focus tracks which pane the user clicked in.
override func mouseDown(with event: NSEvent) {
#if DEBUG
let windowNumber = window?.windowNumber ?? -1
let firstResponderType = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
dlog(
"browser.focus.mouseDown web=\(ObjectIdentifier(self)) " +
"policy=\(allowsFirstResponderAcquisition ? 1 : 0) " +
"pointerDepth=\(pointerFocusAllowanceDepth) win=\(windowNumber) fr=\(firstResponderType)"
)
#endif
NotificationCenter.default.post(name: .webViewDidReceiveClick, object: self)
super.mouseDown(with: event)
withPointerFocusAllowance {
super.mouseDown(with: event)
}
}
// MARK: - Mouse back/forward buttons & middle-click
// MARK: - Mouse back/forward buttons
override func otherMouseDown(with event: NSEvent) {
if event.buttonNumber == 2 {
Self.recordMiddleClickIntent(for: self)
}
#if DEBUG
let point = convert(event.locationInWindow, from: nil)
let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue
dlog(
"browser.mouse.otherDown web=\(ObjectIdentifier(self)) button=\(event.buttonNumber) " +
"clicks=\(event.clickCount) mods=\(mods) point=(\(Int(point.x)),\(Int(point.y)))"
)
#endif
// Button 3 = back, button 4 = forward (multi-button mice like Logitech).
// Consume the event so WebKit doesn't handle it.
switch event.buttonNumber {
case 3:
#if DEBUG
dlog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goBack canGoBack=\(canGoBack ? 1 : 0)")
#endif
goBack()
return
case 4:
#if DEBUG
dlog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goForward canGoForward=\(canGoForward ? 1 : 0)")
#endif
goForward()
return
default:
@ -69,25 +209,23 @@ final class CmuxWebView: WKWebView {
}
override func otherMouseUp(with event: NSEvent) {
// Middle-click (button 2) on a link opens it in a new tab.
if event.buttonNumber == 2 {
let point = convert(event.locationInWindow, from: nil)
findLinkAtPoint(point) { [weak self] url in
guard let self, let url else { return }
NotificationCenter.default.post(
name: .webViewMiddleClickedLink,
object: self,
userInfo: ["url": url]
)
}
return
Self.recordMiddleClickIntent(for: self)
}
#if DEBUG
let point = convert(event.locationInWindow, from: nil)
let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue
dlog(
"browser.mouse.otherUp web=\(ObjectIdentifier(self)) button=\(event.buttonNumber) " +
"clicks=\(event.clickCount) mods=\(mods) point=(\(Int(point.x)),\(Int(point.y)))"
)
#endif
super.otherMouseUp(with: event)
}
/// Use JavaScript to find the nearest anchor element at the given view-local point.
/// Finds the nearest anchor element at a given view-local point.
/// Used as a context-menu download fallback.
private func findLinkAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) {
// WKWebView's coordinate system is flipped (origin top-left for web content).
let flippedY = bounds.height - point.y
let js = """
(() => {
@ -109,6 +247,327 @@ final class CmuxWebView: WKWebView {
}
}
// MARK: - Context menu download support
/// The last context-menu point in view coordinates.
private var lastContextMenuPoint: NSPoint = .zero
/// Saved native WebKit action for "Download Image".
private var fallbackDownloadImageTarget: AnyObject?
private var fallbackDownloadImageAction: Selector?
/// Saved native WebKit action for "Download Linked File".
private var fallbackDownloadLinkedFileTarget: AnyObject?
private var fallbackDownloadLinkedFileAction: Selector?
private func isDownloadableScheme(_ url: URL) -> Bool {
let scheme = url.scheme?.lowercased() ?? ""
return scheme == "http" || scheme == "https" || scheme == "file"
}
private func isOurDownloadMenuAction(target: AnyObject?, action: Selector?) -> Bool {
guard target === self else { return false }
return action == #selector(contextMenuDownloadImage(_:))
|| action == #selector(contextMenuDownloadLinkedFile(_:))
}
private func resolveGoogleRedirectURL(_ url: URL) -> URL? {
guard let host = url.host?.lowercased(), host.contains("google.") else { return nil }
guard var comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
let queryItems = comps.queryItems else { return nil }
let map = Dictionary(uniqueKeysWithValues: queryItems.map { ($0.name.lowercased(), $0.value ?? "") })
let candidates = ["imgurl", "mediaurl", "url", "q"]
for key in candidates {
guard let raw = map[key], !raw.isEmpty,
let decoded = raw.removingPercentEncoding ?? raw as String?,
let candidate = URL(string: decoded),
isDownloadableScheme(candidate) else {
continue
}
return candidate
}
// Some links are wrapped as /url?...
if comps.path.lowercased() == "/url" {
for key in ["url", "q"] {
if let raw = map[key], let candidate = URL(string: raw), isDownloadableScheme(candidate) {
return candidate
}
}
}
return nil
}
private func normalizedLinkedDownloadURL(_ url: URL) -> URL {
resolveGoogleRedirectURL(url) ?? url
}
private func captureFallbackForMenuItemIfNeeded(_ item: NSMenuItem) {
let target = item.target as AnyObject?
let action = item.action
if isOurDownloadMenuAction(target: target, action: action) {
return
}
let box = ContextMenuFallbackBox(target: target, action: action)
objc_setAssociatedObject(
item,
&Self.contextMenuFallbackKey,
box,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
private func fallbackFromSender(
_ sender: Any?,
defaultAction: Selector?,
defaultTarget: AnyObject?
) -> (action: Selector?, target: AnyObject?) {
if let item = sender as? NSMenuItem,
let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox {
return (box.action, box.target)
}
return (defaultAction, defaultTarget)
}
/// Resolve the topmost image URL near a point, accounting for overlay layers.
private func findImageURLAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) {
let flippedY = bounds.height - point.y
let js = """
(() => {
const nodes = document.elementsFromPoint(\(point.x), \(flippedY));
for (const start of nodes) {
let elChain = [];
let seen = new Set();
let walk = (node) => {
let chain = [];
let localSeen = new Set();
let visit = (n) => {
while (n && !localSeen.has(n)) {
localSeen.add(n);
chain.push(n);
n = n.parentElement;
}
};
visit(node);
if (node && node.tagName === 'PICTURE') {
const img = node.querySelector('img');
if (img) visit(img);
}
return chain;
};
for (const el of walk(start)) {
if (!seen.has(el)) {
seen.add(el);
elChain.push(el);
}
}
for (const el of elChain) {
if (el.tagName === 'IMG') {
if (el.currentSrc) return el.currentSrc;
if (el.src) return el.src;
}
if (el.tagName === 'PICTURE') {
const img = el.querySelector('img');
if (img) {
if (img.currentSrc) return img.currentSrc;
if (img.src) return img.src;
}
}
}
}
return '';
})();
"""
evaluateJavaScript(js) { result, _ in
guard let src = result as? String, !src.isEmpty,
let url = URL(string: src) else {
completion(nil)
return
}
completion(url)
}
}
/// Resolve the topmost link URL near a point, accounting for overlay layers.
private func findLinkURLAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) {
let flippedY = bounds.height - point.y
let js = """
(() => {
const nodes = document.elementsFromPoint(\(point.x), \(flippedY));
for (const start of nodes) {
let el = start;
let seen = new Set();
let cur = (() => {
let n = start;
return n;
})();
let walk = (node) => {
let chain = [];
while (node && !seen.has(node)) {
seen.add(node);
chain.push(node);
node = node.parentElement;
}
return chain;
};
for (const n of walk(cur)) {
if (n.tagName === 'A' && n.href) return n.href;
}
}
return '';
})();
"""
evaluateJavaScript(js) { result, _ in
guard let href = result as? String, !href.isEmpty,
let url = URL(string: href) else {
completion(nil)
return
}
completion(url)
}
}
private func resolveContextMenuLinkURL(at point: NSPoint, completion: @escaping (URL?) -> Void) {
if let contextMenuLinkURLProvider {
contextMenuLinkURLProvider(self, point, completion)
return
}
findLinkURLAtPoint(point, completion: completion)
}
private func canOpenInDefaultBrowser(_ url: URL) -> Bool {
let scheme = url.scheme?.lowercased() ?? ""
return scheme == "http" || scheme == "https"
}
private func openContextMenuLinkInDefaultBrowser(_ url: URL) {
if let contextMenuDefaultBrowserOpener {
_ = contextMenuDefaultBrowserOpener(url)
return
}
_ = NSWorkspace.shared.open(url)
}
private func runContextMenuFallback(action: Selector?, target: AnyObject?, sender: Any?) {
guard let action else { return }
// Guard against accidental self-recursion if fallback gets overwritten.
if target === self,
action == #selector(contextMenuDownloadImage(_:))
|| action == #selector(contextMenuDownloadLinkedFile(_:)) {
NSLog("CmuxWebView context fallback skipped (recursive self action)")
return
}
_ = NSApp.sendAction(action, to: target, from: sender)
}
private func notifyContextMenuDownloadState(_ downloading: Bool) {
if Thread.isMainThread {
onContextMenuDownloadStateChanged?(downloading)
} else {
DispatchQueue.main.async { [weak self] in
self?.onContextMenuDownloadStateChanged?(downloading)
}
}
}
private func downloadURLViaSession(
_ url: URL,
suggestedFilename: String?,
sender: Any?,
fallbackAction: Selector?,
fallbackTarget: AnyObject?
) {
guard isDownloadableScheme(url) else {
runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender)
return
}
let scheme = url.scheme?.lowercased() ?? ""
notifyContextMenuDownloadState(true)
if scheme == "file" {
DispatchQueue.main.async {
do {
let data = try Data(contentsOf: url)
let filename = suggestedFilename?.trimmingCharacters(in: .whitespacesAndNewlines)
let saveName = (filename?.isEmpty == false ? filename! : url.lastPathComponent.isEmpty ? "download" : url.lastPathComponent)
let savePanel = NSSavePanel()
savePanel.nameFieldStringValue = saveName
savePanel.canCreateDirectories = true
savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
// Download is already complete; we're now waiting for user save choice.
self.notifyContextMenuDownloadState(false)
savePanel.begin { result in
guard result == .OK, let destURL = savePanel.url else { return }
try? data.write(to: destURL, options: .atomic)
}
} catch {
self.notifyContextMenuDownloadState(false)
self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender)
}
}
return
}
let cookieStore = configuration.websiteDataStore.httpCookieStore
cookieStore.getAllCookies { cookies in
var request = URLRequest(url: url)
request.httpMethod = "GET"
let cookieHeaders = HTTPCookie.requestHeaderFields(with: cookies)
for (key, value) in cookieHeaders {
request.setValue(value, forHTTPHeaderField: key)
}
if let referer = self.url?.absoluteString, !referer.isEmpty {
request.setValue(referer, forHTTPHeaderField: "Referer")
}
if let ua = self.customUserAgent, !ua.isEmpty {
request.setValue(ua, forHTTPHeaderField: "User-Agent")
}
URLSession.shared.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
guard let data, error == nil else {
self.notifyContextMenuDownloadState(false)
self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender)
return
}
let filenameCandidate = suggestedFilename
?? response?.suggestedFilename
?? url.lastPathComponent
let saveName = filenameCandidate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "download" : filenameCandidate
let savePanel = NSSavePanel()
savePanel.nameFieldStringValue = saveName
savePanel.canCreateDirectories = true
savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
// Download is already complete; we're now waiting for user save choice.
self.notifyContextMenuDownloadState(false)
savePanel.begin { result in
guard result == .OK, let destURL = savePanel.url else { return }
do {
try data.write(to: destURL, options: .atomic)
} catch {
self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender)
}
}
}
}.resume()
}
}
private func startContextMenuDownload(
_ url: URL,
sender: Any?,
fallbackAction: Selector?,
fallbackTarget: AnyObject?
) {
NSLog("CmuxWebView context download start: %@", url.absoluteString)
downloadURLViaSession(
url,
suggestedFilename: nil,
sender: sender,
fallbackAction: fallbackAction,
fallbackTarget: fallbackTarget
)
}
// MARK: - Drag-and-drop passthrough
// WKWebView inherently calls registerForDraggedTypes with public.text (and others).
@ -136,8 +595,23 @@ final class CmuxWebView: WKWebView {
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
super.willOpenMenu(menu, with: event)
lastContextMenuPoint = convert(event.locationInWindow, from: nil)
var openLinkInsertionIndex: Int?
var hasDefaultBrowserOpenLinkItem = false
for (index, item) in menu.items.enumerated() {
if !hasDefaultBrowserOpenLinkItem,
(item.action == #selector(contextMenuOpenLinkInDefaultBrowser(_:))
|| item.title == "Open Link in Default Browser") {
hasDefaultBrowserOpenLinkItem = true
}
if openLinkInsertionIndex == nil,
(item.identifier?.rawValue == "WKMenuItemIdentifierOpenLink"
|| item.title == "Open Link") {
openLinkInsertionIndex = index + 1
}
for item in menu.items {
// Rename "Open Link in New Window" to "Open Link in New Tab".
// The UIDelegate's createWebViewWith already handles the action
// by opening the link as a new surface in the same pane.
@ -145,6 +619,183 @@ final class CmuxWebView: WKWebView {
|| item.title.contains("Open Link in New Window") {
item.title = "Open Link in New Tab"
}
if item.identifier?.rawValue == "WKMenuItemIdentifierDownloadImage"
|| item.title == "Download Image" {
NSLog("CmuxWebView context menu hook: download image")
captureFallbackForMenuItemIfNeeded(item)
// Keep global fallback as a secondary safety net.
if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox {
fallbackDownloadImageTarget = box.target
fallbackDownloadImageAction = box.action
} else if !isOurDownloadMenuAction(target: item.target as AnyObject?, action: item.action) {
fallbackDownloadImageTarget = item.target as AnyObject?
fallbackDownloadImageAction = item.action
}
item.target = self
item.action = #selector(contextMenuDownloadImage(_:))
}
if item.identifier?.rawValue == "WKMenuItemIdentifierDownloadLinkedFile"
|| item.title == "Download Linked File" {
NSLog("CmuxWebView context menu hook: download linked file")
captureFallbackForMenuItemIfNeeded(item)
// Keep global fallback as a secondary safety net.
if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox {
fallbackDownloadLinkedFileTarget = box.target
fallbackDownloadLinkedFileAction = box.action
} else if !isOurDownloadMenuAction(target: item.target as AnyObject?, action: item.action) {
fallbackDownloadLinkedFileTarget = item.target as AnyObject?
fallbackDownloadLinkedFileAction = item.action
}
item.target = self
item.action = #selector(contextMenuDownloadLinkedFile(_:))
}
}
if let openLinkInsertionIndex, !hasDefaultBrowserOpenLinkItem {
let item = NSMenuItem(
title: "Open Link in Default Browser",
action: #selector(contextMenuOpenLinkInDefaultBrowser(_:)),
keyEquivalent: ""
)
item.target = self
menu.insertItem(item, at: min(openLinkInsertionIndex, menu.items.count))
}
}
@objc private func contextMenuOpenLinkInDefaultBrowser(_ sender: Any?) {
_ = sender
let point = lastContextMenuPoint
resolveContextMenuLinkURL(at: point) { [weak self] url in
guard let self, let url, self.canOpenInDefaultBrowser(url) else { return }
self.openContextMenuLinkInDefaultBrowser(url)
}
}
@objc private func contextMenuDownloadImage(_ sender: Any?) {
let point = lastContextMenuPoint
let fallback = fallbackFromSender(
sender,
defaultAction: fallbackDownloadImageAction,
defaultTarget: fallbackDownloadImageTarget
)
findImageURLAtPoint(point) { [weak self] url in
guard let self else { return }
if let url {
let scheme = url.scheme?.lowercased() ?? ""
if scheme == "http" || scheme == "https" || scheme == "file" {
NSLog("CmuxWebView context download image URL: %@", url.absoluteString)
self.startContextMenuDownload(
url,
sender: sender,
fallbackAction: fallback.action,
fallbackTarget: fallback.target
)
return
}
}
// Google Images and similar sites often expose blob:/data: image URLs.
// If image URL is not directly downloadable, fall back to the nearby link URL.
self.findLinkURLAtPoint(point) { linkURL in
guard let linkURL else {
NSLog("CmuxWebView context download image: no downloadable image/link URL, using fallback action")
self.runContextMenuFallback(
action: fallback.action,
target: fallback.target,
sender: sender
)
return
}
let linkScheme = linkURL.scheme?.lowercased() ?? ""
guard linkScheme == "http" || linkScheme == "https" || linkScheme == "file" else {
NSLog("CmuxWebView context download image: link URL not downloadable (%@), using fallback action", linkURL.absoluteString)
self.runContextMenuFallback(
action: fallback.action,
target: fallback.target,
sender: sender
)
return
}
NSLog("CmuxWebView context download image fallback to link URL: %@", linkURL.absoluteString)
self.startContextMenuDownload(
linkURL,
sender: sender,
fallbackAction: fallback.action,
fallbackTarget: fallback.target
)
}
}
}
@objc private func contextMenuDownloadLinkedFile(_ sender: Any?) {
let point = lastContextMenuPoint
let fallback = fallbackFromSender(
sender,
defaultAction: fallbackDownloadLinkedFileAction,
defaultTarget: fallbackDownloadLinkedFileTarget
)
findLinkURLAtPoint(point) { [weak self] url in
guard let self else { return }
if let url {
let normalized = self.normalizedLinkedDownloadURL(url)
if self.isDownloadableScheme(normalized) {
NSLog("CmuxWebView context download linked file URL: %@ (normalized=%@)", url.absoluteString, normalized.absoluteString)
self.startContextMenuDownload(
normalized,
sender: sender,
fallbackAction: fallback.action,
fallbackTarget: fallback.target
)
return
}
}
// Fallback 1: image URL under cursor (useful on image-heavy result pages).
self.findImageURLAtPoint(point) { imageURL in
if let imageURL, self.isDownloadableScheme(imageURL) {
NSLog("CmuxWebView context download linked file fallback image URL: %@", imageURL.absoluteString)
self.startContextMenuDownload(
imageURL,
sender: sender,
fallbackAction: fallback.action,
fallbackTarget: fallback.target
)
return
}
// Fallback 2: simpler nearest-anchor lookup.
self.findLinkAtPoint(point) { fallbackURL in
guard let fallbackURL else {
NSLog("CmuxWebView context download linked file: URL nil, using fallback action")
self.runContextMenuFallback(
action: fallback.action,
target: fallback.target,
sender: sender
)
return
}
let normalized = self.normalizedLinkedDownloadURL(fallbackURL)
guard self.isDownloadableScheme(normalized) else {
NSLog("CmuxWebView context download linked file: unsupported URL %@, using fallback action", fallbackURL.absoluteString)
self.runContextMenuFallback(
action: fallback.action,
target: fallback.target,
sender: sender
)
return
}
NSLog("CmuxWebView context download linked file fallback URL: %@ (normalized=%@)", fallbackURL.absoluteString, normalized.absoluteString)
self.startContextMenuDownload(
normalized,
sender: sender,
fallbackAction: fallback.action,
fallbackTarget: fallback.target
)
}
}
}
}
}

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

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

@ -39,8 +39,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.
@ -68,10 +69,14 @@ 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()
@ -90,4 +95,34 @@ 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 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,9 @@
import Sentry
/// Add a Sentry breadcrumb for user-action context in hang/crash reports.
func sentryBreadcrumb(_ message: String, category: String = "ui", data: [String: Any]? = nil) {
let crumb = Breadcrumb(level: .info, category: category)
crumb.message = message
crumb.data = data
SentrySDK.addBreadcrumb(crumb)
}

View file

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

View file

@ -2,6 +2,9 @@ import SwiftUI
@MainActor
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,17 @@
import Foundation
import Security
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 +19,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,8 +34,126 @@ 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 your keychain."
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 service = "com.cmuxterm.app.socket-control"
static let account = "local-socket-password"
private static var baseQuery: [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
}
static func configuredPassword(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> String? {
if let envPassword = environment[SocketControlSettings.socketPasswordEnvKey], !envPassword.isEmpty {
return envPassword
}
return try? loadPassword()
}
static func hasConfiguredPassword(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> Bool {
guard let configured = configuredPassword(environment: environment) else { return false }
return !configured.isEmpty
}
static func verify(
password candidate: String,
environment: [String: String] = ProcessInfo.processInfo.environment
) -> Bool {
guard let expected = configuredPassword(environment: environment), !expected.isEmpty else {
return false
}
return expected == candidate
}
static func loadPassword() throws -> String? {
var query = baseQuery
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound {
return nil
}
guard status == errSecSuccess else {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
}
guard let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
static func savePassword(_ password: String) throws {
let normalized = password.trimmingCharacters(in: .newlines)
if normalized.isEmpty {
try clearPassword()
return
}
let data = Data(normalized.utf8)
var lookup = baseQuery
lookup[kSecReturnData as String] = true
lookup[kSecMatchLimit as String] = kSecMatchLimitOne
var existing: CFTypeRef?
let lookupStatus = SecItemCopyMatching(lookup as CFDictionary, &existing)
switch lookupStatus {
case errSecSuccess:
let attrsToUpdate: [String: Any] = [
kSecValueData as String: data
]
let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attrsToUpdate as CFDictionary)
guard updateStatus == errSecSuccess else {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(updateStatus))
}
case errSecItemNotFound:
var add = baseQuery
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(add as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(addStatus))
}
default:
throw NSError(domain: NSOSStatusErrorDomain, code: Int(lookupStatus))
}
}
static func clearPassword() throws {
let status = SecItemDelete(baseQuery as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status))
}
}
}
@ -38,36 +161,131 @@ enum SocketControlMode: String, CaseIterable, Identifiable {
struct SocketControlSettings {
static let appStorageKey = "socketControlMode"
static let legacyEnabledKey = "socketControlEnabled"
static let allowSocketPathOverrideKey = "CMUX_ALLOW_SOCKET_OVERRIDE"
static let socketPasswordEnvKey = "CMUX_SOCKET_PASSWORD"
/// 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 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 +299,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

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

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

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,28 @@ struct WorkspaceContentView: View {
syncBonsplitNotificationBadges()
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in
refreshGhosttyAppearanceConfig()
refreshGhosttyAppearanceConfig(reason: "ghosttyConfigDidReload")
}
.onChange(of: colorScheme) { _, _ in
.onChange(of: colorScheme) { oldValue, newValue in
// Keep split overlay color/opacity in sync with light/dark theme transitions.
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 +172,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 +293,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 +329,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 +383,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

View file

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

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

File diff suppressed because it is too large Load diff

View file

@ -162,7 +162,70 @@ 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 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 +236,7 @@ final class GhosttyConfigTests: XCTestCase {
}
defaults.removeObject(forKey: ClaudeCodeIntegrationSettings.hooksEnabledKey)
XCTAssertFalse(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
XCTAssertTrue(ClaudeCodeIntegrationSettings.hooksEnabled(defaults: defaults))
}
func testClaudeCodeIntegrationRespectsStoredPreference() {
@ -208,6 +271,75 @@ final class GhosttyConfigTests: XCTestCase {
}
}
final class WorkspaceChromeThemeTests: XCTestCase {
func testResolvedChromeColorsUsesLightGhosttyBackground() {
guard let backgroundColor = NSColor(hex: "#FDF6E3") else {
XCTFail("Expected valid test color")
return
}
let colors = Workspace.resolvedChromeColors(from: backgroundColor)
XCTAssertEqual(colors.backgroundHex, "#FDF6E3")
XCTAssertNil(colors.borderHex)
}
func testResolvedChromeColorsUsesDarkGhosttyBackground() {
guard let backgroundColor = NSColor(hex: "#272822") else {
XCTFail("Expected valid test color")
return
}
let colors = Workspace.resolvedChromeColors(from: backgroundColor)
XCTAssertEqual(colors.backgroundHex, "#272822")
XCTAssertNil(colors.borderHex)
}
}
final class WorkspaceAppearanceConfigResolutionTests: XCTestCase {
func testResolvedAppearanceConfigPrefersGhosttyRuntimeBackgroundOverLoadedConfig() {
guard let loadedBackground = NSColor(hex: "#112233"),
let runtimeBackground = NSColor(hex: "#FDF6E3"),
let loadedForeground = NSColor(hex: "#ABCDEF") else {
XCTFail("Expected valid test colors")
return
}
var loaded = GhosttyConfig()
loaded.backgroundColor = loadedBackground
loaded.foregroundColor = loadedForeground
loaded.unfocusedSplitOpacity = 0.42
let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig(
loadConfig: { loaded },
defaultBackground: { runtimeBackground }
)
XCTAssertEqual(resolved.backgroundColor.hexString(), "#FDF6E3")
XCTAssertEqual(resolved.foregroundColor.hexString(), "#ABCDEF")
XCTAssertEqual(resolved.unfocusedSplitOpacity, 0.42, accuracy: 0.0001)
}
func testResolvedAppearanceConfigPrefersExplicitBackgroundOverride() {
guard let loadedBackground = NSColor(hex: "#112233"),
let runtimeBackground = NSColor(hex: "#FDF6E3"),
let explicitOverride = NSColor(hex: "#272822") else {
XCTFail("Expected valid test colors")
return
}
var loaded = GhosttyConfig()
loaded.backgroundColor = loadedBackground
let resolved = WorkspaceContentView.resolveGhosttyAppearanceConfig(
backgroundOverride: explicitOverride,
loadConfig: { loaded },
defaultBackground: { runtimeBackground }
)
XCTAssertEqual(resolved.backgroundColor.hexString(), "#272822")
}
}
final class NotificationBurstCoalescerTests: XCTestCase {
func testSignalsInSameBurstFlushOnce() {
let coalescer = NotificationBurstCoalescer(delay: 0.01)
@ -271,6 +403,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 +575,168 @@ 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"
)
}
}
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 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"])
}
}

View file

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

View file

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

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

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

View file

@ -35,4 +35,31 @@ 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))
let start = resizer.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
let farRight = start.withOffset(CGVector(dx: 5000, dy: 0))
start.press(forDuration: 0.1, thenDragTo: farRight)
let windowFrame = window.frame
let remainingWidth = max(0, windowFrame.maxX - resizer.frame.maxX)
let minimumExpectedRemaining = windowFrame.width * 0.45
XCTAssertGreaterThanOrEqual(
remainingWidth,
minimumExpectedRemaining,
"Expected sidebar max-width clamp to leave substantial terminal width. " +
"remaining=\(remainingWidth), window=\(windowFrame.width)"
)
}
}

View file

@ -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()
}
}
@ -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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

333
tests/test_open_wrapper.py Executable file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""
Regression test for the default sidebar active workspace indicator style.
"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
def get_repo_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
)
if result.returncode == 0:
return Path(result.stdout.strip())
return Path.cwd()
def main() -> int:
repo_root = get_repo_root()
tab_manager = repo_root / "Sources" / "TabManager.swift"
if not tab_manager.exists():
print(f"FAIL: Missing file {tab_manager}")
return 1
content = tab_manager.read_text(encoding="utf-8")
pattern = r"static let defaultStyle:\s*SidebarActiveTabIndicatorStyle\s*=\s*\.leftRail\b"
if re.search(pattern, content) is None:
rel = tab_manager.relative_to(repo_root)
print(f"FAIL: Expected default style `.leftRail` in {rel}")
return 1
print("PASS: sidebar indicator default style is left rail")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -20,6 +20,9 @@ import subprocess
import sys
import tempfile
import time
import json
import glob
import plistlib
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux, cmuxError
@ -68,34 +71,169 @@ def _raw_send(sock, command: str, timeout: float = 3.0) -> str:
return data.decode().strip()
def _preferred_worktree_slug():
env_slug = os.environ.get("CMUX_TAG") or os.environ.get("CMUX_BRANCH_SLUG")
if env_slug:
return env_slug.strip().lower()
cwd = os.getcwd()
marker = "/worktrees/"
if marker in cwd:
tail = cwd.split(marker, 1)[1]
slug = tail.split("/", 1)[0].strip().lower()
if slug:
return slug
return ""
def _derived_app_candidates_for_current_worktree():
project_path = os.path.realpath(os.path.join(os.getcwd(), "GhosttyTabs.xcodeproj"))
info_paths = glob.glob(os.path.expanduser(
"~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*/info.plist"
))
matches = []
for info_path in info_paths:
try:
with open(info_path, "rb") as f:
info = plistlib.load(f)
except Exception:
continue
workspace_path = info.get("WorkspacePath")
if not workspace_path:
continue
if os.path.realpath(workspace_path) != project_path:
continue
derived_root = os.path.dirname(info_path)
app_path = os.path.join(derived_root, "Build/Products/Debug/cmux DEV.app")
if os.path.exists(app_path):
matches.append(app_path)
return matches
def _find_app():
r = subprocess.run(
["find", "/Users/cmux/Library/Developer/Xcode/DerivedData",
"-path", "*/Build/Products/Debug/cmux DEV.app", "-print", "-quit"],
capture_output=True, text=True, timeout=10
)
return r.stdout.strip()
explicit = os.environ.get("CMUX_APP_PATH")
if explicit and os.path.exists(explicit):
return explicit
preferred_slug = _preferred_worktree_slug()
if preferred_slug:
preferred_tmp = []
preferred_tmp.extend(glob.glob(f"/tmp/cmux-{preferred_slug}/Build/Products/Debug/cmux DEV*.app"))
preferred_tmp.extend(glob.glob(f"/private/tmp/cmux-{preferred_slug}/Build/Products/Debug/cmux DEV*.app"))
preferred_tmp = [p for p in preferred_tmp if os.path.exists(p)]
if preferred_tmp:
preferred_tmp.sort(key=os.path.getmtime, reverse=True)
return preferred_tmp[0]
direct_matches = _derived_app_candidates_for_current_worktree()
if direct_matches:
direct_matches.sort(key=os.path.getmtime, reverse=True)
return direct_matches[0]
home = os.path.expanduser("~")
derived_candidates = glob.glob(os.path.join(
home, "Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux DEV.app"
))
tmp_candidates = []
tmp_candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux DEV*.app"))
tmp_candidates.extend(glob.glob("/private/tmp/cmux-*/Build/Products/Debug/cmux DEV*.app"))
derived_candidates = [p for p in derived_candidates if os.path.exists(p)]
tmp_candidates = [p for p in tmp_candidates if os.path.exists(p)]
if preferred_slug:
preferred_derived = [p for p in derived_candidates if preferred_slug in p.lower()]
preferred_tmp = [p for p in tmp_candidates if preferred_slug in p.lower()]
if preferred_derived:
derived_candidates = preferred_derived
if preferred_tmp:
tmp_candidates = preferred_tmp
if derived_candidates:
derived_candidates.sort(key=os.path.getmtime, reverse=True)
return derived_candidates[0]
if tmp_candidates:
tmp_candidates.sort(key=os.path.getmtime, reverse=True)
return tmp_candidates[0]
return ""
def _find_cli(preferred_app_path: 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
if preferred_app_path:
debug_dir = os.path.dirname(preferred_app_path)
sibling = os.path.join(debug_dir, "cmux")
if os.path.exists(sibling) and os.access(sibling, os.X_OK):
return sibling
candidates = []
home = os.path.expanduser("~")
candidates.extend(glob.glob(os.path.join(
home, "Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux"
)))
candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux"))
candidates.extend(glob.glob("/private/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 not candidates:
return ""
preferred_slug = _preferred_worktree_slug()
if preferred_slug:
preferred = [p for p in candidates if preferred_slug in p.lower()]
if preferred:
candidates = preferred
candidates.sort(key=os.path.getmtime, reverse=True)
return candidates[0]
def _wait_for_socket(socket_path: str, timeout: float = 10.0) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if os.path.exists(socket_path):
return True
try:
sock = _raw_connect(socket_path, timeout=0.3)
sock.close()
return True
except Exception:
pass
time.sleep(0.5)
return False
def _kill_cmux():
subprocess.run(["pkill", "-x", "cmux DEV"], capture_output=True)
def _kill_cmux(app_path: str = None):
if app_path:
exe = os.path.join(app_path, "Contents/MacOS/cmux DEV")
subprocess.run(["pkill", "-f", exe], capture_output=True)
else:
subprocess.run(["pkill", "-x", "cmux DEV"], capture_output=True)
time.sleep(1.5)
def _launch_cmux(app_path: str, socket_path: str, mode: str = None):
def _launch_cmux(app_path: str, socket_path: str, mode: str = None, extra_env: dict = None):
if os.path.exists(socket_path):
try:
os.unlink(socket_path)
except OSError:
pass
env_args = []
if mode:
env_args = ["--env", f"CMUX_SOCKET_MODE={mode}"]
subprocess.Popen(["open", "-a", app_path] + env_args)
launch_env = {
"CMUX_SOCKET_PATH": socket_path,
"CMUX_ALLOW_SOCKET_OVERRIDE": "1",
}
if extra_env:
launch_env.update(extra_env)
for key, value in launch_env.items():
env_args.extend(["--env", f"{key}={value}"])
subprocess.Popen(["open", "-na", app_path] + env_args)
if not _wait_for_socket(socket_path):
raise RuntimeError(f"Socket {socket_path} not created after launch")
time.sleep(8)
@ -249,8 +387,8 @@ fi
f.write(hook_line)
# Kill existing cmux, launch in cmuxOnly mode (default)
_kill_cmux()
_launch_cmux(app_path, socket_path)
_kill_cmux(app_path)
_launch_cmux(app_path, socket_path, mode="cmuxOnly")
# Wait for marker (the shell sources .zprofile on startup)
for _ in range(40):
@ -305,7 +443,7 @@ def test_allowall_mode_works(socket_path: str, app_path: str) -> TestResult:
"""Verify CMUX_SOCKET_MODE=allowAll bypasses ancestry check."""
result = TestResult("allowAll mode allows external")
try:
_kill_cmux()
_kill_cmux(app_path)
_launch_cmux(app_path, socket_path, mode="allowAll")
sock = _raw_connect(socket_path)
@ -321,6 +459,178 @@ def test_allowall_mode_works(socket_path: str, app_path: str) -> TestResult:
return result
def test_password_mode_requires_auth(socket_path: str, app_path: str) -> TestResult:
"""Verify password mode rejects unauthenticated commands."""
result = TestResult("Password mode requires auth")
password = f"cmux-pass-{os.getpid()}"
try:
_kill_cmux(app_path)
_launch_cmux(
app_path,
socket_path,
mode="password",
extra_env={"CMUX_SOCKET_PASSWORD": password}
)
sock = _raw_connect(socket_path)
response = _raw_send(sock, "ping")
sock.close()
if "Authentication required" in response:
result.success("Unauthenticated command rejected in password mode")
else:
result.failure(f"Unexpected response without auth: {response!r}")
except Exception as e:
result.failure(f"{type(e).__name__}: {e}")
return result
def test_password_mode_v1_auth_flow(socket_path: str, app_path: str) -> TestResult:
"""Verify v1 auth command unlocks the connection only with correct password."""
result = TestResult("Password mode v1 auth flow")
password = f"cmux-pass-{os.getpid()}"
try:
_kill_cmux(app_path)
_launch_cmux(
app_path,
socket_path,
mode="password",
extra_env={"CMUX_SOCKET_PASSWORD": password}
)
sock = _raw_connect(socket_path)
try:
wrong = _raw_send(sock, "auth wrong-password")
if "Invalid password" not in wrong:
result.failure(f"Expected invalid password error, got: {wrong!r}")
return result
ok = _raw_send(sock, f"auth {password}")
if "OK: Authenticated" not in ok:
result.failure(f"Expected auth success, got: {ok!r}")
return result
pong = _raw_send(sock, "ping")
if pong != "PONG":
result.failure(f"Expected PONG after auth, got: {pong!r}")
return result
finally:
sock.close()
result.success("v1 auth gate works")
except Exception as e:
result.failure(f"{type(e).__name__}: {e}")
return result
def test_password_mode_v2_auth_flow(socket_path: str, app_path: str) -> TestResult:
"""Verify v2 auth.login unlocks subsequent v2 requests."""
result = TestResult("Password mode v2 auth flow")
password = f"cmux-pass-{os.getpid()}"
try:
_kill_cmux(app_path)
_launch_cmux(
app_path,
socket_path,
mode="password",
extra_env={"CMUX_SOCKET_PASSWORD": password}
)
sock = _raw_connect(socket_path)
try:
unauth = _raw_send(sock, json.dumps({
"id": "1",
"method": "system.ping",
"params": {}
}))
unauth_obj = json.loads(unauth)
if unauth_obj.get("error", {}).get("code") != "auth_required":
result.failure(f"Expected auth_required, got: {unauth!r}")
return result
login = _raw_send(sock, json.dumps({
"id": "2",
"method": "auth.login",
"params": {"password": password}
}))
login_obj = json.loads(login)
if not login_obj.get("ok"):
result.failure(f"Expected auth.login success, got: {login!r}")
return result
pong = _raw_send(sock, json.dumps({
"id": "3",
"method": "system.ping",
"params": {}
}))
pong_obj = json.loads(pong)
pong_value = pong_obj.get("result", {}).get("pong")
if pong_value is not True:
result.failure(f"Expected pong=true after auth.login, got: {pong!r}")
return result
finally:
sock.close()
result.success("v2 auth.login gate works")
except Exception as e:
result.failure(f"{type(e).__name__}: {e}")
return result
def test_password_mode_cli_exit_code(socket_path: str, app_path: str) -> TestResult:
"""Verify CLI exits non-zero on auth-required and succeeds with --password."""
result = TestResult("Password mode CLI exit code")
password = f"cmux-pass-{os.getpid()}"
try:
cli_path = _find_cli(preferred_app_path=app_path)
if not cli_path:
result.failure("Could not find cmux CLI binary")
return result
_kill_cmux(app_path)
_launch_cmux(
app_path,
socket_path,
mode="password",
extra_env={"CMUX_SOCKET_PASSWORD": password}
)
no_auth = subprocess.run(
[cli_path, "--socket", socket_path, "ping"],
capture_output=True,
text=True,
timeout=10
)
combined = f"{no_auth.stdout}\n{no_auth.stderr}"
if no_auth.returncode == 0:
result.failure("CLI ping without password exited 0 in password mode")
return result
if "Authentication required" not in combined:
result.failure(f"Unexpected unauthenticated CLI output: {combined!r}")
return result
with_auth = subprocess.run(
[cli_path, "--socket", socket_path, "--password", password, "ping"],
capture_output=True,
text=True,
timeout=10
)
if with_auth.returncode != 0:
result.failure(
f"CLI ping with password failed: exit={with_auth.returncode} "
f"stdout={with_auth.stdout!r} stderr={with_auth.stderr!r}"
)
return result
if "PONG" not in with_auth.stdout:
result.failure(f"Expected PONG with password, got: {with_auth.stdout!r}")
return result
result.success("CLI exits non-zero for auth_required and succeeds with --password")
except Exception as e:
result.failure(f"{type(e).__name__}: {e}")
return result
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
@ -337,7 +647,11 @@ def run_tests():
return 1
print(f"App: {app_path}")
socket_path = _find_socket_path()
socket_path = f"/tmp/cmux-test-socket-access-{os.getpid()}.sock"
try:
os.unlink(socket_path)
except OSError:
pass
print(f"Socket: {socket_path}")
print()
@ -356,9 +670,9 @@ def run_tests():
print("-" * 50)
# Ensure cmux is running in cmuxOnly mode
_kill_cmux()
_kill_cmux(app_path)
print(" Launching cmux in cmuxOnly mode...")
_launch_cmux(app_path, socket_path)
_launch_cmux(app_path, socket_path, mode="cmuxOnly")
run_test(test_external_rejected, socket_path)
run_test(test_connection_closed_after_reject, socket_path)
@ -380,9 +694,19 @@ def run_tests():
run_test(test_allowall_mode_works, socket_path, app_path)
print()
# ── Phase 4: password mode auth gate ──
print("Phase 4: password mode — auth required + login flow")
print("-" * 50)
run_test(test_password_mode_requires_auth, socket_path, app_path)
run_test(test_password_mode_v1_auth_flow, socket_path, app_path)
run_test(test_password_mode_v2_auth_flow, socket_path, app_path)
run_test(test_password_mode_cli_exit_code, socket_path, app_path)
print()
# ── Cleanup: leave cmux in cmuxOnly mode ──
_kill_cmux()
_launch_cmux(app_path, socket_path)
_kill_cmux(app_path)
_launch_cmux(app_path, socket_path, mode="cmuxOnly")
# ── Summary ──
print("=" * 60)

View file

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

View file

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

View file

@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Regression: `new-workspace --command` should execute without selecting the workspace."""
from __future__ import annotations
import glob
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_cli(cli: str, args: list[str]) -> tuple[subprocess.CompletedProcess[str], float]:
env = dict(os.environ)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
env.pop("CMUX_TAB_ID", None)
started = time.monotonic()
proc = subprocess.run(
[cli, "--socket", SOCKET_PATH] + args,
capture_output=True,
text=True,
check=False,
env=env,
)
elapsed = time.monotonic() - started
return proc, elapsed
def main() -> int:
cli = _find_cli_binary()
marker = Path(tempfile.gettempdir()) / f"cmux_new_workspace_command_{os.getpid()}.txt"
created_ws_id: str | None = None
try:
marker.unlink(missing_ok=True)
except OSError:
pass
with cmux(SOCKET_PATH) as c:
try:
baseline_ws_id = c.current_workspace()
token = f"queued-{os.getpid()}-{int(time.time() * 1000)}"
cmd_text = f"echo {token} > {marker}"
proc, elapsed = _run_cli(cli, ["new-workspace", "--command", cmd_text])
combined = f"{proc.stdout}\n{proc.stderr}".strip()
_must(proc.returncode == 0, f"CLI failed ({proc.returncode}): {combined}")
_must(elapsed < 1.5, f"new-workspace --command should return quickly, took {elapsed:.2f}s")
output = (proc.stdout or "").strip()
_must(output.startswith("OK "), f"Expected OK response, got: {output!r}")
_must("Surface not ready" not in combined, f"Unexpected surface readiness error: {combined}")
created_ws_id = output[3:].strip()
_must(bool(created_ws_id), f"Missing workspace id in output: {output!r}")
# Creation with --command should not steal focus.
_must(c.current_workspace() == baseline_ws_id, "new-workspace --command should preserve selected workspace")
observed = ""
deadline = time.time() + 12.0
while time.time() < deadline:
if marker.exists():
try:
observed = marker.read_text(encoding="utf-8").strip()
except OSError:
observed = ""
if observed:
break
time.sleep(0.05)
_must(marker.exists(), f"Command marker file was not created: {marker}")
_must(observed == token, f"Queued command did not execute as expected: expected={token!r} observed={observed!r}")
_must(c.current_workspace() == baseline_ws_id, "Command execution should not switch selected workspace")
finally:
if created_ws_id:
try:
c.close_workspace(created_ws_id)
except Exception:
pass
try:
marker.unlink(missing_ok=True)
except OSError:
pass
print("PASS: new-workspace --command executes without opening the created workspace")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Regression: non-focus CLI commands should not switch the selected workspace."""
import glob
import os
import subprocess
import sys
from pathlib import Path
from typing import List
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_cli(cli: str, args: List[str]) -> str:
env = dict(os.environ)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
env.pop("CMUX_TAB_ID", None)
cmd = [cli, "--socket", SOCKET_PATH] + args
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
if proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
return proc.stdout.strip()
def _current_workspace(c: cmux) -> str:
payload = c._call("workspace.current") or {}
ws_id = str(payload.get("workspace_id") or "")
if not ws_id:
raise cmuxError(f"workspace.current returned no workspace_id: {payload}")
return ws_id
def main() -> int:
cli = _find_cli_binary()
with cmux(SOCKET_PATH) as c:
baseline_ws = _current_workspace(c)
created = _run_cli(cli, ["new-workspace"])
_must(created.startswith("OK "), f"new-workspace expected OK response, got: {created}")
created_ws = created.removeprefix("OK ").strip()
_must(bool(created_ws), f"new-workspace returned no workspace id: {created}")
_must(_current_workspace(c) == baseline_ws, "new-workspace should not switch selected workspace")
_run_cli(cli, ["new-surface", "--workspace", created_ws])
_must(_current_workspace(c) == baseline_ws, "new-surface --workspace should not switch selected workspace")
_run_cli(cli, ["new-pane", "--workspace", created_ws, "--direction", "right"])
_must(_current_workspace(c) == baseline_ws, "new-pane --workspace should not switch selected workspace")
_run_cli(cli, ["tab-action", "--workspace", created_ws, "--action", "new-terminal-right"])
_must(_current_workspace(c) == baseline_ws, "tab-action new-terminal-right should not switch selected workspace")
c.close_workspace(created_ws)
print("PASS: non-focus CLI commands preserve selected workspace")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Regression: explicit `rename-tab` CLI command parity with tab.action rename."""
import glob
import os
import subprocess
import sys
import time
from pathlib import Path
from typing import Dict, List, Optional
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_cli(cli: str, args: List[str], env: Optional[Dict[str, str]] = None) -> str:
merged_env = dict(os.environ)
merged_env.pop("CMUX_WORKSPACE_ID", None)
merged_env.pop("CMUX_SURFACE_ID", None)
merged_env.pop("CMUX_TAB_ID", None)
if env:
merged_env.update(env)
cmd = [cli, "--socket", SOCKET_PATH] + args
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=merged_env)
if proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
return proc.stdout.strip()
def _surface_title(c: cmux, workspace_id: str, surface_id: str) -> str:
payload = c._call("surface.list", {"workspace_id": workspace_id}) or {}
for row in payload.get("surfaces") or []:
if str(row.get("id") or "") == surface_id:
return str(row.get("title") or "")
raise cmuxError(f"surface.list missing surface {surface_id} in workspace {workspace_id}: {payload}")
def main() -> int:
cli = _find_cli_binary()
stamp = int(time.time() * 1000)
with cmux(SOCKET_PATH) as c:
caps = c.capabilities() or {}
methods = set(caps.get("methods") or [])
_must("tab.action" in methods, f"Missing tab.action in capabilities: {sorted(methods)[:40]}")
created = c._call("workspace.create") or {}
ws_id = str(created.get("workspace_id") or "")
_must(bool(ws_id), f"workspace.create returned no workspace_id: {created}")
c._call("workspace.select", {"workspace_id": ws_id})
current = c._call("surface.current", {"workspace_id": ws_id}) or {}
surface_id = str(current.get("surface_id") or "")
_must(bool(surface_id), f"surface.current returned no surface_id: {current}")
socket_title = f"socket rename {stamp}"
c._call(
"tab.action",
{
"workspace_id": ws_id,
"surface_id": surface_id,
"action": "rename",
"title": socket_title,
},
)
_must(_surface_title(c, ws_id, surface_id) == socket_title, "tab.action rename did not update tab title")
cli_title = f"cli rename {stamp}"
_run_cli(cli, ["rename-tab", "--workspace", ws_id, "--tab", surface_id, cli_title])
_must(_surface_title(c, ws_id, surface_id) == cli_title, "rename-tab --tab did not update tab title")
env_title = f"env rename {stamp}"
_run_cli(
cli,
["rename-tab", env_title],
env={
"CMUX_WORKSPACE_ID": ws_id,
"CMUX_TAB_ID": surface_id,
},
)
_must(_surface_title(c, ws_id, surface_id) == env_title, "rename-tab via CMUX_TAB_ID did not update tab title")
invalid = subprocess.run(
[cli, "--socket", SOCKET_PATH, "rename-tab", "--workspace", ws_id],
capture_output=True,
text=True,
check=False,
env={k: v for k, v in os.environ.items() if k not in {"CMUX_WORKSPACE_ID", "CMUX_SURFACE_ID", "CMUX_TAB_ID"}},
)
invalid_output = f"{invalid.stdout}\n{invalid.stderr}"
_must(invalid.returncode != 0, "Expected rename-tab without title to fail")
_must("rename-tab requires a title" in invalid_output, f"Unexpected rename-tab error: {invalid_output!r}")
c.close_workspace(ws_id)
print("PASS: rename-tab CLI parity works with explicit and env-derived targets")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -7,7 +7,7 @@ import subprocess
import sys
import time
from pathlib import Path
from typing import List
from typing import Dict, List, Optional
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
@ -39,11 +39,13 @@ def _find_cli_binary() -> str:
return candidates[0]
def _run_cli(cli: str, args: List[str]) -> str:
def _run_cli(cli: str, args: List[str], env_overrides: Optional[Dict[str, str]] = None) -> str:
env = dict(os.environ)
# Keep this test deterministic when running from inside another cmux shell.
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
if env_overrides:
env.update(env_overrides)
cmd = [cli, "--socket", SOCKET_PATH] + args
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
if proc.returncode != 0:
@ -93,6 +95,17 @@ def main() -> int:
"cmux rename-window without --workspace should target current workspace",
)
env_title = f"tmux env {stamp}"
_run_cli(
cli,
["rename-workspace", env_title],
env_overrides={"CMUX_WORKSPACE_ID": ws_id},
)
_must(
_workspace_title(c, ws_id) == env_title,
"cmux rename-workspace should default to CMUX_WORKSPACE_ID",
)
env = dict(os.environ)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)

View file

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

View file

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

View file

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

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