Merge origin/main into feat-cmux-themes-command

This commit is contained in:
Lawrence Chen 2026-03-13 17:22:12 -07:00
commit cd04bb8932
No known key found for this signature in database
53 changed files with 3425 additions and 336 deletions

View file

@ -13,10 +13,9 @@ on:
concurrency: concurrency:
group: nightly-build-${{ github.ref_name }} group: nightly-build-${{ github.ref_name }}
# Queue main pushes instead of hard-canceling older runs. The decide job # Only the newest nightly matters. Cancel older runs so a fresh main push
# already coalesces to the current main HEAD, and we re-check HEAD before # does not sit behind an outdated build that would be discarded anyway.
# publishing so stale queued runs exit cleanly instead of showing up red. cancel-in-progress: true
cancel-in-progress: false
permissions: permissions:
contents: write contents: write
@ -100,7 +99,7 @@ jobs:
build-sign-notarize-nightly: build-sign-notarize-nightly:
needs: decide needs: decide
if: needs.decide.outputs.should_build == 'true' if: needs.decide.outputs.should_build == 'true'
runs-on: macos-15 runs-on: depot-macos-latest
steps: steps:
- name: Checkout build ref - name: Checkout build ref
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@ -108,7 +107,29 @@ jobs:
ref: ${{ needs.decide.outputs.head_sha }} ref: ${{ needs.decide.outputs.head_sha }}
submodules: recursive submodules: recursive
- name: Check whether build commit is still current main HEAD before build
if: needs.decide.outputs.should_publish == 'true'
id: current_head_prebuild
run: |
set -euo pipefail
CURRENT_MAIN_SHA="$(git ls-remote origin refs/heads/main | awk '{print $1}')"
BUILD_SHA="${{ needs.decide.outputs.head_sha }}"
if [ "$CURRENT_MAIN_SHA" = "$BUILD_SHA" ]; then
STILL_CURRENT=true
else
STILL_CURRENT=false
fi
echo "still_current=${STILL_CURRENT}" >> "$GITHUB_OUTPUT"
{
echo "### Pre-build publish guard"
echo
echo "- build sha: \`$BUILD_SHA\`"
echo "- current main sha: \`$CURRENT_MAIN_SHA\`"
echo "- continue build/sign/publish: \`$STILL_CURRENT\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Select Xcode - name: Select Xcode
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
run: | run: |
set -euo pipefail set -euo pipefail
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
@ -128,14 +149,17 @@ jobs:
xcrun --sdk macosx --show-sdk-path xcrun --sdk macosx --show-sdk-path
- name: Install build deps - name: Install build deps
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
run: | run: |
npm install --global "create-dmg@${CREATE_DMG_VERSION}" npm install --global "create-dmg@${CREATE_DMG_VERSION}"
- name: Download pre-built GhosttyKit.xcframework - name: Download pre-built GhosttyKit.xcframework
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
run: | run: |
./scripts/download-prebuilt-ghosttykit.sh ./scripts/download-prebuilt-ghosttykit.sh
- name: Cache Swift packages - name: Cache Swift packages
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with: with:
path: .spm-cache path: .spm-cache
@ -143,6 +167,7 @@ jobs:
restore-keys: spm- restore-keys: spm-
- name: Derive Sparkle public key from private key - name: Derive Sparkle public key from private key
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
env: env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: | run: |
@ -154,16 +179,8 @@ jobs:
echo "Derived Sparkle public key: $DERIVED_PUBLIC_KEY" echo "Derived Sparkle public key: $DERIVED_PUBLIC_KEY"
echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV" echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV"
- name: Build Apple Silicon app (Release) - name: Build universal nightly app (Release)
run: | if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
xcodebuild -scheme cmux -configuration Release -derivedDataPath build-arm \
-destination 'platform=macOS,arch=arm64' \
-clonedSourcePackagesDirPath .spm-cache \
ARCHS="arm64" \
ONLY_ACTIVE_ARCH=YES \
CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build
- name: Build universal app (Release)
run: | run: |
xcodebuild -scheme cmux -configuration Release -derivedDataPath build-universal \ xcodebuild -scheme cmux -configuration Release -derivedDataPath build-universal \
-destination 'generic/platform=macOS' \ -destination 'generic/platform=macOS' \
@ -173,35 +190,29 @@ jobs:
CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build
- name: Verify nightly binary architectures - name: Verify nightly binary architectures
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
run: | run: |
set -euo pipefail set -euo pipefail
ARM_APP_BINARY="build-arm/Build/Products/Release/cmux.app/Contents/MacOS/cmux"
ARM_CLI_BINARY="build-arm/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux"
APP_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/MacOS/cmux" APP_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/MacOS/cmux"
CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux" CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux"
ARM_APP_ARCHS="$(lipo -archs "$ARM_APP_BINARY")"
ARM_CLI_ARCHS="$(lipo -archs "$ARM_CLI_BINARY")"
APP_ARCHS="$(lipo -archs "$APP_BINARY")" APP_ARCHS="$(lipo -archs "$APP_BINARY")"
CLI_ARCHS="$(lipo -archs "$CLI_BINARY")" CLI_ARCHS="$(lipo -archs "$CLI_BINARY")"
echo "Arm app binary architectures: $ARM_APP_ARCHS"
echo "Arm CLI binary architectures: $ARM_CLI_ARCHS"
echo "App binary architectures: $APP_ARCHS" echo "App binary architectures: $APP_ARCHS"
echo "CLI binary architectures: $CLI_ARCHS" echo "CLI binary architectures: $CLI_ARCHS"
[[ "$ARM_APP_ARCHS" == "arm64" ]]
[[ "$ARM_CLI_ARCHS" == "arm64" ]]
[[ "$APP_ARCHS" == *arm64* && "$APP_ARCHS" == *x86_64* ]] [[ "$APP_ARCHS" == *arm64* && "$APP_ARCHS" == *x86_64* ]]
[[ "$CLI_ARCHS" == *arm64* && "$CLI_ARCHS" == *x86_64* ]] [[ "$CLI_ARCHS" == *arm64* && "$CLI_ARCHS" == *x86_64* ]]
- name: Run CLI version memory guard regression - name: Run CLI version memory guard regression
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
run: | run: |
set -euo pipefail set -euo pipefail
CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux" CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux"
[ -x "$CLI_BINARY" ] || { echo "cmux CLI binary not found at $CLI_BINARY" >&2; exit 1; } [ -x "$CLI_BINARY" ] || { echo "cmux CLI binary not found at $CLI_BINARY" >&2; exit 1; }
CMUX_CLI_BIN="$CLI_BINARY" python3 tests/test_cli_version_memory_guard.py CMUX_CLI_BIN="$CLI_BINARY" python3 tests/test_cli_version_memory_guard.py
- name: Check whether build commit is still current main HEAD - name: Check whether build commit is still current main HEAD after build
if: needs.decide.outputs.should_publish == 'true' if: needs.decide.outputs.should_publish == 'true' && steps.current_head_prebuild.outputs.still_current == 'true'
id: current_head id: current_head_postbuild
run: | run: |
set -euo pipefail set -euo pipefail
CURRENT_MAIN_SHA="$(git ls-remote origin refs/heads/main | awk '{print $1}')" CURRENT_MAIN_SHA="$(git ls-remote origin refs/heads/main | awk '{print $1}')"
@ -213,7 +224,7 @@ jobs:
fi fi
echo "still_current=${STILL_CURRENT}" >> "$GITHUB_OUTPUT" echo "still_current=${STILL_CURRENT}" >> "$GITHUB_OUTPUT"
{ {
echo "### Publish guard" echo "### Post-build publish guard"
echo echo
echo "- build sha: \`$BUILD_SHA\`" echo "- build sha: \`$BUILD_SHA\`"
echo "- current main sha: \`$CURRENT_MAIN_SHA\`" echo "- current main sha: \`$CURRENT_MAIN_SHA\`"
@ -221,14 +232,13 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY" } >> "$GITHUB_STEP_SUMMARY"
- name: Inject nightly identities and metadata - name: Inject nightly identities and metadata
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true')
run: | run: |
set -euo pipefail set -euo pipefail
SHORT_SHA="${{ needs.decide.outputs.short_sha }}" SHORT_SHA="${{ needs.decide.outputs.short_sha }}"
ARM_APP_DIR="build-arm/Build/Products/Release" APP_DIR="build-universal/Build/Products/Release"
UNIVERSAL_APP_DIR="build-universal/Build/Products/Release"
BASE_MARKETING=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "${ARM_APP_DIR}/cmux.app/Contents/Info.plist") BASE_MARKETING=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "${APP_DIR}/cmux.app/Contents/Info.plist")
NIGHTLY_DATE=$(date -u +%Y%m%d) NIGHTLY_DATE=$(date -u +%Y%m%d)
# Build number: unique/monotonic per workflow run attempt so same-day # Build number: unique/monotonic per workflow run attempt so same-day
@ -241,10 +251,8 @@ jobs:
fi fi
echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV" echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV"
ARM_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg" NIGHTLY_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg"
UNIVERSAL_DMG_IMMUTABLE="cmux-nightly-universal-macos-${NIGHTLY_BUILD}.dmg" echo "NIGHTLY_DMG_IMMUTABLE=${NIGHTLY_DMG_IMMUTABLE}" >> "$GITHUB_ENV"
echo "NIGHTLY_DMG_IMMUTABLE=${ARM_DMG_IMMUTABLE}" >> "$GITHUB_ENV"
echo "NIGHTLY_UNIVERSAL_DMG_IMMUTABLE=${UNIVERSAL_DMG_IMMUTABLE}" >> "$GITHUB_ENV"
prepare_variant() { prepare_variant() {
local app_dir="$1" local app_dir="$1"
@ -267,25 +275,19 @@ jobs:
} }
prepare_variant \ prepare_variant \
"$ARM_APP_DIR" \ "$APP_DIR" \
"com.cmuxterm.app.nightly" \ "com.cmuxterm.app.nightly" \
"https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml" "https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml"
prepare_variant \
"$UNIVERSAL_APP_DIR" \
"com.cmuxterm.app.nightly.universal" \
"https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast-universal.xml"
echo "Nightly app name: cmux NIGHTLY" echo "Nightly app name: cmux NIGHTLY"
echo "Nightly arm64 bundle ID: com.cmuxterm.app.nightly" echo "Nightly bundle ID: com.cmuxterm.app.nightly"
echo "Nightly universal bundle ID: com.cmuxterm.app.nightly.universal"
echo "Nightly marketing version: ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" echo "Nightly marketing version: ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}"
echo "Nightly build number: ${NIGHTLY_BUILD}" echo "Nightly build number: ${NIGHTLY_BUILD}"
echo "Nightly arm64 immutable DMG: ${ARM_DMG_IMMUTABLE}" echo "Nightly immutable DMG: ${NIGHTLY_DMG_IMMUTABLE}"
echo "Nightly universal immutable DMG: ${UNIVERSAL_DMG_IMMUTABLE}"
echo "Commit SHA: ${SHORT_SHA}" echo "Commit SHA: ${SHORT_SHA}"
- name: Import signing cert - name: Import signing cert
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true')
env: env:
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
@ -309,7 +311,7 @@ jobs:
security list-keychains -d user -s build.keychain security list-keychains -d user -s build.keychain
- name: Codesign apps - name: Codesign apps
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true')
env: env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: | run: |
@ -319,7 +321,6 @@ jobs:
fi fi
ENTITLEMENTS="cmux.entitlements" ENTITLEMENTS="cmux.entitlements"
for APP_PATH in \ for APP_PATH in \
"build-arm/Build/Products/Release/cmux NIGHTLY.app" \
"build-universal/Build/Products/Release/cmux NIGHTLY.app" "build-universal/Build/Products/Release/cmux NIGHTLY.app"
do do
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux" CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
@ -331,7 +332,7 @@ jobs:
done done
- name: Notarize apps and dmgs - name: Notarize apps and dmgs
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true')
env: env:
APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
@ -391,16 +392,12 @@ jobs:
} }
notarize_and_package \ notarize_and_package \
"build-arm/Build/Products/Release/cmux NIGHTLY.app" \ "build-universal/Build/Products/Release/cmux NIGHTLY.app" \
"cmux-nightly-macos.dmg" \ "cmux-nightly-macos.dmg" \
"$NIGHTLY_DMG_IMMUTABLE" "$NIGHTLY_DMG_IMMUTABLE"
notarize_and_package \
"build-universal/Build/Products/Release/cmux NIGHTLY.app" \
"cmux-nightly-universal-macos.dmg" \
"$NIGHTLY_UNIVERSAL_DMG_IMMUTABLE"
- name: Upload dSYMs to Sentry - name: Upload dSYMs to Sentry
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true')
env: env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: manaflow SENTRY_ORG: manaflow
@ -412,11 +409,10 @@ jobs:
fi fi
brew install getsentry/tools/sentry-cli || true brew install getsentry/tools/sentry-cli || true
sentry-cli debug-files upload --include-sources \ sentry-cli debug-files upload --include-sources \
build-arm/Build/Products/Release/ \
build-universal/Build/Products/Release/ build-universal/Build/Products/Release/
- name: Generate Sparkle appcasts (nightly) - name: Generate Sparkle appcasts (nightly)
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true' if: needs.decide.outputs.should_publish != 'true' || (steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true')
env: env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: | run: |
@ -425,7 +421,9 @@ jobs:
exit 1 exit 1
fi fi
./scripts/sparkle_generate_appcast.sh "$NIGHTLY_DMG_IMMUTABLE" nightly appcast.xml ./scripts/sparkle_generate_appcast.sh "$NIGHTLY_DMG_IMMUTABLE" nightly appcast.xml
./scripts/sparkle_generate_appcast.sh "$NIGHTLY_UNIVERSAL_DMG_IMMUTABLE" nightly appcast-universal.xml # Keep the legacy universal feed alive long enough for older nightly
# installs to migrate onto the unified nightly appcast.
cp appcast.xml appcast-universal.xml
- name: Upload branch nightly artifacts - name: Upload branch nightly artifacts
if: needs.decide.outputs.should_publish != 'true' if: needs.decide.outputs.should_publish != 'true'
@ -434,13 +432,12 @@ jobs:
name: cmux-nightly-${{ needs.decide.outputs.short_sha }} name: cmux-nightly-${{ needs.decide.outputs.short_sha }}
path: | path: |
cmux-nightly-macos*.dmg cmux-nightly-macos*.dmg
cmux-nightly-universal-macos*.dmg
appcast.xml appcast.xml
appcast-universal.xml appcast-universal.xml
if-no-files-found: error if-no-files-found: error
- name: Move nightly tag to built commit - name: Move nightly tag to built commit
if: needs.decide.outputs.should_publish == 'true' && steps.current_head.outputs.still_current == 'true' if: needs.decide.outputs.should_publish == 'true' && steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true'
run: | run: |
set -euo pipefail set -euo pipefail
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
@ -449,7 +446,7 @@ jobs:
git push origin refs/tags/nightly --force git push origin refs/tags/nightly --force
- name: Publish nightly release assets - name: Publish nightly release assets
if: needs.decide.outputs.should_publish == 'true' && steps.current_head.outputs.still_current == 'true' if: needs.decide.outputs.should_publish == 'true' && steps.current_head_prebuild.outputs.still_current == 'true' && steps.current_head_postbuild.outputs.still_current == 'true'
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
with: with:
tag_name: nightly tag_name: nightly
@ -459,17 +456,15 @@ jobs:
body: | body: |
Automated nightly build for `${{ needs.decide.outputs.short_sha }}`. Automated nightly build for `${{ needs.decide.outputs.short_sha }}`.
**cmux NIGHTLY** has two update tracks: **cmux NIGHTLY** is published as a universal app:
- Apple Silicon: bundle ID `com.cmuxterm.app.nightly`, feed `appcast.xml` - bundle ID `com.cmuxterm.app.nightly`
- Universal: bundle ID `com.cmuxterm.app.nightly.universal`, feed `appcast-universal.xml` - feed `appcast.xml`
- compatibility feed `appcast-universal.xml` for older universal nightlies
[Download cmux-nightly-macos.dmg](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg) [Download cmux-nightly-macos.dmg](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
[Download cmux-nightly-universal-macos.dmg](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-universal-macos.dmg)
files: | files: |
cmux-nightly-macos-${{ github.run_id }}*.dmg cmux-nightly-macos-${{ github.run_id }}*.dmg
cmux-nightly-macos.dmg cmux-nightly-macos.dmg
cmux-nightly-universal-macos-${{ github.run_id }}*.dmg
cmux-nightly-universal-macos.dmg
appcast.xml appcast.xml
appcast-universal.xml appcast-universal.xml
overwrite_files: true overwrite_files: true

View file

@ -93,6 +93,7 @@
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; }; F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; };
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; }; F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; };
FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */; };
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; }; A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; };
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; }; A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; };
DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; }; DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; };
@ -238,6 +239,7 @@
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; }; F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = "<group>"; }; F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = "<group>"; };
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = "<group>"; };
A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; }; A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; };
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; }; A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; };
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; }; DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
@ -472,6 +474,7 @@
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */, F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */,
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */,
A5008380 /* BrowserFindJavaScriptTests.swift */, A5008380 /* BrowserFindJavaScriptTests.swift */,
A5008382 /* CommandPaletteSearchEngineTests.swift */, A5008382 /* CommandPaletteSearchEngineTests.swift */,
); );
@ -711,6 +714,7 @@
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */, F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */, F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */, F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */,
FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */,
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */,
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */, A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */,
); );

View file

@ -213,6 +213,8 @@ Browser developer-tool shortcuts follow Safari defaults and are customizable in
cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the stable version. Built automatically from the latest `main` commit and auto-updates via its own Sparkle feed. cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the stable version. Built automatically from the latest `main` commit and auto-updates via its own Sparkle feed.
Report nightly bugs on [GitHub Issues](https://github.com/manaflow-ai/cmux/issues) or in [#nightly-bugs on Discord](https://discord.gg/xsgFEVrWCZ).
## Session restore (current behavior) ## Session restore (current behavior)
On relaunch, cmux currently restores app layout and metadata only: On relaunch, cmux currently restores app layout and metadata only:

View file

@ -93,15 +93,27 @@
</array> </array>
</dict> </dict>
</array> </array>
<key>UTImportedTypeDeclarations</key> <key>UTExportedTypeDeclarations</key>
<array> <array>
<dict> <dict>
<key>UTTypeIdentifier</key> <key>UTTypeIdentifier</key>
<string>com.splittabbar.tabtransfer</string> <string>com.splittabbar.tabtransfer</string>
<key>UTTypeDescription</key>
<string>Bonsplit Tab Transfer</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
</dict> </dict>
<dict> <dict>
<key>UTTypeIdentifier</key> <key>UTTypeIdentifier</key>
<string>com.cmux.sidebar-tab-reorder</string> <string>com.cmux.sidebar-tab-reorder</string>
<key>UTTypeDescription</key>
<string>cmux Sidebar Tab Reorder</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
</dict> </dict>
</array> </array>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>

View file

@ -845,13 +845,13 @@
"en": { "en": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "Welcome" "value": "Welcome to cmux!"
} }
}, },
"ja": { "ja": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "ようこそ" "value": "cmuxへようこそ"
} }
} }
} }
@ -873,6 +873,23 @@
} }
} }
}, },
"sidebar.help.discord": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Discord"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Discord"
}
}
}
},
"sidebar.help.githubIssues": { "sidebar.help.githubIssues": {
"extractionState": "manual", "extractionState": "manual",
"localizations": { "localizations": {
@ -27859,6 +27876,57 @@
} }
} }
}, },
"dialog.closeWorkspaces.message": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "This will close %1$lld workspaces and all of their panels:\n%2$@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%1$lld 個のワークスペースと、それぞれのすべてのパネルを閉じます:\n%2$@"
}
}
}
},
"dialog.closeWorkspaces.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Close workspaces?"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ワークスペースを閉じますか?"
}
}
}
},
"dialog.closeWorkspacesWindow.message": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "現在のウィンドウと、その %1$lld 個のワークスペースと、それぞれのすべてのパネルを閉じます:\n%2$@"
}
}
}
},
"dialog.closeWorkspace.message": { "dialog.closeWorkspace.message": {
"extractionState": "manual", "extractionState": "manual",
"localizations": { "localizations": {
@ -34978,6 +35046,23 @@
} }
} }
}, },
"menu.openInVSCodeDesktop": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Open Current Directory in VS Code"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "現在のディレクトリを VS Code で開く"
}
}
}
},
"menu.openInWarp": { "menu.openInWarp": {
"extractionState": "manual", "extractionState": "manual",
"localizations": { "localizations": {
@ -40193,6 +40278,119 @@
} }
} }
}, },
"settings.app.appIcon.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Dock and app switcher"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "Dockとアプリスイッチャー"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "程序坞和应用切换器"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "Dock 和 App 切換器"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "Dock 및 앱 전환기"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Dock und App-Umschalter"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Dock y selector de apps"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Dock et sélecteur d'apps"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Dock e selettore app"
}
},
"da": {
"stringUnit": {
"state": "translated",
"value": "Dock og appskifter"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Dock i przełącznik aplikacji"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Dock и переключатель приложений"
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "Dock i prebacivač aplikacija"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "شريط Dock ومبدّل التطبيقات"
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "Dock og appbytter"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Dock e alternador de apps"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "Dock และตัวสลับแอป"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Dock ve uygulama değiştirici"
}
}
}
},
"settings.app.dockBadge": { "settings.app.dockBadge": {
"extractionState": "manual", "extractionState": "manual",
"localizations": { "localizations": {
@ -40419,6 +40617,40 @@
} }
} }
}, },
"settings.app.showInMenuBar": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Show in Menu Bar"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "メニューバーに表示"
}
}
}
},
"settings.app.showInMenuBar.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Keep cmux in the menu bar for unread notifications and quick actions."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "未読通知の確認やクイック操作のために、cmuxをメニューバーに表示します。"
}
}
}
},
"settings.app.language": { "settings.app.language": {
"extractionState": "manual", "extractionState": "manual",
"localizations": { "localizations": {
@ -41097,6 +41329,57 @@
} }
} }
}, },
"settings.app.closeWorkspaceOnLastSurfaceShortcut": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Closing Last Surface Closes Workspace"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "最後のサーフェスを閉じるとワークスペースも閉じる"
}
}
}
},
"settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOff": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Closing the last surface keeps the workspace open. Use Cmd+Shift+W to close a workspace explicitly."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "最後のサーフェスを閉じてもワークスペースは残ります。ワークスペースを明示的に閉じるにはCmd+Shift+Wを使います。"
}
}
}
},
"settings.app.closeWorkspaceOnLastSurfaceShortcut.subtitleOn": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Closing the last surface also closes its workspace."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "最後のサーフェスを閉じると、そのワークスペースも閉じます。"
}
}
}
},
"settings.app.openSidebarPRLinks": { "settings.app.openSidebarPRLinks": {
"extractionState": "manual", "extractionState": "manual",
"localizations": { "localizations": {
@ -45019,6 +45302,74 @@
} }
} }
}, },
"settings.notifications.paneRing.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Show a blue ring around panes with unread notifications."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "未読の通知があるペインの周囲に青いリングを表示します。"
}
}
}
},
"settings.notifications.paneFlash.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Briefly flash a blue outline when cmux highlights a pane."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "cmux がペインを強調表示するときに短い青いアウトラインを表示します。"
}
}
}
},
"settings.notifications.paneFlash.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Pane Flash"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ペインフラッシュ"
}
}
}
},
"settings.notifications.paneRing.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Unread Pane Ring"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "未読ペインリング"
}
}
}
},
"settings.notifications.sound.title": { "settings.notifications.sound.title": {
"extractionState": "manual", "extractionState": "manual",
"localizations": { "localizations": {

View file

@ -51,6 +51,7 @@ _CMUX_PR_FORCE="${_CMUX_PR_FORCE:-0}"
_CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}" _CMUX_ASYNC_JOB_TIMEOUT="${_CMUX_ASYNC_JOB_TIMEOUT:-20}"
_CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}" _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
_CMUX_SHELL_ACTIVITY_LAST="${_CMUX_SHELL_ACTIVITY_LAST:-}"
_CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}" _CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}"
_CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}" _CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}"
@ -103,6 +104,19 @@ _cmux_report_tty_once() {
} >/dev/null 2>&1 & disown } >/dev/null 2>&1 & disown
} }
_cmux_report_shell_activity_state() {
local state="$1"
[[ -n "$state" ]] || return 0
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
[[ "$_CMUX_SHELL_ACTIVITY_LAST" == "$state" ]] && return 0
_CMUX_SHELL_ACTIVITY_LAST="$state"
{
_cmux_send "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 & disown
}
_cmux_ports_kick() { _cmux_ports_kick() {
# Lightweight: just tell the app to run a batched scan for this panel. # Lightweight: just tell the app to run a batched scan for this panel.
# The app coalesces kicks across all panels and runs a single ps+lsof. # The app coalesces kicks across all panels and runs a single ps+lsof.
@ -291,10 +305,33 @@ _cmux_bash_cleanup() {
_cmux_stop_pr_poll_loop _cmux_stop_pr_poll_loop
} }
_cmux_preexec_command() {
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
if [[ -z "$_CMUX_TTY_NAME" ]]; then
local t
t="$(tty 2>/dev/null || true)"
t="${t##*/}"
[[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
fi
_cmux_report_shell_activity_state running
_cmux_report_tty_once
_cmux_ports_kick
_cmux_stop_pr_poll_loop
}
_cmux_bash_preexec_hook() {
_cmux_preexec_command
}
_cmux_prompt_command() { _cmux_prompt_command() {
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0
_cmux_report_shell_activity_state prompt
local now=$SECONDS local now=$SECONDS
local pwd="$PWD" local pwd="$PWD"
@ -439,6 +476,17 @@ _cmux_install_prompt_command() {
;; ;;
esac esac
fi fi
if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )); then
if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3) )); then
builtin readonly _CMUX_BASH_PS0='${ _cmux_bash_preexec_hook; }'
else
builtin readonly _CMUX_BASH_PS0='$(_cmux_bash_preexec_hook >/dev/null)'
fi
if [[ "$PS0" != *"${_CMUX_BASH_PS0}"* ]]; then
PS0=$PS0"${_CMUX_BASH_PS0}"
fi
fi
} }
# Ensure Resources/bin is at the front of PATH, and remove the app's # Ensure Resources/bin is at the front of PATH, and remove the app's

View file

@ -55,6 +55,7 @@ typeset -g _CMUX_ASYNC_JOB_TIMEOUT=20
typeset -g _CMUX_PORTS_LAST_RUN=0 typeset -g _CMUX_PORTS_LAST_RUN=0
typeset -g _CMUX_CMD_START=0 typeset -g _CMUX_CMD_START=0
typeset -g _CMUX_SHELL_ACTIVITY_LAST=""
typeset -g _CMUX_TTY_NAME="" typeset -g _CMUX_TTY_NAME=""
typeset -g _CMUX_TTY_REPORTED=0 typeset -g _CMUX_TTY_REPORTED=0
@ -110,6 +111,19 @@ _cmux_report_tty_once() {
} >/dev/null 2>&1 &! } >/dev/null 2>&1 &!
} }
_cmux_report_shell_activity_state() {
local state="$1"
[[ -n "$state" ]] || return 0
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
[[ "$_CMUX_SHELL_ACTIVITY_LAST" == "$state" ]] && return 0
_CMUX_SHELL_ACTIVITY_LAST="$state"
{
_cmux_send "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 &!
}
_cmux_ports_kick() { _cmux_ports_kick() {
# Lightweight: just tell the app to run a batched scan for this panel. # Lightweight: just tell the app to run a batched scan for this panel.
# The app coalesces kicks across all panels and runs a single ps+lsof. # The app coalesces kicks across all panels and runs a single ps+lsof.
@ -361,6 +375,7 @@ _cmux_preexec() {
fi fi
_CMUX_CMD_START=$EPOCHSECONDS _CMUX_CMD_START=$EPOCHSECONDS
_cmux_report_shell_activity_state running
# Heuristic: commands that may change git branch/dirty state without changing $PWD. # Heuristic: commands that may change git branch/dirty state without changing $PWD.
local cmd="${1## }" local cmd="${1## }"
@ -384,6 +399,7 @@ _cmux_precmd() {
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0 [[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0 [[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0 [[ -n "$CMUX_PANEL_ID" ]] || return 0
_cmux_report_shell_activity_state prompt
if [[ -z "$_CMUX_TTY_NAME" ]]; then if [[ -z "$_CMUX_TTY_NAME" ]]; then
local t local t

View file

@ -396,6 +396,7 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
case terminal case terminal
case tower case tower
case vscode case vscode
case vscodeInline
case warp case warp
case windsurf case windsurf
case xcode case xcode
@ -446,6 +447,8 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
case .tower: case .tower:
return String(localized: "menu.openInTower", defaultValue: "Open Current Directory in Tower") return String(localized: "menu.openInTower", defaultValue: "Open Current Directory in Tower")
case .vscode: case .vscode:
return String(localized: "menu.openInVSCodeDesktop", defaultValue: "Open Current Directory in VS Code")
case .vscodeInline:
return String(localized: "menu.openInVSCode", defaultValue: "Open Current Directory in VS Code (Inline)") return String(localized: "menu.openInVSCode", defaultValue: "Open Current Directory in VS Code (Inline)")
case .warp: case .warp:
return String(localized: "menu.openInWarp", defaultValue: "Open Current Directory in Warp") return String(localized: "menu.openInWarp", defaultValue: "Open Current Directory in Warp")
@ -478,6 +481,8 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
case .tower: case .tower:
return common + ["tower", "git", "client"] return common + ["tower", "git", "client"]
case .vscode: case .vscode:
return common + ["vs", "code", "visual", "studio", "desktop", "app"]
case .vscodeInline:
return common + ["vs", "code", "visual", "studio", "inline", "browser", "serve-web"] return common + ["vs", "code", "visual", "studio", "inline", "browser", "serve-web"]
case .warp: case .warp:
return common + ["warp", "terminal", "shell"] return common + ["warp", "terminal", "shell"]
@ -492,7 +497,7 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
func isAvailable(in environment: DetectionEnvironment = .live) -> Bool { func isAvailable(in environment: DetectionEnvironment = .live) -> Bool {
guard let applicationPath = applicationPath(in: environment) else { return false } guard let applicationPath = applicationPath(in: environment) else { return false }
guard self == .vscode else { return true } guard self == .vscodeInline else { return true }
return VSCodeCLILaunchConfigurationBuilder.launchConfiguration( return VSCodeCLILaunchConfigurationBuilder.launchConfiguration(
vscodeApplicationURL: URL(fileURLWithPath: applicationPath, isDirectory: true), vscodeApplicationURL: URL(fileURLWithPath: applicationPath, isDirectory: true),
isExecutableAtPath: environment.isExecutableFileAtPath isExecutableAtPath: environment.isExecutableFileAtPath
@ -557,6 +562,11 @@ enum TerminalDirectoryOpenTarget: String, CaseIterable {
"/Applications/Visual Studio Code.app", "/Applications/Visual Studio Code.app",
"/Applications/Code.app", "/Applications/Code.app",
] ]
case .vscodeInline:
return [
"/Applications/Visual Studio Code.app",
"/Applications/Code.app",
]
case .warp: case .warp:
return ["/Applications/Warp.app"] return ["/Applications/Warp.app"]
case .windsurf: case .windsurf:
@ -1910,6 +1920,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private var windowKeyObserver: NSObjectProtocol? private var windowKeyObserver: NSObjectProtocol?
private var shortcutMonitor: Any? private var shortcutMonitor: Any?
private var shortcutDefaultsObserver: NSObjectProtocol? private var shortcutDefaultsObserver: NSObjectProtocol?
private var menuBarVisibilityObserver: NSObjectProtocol?
private var splitButtonTooltipRefreshScheduled = false private var splitButtonTooltipRefreshScheduled = false
private var ghosttyConfigObserver: NSObjectProtocol? private var ghosttyConfigObserver: NSObjectProtocol?
private var ghosttyGotoSplitLeftShortcut: StoredShortcut? private var ghosttyGotoSplitLeftShortcut: StoredShortcut?
@ -2208,7 +2219,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
ensureApplicationIcon() ensureApplicationIcon()
if !isRunningUnderXCTest { if !isRunningUnderXCTest {
configureUserNotifications() configureUserNotifications()
setupMenuBarExtra() installMenuBarVisibilityObserver()
syncMenuBarExtraVisibility()
// Sparkle updater is started lazily on first manual check. This avoids any // Sparkle updater is started lazily on first manual check. This avoids any
// first-launch permission prompts and keeps cmux aligned with the update pill UI. // first-launch permission prompts and keeps cmux aligned with the update pill UI.
} }
@ -4588,6 +4600,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier })
} }
private func resolvedWindow(for context: MainWindowContext) -> NSWindow? {
guard let window = context.window ?? windowForMainWindowId(context.windowId) else {
return nil
}
context.window = window
return window
}
private func mainWindowId(from window: NSWindow) -> UUID? { private func mainWindowId(from window: NSWindow) -> UUID? {
guard let raw = window.identifier?.rawValue else { return nil } guard let raw = window.identifier?.rawValue else { return nil }
let prefix = "cmux.main." let prefix = "cmux.main."
@ -4665,6 +4685,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return removed return removed
} }
private func discardOrphanedMainWindowContext(_ context: MainWindowContext) {
let contextKeys = mainWindowContexts.compactMap { key, value in
value === context ? key : nil
}
for key in contextKeys {
mainWindowContexts.removeValue(forKey: key)
}
commandPaletteVisibilityByWindowId.removeValue(forKey: context.windowId)
commandPalettePendingOpenByWindowId.removeValue(forKey: context.windowId)
commandPaletteRecentRequestAtByWindowId.removeValue(forKey: context.windowId)
commandPaletteEscapeSuppressionByWindowId.remove(context.windowId)
commandPaletteEscapeSuppressionStartedAtByWindowId.removeValue(forKey: context.windowId)
commandPaletteSelectionByWindowId.removeValue(forKey: context.windowId)
commandPaletteSnapshotByWindowId.removeValue(forKey: context.windowId)
if tabManager === context.tabManager {
if let nextContext = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil }) {
tabManager = nextContext.tabManager
sidebarState = nextContext.sidebarState
sidebarSelectionState = nextContext.sidebarSelectionState
TerminalController.shared.setActiveTabManager(nextContext.tabManager)
} else {
tabManager = nil
sidebarState = nil
sidebarSelectionState = nil
TerminalController.shared.setActiveTabManager(nil)
}
}
if let store = notificationStore {
for tab in context.tabManager.tabs {
store.clearNotifications(forTabId: tab.id)
}
}
}
private func mainWindowId(for window: NSWindow) -> UUID? { private func mainWindowId(for window: NSWindow) -> UUID? {
if let context = mainWindowContexts[ObjectIdentifier(window)] { if let context = mainWindowContexts[ObjectIdentifier(window)] {
return context.windowId return context.windowId
@ -5090,11 +5147,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
#endif #endif
return nil return nil
} }
if let window = context.window ?? windowForMainWindowId(context.windowId) { guard let window = resolvedWindow(for: context) else {
setActiveMainWindow(window) #if DEBUG
if shouldBringToFront { logWorkspaceCreationRouting(
bringToFront(window) phase: "no_context",
} source: debugSource,
reason: "context_window_missing",
event: event,
chosenContext: context,
workingDirectory: workingDirectory
)
#endif
discardOrphanedMainWindowContext(context)
return nil
}
setActiveMainWindow(window)
if shouldBringToFront {
bringToFront(window)
} }
let workspace: Workspace let workspace: Workspace
@ -5183,7 +5252,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
} }
} }
let fallback = mainWindowContexts.values.first let fallback = mainWindowContexts.values.first(where: { resolvedWindow(for: $0) != nil })
#if DEBUG #if DEBUG
logWorkspaceCreationRouting( logWorkspaceCreationRouting(
phase: "choose", phase: "choose",
@ -5547,6 +5616,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
} }
private func setupMenuBarExtra() { private func setupMenuBarExtra() {
guard menuBarExtraController == nil else { return }
let store = TerminalNotificationStore.shared let store = TerminalNotificationStore.shared
menuBarExtraController = MenuBarExtraController( menuBarExtraController = MenuBarExtraController(
notificationStore: store, notificationStore: store,
@ -5575,6 +5645,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
) )
} }
private func installMenuBarVisibilityObserver() {
guard menuBarVisibilityObserver == nil else { return }
menuBarVisibilityObserver = NotificationCenter.default.addObserver(
forName: UserDefaults.didChangeNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.syncMenuBarExtraVisibility()
}
}
}
private func syncMenuBarExtraVisibility(defaults: UserDefaults = .standard) {
if MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults) {
setupMenuBarExtra()
return
}
menuBarExtraController?.removeFromMenuBar()
menuBarExtraController = nil
}
@MainActor @MainActor
static func presentPreferencesWindow( static func presentPreferencesWindow(
navigationTarget: SettingsNavigationTarget? = nil, navigationTarget: SettingsNavigationTarget? = nil,
@ -7753,6 +7846,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
// equivalents working and avoid surprising actions while the confirmation is up. // equivalents working and avoid surprising actions while the confirmation is up.
let closeConfirmationTitles = [ let closeConfirmationTitles = [
String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"), String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"),
String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?"),
String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"), String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"),
String(localized: "dialog.closeWindow.title", defaultValue: "Close window?"), String(localized: "dialog.closeWindow.title", defaultValue: "Close window?"),
@ -10198,6 +10292,13 @@ final class MenuBarExtraController: NSObject, NSMenuDelegate {
refreshUI() refreshUI()
} }
func removeFromMenuBar() {
notificationsCancellable?.cancel()
notificationsCancellable = nil
statusItem.menu = nil
NSStatusBar.system.removeStatusItem(statusItem)
}
private func refreshUI() { private func refreshUI() {
let snapshot = NotificationMenuSnapshotBuilder.make( let snapshot = NotificationMenuSnapshotBuilder.make(
notifications: notificationStore.notifications, notifications: notificationStore.notifications,
@ -10520,6 +10621,18 @@ enum MenuBarBuildHintFormatter {
} }
} }
enum MenuBarExtraSettings {
static let showInMenuBarKey = "showMenuBarExtra"
static let defaultShowInMenuBar = true
static func showsMenuBarExtra(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: showInMenuBarKey) == nil {
return defaultShowInMenuBar
}
return defaults.bool(forKey: showInMenuBarKey)
}
}
struct MenuBarBadgeRenderConfig { struct MenuBarBadgeRenderConfig {
var badgeRect: NSRect var badgeRect: NSRect
var singleDigitFontSize: CGFloat var singleDigitFontSize: CGFloat

View file

@ -1330,6 +1330,8 @@ struct ContentView: View {
@State private var retiringWorkspaceId: UUID? @State private var retiringWorkspaceId: UUID?
@State private var workspaceHandoffGeneration: UInt64 = 0 @State private var workspaceHandoffGeneration: UInt64 = 0
@State private var workspaceHandoffFallbackTask: Task<Void, Never>? @State private var workspaceHandoffFallbackTask: Task<Void, Never>?
@State private var didApplyUITestSidebarSelection = false
@State private var workspaceHandoffReadyCheckTask: Task<Void, Never>?
@State private var titlebarThemeGeneration: UInt64 = 0 @State private var titlebarThemeGeneration: UInt64 = 0
@State private var sidebarDraggedTabId: UUID? @State private var sidebarDraggedTabId: UUID?
@State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) @State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
@ -2233,6 +2235,8 @@ struct ContentView: View {
selectedTabIds = [selectedId] selectedTabIds = [selectedId]
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
} }
syncSidebarSelectedWorkspaceIds()
applyUITestSidebarSelectionIfNeeded(tabs: tabManager.tabs)
updateTitlebarText() updateTitlebarText()
// Startup recovery (#399): if session restore or a race condition leaves the // Startup recovery (#399): if session restore or a race condition leaves the
@ -2267,6 +2271,9 @@ struct ContentView: View {
didRecover = true didRecover = true
} }
syncSidebarSelectedWorkspaceIds()
applyUITestSidebarSelectionIfNeeded(tabs: tabManager.tabs)
if didRecover { if didRecover {
#if DEBUG #if DEBUG
dlog("startup.recovery tabCount=\(tabManager.tabs.count) selected=\(tabManager.selectedTabId?.uuidString.prefix(8) ?? "nil") mounted=\(mountedWorkspaceIds.count)") dlog("startup.recovery tabCount=\(tabManager.tabs.count) selected=\(tabManager.selectedTabId?.uuidString.prefix(8) ?? "nil") mounted=\(mountedWorkspaceIds.count)")
@ -2302,6 +2309,10 @@ struct ContentView: View {
updateTitlebarText() updateTitlebarText()
}) })
view = AnyView(view.onChange(of: selectedTabIds) { _ in
syncSidebarSelectedWorkspaceIds()
})
view = AnyView(view.onChange(of: tabManager.isWorkspaceCycleHot) { _ in view = AnyView(view.onChange(of: tabManager.isWorkspaceCycleHot) { _ in
#if DEBUG #if DEBUG
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
@ -2401,6 +2412,8 @@ struct ContentView: View {
lastSidebarSelectionIndex = nil lastSidebarSelectionIndex = nil
} }
} }
syncSidebarSelectedWorkspaceIds()
applyUITestSidebarSelectionIfNeeded(tabs: tabs)
}) })
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: SidebarDragLifecycleNotification.stateDidChange)) { notification in view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: SidebarDragLifecycleNotification.stateDidChange)) { notification in
@ -2869,6 +2882,8 @@ struct ContentView: View {
retiringWorkspaceId = nil retiringWorkspaceId = nil
workspaceHandoffFallbackTask?.cancel() workspaceHandoffFallbackTask?.cancel()
workspaceHandoffFallbackTask = nil workspaceHandoffFallbackTask = nil
workspaceHandoffReadyCheckTask?.cancel()
workspaceHandoffReadyCheckTask = nil
return return
} }
@ -2876,6 +2891,7 @@ struct ContentView: View {
let generation = workspaceHandoffGeneration let generation = workspaceHandoffGeneration
retiringWorkspaceId = oldSelectedId retiringWorkspaceId = oldSelectedId
workspaceHandoffFallbackTask?.cancel() workspaceHandoffFallbackTask?.cancel()
workspaceHandoffReadyCheckTask?.cancel()
#if DEBUG #if DEBUG
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() { if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
@ -2891,6 +2907,36 @@ struct ContentView: View {
} }
#endif #endif
workspaceHandoffReadyCheckTask = Task { [generation, newSelectedId] in
for delay in [0, 20_000_000, 40_000_000, 60_000_000] {
if delay > 0 {
do {
try await Task.sleep(nanoseconds: UInt64(delay))
} catch {
return
}
}
let completed = await MainActor.run { () -> Bool in
guard workspaceHandoffGeneration == generation else { return false }
guard retiringWorkspaceId != nil else { return false }
guard canCompleteWorkspaceHandoffImmediately(for: newSelectedId) else { return false }
#if DEBUG
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
dlog(
"ws.handoff.fastReady id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newSelectedId))"
)
} else {
dlog("ws.handoff.fastReady id=none selected=\(debugShortWorkspaceId(newSelectedId))")
}
#endif
completeWorkspaceHandoff(reason: "ready")
return true
}
if completed { return }
}
}
workspaceHandoffFallbackTask = Task { [generation] in workspaceHandoffFallbackTask = Task { [generation] in
do { do {
try await Task.sleep(nanoseconds: 150_000_000) try await Task.sleep(nanoseconds: 150_000_000)
@ -2910,9 +2956,20 @@ struct ContentView: View {
completeWorkspaceHandoff(reason: reason) completeWorkspaceHandoff(reason: reason)
} }
private func canCompleteWorkspaceHandoffImmediately(for workspaceId: UUID) -> Bool {
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return true }
if let focusedPanelId = workspace.focusedPanelId,
workspace.browserPanel(for: focusedPanelId) != nil {
return true
}
return workspace.hasLoadedTerminalSurface()
}
private func completeWorkspaceHandoff(reason: String) { private func completeWorkspaceHandoff(reason: String) {
workspaceHandoffFallbackTask?.cancel() workspaceHandoffFallbackTask?.cancel()
workspaceHandoffFallbackTask = nil workspaceHandoffFallbackTask = nil
workspaceHandoffReadyCheckTask?.cancel()
workspaceHandoffReadyCheckTask = nil
let retiring = retiringWorkspaceId let retiring = retiringWorkspaceId
// Hide portal-hosted views for the retiring workspace BEFORE clearing // Hide portal-hosted views for the retiring workspace BEFORE clearing
@ -4659,7 +4716,7 @@ struct ContentView: View {
keywords: ["vscode", "inline", "serve-web", "stop", "server"], keywords: ["vscode", "inline", "serve-web", "stop", "server"],
when: { context in when: { context in
context.bool(CommandPaletteContextKeys.panelIsTerminal) context.bool(CommandPaletteContextKeys.panelIsTerminal)
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscode)) && context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscodeInline))
} }
) )
) )
@ -4671,7 +4728,7 @@ struct ContentView: View {
keywords: ["vscode", "inline", "serve-web", "restart", "server"], keywords: ["vscode", "inline", "serve-web", "restart", "server"],
when: { context in when: { context in
context.bool(CommandPaletteContextKeys.panelIsTerminal) context.bool(CommandPaletteContextKeys.panelIsTerminal)
&& context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscode)) && context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(.vscodeInline))
} }
) )
) )
@ -5962,11 +6019,7 @@ struct ContentView: View {
} }
private func closeWorkspaceIds(_ workspaceIds: [UUID], allowPinned: Bool) { private func closeWorkspaceIds(_ workspaceIds: [UUID], allowPinned: Bool) {
for workspaceId in workspaceIds { tabManager.closeWorkspacesWithConfirmation(workspaceIds, allowPinned: allowPinned)
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { continue }
guard allowPinned || !workspace.isPinned else { continue }
tabManager.closeWorkspaceWithConfirmation(workspace)
}
} }
private func closeOtherSelectedWorkspaces() { private func closeOtherSelectedWorkspaces() {
@ -5976,19 +6029,53 @@ struct ContentView: View {
} }
private func closeSelectedWorkspacesBelow() { private func closeSelectedWorkspacesBelow() {
guard let workspace = tabManager.selectedWorkspace, guard tabManager.selectedWorkspace != nil,
let anchorIndex = selectedWorkspaceIndex() else { return } let anchorIndex = selectedWorkspaceIndex() else { return }
let workspaceIds = tabManager.tabs.suffix(from: anchorIndex + 1).map(\.id) let workspaceIds = tabManager.tabs.suffix(from: anchorIndex + 1).map(\.id)
closeWorkspaceIds(workspaceIds, allowPinned: false) closeWorkspaceIds(workspaceIds, allowPinned: false)
} }
private func closeSelectedWorkspacesAbove() { private func closeSelectedWorkspacesAbove() {
guard let workspace = tabManager.selectedWorkspace, guard tabManager.selectedWorkspace != nil,
let anchorIndex = selectedWorkspaceIndex() else { return } let anchorIndex = selectedWorkspaceIndex() else { return }
let workspaceIds = tabManager.tabs.prefix(upTo: anchorIndex).map(\.id) let workspaceIds = tabManager.tabs.prefix(upTo: anchorIndex).map(\.id)
closeWorkspaceIds(workspaceIds, allowPinned: false) closeWorkspaceIds(workspaceIds, allowPinned: false)
} }
private func syncSidebarSelectedWorkspaceIds() {
tabManager.setSidebarSelectedWorkspaceIds(selectedTabIds)
}
private func applyUITestSidebarSelectionIfNeeded(tabs: [Workspace]) {
#if DEBUG
guard !didApplyUITestSidebarSelection else { return }
let env = ProcessInfo.processInfo.environment
guard let rawValue = env["CMUX_UI_TEST_SIDEBAR_SELECTED_WORKSPACE_INDICES"]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!rawValue.isEmpty else {
return
}
var indices: [Int] = []
for token in rawValue.split(separator: ",") {
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard let index = Int(trimmed), index >= 0 else { return }
if !indices.contains(index) {
indices.append(index)
}
}
guard let lastIndex = indices.last, !indices.isEmpty, lastIndex < tabs.count else { return }
let selectedIds = Set(indices.map { tabs[$0].id })
selectedTabIds = selectedIds
lastSidebarSelectionIndex = lastIndex
tabManager.selectWorkspace(tabs[lastIndex])
sidebarSelectionState.selection = .tabs
didApplyUITestSidebarSelection = true
#endif
}
private func beginRenameWorkspaceFlow() { private func beginRenameWorkspaceFlow() {
guard let workspace = tabManager.selectedWorkspace else { guard let workspace = tabManager.selectedWorkspace else {
NSSound.beep() NSSound.beep()
@ -6102,7 +6189,7 @@ struct ContentView: View {
case .finder: case .finder:
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directoryURL.path) NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directoryURL.path)
return true return true
case .vscode: case .vscodeInline:
return openFocusedDirectoryInInlineVSCode(directoryURL) return openFocusedDirectoryInInlineVSCode(directoryURL)
default: default:
guard let applicationURL = target.applicationURL() else { return false } guard let applicationURL = target.applicationURL() else { return false }
@ -6113,7 +6200,7 @@ struct ContentView: View {
} }
private func openFocusedDirectoryInInlineVSCode(_ directoryURL: URL) -> Bool { private func openFocusedDirectoryInInlineVSCode(_ directoryURL: URL) -> Bool {
guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscode.applicationURL(), guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscodeInline.applicationURL(),
let workspace = tabManager.selectedWorkspace, let workspace = tabManager.selectedWorkspace,
let sourcePanelId = workspace.focusedPanelId else { let sourcePanelId = workspace.focusedPanelId else {
return false return false
@ -6149,7 +6236,7 @@ struct ContentView: View {
} }
private func restartInlineVSCodeServeWeb() -> Bool { private func restartInlineVSCodeServeWeb() -> Bool {
guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscode.applicationURL() else { guard let vscodeApplicationURL = TerminalDirectoryOpenTarget.vscodeInline.applicationURL() else {
return false return false
} }
VSCodeServeWebController.shared.restart(vscodeApplicationURL: vscodeApplicationURL) { serveWebURL in VSCodeServeWebController.shared.restart(vscodeApplicationURL: vscodeApplicationURL) { serveWebURL in
@ -7179,6 +7266,9 @@ struct VerticalTabsSidebar: View {
} }
var body: some View { var body: some View {
let workspaceCount = tabManager.tabs.count
let canCloseWorkspace = workspaceCount > 1
VStack(spacing: 0) { VStack(spacing: 0) {
GeometryReader { proxy in GeometryReader { proxy in
ScrollView { ScrollView {
@ -7195,7 +7285,12 @@ struct VerticalTabsSidebar: View {
tab: tab, tab: tab,
index: index, index: index,
isActive: tabManager.selectedTabId == tab.id, isActive: tabManager.selectedTabId == tab.id,
tabCount: tabManager.tabs.count, workspaceShortcutDigit: WorkspaceShortcutMapper.commandDigitForWorkspace(
at: index,
workspaceCount: workspaceCount
),
canCloseWorkspace: canCloseWorkspace,
accessibilityWorkspaceCount: workspaceCount,
unreadCount: notificationStore.unreadCount(forTabId: tab.id), unreadCount: notificationStore.unreadCount(forTabId: tab.id),
latestNotificationText: { latestNotificationText: {
guard showsSidebarNotificationMessage, guard showsSidebarNotificationMessage,
@ -8342,6 +8437,7 @@ private enum SidebarHelpMenuAction {
case changelog case changelog
case github case github
case githubIssues case githubIssues
case discord
case checkForUpdates case checkForUpdates
case sendFeedback case sendFeedback
case welcome case welcome
@ -8842,6 +8938,7 @@ private struct SidebarHelpMenuButton: View {
private let changelogURL = URL(string: "https://cmux.dev/docs/changelog") private let changelogURL = URL(string: "https://cmux.dev/docs/changelog")
private let githubURL = URL(string: "https://github.com/manaflow-ai/cmux") private let githubURL = URL(string: "https://github.com/manaflow-ai/cmux")
private let githubIssuesURL = URL(string: "https://github.com/manaflow-ai/cmux/issues") private let githubIssuesURL = URL(string: "https://github.com/manaflow-ai/cmux/issues")
private let discordURL = URL(string: "https://discord.gg/xsgFEVrWCZ")
private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help") private let helpTitle = String(localized: "sidebar.help.button", defaultValue: "Help")
private let buttonSize: CGFloat = 22 private let buttonSize: CGFloat = 22
private let iconSize: CGFloat = 11 private let iconSize: CGFloat = 11
@ -8886,7 +8983,7 @@ private struct SidebarHelpMenuButton: View {
private var helpPopover: some View { private var helpPopover: some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
helpOptionButton( helpOptionButton(
title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome"), title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome to cmux!"),
action: .welcome, action: .welcome,
accessibilityIdentifier: "SidebarHelpMenuOptionWelcome", accessibilityIdentifier: "SidebarHelpMenuOptionWelcome",
isExternalLink: false isExternalLink: false
@ -8937,6 +9034,14 @@ private struct SidebarHelpMenuButton: View {
isExternalLink: true isExternalLink: true
) )
} }
if discordURL != nil {
helpOptionButton(
title: String(localized: "sidebar.help.discord", defaultValue: "Discord"),
action: .discord,
accessibilityIdentifier: "SidebarHelpMenuOptionDiscord",
isExternalLink: true
)
}
helpOptionButton( helpOptionButton(
title: String(localized: "command.checkForUpdates.title", defaultValue: "Check for Updates"), title: String(localized: "command.checkForUpdates.title", defaultValue: "Check for Updates"),
action: .checkForUpdates, action: .checkForUpdates,
@ -9027,6 +9132,9 @@ private struct SidebarHelpMenuButton: View {
case .githubIssues: case .githubIssues:
guard let githubIssuesURL else { return } guard let githubIssuesURL else { return }
NSWorkspace.shared.open(githubIssuesURL) NSWorkspace.shared.open(githubIssuesURL)
case .discord:
guard let discordURL else { return }
NSWorkspace.shared.open(discordURL)
case .checkForUpdates: case .checkForUpdates:
Task { @MainActor in Task { @MainActor in
AppDelegate.shared?.checkForUpdates(nil) AppDelegate.shared?.checkForUpdates(nil)
@ -9464,7 +9572,9 @@ private struct TabItemView: View, Equatable {
lhs.tab === rhs.tab && lhs.tab === rhs.tab &&
lhs.index == rhs.index && lhs.index == rhs.index &&
lhs.isActive == rhs.isActive && lhs.isActive == rhs.isActive &&
lhs.tabCount == rhs.tabCount && lhs.workspaceShortcutDigit == rhs.workspaceShortcutDigit &&
lhs.canCloseWorkspace == rhs.canCloseWorkspace &&
lhs.accessibilityWorkspaceCount == rhs.accessibilityWorkspaceCount &&
lhs.unreadCount == rhs.unreadCount && lhs.unreadCount == rhs.unreadCount &&
lhs.latestNotificationText == rhs.latestNotificationText && lhs.latestNotificationText == rhs.latestNotificationText &&
lhs.rowSpacing == rhs.rowSpacing && lhs.rowSpacing == rhs.rowSpacing &&
@ -9480,7 +9590,9 @@ private struct TabItemView: View, Equatable {
@ObservedObject var tab: Tab @ObservedObject var tab: Tab
let index: Int let index: Int
let isActive: Bool let isActive: Bool
let tabCount: Int let workspaceShortcutDigit: Int?
let canCloseWorkspace: Bool
let accessibilityWorkspaceCount: Int
let unreadCount: Int let unreadCount: Int
let latestNotificationText: String? let latestNotificationText: String?
let rowSpacing: CGFloat let rowSpacing: CGFloat
@ -9583,12 +9695,8 @@ private struct TabItemView: View, Equatable {
usesInvertedActiveForeground ? 1.0 : 0.9 usesInvertedActiveForeground ? 1.0 : 0.9
} }
private var workspaceShortcutDigit: Int? {
WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabCount)
}
private var showCloseButton: Bool { private var showCloseButton: Bool {
isHovering && tabCount > 1 && !(showsModifierShortcutHints || alwaysShowShortcutHints) isHovering && canCloseWorkspace && !(showsModifierShortcutHints || alwaysShowShortcutHints)
} }
private var workspaceShortcutLabel: String? { private var workspaceShortcutLabel: String? {
@ -10225,7 +10333,7 @@ private struct TabItemView: View, Equatable {
} }
private var accessibilityTitle: String { private var accessibilityTitle: String {
String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(tabCount)") String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(accessibilityWorkspaceCount)")
} }
private func moveBy(_ delta: Int) { private func moveBy(_ delta: Int) {
@ -10289,16 +10397,7 @@ private struct TabItemView: View, Equatable {
} }
private func closeTabs(_ targetIds: [UUID], allowPinned: Bool) { private func closeTabs(_ targetIds: [UUID], allowPinned: Bool) {
let idsToClose = targetIds.filter { id in tabManager.closeWorkspacesWithConfirmation(targetIds, allowPinned: allowPinned)
guard let tab = tabManager.tabs.first(where: { $0.id == id }) else { return false }
return allowPinned || !tab.isPinned
}
for id in idsToClose {
if let tab = tabManager.tabs.first(where: { $0.id == id }) {
tabManager.closeWorkspaceWithConfirmation(tab)
}
}
selectedTabIds.subtract(idsToClose)
syncSelectionAfterMutation() syncSelectionAfterMutation()
} }

View file

@ -8,6 +8,7 @@ import Darwin
import Sentry import Sentry
import Bonsplit import Bonsplit
import IOSurface import IOSurface
import UniformTypeIdentifiers
#if os(macOS) #if os(macOS)
func cmuxShouldUseTransparentBackgroundWindow() -> Bool { func cmuxShouldUseTransparentBackgroundWindow() -> Bool {
@ -75,6 +76,7 @@ private enum GhosttyPasteboardHelper {
) )
private static let utf8PlainTextType = NSPasteboard.PasteboardType("public.utf8-plain-text") private static let utf8PlainTextType = NSPasteboard.PasteboardType("public.utf8-plain-text")
private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t" private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t"
private static let objectReplacementCharacter = Character(UnicodeScalar(0xFFFC)!)
static func pasteboard(for location: ghostty_clipboard_e) -> NSPasteboard? { static func pasteboard(for location: ghostty_clipboard_e) -> NSPasteboard? {
switch location { switch location {
@ -99,13 +101,35 @@ private enum GhosttyPasteboardHelper {
return value return value
} }
return pasteboard.string(forType: utf8PlainTextType) if let value = pasteboard.string(forType: utf8PlainTextType) {
return value
}
if hasImageData(in: pasteboard),
let html = pasteboard.string(forType: .html),
htmlHasNoVisibleText(html) {
return nil
}
if let htmlText = attributedStringContents(from: pasteboard, type: .html, documentType: .html) {
return htmlText
}
if let rtfText = attributedStringContents(from: pasteboard, type: .rtf, documentType: .rtf) {
return rtfText
}
return attributedStringContents(from: pasteboard, type: .rtfd, documentType: .rtfd)
} }
static func hasString(for location: ghostty_clipboard_e) -> Bool { static func hasString(for location: ghostty_clipboard_e) -> Bool {
guard let pasteboard = pasteboard(for: location) else { return false } guard let pasteboard = pasteboard(for: location) else { return false }
if let text = stringContents(from: pasteboard), !text.isEmpty { return true } let types = pasteboard.types ?? []
return clipboardHasImageOnly() if types.contains(.fileURL) || types.contains(.string) || types.contains(utf8PlainTextType)
|| types.contains(.html) || types.contains(.rtf) || types.contains(.rtfd) {
return true
}
return hasImageData(in: pasteboard)
} }
static func writeString(_ string: String, to location: ghostty_clipboard_e) { static func writeString(_ string: String, to location: ghostty_clipboard_e) {
@ -122,40 +146,184 @@ private enum GhosttyPasteboardHelper {
return result return result
} }
private static let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB private static func attributedStringContents(
from pasteboard: NSPasteboard,
type: NSPasteboard.PasteboardType,
documentType: NSAttributedString.DocumentType
) -> String? {
let attributed = attributedString(
from: pasteboard,
type: type,
documentType: documentType
)
/// Quick check: does the clipboard have image data and no text? let sanitized = attributed?.string
static func clipboardHasImageOnly() -> Bool { .split(separator: objectReplacementCharacter, omittingEmptySubsequences: false)
let pb = NSPasteboard.general .joined(separator: " ")
let types = pb.types ?? [] .trimmingCharacters(in: .whitespacesAndNewlines)
let hasText = types.contains(.string) || types.contains(.html)
|| types.contains(.rtf) || types.contains(.rtfd) guard let sanitized, !sanitized.isEmpty else { return nil }
if hasText { return false } return sanitized
return types.contains(.tiff) || types.contains(.png)
} }
/// When the clipboard contains only image data (no text/HTML), saves it as private static func attributedString(
/// a temporary PNG file and returns the shell-escaped file path. Returns nil from pasteboard: NSPasteboard,
/// if the clipboard contains text or no image. type: NSPasteboard.PasteboardType,
static func saveClipboardImageIfNeeded() -> String? { documentType: NSAttributedString.DocumentType
let pb = NSPasteboard.general ) -> NSAttributedString? {
let types = pb.types ?? [] let data =
pasteboard.data(forType: type)
?? pasteboard.string(forType: type)?.data(using: .utf8)
guard let data else { return nil }
// If pasteboard has text/HTML, this is a normal copy. return try? NSAttributedString(
let hasText = types.contains(.string) || types.contains(.html) data: data,
|| types.contains(.rtf) || types.contains(.rtfd) options: [
if hasText { return nil } .documentType: documentType,
.characterEncoding: String.Encoding.utf8.rawValue
],
documentAttributes: nil
)
}
// Check for image types (TIFF from screenshots, PNG from some tools). private static func rtfdAttachmentImageRepresentation(
guard types.contains(.tiff) || types.contains(.png) else { return nil } in pasteboard: NSPasteboard
guard let image = NSImage(pasteboard: pb), ) -> (data: Data, fileExtension: String)? {
let tiffData = image.tiffRepresentation, guard let attributed = attributedString(
let bitmap = NSBitmapImageRep(data: tiffData), from: pasteboard,
let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil } type: .rtfd,
documentType: .rtfd
) else { return nil }
guard pngData.count <= maxClipboardImageSize else { var result: (data: Data, fileExtension: String)?
attributed.enumerateAttribute(
.attachment,
in: NSRange(location: 0, length: attributed.length)
) { value, _, stop in
guard let attachment = value as? NSTextAttachment else { return }
if let fileWrapper = attachment.fileWrapper,
let data = fileWrapper.regularFileContents,
let imageRepresentation = imageAttachmentRepresentation(
data: data,
preferredFilename: fileWrapper.preferredFilename
) {
result = imageRepresentation
stop.pointee = true
}
}
return result
}
private static func imageAttachmentRepresentation(
data: Data,
preferredFilename: String?
) -> (data: Data, fileExtension: String)? {
let pathExtension =
(preferredFilename as NSString?)?.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines)
?? ""
if let type = !pathExtension.isEmpty ? UTType(filenameExtension: pathExtension) : nil,
type.conforms(to: .image),
let fileExtension = type.preferredFilenameExtension ?? nonEmpty(pathExtension) {
return (data, fileExtension)
}
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
let typeIdentifier = CGImageSourceGetType(imageSource) as String?,
let type = UTType(typeIdentifier),
type.conforms(to: .image),
let fileExtension = type.preferredFilenameExtension else { return nil }
return (data, fileExtension)
}
private static func nonEmpty(_ value: String) -> String? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private static func hasImageData(in pasteboard: NSPasteboard) -> Bool {
let types = pasteboard.types ?? []
if types.contains(.tiff) || types.contains(.png) {
return true
}
return types.contains { type in
guard let utType = UTType(type.rawValue) else { return false }
return utType.conforms(to: .image)
}
}
private static func directImageRepresentation(
in pasteboard: NSPasteboard
) -> (data: Data, fileExtension: String)? {
if let pngData = pasteboard.data(forType: .png) {
return (pngData, "png")
}
for type in pasteboard.types ?? [] {
guard type != .png,
type != .tiff,
let utType = UTType(type.rawValue),
utType.conforms(to: .image),
let imageData = pasteboard.data(forType: type),
let fileExtension = utType.preferredFilenameExtension,
!fileExtension.isEmpty else { continue }
return (imageData, fileExtension)
}
return nil
}
private static func htmlHasNoVisibleText(_ html: String) -> Bool {
let withoutComments = html.replacingOccurrences(
of: "<!--[\\s\\S]*?-->",
with: " ",
options: .regularExpression
)
let withoutTags = withoutComments.replacingOccurrences(
of: "<[^>]+>",
with: " ",
options: .regularExpression
)
let normalized = withoutTags
.replacingOccurrences(of: "&nbsp;", with: " ")
.replacingOccurrences(of: "&#160;", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
return normalized.isEmpty
}
/// When the clipboard contains only image data (or rich text that resolves to
/// an attachment-only image), saves it as a temporary image file and returns the
/// shell-escaped file path. Returns nil if the clipboard contains text or no image.
static func saveClipboardImageIfNeeded(
from pasteboard: NSPasteboard = .general,
assumeNoText: Bool = false
) -> String? {
if !assumeNoText && stringContents(from: pasteboard) != nil { return nil }
let imageData: Data
let fileExtension: String
if let directImage = directImageRepresentation(in: pasteboard) {
imageData = directImage.data
fileExtension = directImage.fileExtension
} else if let rtfdAttachment = rtfdAttachmentImageRepresentation(in: pasteboard) {
imageData = rtfdAttachment.data
fileExtension = rtfdAttachment.fileExtension
} else {
guard hasImageData(in: pasteboard),
let image = NSImage(pasteboard: pasteboard),
let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil }
imageData = pngData
fileExtension = "png"
}
let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB
guard imageData.count <= maxClipboardImageSize else {
#if DEBUG #if DEBUG
dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(pngData.count)") dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(imageData.count)")
#endif #endif
return nil return nil
} }
@ -164,11 +332,11 @@ private enum GhosttyPasteboardHelper {
formatter.dateFormat = "yyyy-MM-dd-HHmmss" formatter.dateFormat = "yyyy-MM-dd-HHmmss"
formatter.locale = Locale(identifier: "en_US_POSIX") formatter.locale = Locale(identifier: "en_US_POSIX")
let timestamp = formatter.string(from: Date()) let timestamp = formatter.string(from: Date())
let filename = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).png" let filename = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).\(fileExtension)"
let path = (NSTemporaryDirectory() as NSString).appendingPathComponent(filename) let path = (NSTemporaryDirectory() as NSString).appendingPathComponent(filename)
do { do {
try pngData.write(to: URL(fileURLWithPath: path)) try imageData.write(to: URL(fileURLWithPath: path))
} catch { } catch {
#if DEBUG #if DEBUG
dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)") dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)")
@ -180,6 +348,16 @@ private enum GhosttyPasteboardHelper {
} }
} }
#if DEBUG
func cmuxPasteboardStringContentsForTesting(_ pasteboard: NSPasteboard) -> String? {
GhosttyPasteboardHelper.stringContents(from: pasteboard)
}
func cmuxPasteboardImagePathForTesting(_ pasteboard: NSPasteboard) -> String? {
GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: pasteboard)
}
#endif
enum TerminalOpenURLTarget: Equatable { enum TerminalOpenURLTarget: Equatable {
case embeddedBrowser(URL) case embeddedBrowser(URL)
case external(URL) case external(URL)
@ -877,7 +1055,11 @@ class GhosttyApp {
// When clipboard has only image data (e.g. screenshot), save as temp // When clipboard has only image data (e.g. screenshot), save as temp
// PNG and paste the file path so CLI tools can receive images. // PNG and paste the file path so CLI tools can receive images.
if value.isEmpty, let imagePath = GhosttyPasteboardHelper.saveClipboardImageIfNeeded() { if value.isEmpty,
let imagePath = pasteboard.flatMap({
GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: $0, assumeNoText: true)
})
{
value = imagePath value = imagePath
} }
@ -2329,6 +2511,7 @@ final class TerminalSurface: Identifiable, ObservableObject {
private let surfaceContext: ghostty_surface_context_e private let surfaceContext: ghostty_surface_context_e
private let configTemplate: ghostty_surface_config_s? private let configTemplate: ghostty_surface_config_s?
private let workingDirectory: String? private let workingDirectory: String?
var requestedWorkingDirectory: String? { workingDirectory }
private var additionalEnvironment: [String: String] private var additionalEnvironment: [String: String]
let hostedView: GhosttySurfaceScrollView let hostedView: GhosttySurfaceScrollView
private let surfaceView: GhosttyNSView private let surfaceView: GhosttyNSView
@ -2341,6 +2524,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
private let maxPendingTextBytes = 1_048_576 private let maxPendingTextBytes = 1_048_576
private var backgroundSurfaceStartQueued = false private var backgroundSurfaceStartQueued = false
private var surfaceCallbackContext: Unmanaged<GhosttySurfaceCallbackContext>? private var surfaceCallbackContext: Unmanaged<GhosttySurfaceCallbackContext>?
#if DEBUG
private var needsConfirmCloseOverrideForTesting: Bool?
#endif
private enum PortalLifecycleState: String { private enum PortalLifecycleState: String {
case live case live
case closing case closing
@ -2844,6 +3030,27 @@ final class TerminalSurface: Identifiable, ObservableObject {
} }
env["ZDOTDIR"] = integrationDir env["ZDOTDIR"] = integrationDir
} else if shellName == "bash" {
if GhosttyApp.shared.shellIntegrationMode() != "none" {
env["CMUX_LOAD_GHOSTTY_BASH_INTEGRATION"] = "1"
}
// macOS ships /bin/bash 3.2, where Ghostty's automatic bash
// integration is unsupported and HOME-based wrapper startup is
// not reliable. Bootstrap cmux bash integration on the first
// interactive prompt instead.
env["PROMPT_COMMAND"] = """
unset PROMPT_COMMAND; \
if [[ "${CMUX_LOAD_GHOSTTY_BASH_INTEGRATION:-0}" == "1" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then \
_cmux_ghostty_bash="$GHOSTTY_RESOURCES_DIR/shell-integration/bash/ghostty.bash"; \
[[ -r "$_cmux_ghostty_bash" ]] && source "$_cmux_ghostty_bash"; \
fi; \
if [[ "${CMUX_SHELL_INTEGRATION:-1}" != "0" && -n "${CMUX_SHELL_INTEGRATION_DIR:-}" ]]; then \
_cmux_bash_integration="$CMUX_SHELL_INTEGRATION_DIR/cmux-bash-integration.bash"; \
[[ -r "$_cmux_bash_integration" ]] && source "$_cmux_bash_integration"; \
fi; \
unset _cmux_ghostty_bash _cmux_bash_integration; \
if declare -F _cmux_prompt_command >/dev/null 2>&1; then _cmux_prompt_command; fi
"""
} }
} }
@ -3091,6 +3298,11 @@ final class TerminalSurface: Identifiable, ObservableObject {
} }
func needsConfirmClose() -> Bool { func needsConfirmClose() -> Bool {
#if DEBUG
if let needsConfirmCloseOverrideForTesting {
return needsConfirmCloseOverrideForTesting
}
#endif
guard let surface = surface else { return false } guard let surface = surface else { return false }
return ghostty_surface_needs_confirm_quit(surface) return ghostty_surface_needs_confirm_quit(surface)
} }
@ -3209,6 +3421,11 @@ final class TerminalSurface: Identifiable, ObservableObject {
} }
#if DEBUG #if DEBUG
@MainActor
func setNeedsConfirmCloseOverrideForTesting(_ value: Bool?) {
needsConfirmCloseOverrideForTesting = value
}
/// Test-only helper to deterministically simulate a released runtime surface. /// Test-only helper to deterministically simulate a released runtime surface.
@MainActor @MainActor
func releaseSurfaceForTesting() { func releaseSurfaceForTesting() {

View file

@ -2132,6 +2132,7 @@ final class BrowserPanel: Panel, ObservableObject {
} }
func triggerFlash() { func triggerFlash() {
guard NotificationPaneFlashSettings.isEnabled() else { return }
focusFlashToken &+= 1 focusFlashToken &+= 1
} }

View file

@ -76,6 +76,7 @@ final class MarkdownPanel: Panel, ObservableObject {
} }
func triggerFlash() { func triggerFlash() {
guard NotificationPaneFlashSettings.isEnabled() else { return }
focusFlashToken += 1 focusFlashToken += 1
} }

View file

@ -63,6 +63,10 @@ final class TerminalPanel: Panel, ObservableObject {
surface.hostedView surface.hostedView
} }
var requestedWorkingDirectory: String? {
surface.requestedWorkingDirectory
}
init(workspaceId: UUID, surface: TerminalSurface) { init(workspaceId: UUID, surface: TerminalSurface) {
self.id = surface.id self.id = surface.id
self.workspaceId = workspaceId self.workspaceId = workspaceId
@ -193,10 +197,12 @@ final class TerminalPanel: Panel, ObservableObject {
} }
func triggerFlash() { func triggerFlash() {
guard NotificationPaneFlashSettings.isEnabled() else { return }
hostedView.triggerFlash() hostedView.triggerFlash()
} }
func triggerNotificationDismissFlash() { func triggerNotificationDismissFlash() {
guard NotificationPaneFlashSettings.isEnabled() else { return }
hostedView.triggerFlash(style: .notificationDismiss) hostedView.triggerFlash(style: .notificationDismiss)
} }

View file

@ -5,6 +5,8 @@ import AppKit
/// View for rendering a terminal panel /// View for rendering a terminal panel
struct TerminalPanelView: View { struct TerminalPanelView: View {
@ObservedObject var panel: TerminalPanel @ObservedObject var panel: TerminalPanel
@AppStorage(NotificationPaneRingSettings.enabledKey)
private var notificationPaneRingEnabled = NotificationPaneRingSettings.defaultEnabled
let isFocused: Bool let isFocused: Bool
let isVisibleInUI: Bool let isVisibleInUI: Bool
let portalPriority: Int let portalPriority: Int
@ -23,7 +25,7 @@ struct TerminalPanelView: View {
isVisibleInUI: isVisibleInUI, isVisibleInUI: isVisibleInUI,
portalZPriority: portalPriority, portalZPriority: portalPriority,
showsInactiveOverlay: isSplit && !isFocused, showsInactiveOverlay: isSplit && !isFocused,
showsUnreadNotificationRing: hasUnreadNotification, showsUnreadNotificationRing: hasUnreadNotification && notificationPaneRingEnabled,
inactiveOverlayColor: appearance.unfocusedOverlayNSColor, inactiveOverlayColor: appearance.unfocusedOverlayNSColor,
inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity, inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity,
searchState: panel.searchState, searchState: panel.searchState,

View file

@ -643,6 +643,33 @@ class TabManager: ObservableObject {
private static var nextPortOrdinal: Int = 0 private static var nextPortOrdinal: Int = 0
private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0] private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0]
@Published var selectedTabId: UUID? { @Published var selectedTabId: UUID? {
willSet {
#if DEBUG
guard newValue != selectedTabId else {
debugPendingWorkspaceSwitchTrigger = nil
debugPendingWorkspaceSwitchTarget = nil
debugPreparedWorkspaceSwitchTarget = nil
return
}
if debugPreparedWorkspaceSwitchTarget == newValue {
debugPreparedWorkspaceSwitchTarget = nil
debugPendingWorkspaceSwitchTrigger = nil
debugPendingWorkspaceSwitchTarget = nil
} else {
let trigger = (debugPendingWorkspaceSwitchTarget == newValue
? debugPendingWorkspaceSwitchTrigger
: nil) ?? "direct"
debugPendingWorkspaceSwitchTrigger = nil
debugPendingWorkspaceSwitchTarget = nil
debugBeginWorkspaceSwitch(
trigger: trigger,
from: selectedTabId,
to: newValue
)
}
#endif
}
didSet { didSet {
guard selectedTabId != oldValue else { return } guard selectedTabId != oldValue else { return }
sentryBreadcrumb("workspace.switch", data: [ sentryBreadcrumb("workspace.switch", data: [
@ -713,10 +740,24 @@ class TabManager: ObservableObject {
private var workspaceCycleGeneration: UInt64 = 0 private var workspaceCycleGeneration: UInt64 = 0
private var workspaceCycleCooldownTask: Task<Void, Never>? private var workspaceCycleCooldownTask: Task<Void, Never>?
private var pendingWorkspaceUnfocusTarget: (tabId: UUID, panelId: UUID)? private var pendingWorkspaceUnfocusTarget: (tabId: UUID, panelId: UUID)?
private var sidebarSelectedWorkspaceIds: Set<UUID> = []
var confirmCloseHandler: ((String, String, Bool) -> Bool)?
private struct WorkspaceCreationSnapshot {
let tabs: [Workspace]
let selectedTabId: UUID?
var selectedWorkspace: Workspace? {
guard let selectedTabId else { return nil }
return tabs.first(where: { $0.id == selectedTabId })
}
}
#if DEBUG #if DEBUG
private var debugWorkspaceSwitchCounter: UInt64 = 0 private var debugWorkspaceSwitchCounter: UInt64 = 0
private var debugWorkspaceSwitchId: UInt64 = 0 private var debugWorkspaceSwitchId: UInt64 = 0
private var debugWorkspaceSwitchStartTime: CFTimeInterval = 0 private var debugWorkspaceSwitchStartTime: CFTimeInterval = 0
private var debugPendingWorkspaceSwitchTrigger: String?
private var debugPendingWorkspaceSwitchTarget: UUID?
private var debugPreparedWorkspaceSwitchTarget: UUID?
#endif #endif
#if DEBUG #if DEBUG
@ -883,25 +924,32 @@ class TabManager: ObservableObject {
placementOverride: NewWorkspacePlacement? = nil, placementOverride: NewWorkspacePlacement? = nil,
autoWelcomeIfNeeded: Bool = true autoWelcomeIfNeeded: Bool = true
) -> Workspace { ) -> Workspace {
sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1]) // Snapshot current published state once so workspace creation doesn't repeatedly
// bounce through Combine-backed accessors while we're preparing the new workspace.
let snapshot = workspaceCreationSnapshot()
let nextTabCount = snapshot.tabs.count + 1
sentryBreadcrumb("workspace.create", data: ["tabCount": nextTabCount])
let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory)
let workingDirectory = explicitWorkingDirectory ?? preferredWorkingDirectoryForNewTab() let workingDirectory = explicitWorkingDirectory ?? preferredWorkingDirectoryForNewTab(snapshot: snapshot)
let inheritedConfig = inheritedTerminalConfigForNewWorkspace() let inheritedConfig = inheritedTerminalConfigForNewWorkspace(snapshot: snapshot)
let ordinal = Self.nextPortOrdinal let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1 Self.nextPortOrdinal += 1
let newWorkspace = Workspace( let newWorkspace = Workspace(
title: "Terminal \(tabs.count + 1)", title: "Terminal \(nextTabCount)",
workingDirectory: workingDirectory, workingDirectory: workingDirectory,
portOrdinal: ordinal, portOrdinal: ordinal,
configTemplate: inheritedConfig configTemplate: inheritedConfig
) )
newWorkspace.owningTabManager = self
wireClosedBrowserTracking(for: newWorkspace) wireClosedBrowserTracking(for: newWorkspace)
let insertIndex = newTabInsertIndex(placementOverride: placementOverride) let insertIndex = newTabInsertIndex(snapshot: snapshot, placementOverride: placementOverride)
if insertIndex >= 0 && insertIndex <= tabs.count { var updatedTabs = snapshot.tabs
tabs.insert(newWorkspace, at: insertIndex) if insertIndex >= 0 && insertIndex <= updatedTabs.count {
updatedTabs.insert(newWorkspace, at: insertIndex)
} else { } else {
tabs.append(newWorkspace) updatedTabs.append(newWorkspace)
} }
tabs = updatedTabs
if let explicitWorkingDirectory, if let explicitWorkingDirectory,
let terminalPanel = newWorkspace.focusedTerminalPanel { let terminalPanel = newWorkspace.focusedTerminalPanel {
scheduleInitialWorkspaceGitMetadataRefresh( scheduleInitialWorkspaceGitMetadataRefresh(
@ -915,6 +963,9 @@ class TabManager: ObservableObject {
newWorkspace.requestBackgroundTerminalSurfaceStartIfNeeded() newWorkspace.requestBackgroundTerminalSurfaceStartIfNeeded()
} }
if select { if select {
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("create", to: newWorkspace.id)
#endif
selectedTabId = newWorkspace.id selectedTabId = newWorkspace.id
NotificationCenter.default.post( NotificationCenter.default.post(
name: .ghosttyDidFocusTab, name: .ghosttyDidFocusTab,
@ -925,8 +976,8 @@ class TabManager: ObservableObject {
#if DEBUG #if DEBUG
UITestRecorder.incrementInt("addTabInvocations") UITestRecorder.incrementInt("addTabInvocations")
UITestRecorder.record([ UITestRecorder.record([
"tabCount": String(tabs.count), "tabCount": String(updatedTabs.count),
"selectedTabId": select ? newWorkspace.id.uuidString : (selectedTabId?.uuidString ?? "") "selectedTabId": select ? newWorkspace.id.uuidString : (snapshot.selectedTabId?.uuidString ?? "")
]) ])
#endif #endif
if autoWelcomeIfNeeded && select && !UserDefaults.standard.bool(forKey: WelcomeSettings.shownKey) { if autoWelcomeIfNeeded && select && !UserDefaults.standard.bool(forKey: WelcomeSettings.shownKey) {
@ -1154,7 +1205,20 @@ class TabManager: ObservableObject {
} }
func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? { func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? {
guard let workspace = selectedWorkspace else { return nil } terminalPanelForWorkspaceConfigInheritanceSource(snapshot: workspaceCreationSnapshot())
}
private func workspaceCreationSnapshot() -> WorkspaceCreationSnapshot {
WorkspaceCreationSnapshot(
tabs: tabs,
selectedTabId: selectedTabId
)
}
private func terminalPanelForWorkspaceConfigInheritanceSource(
snapshot: WorkspaceCreationSnapshot
) -> TerminalPanel? {
guard let workspace = snapshot.selectedWorkspace else { return nil }
if let focusedTerminal = workspace.focusedTerminalPanel { if let focusedTerminal = workspace.focusedTerminalPanel {
return focusedTerminal return focusedTerminal
} }
@ -1169,13 +1233,19 @@ class TabManager: ObservableObject {
} }
private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? { private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? {
if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource()?.surface.surface { inheritedTerminalConfigForNewWorkspace(snapshot: workspaceCreationSnapshot())
}
private func inheritedTerminalConfigForNewWorkspace(
snapshot: WorkspaceCreationSnapshot
) -> ghostty_surface_config_s? {
if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource(snapshot: snapshot)?.surface.surface {
return cmuxInheritedSurfaceConfig( return cmuxInheritedSurfaceConfig(
sourceSurface: sourceSurface, sourceSurface: sourceSurface,
context: GHOSTTY_SURFACE_CONTEXT_TAB context: GHOSTTY_SURFACE_CONTEXT_TAB
) )
} }
if let fallbackFontPoints = selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() { if let fallbackFontPoints = snapshot.selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() {
var config = ghostty_surface_config_new() var config = ghostty_surface_config_new()
config.font_size = fallbackFontPoints config.font_size = fallbackFontPoints
return config return config
@ -1191,24 +1261,36 @@ class TabManager: ObservableObject {
} }
private func newTabInsertIndex(placementOverride: NewWorkspacePlacement? = nil) -> Int { private func newTabInsertIndex(placementOverride: NewWorkspacePlacement? = nil) -> Int {
newTabInsertIndex(snapshot: workspaceCreationSnapshot(), placementOverride: placementOverride)
}
private func newTabInsertIndex(
snapshot: WorkspaceCreationSnapshot,
placementOverride: NewWorkspacePlacement? = nil
) -> Int {
let placement = placementOverride ?? WorkspacePlacementSettings.current() let placement = placementOverride ?? WorkspacePlacementSettings.current()
let pinnedCount = tabs.filter { $0.isPinned }.count let pinnedCount = snapshot.tabs.filter { $0.isPinned }.count
let selectedIndex = selectedTabId.flatMap { tabId in let selectedIndex = snapshot.selectedTabId.flatMap { tabId in
tabs.firstIndex(where: { $0.id == tabId }) snapshot.tabs.firstIndex(where: { $0.id == tabId })
} }
let selectedIsPinned = selectedIndex.map { tabs[$0].isPinned } ?? false let selectedIsPinned = selectedIndex.map { snapshot.tabs[$0].isPinned } ?? false
return WorkspacePlacementSettings.insertionIndex( return WorkspacePlacementSettings.insertionIndex(
placement: placement, placement: placement,
selectedIndex: selectedIndex, selectedIndex: selectedIndex,
selectedIsPinned: selectedIsPinned, selectedIsPinned: selectedIsPinned,
pinnedCount: pinnedCount, pinnedCount: pinnedCount,
totalCount: tabs.count totalCount: snapshot.tabs.count
) )
} }
private func preferredWorkingDirectoryForNewTab() -> String? { private func preferredWorkingDirectoryForNewTab() -> String? {
guard let selectedTabId, preferredWorkingDirectoryForNewTab(snapshot: workspaceCreationSnapshot())
let tab = tabs.first(where: { $0.id == selectedTabId }) else { }
private func preferredWorkingDirectoryForNewTab(
snapshot: WorkspaceCreationSnapshot
) -> String? {
guard let tab = snapshot.selectedWorkspace else {
return nil return nil
} }
let focusedDirectory = tab.focusedPanelId let focusedDirectory = tab.focusedPanelId
@ -1322,6 +1404,15 @@ class TabManager: ObservableObject {
tab.updatePanelDirectory(panelId: surfaceId, directory: normalized) tab.updatePanelDirectory(panelId: surfaceId, directory: normalized)
} }
func updateSurfaceShellActivity(
tabId: UUID,
surfaceId: UUID,
state: Workspace.PanelShellActivityState
) {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
tab.updatePanelShellActivityState(panelId: surfaceId, state: state)
}
private func normalizeDirectory(_ directory: String) -> String { private func normalizeDirectory(_ directory: String) -> String {
let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return directory } guard !trimmed.isEmpty else { return directory }
@ -1338,10 +1429,12 @@ class TabManager: ObservableObject {
guard let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return } guard let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return }
sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
clearInitialWorkspaceGitProbe(workspaceId: workspace.id) clearInitialWorkspaceGitProbe(workspaceId: workspace.id)
sidebarSelectedWorkspaceIds.remove(workspace.id)
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
unwireClosedBrowserTracking(for: workspace) unwireClosedBrowserTracking(for: workspace)
workspace.teardownAllPanels() workspace.teardownAllPanels()
workspace.owningTabManager = nil
tabs.remove(at: index) tabs.remove(at: index)
@ -1360,9 +1453,11 @@ class TabManager: ObservableObject {
func detachWorkspace(tabId: UUID) -> Workspace? { func detachWorkspace(tabId: UUID) -> Workspace? {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil } guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil }
clearInitialWorkspaceGitProbe(workspaceId: tabId) clearInitialWorkspaceGitProbe(workspaceId: tabId)
sidebarSelectedWorkspaceIds.remove(tabId)
let removed = tabs.remove(at: index) let removed = tabs.remove(at: index)
unwireClosedBrowserTracking(for: removed) unwireClosedBrowserTracking(for: removed)
removed.owningTabManager = nil
lastFocusedPanelByTab.removeValue(forKey: removed.id) lastFocusedPanelByTab.removeValue(forKey: removed.id)
if tabs.isEmpty { if tabs.isEmpty {
@ -1381,6 +1476,7 @@ class TabManager: ObservableObject {
/// Attach an existing workspace to this window. /// Attach an existing workspace to this window.
func attachWorkspace(_ workspace: Workspace, at index: Int? = nil, select: Bool = true) { func attachWorkspace(_ workspace: Workspace, at index: Int? = nil, select: Bool = true) {
workspace.owningTabManager = self
wireClosedBrowserTracking(for: workspace) wireClosedBrowserTracking(for: workspace)
let insertIndex: Int = { let insertIndex: Int = {
guard let index else { return tabs.count } guard let index else { return tabs.count }
@ -1441,6 +1537,11 @@ class TabManager: ObservableObject {
#if DEBUG #if DEBUG
UITestRecorder.incrementInt("closeTabInvocations") UITestRecorder.incrementInt("closeTabInvocations")
#endif #endif
let sidebarSelectionIds = orderedSidebarSelectedWorkspaceIds()
if sidebarSelectionIds.count > 1 {
closeWorkspacesWithConfirmation(sidebarSelectionIds, allowPinned: true)
return
}
guard let selectedId = selectedTabId, guard let selectedId = selectedTabId,
let workspace = tabs.first(where: { $0.id == selectedId }) else { return } let workspace = tabs.first(where: { $0.id == selectedId }) else { return }
closeWorkspaceWithConfirmation(workspace) closeWorkspaceWithConfirmation(workspace)
@ -1455,7 +1556,36 @@ class TabManager: ObservableObject {
closeWorkspaceWithConfirmation(workspace) closeWorkspaceWithConfirmation(workspace)
} }
func setSidebarSelectedWorkspaceIds(_ workspaceIds: Set<UUID>) {
let existingIds = Set(tabs.map(\.id))
sidebarSelectedWorkspaceIds = workspaceIds.intersection(existingIds)
}
func closeWorkspacesWithConfirmation(_ workspaceIds: [UUID], allowPinned: Bool) {
let workspaces = orderedClosableWorkspaces(workspaceIds, allowPinned: allowPinned)
guard !workspaces.isEmpty else { return }
guard workspaces.count > 1 else {
closeWorkspaceWithConfirmation(workspaces[0])
return
}
let plan = closeWorkspacesPlan(for: workspaces)
guard confirmClose(
title: plan.title,
message: plan.message,
acceptCmdD: plan.acceptCmdD
) else { return }
for workspace in plan.workspaces {
guard tabs.contains(where: { $0.id == workspace.id }) else { continue }
closeWorkspaceIfRunningProcess(workspace, requiresConfirmation: false)
}
}
func selectWorkspace(_ workspace: Workspace) { func selectWorkspace(_ workspace: Workspace) {
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id)
#endif
selectedTabId = workspace.id selectedTabId = workspace.id
} }
@ -1463,6 +1593,11 @@ class TabManager: ObservableObject {
func selectTab(_ tab: Workspace) { selectWorkspace(tab) } func selectTab(_ tab: Workspace) { selectWorkspace(tab) }
private func confirmClose(title: String, message: String, acceptCmdD: Bool) -> Bool { private func confirmClose(title: String, message: String, acceptCmdD: Bool) -> Bool {
if let confirmCloseHandler {
return confirmCloseHandler(title, message, acceptCmdD)
}
_ = acceptCmdD
let alert = NSAlert() let alert = NSAlert()
alert.messageText = title alert.messageText = title
alert.informativeText = message alert.informativeText = message
@ -1470,15 +1605,18 @@ class TabManager: ObservableObject {
alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close")) alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
// macOS convention: Cmd+D = confirm destructive close (e.g. "Don't Save"). if let closeButton = alert.buttons.first {
// We only opt into this for the "close last workspace => close window" path to avoid closeButton.keyEquivalent = "\r"
// conflicting with app-level Cmd+D (split right) during normal usage. closeButton.keyEquivalentModifierMask = []
if acceptCmdD, let closeButton = alert.buttons.first {
closeButton.keyEquivalent = "d"
closeButton.keyEquivalentModifierMask = [.command]
// Keep Return/Enter behavior by explicitly setting the default button cell.
alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell
alert.window.initialFirstResponder = closeButton
}
if let cancelButton = alert.buttons.dropFirst().first {
cancelButton.keyEquivalent = "\u{1b}"
}
if NSApp.activationPolicy() == .regular {
NSApp.activate(ignoringOtherApps: true)
} }
return alert.runModal() == .alertFirstButtonReturn return alert.runModal() == .alertFirstButtonReturn
@ -1490,6 +1628,13 @@ class TabManager: ObservableObject {
let titles: [String] let titles: [String]
} }
private struct CloseWorkspacesPlan {
let workspaces: [Workspace]
let title: String
let message: String
let acceptCmdD: Bool
}
private func closeOtherTabsInFocusedPanePlan() -> CloseOtherTabsInFocusedPanePlan? { private func closeOtherTabsInFocusedPanePlan() -> CloseOtherTabsInFocusedPanePlan? {
guard let workspace = selectedWorkspace else { return nil } guard let workspace = selectedWorkspace else { return nil }
guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else { guard let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else {
@ -1532,9 +1677,62 @@ class TabManager: ObservableObject {
return String(localized: "tab.untitled", defaultValue: "Untitled Tab") return String(localized: "tab.untitled", defaultValue: "Untitled Tab")
} }
private func closeWorkspaceIfRunningProcess(_ workspace: Workspace) { private func orderedClosableWorkspaces(_ workspaceIds: [UUID], allowPinned: Bool) -> [Workspace] {
let targetIds = Set(workspaceIds)
return tabs.compactMap { workspace in
guard targetIds.contains(workspace.id) else { return nil }
guard allowPinned || !workspace.isPinned else { return nil }
return workspace
}
}
private func orderedSidebarSelectedWorkspaceIds() -> [UUID] {
tabs.compactMap { workspace in
sidebarSelectedWorkspaceIds.contains(workspace.id) ? workspace.id : nil
}
}
private func closeWorkspacesPlan(for workspaces: [Workspace]) -> CloseWorkspacesPlan {
let willCloseWindow = workspaces.count == tabs.count
let title = willCloseWindow
? String(localized: "dialog.closeWindow.title", defaultValue: "Close window?")
: String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?")
let titleLines = workspaces
.map { "\(closeWorkspaceDisplayTitle($0.title))" }
.joined(separator: "\n")
let format = willCloseWindow
? String(
localized: "dialog.closeWorkspacesWindow.message",
defaultValue: "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@"
)
: String(
localized: "dialog.closeWorkspaces.message",
defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@"
)
let message = String(format: format, locale: .current, Int64(workspaces.count), titleLines)
return CloseWorkspacesPlan(
workspaces: workspaces,
title: title,
message: message,
acceptCmdD: willCloseWindow
)
}
private func closeWorkspaceDisplayTitle(_ title: String?) -> String {
let collapsed = title?
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\r", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
if let collapsed, !collapsed.isEmpty {
return collapsed
}
return String(localized: "workspace.displayName.fallback", defaultValue: "Workspace")
}
private func closeWorkspaceIfRunningProcess(_ workspace: Workspace, requiresConfirmation: Bool = true) {
let willCloseWindow = tabs.count <= 1 let willCloseWindow = tabs.count <= 1
if workspaceNeedsConfirmClose(workspace), if requiresConfirmation,
workspaceNeedsConfirmClose(workspace),
!confirmClose( !confirmClose(
title: String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"), title: String(localized: "dialog.closeWorkspace.title", defaultValue: "Close workspace?"),
message: String(localized: "dialog.closeWorkspace.message", defaultValue: "This will close the workspace and all of its panels."), message: String(localized: "dialog.closeWorkspace.message", defaultValue: "This will close the workspace and all of its panels."),
@ -1544,13 +1742,27 @@ class TabManager: ObservableObject {
} }
if tabs.count <= 1 { if tabs.count <= 1 {
// Last workspace in this window: close the window (Cmd+Shift+W behavior). // Last workspace in this window: close the window (Cmd+Shift+W behavior).
AppDelegate.shared?.closeMainWindowContainingTabId(workspace.id) if let window {
window.performClose(nil)
} else {
AppDelegate.shared?.closeMainWindowContainingTabId(workspace.id)
}
} else { } else {
closeWorkspace(workspace) closeWorkspace(workspace)
} }
} }
private func closePanelWithConfirmation(tab: Workspace, panelId: UUID) { private func closePanelWithConfirmation(tab: Workspace, panelId: UUID) {
guard tab.panels[panelId] != nil else {
#if DEBUG
dlog(
"surface.close.shortcut.skip tab=\(tab.id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) reason=missingPanel"
)
#endif
return
}
let bonsplitTabCount = tab.bonsplitController.allPaneIds.reduce(0) { partial, paneId in let bonsplitTabCount = tab.bonsplitController.allPaneIds.reduce(0) { partial, paneId in
partial + tab.bonsplitController.tabs(inPane: paneId).count partial + tab.bonsplitController.tabs(inPane: paneId).count
} }
@ -1568,73 +1780,13 @@ class TabManager: ObservableObject {
) )
#endif #endif
// Cmd+W closes the focused Bonsplit tab (a "tab" in the UI). When the workspace only has // Route Cmd+W through Bonsplit/Workspace close handling so it matches the tab close
// a single tab left, closing it should close the workspace (and possibly the window), // button, including shared confirmation, last-surface workspace/window-close behavior,
// rather than creating a replacement terminal. // and the usual replacement-panel flow when the close does not collapse the workspace.
let effectiveSurfaceCount = max(tab.panels.count, bonsplitTabCount) if let surfaceId = tab.surfaceIdFromPanelId(panelId) {
let isLastTabInWorkspace = effectiveSurfaceCount <= 1 tab.markExplicitClose(surfaceId: surfaceId)
if isLastTabInWorkspace {
let willCloseWindow = tabs.count <= 1
let needsConfirm = workspaceNeedsConfirmClose(tab)
if needsConfirm {
let message = willCloseWindow
? String(localized: "dialog.closeLastTabWindow.message", defaultValue: "This will close the last tab and close the window.")
: String(localized: "dialog.closeLastTabWorkspace.message", defaultValue: "This will close the last tab and close its workspace.")
#if DEBUG
dlog(
"surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) reason=lastTab"
)
#endif
guard confirmClose(
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: message,
acceptCmdD: willCloseWindow
) else {
#if DEBUG
dlog(
"surface.close.shortcut.cancel tab=\(tab.id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) reason=lastTabConfirmDismissed"
)
#endif
return
}
}
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id)
if willCloseWindow {
AppDelegate.shared?.closeMainWindowContainingTabId(tab.id)
} else {
closeWorkspace(tab)
}
return
} }
let closed = tab.closePanel(panelId)
if let terminalPanel = tab.terminalPanel(for: panelId),
terminalPanel.needsConfirmClose() {
#if DEBUG
dlog(
"surface.close.shortcut.confirm tab=\(tab.id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) reason=terminalNeedsConfirm"
)
#endif
guard confirmClose(
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."),
acceptCmdD: false
) else {
#if DEBUG
dlog(
"surface.close.shortcut.cancel tab=\(tab.id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) reason=terminalConfirmDismissed"
)
#endif
return
}
}
// We already confirmed (if needed); bypass Bonsplit's delegate gating.
let closed = tab.closePanel(panelId, force: true)
#if DEBUG #if DEBUG
dlog( dlog(
"surface.close.shortcut tab=\(tab.id.uuidString.prefix(5)) " + "surface.close.shortcut tab=\(tab.id.uuidString.prefix(5)) " +
@ -1656,7 +1808,7 @@ class TabManager: ObservableObject {
guard tab.panels[surfaceId] != nil else { return } guard tab.panels[surfaceId] != nil else { return }
if let terminalPanel = tab.terminalPanel(for: surfaceId), if let terminalPanel = tab.terminalPanel(for: surfaceId),
terminalPanel.needsConfirmClose() { tab.panelNeedsConfirmClose(panelId: surfaceId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
guard confirmClose( guard confirmClose(
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"), title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."), message: String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab."),
@ -2030,6 +2182,9 @@ class TabManager: ObservableObject {
// Keep selected-surface intent stable across selectedTabId didSet async restore. // Keep selected-surface intent stable across selectedTabId didSet async restore.
lastFocusedPanelByTab[tabId] = surfaceId lastFocusedPanelByTab[tabId] = surfaceId
} }
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("focus", to: tabId)
#endif
selectedTabId = tabId selectedTabId = tabId
NotificationCenter.default.post( NotificationCenter.default.post(
name: .ghosttyDidFocusTab, name: .ghosttyDidFocusTab,
@ -2097,13 +2252,7 @@ class TabManager: ObservableObject {
let nextIndex = (currentIndex + 1) % tabs.count let nextIndex = (currentIndex + 1) % tabs.count
#if DEBUG #if DEBUG
let nextId = tabs[nextIndex].id let nextId = tabs[nextIndex].id
debugWorkspaceSwitchCounter &+= 1 debugPrepareWorkspaceSwitch("next", from: currentId, to: nextId)
debugWorkspaceSwitchId = debugWorkspaceSwitchCounter
debugWorkspaceSwitchStartTime = CACurrentMediaTime()
dlog(
"ws.switch.begin id=\(debugWorkspaceSwitchId) dir=next from=\(Self.debugShortWorkspaceId(currentId)) " +
"to=\(Self.debugShortWorkspaceId(nextId)) hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)"
)
#endif #endif
activateWorkspaceCycleHotWindow() activateWorkspaceCycleHotWindow()
selectedTabId = tabs[nextIndex].id selectedTabId = tabs[nextIndex].id
@ -2115,13 +2264,7 @@ class TabManager: ObservableObject {
let prevIndex = (currentIndex - 1 + tabs.count) % tabs.count let prevIndex = (currentIndex - 1 + tabs.count) % tabs.count
#if DEBUG #if DEBUG
let prevId = tabs[prevIndex].id let prevId = tabs[prevIndex].id
debugWorkspaceSwitchCounter &+= 1 debugPrepareWorkspaceSwitch("prev", from: currentId, to: prevId)
debugWorkspaceSwitchId = debugWorkspaceSwitchCounter
debugWorkspaceSwitchStartTime = CACurrentMediaTime()
dlog(
"ws.switch.begin id=\(debugWorkspaceSwitchId) dir=prev from=\(Self.debugShortWorkspaceId(currentId)) " +
"to=\(Self.debugShortWorkspaceId(prevId)) hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)"
)
#endif #endif
activateWorkspaceCycleHotWindow() activateWorkspaceCycleHotWindow()
selectedTabId = tabs[prevIndex].id selectedTabId = tabs[prevIndex].id
@ -2194,6 +2337,40 @@ class TabManager: ObservableObject {
return (debugWorkspaceSwitchId, debugWorkspaceSwitchStartTime) return (debugWorkspaceSwitchId, debugWorkspaceSwitchStartTime)
} }
private func debugPrimeWorkspaceSwitchTrigger(_ trigger: String, to target: UUID?) {
guard selectedTabId != target else {
debugPendingWorkspaceSwitchTrigger = nil
debugPendingWorkspaceSwitchTarget = nil
return
}
debugPendingWorkspaceSwitchTrigger = trigger
debugPendingWorkspaceSwitchTarget = target
}
private func debugPrepareWorkspaceSwitch(_ trigger: String, from: UUID?, to: UUID?) {
guard from != to else {
debugPendingWorkspaceSwitchTrigger = nil
debugPendingWorkspaceSwitchTarget = nil
debugPreparedWorkspaceSwitchTarget = nil
return
}
debugPendingWorkspaceSwitchTrigger = nil
debugPendingWorkspaceSwitchTarget = nil
debugBeginWorkspaceSwitch(trigger: trigger, from: from, to: to)
debugPreparedWorkspaceSwitchTarget = to
}
private func debugBeginWorkspaceSwitch(trigger: String, from: UUID?, to: UUID?) {
debugWorkspaceSwitchCounter &+= 1
debugWorkspaceSwitchId = debugWorkspaceSwitchCounter
debugWorkspaceSwitchStartTime = CACurrentMediaTime()
dlog(
"ws.switch.begin id=\(debugWorkspaceSwitchId) trigger=\(trigger) " +
"from=\(Self.debugShortWorkspaceId(from)) to=\(Self.debugShortWorkspaceId(to)) " +
"hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)"
)
}
private static func debugShortWorkspaceId(_ id: UUID?) -> String { private static func debugShortWorkspaceId(_ id: UUID?) -> String {
guard let id else { return "nil" } guard let id else { return "nil" }
return String(id.uuidString.prefix(5)) return String(id.uuidString.prefix(5))
@ -2206,6 +2383,9 @@ class TabManager: ObservableObject {
func selectTab(at index: Int) { func selectTab(at index: Int) {
guard index >= 0 && index < tabs.count else { return } guard index >= 0 && index < tabs.count else { return }
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select_index", to: tabs[index].id)
#endif
selectedTabId = tabs[index].id selectedTabId = tabs[index].id
} }
@ -3937,6 +4117,7 @@ extension TabManager {
workingDirectory: workspaceSnapshot.currentDirectory, workingDirectory: workspaceSnapshot.currentDirectory,
portOrdinal: ordinal portOrdinal: ordinal
) )
workspace.owningTabManager = self
workspace.restoreSessionSnapshot(workspaceSnapshot) workspace.restoreSessionSnapshot(workspaceSnapshot)
wireClosedBrowserTracking(for: workspace) wireClosedBrowserTracking(for: workspace)
newTabs.append(workspace) newTabs.append(workspace)
@ -3946,6 +4127,7 @@ extension TabManager {
let ordinal = Self.nextPortOrdinal let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1 Self.nextPortOrdinal += 1
let fallback = Workspace(title: "Terminal 1", portOrdinal: ordinal) let fallback = Workspace(title: "Terminal 1", portOrdinal: ordinal)
fallback.owningTabManager = self
wireClosedBrowserTracking(for: fallback) wireClosedBrowserTracking(for: fallback)
newTabs.append(fallback) newTabs.append(fallback)
} }

View file

@ -320,7 +320,9 @@ class TerminalController {
private final class SocketFastPathState: @unchecked Sendable { private final class SocketFastPathState: @unchecked Sendable {
private let queue = DispatchQueue(label: "com.cmux.socket-fast-path") private let queue = DispatchQueue(label: "com.cmux.socket-fast-path")
private var lastReportedDirectories: [SocketSurfaceKey: String] = [:] private var lastReportedDirectories: [SocketSurfaceKey: String] = [:]
private var lastReportedShellStates: [SocketSurfaceKey: Workspace.PanelShellActivityState] = [:]
private let maxTrackedDirectories = 4096 private let maxTrackedDirectories = 4096
private let maxTrackedShellStates = 4096
func shouldPublishDirectory(workspaceId: UUID, panelId: UUID, directory: String) -> Bool { func shouldPublishDirectory(workspaceId: UUID, panelId: UUID, directory: String) -> Bool {
let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId) let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId)
@ -335,6 +337,24 @@ class TerminalController {
return true return true
} }
} }
func shouldPublishShellActivity(
workspaceId: UUID,
panelId: UUID,
state: Workspace.PanelShellActivityState
) -> Bool {
let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId)
return queue.sync {
if lastReportedShellStates[key] == state {
return false
}
if lastReportedShellStates.count >= maxTrackedShellStates {
lastReportedShellStates.removeAll(keepingCapacity: true)
}
lastReportedShellStates[key] = state
return true
}
}
} }
private static let socketFastPathState = SocketFastPathState() private static let socketFastPathState = SocketFastPathState()
@ -362,6 +382,21 @@ class TerminalController {
return trimmed return trimmed
} }
nonisolated static func parseReportedShellActivityState(
_ rawState: String
) -> Workspace.PanelShellActivityState? {
switch rawState.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "prompt", "idle":
return .promptIdle
case "running", "busy", "command":
return .commandRunning
case "unknown", "clear":
return .unknown
default:
return nil
}
}
/// Update which window's TabManager receives socket commands. /// Update which window's TabManager receives socket commands.
/// This is used when the user switches between multiple terminal windows. /// This is used when the user switches between multiple terminal windows.
func setActiveTabManager(_ tabManager: TabManager?) { func setActiveTabManager(_ tabManager: TabManager?) {
@ -1456,6 +1491,9 @@ class TerminalController {
case "ports_kick": case "ports_kick":
return portsKick(args) return portsKick(args)
case "report_shell_state":
return reportShellState(args)
case "report_pwd": case "report_pwd":
return reportPwd(args) return reportPwd(args)
@ -9705,6 +9743,7 @@ class TerminalController {
report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports
report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning
ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel
report_shell_state <prompt|running> [--tab=X] [--panel=Y] - Report whether the shell is idle at a prompt or running a command
report_pwd <path> [--tab=X] [--panel=Y] - Report current working directory report_pwd <path> [--tab=X] [--panel=Y] - Report current working directory
clear_ports [--tab=X] [--panel=Y] - Clear listening ports clear_ports [--tab=X] [--panel=Y] - Clear listening ports
sidebar_state [--tab=X] - Dump sidebar metadata sidebar_state [--tab=X] - Dump sidebar metadata
@ -13603,6 +13642,72 @@ class TerminalController {
return result return result
} }
private func reportShellState(_ args: String) -> String {
let parsed = parseOptions(args)
guard let rawState = parsed.positional.first, !rawState.isEmpty else {
return "ERROR: Missing shell state — usage: report_shell_state <prompt|running> [--tab=X] [--panel=Y]"
}
guard let state = Self.parseReportedShellActivityState(rawState) else {
return "ERROR: Invalid shell state '\(rawState)' — expected prompt or running"
}
if let scope = Self.explicitSocketScope(options: parsed.options) {
guard Self.socketFastPathState.shouldPublishShellActivity(
workspaceId: scope.workspaceId,
panelId: scope.panelId,
state: state
) else {
return "OK"
}
DispatchQueue.main.async {
guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId) else { return }
tabManager.updateSurfaceShellActivity(tabId: scope.workspaceId, surfaceId: scope.panelId, state: state)
}
return "OK"
}
guard let tabManager else { return "ERROR: TabManager not available" }
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: report_shell_state <prompt|running> [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
tabManager.updateSurfaceShellActivity(tabId: tab.id, surfaceId: surfaceId, state: state)
}
return result
}
private func clearPorts(_ args: String) -> String { private func clearPorts(_ args: String) -> String {
let parsed = parseOptions(args) let parsed = parseOptions(args)
var result = "OK" var result = "OK"

View file

@ -527,6 +527,23 @@ enum NotificationBadgeSettings {
} }
} }
enum NotificationPaneRingSettings {
static let enabledKey = "notificationPaneRingEnabled"
static let defaultEnabled = true
}
enum NotificationPaneFlashSettings {
static let enabledKey = "notificationPaneFlashEnabled"
static let defaultEnabled = true
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: enabledKey) == nil {
return defaultEnabled
}
return defaults.bool(forKey: enabledKey)
}
}
enum TaggedRunBadgeSettings { enum TaggedRunBadgeSettings {
static let environmentKey = "CMUX_TAG" static let environmentKey = "CMUX_TAG"
private static let maxTagLength = 10 private static let maxTagLength = 10

View file

@ -952,6 +952,7 @@ final class Workspace: Identifiable, ObservableObject {
/// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore. /// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore.
var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)? var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)?
weak var owningTabManager: TabManager?
// Closing tabs mutates split layout immediately; terminal views handle their own AppKit // Closing tabs mutates split layout immediately; terminal views handle their own AppKit
@ -1004,6 +1005,7 @@ final class Workspace: Identifiable, ObservableObject {
@Published var surfaceListeningPorts: [UUID: [Int]] = [:] @Published var surfaceListeningPorts: [UUID: [Int]] = [:]
@Published var listeningPorts: [Int] = [] @Published var listeningPorts: [Int] = []
var surfaceTTYNames: [UUID: String] = [:] var surfaceTTYNames: [UUID: String] = [:]
private var panelShellActivityStates: [UUID: PanelShellActivityState] = [:]
private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:]
var focusedSurfaceId: UUID? { focusedPanelId } var focusedSurfaceId: UUID? { focusedPanelId }
@ -1020,6 +1022,26 @@ final class Workspace: Identifiable, ObservableObject {
static let markdown = "markdown" static let markdown = "markdown"
} }
enum PanelShellActivityState: String {
case unknown
case promptIdle
case commandRunning
}
nonisolated static func resolveCloseConfirmation(
shellActivityState: PanelShellActivityState?,
fallbackNeedsConfirmClose: Bool
) -> Bool {
switch shellActivityState ?? .unknown {
case .promptIdle:
return false
case .commandRunning:
return true
case .unknown:
return fallbackNeedsConfirmClose
}
}
// MARK: - Initialization // MARK: - Initialization
private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips {
@ -1187,6 +1209,9 @@ final class Workspace: Identifiable, ObservableObject {
bonsplitController.onExternalTabDrop = { [weak self] request in bonsplitController.onExternalTabDrop = { [weak self] request in
self?.handleExternalTabDrop(request) ?? false self?.handleExternalTabDrop(request) ?? false
} }
bonsplitController.onTabCloseRequest = { [weak self] tabId, _ in
self?.markExplicitClose(surfaceId: tabId)
}
// Set ourselves as delegate // Set ourselves as delegate
bonsplitController.delegate = self bonsplitController.delegate = self
@ -1233,6 +1258,10 @@ final class Workspace: Identifiable, ObservableObject {
/// Prevents repeated close gestures (e.g., middle-click spam) from stacking dialogs. /// Prevents repeated close gestures (e.g., middle-click spam) from stacking dialogs.
private var pendingCloseConfirmTabIds: Set<TabID> = [] private var pendingCloseConfirmTabIds: Set<TabID> = []
/// Tab IDs whose next close attempt came from an explicit user close gesture
/// (Cmd+W or the tab-strip X button), rather than an internal close/move flow.
private var explicitUserCloseTabIds: Set<TabID> = []
/// Deterministic tab selection to apply after a tab closes. /// Deterministic tab selection to apply after a tab closes.
/// Keyed by the closing tab ID, value is the tab ID we want to select next. /// Keyed by the closing tab ID, value is the tab ID we want to select next.
private var postCloseSelectTabId: [TabID: TabID] = [:] private var postCloseSelectTabId: [TabID: TabID] = [:]
@ -1299,6 +1328,10 @@ final class Workspace: Identifiable, ObservableObject {
surfaceIdToPanelId[surfaceId] surfaceIdToPanelId[surfaceId]
} }
func markExplicitClose(surfaceId: TabID) {
explicitUserCloseTabIds.insert(surfaceId)
}
func surfaceIdFromPanelId(_ panelId: UUID) -> TabID? { func surfaceIdFromPanelId(_ panelId: UUID) -> TabID? {
surfaceIdToPanelId.first { $0.value == panelId }?.key surfaceIdToPanelId.first { $0.value == panelId }?.key
} }
@ -1650,6 +1683,26 @@ final class Workspace: Identifiable, ObservableObject {
} }
} }
func updatePanelShellActivityState(panelId: UUID, state: PanelShellActivityState) {
guard panels[panelId] != nil else { return }
let previousState = panelShellActivityStates[panelId] ?? .unknown
guard previousState != state else { return }
panelShellActivityStates[panelId] = state
#if DEBUG
dlog(
"surface.shellState workspace=\(id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) from=\(previousState.rawValue) to=\(state.rawValue)"
)
#endif
}
func panelNeedsConfirmClose(panelId: UUID, fallbackNeedsConfirmClose: Bool) -> Bool {
Self.resolveCloseConfirmation(
shellActivityState: panelShellActivityStates[panelId],
fallbackNeedsConfirmClose: fallbackNeedsConfirmClose
)
}
func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) { func updatePanelGitBranch(panelId: UUID, branch: String, isDirty: Bool) {
let state = SidebarGitBranchState(branch: branch, isDirty: isDirty) let state = SidebarGitBranchState(branch: branch, isDirty: isDirty)
let existing = panelGitBranches[panelId] let existing = panelGitBranches[panelId]
@ -1791,6 +1844,7 @@ final class Workspace: Identifiable, ObservableObject {
manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) } manualUnreadMarkedAt = manualUnreadMarkedAt.filter { validSurfaceIds.contains($0.key) }
surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) } surfaceListeningPorts = surfaceListeningPorts.filter { validSurfaceIds.contains($0.key) }
surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) } surfaceTTYNames = surfaceTTYNames.filter { validSurfaceIds.contains($0.key) }
panelShellActivityStates = panelShellActivityStates.filter { validSurfaceIds.contains($0.key) }
panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) } panelPullRequests = panelPullRequests.filter { validSurfaceIds.contains($0.key) }
recomputeListeningPorts() recomputeListeningPorts()
} }
@ -2068,12 +2122,26 @@ final class Workspace: Identifiable, ObservableObject {
let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId)
// Inherit working directory: prefer the source panel's reported cwd, // Inherit working directory: prefer the source panel's reported cwd,
// fall back to the workspace's current directory. // then its requested startup cwd if shell integration has not reported
let splitWorkingDirectory: String? = panelDirectories[panelId] // back yet, and finally fall back to the workspace's current directory.
?? (currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let splitWorkingDirectory: String? = {
? nil : currentDirectory) if let panelDirectory = panelDirectories[panelId]?.trimmingCharacters(in: .whitespacesAndNewlines),
!panelDirectory.isEmpty {
return panelDirectory
}
if let requestedWorkingDirectory = terminalPanel(for: panelId)?
.requestedWorkingDirectory?
.trimmingCharacters(in: .whitespacesAndNewlines),
!requestedWorkingDirectory.isEmpty {
return requestedWorkingDirectory
}
let workspaceDirectory = currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
return workspaceDirectory.isEmpty ? nil : workspaceDirectory
}()
#if DEBUG #if DEBUG
dlog("split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")") dlog(
"split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") requestedDir=\(terminalPanel(for: panelId)?.requestedWorkingDirectory ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")"
)
#endif #endif
// Create the new terminal panel. // Create the new terminal panel.
@ -3479,9 +3547,9 @@ final class Workspace: Identifiable, ObservableObject {
/// Check if any panel needs close confirmation /// Check if any panel needs close confirmation
func needsConfirmClose() -> Bool { func needsConfirmClose() -> Bool {
for panel in panels.values { for (panelId, panel) in panels {
if let terminalPanel = panel as? TerminalPanel, if let terminalPanel = panel as? TerminalPanel,
terminalPanel.needsConfirmClose() { panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
return true return true
} }
} }
@ -4120,6 +4188,18 @@ final class Workspace: Identifiable, ObservableObject {
// MARK: - BonsplitDelegate // MARK: - BonsplitDelegate
extension Workspace: BonsplitDelegate { extension Workspace: BonsplitDelegate {
@MainActor
private func shouldCloseWorkspaceOnLastSurface(for tabId: TabID) -> Bool {
let manager = owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: id) ?? AppDelegate.shared?.tabManager
guard panels.count <= 1,
panelIdFromSurfaceId(tabId) != nil,
let manager,
manager.tabs.contains(where: { $0.id == id }) else {
return false
}
return true
}
@MainActor @MainActor
private func confirmClosePanel(for tabId: TabID) async -> Bool { private func confirmClosePanel(for tabId: TabID) async -> Bool {
let alert = NSAlert() let alert = NSAlert()
@ -4129,6 +4209,16 @@ extension Workspace: BonsplitDelegate {
alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close")) alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
if let closeButton = alert.buttons.first {
closeButton.keyEquivalent = "\r"
closeButton.keyEquivalentModifierMask = []
alert.window.defaultButtonCell = closeButton.cell as? NSButtonCell
alert.window.initialFirstResponder = closeButton
}
if let cancelButton = alert.buttons.dropFirst().first {
cancelButton.keyEquivalent = "\u{1b}"
}
// Prefer a sheet if we can find a window, otherwise fall back to modal. // Prefer a sheet if we can find a window, otherwise fall back to modal.
if let window = NSApp.keyWindow ?? NSApp.mainWindow { if let window = NSApp.keyWindow ?? NSApp.mainWindow {
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
@ -4522,6 +4612,8 @@ extension Workspace: BonsplitDelegate {
} }
} }
let explicitUserClose = explicitUserCloseTabIds.remove(tab.id) != nil
if forceCloseTabIds.contains(tab.id) { if forceCloseTabIds.contains(tab.id) {
stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane) stageClosedBrowserRestoreSnapshotIfNeeded(for: tab, inPane: pane)
recordPostCloseSelection() recordPostCloseSelection()
@ -4535,6 +4627,12 @@ extension Workspace: BonsplitDelegate {
return false return false
} }
if explicitUserClose && shouldCloseWorkspaceOnLastSurface(for: tab.id) {
clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
owningTabManager?.closeWorkspaceWithConfirmation(self)
return false
}
// Check if the panel needs close confirmation // Check if the panel needs close confirmation
guard let panelId = panelIdFromSurfaceId(tab.id), guard let panelId = panelIdFromSurfaceId(tab.id),
let terminalPanel = terminalPanel(for: panelId) else { let terminalPanel = terminalPanel(for: panelId) else {
@ -4546,7 +4644,7 @@ extension Workspace: BonsplitDelegate {
// If confirmation is required, Bonsplit will call into this delegate and we must return false. // If confirmation is required, Bonsplit will call into this delegate and we must return false.
// Show an app-level confirmation, then re-attempt the close with forceCloseTabIds to bypass // Show an app-level confirmation, then re-attempt the close with forceCloseTabIds to bypass
// this gating on the second pass. // this gating on the second pass.
if terminalPanel.needsConfirmClose() { if panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
clearStagedClosedBrowserRestoreSnapshot(for: tab.id) clearStagedClosedBrowserRestoreSnapshot(for: tab.id)
if pendingCloseConfirmTabIds.contains(tab.id) { if pendingCloseConfirmTabIds.contains(tab.id) {
return false return false
@ -4646,6 +4744,7 @@ extension Workspace: BonsplitDelegate {
manualUnreadPanelIds.remove(panelId) manualUnreadPanelIds.remove(panelId)
manualUnreadMarkedAt.removeValue(forKey: panelId) manualUnreadMarkedAt.removeValue(forKey: panelId)
panelSubscriptions.removeValue(forKey: panelId) panelSubscriptions.removeValue(forKey: panelId)
panelShellActivityStates.removeValue(forKey: panelId)
surfaceTTYNames.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId)
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
@ -4653,6 +4752,7 @@ extension Workspace: BonsplitDelegate {
if lastTerminalConfigInheritancePanelId == panelId { if lastTerminalConfigInheritancePanelId == panelId {
lastTerminalConfigInheritancePanelId = nil lastTerminalConfigInheritancePanelId = nil
} }
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId)
// Keep the workspace invariant for normal close paths. // Keep the workspace invariant for normal close paths.
// Detach/move flows intentionally allow a temporary empty workspace so AppDelegate can // Detach/move flows intentionally allow a temporary empty workspace so AppDelegate can
@ -4824,6 +4924,7 @@ extension Workspace: BonsplitDelegate {
pinnedPanelIds.remove(panelId) pinnedPanelIds.remove(panelId)
manualUnreadPanelIds.remove(panelId) manualUnreadPanelIds.remove(panelId)
panelSubscriptions.removeValue(forKey: panelId) panelSubscriptions.removeValue(forKey: panelId)
panelShellActivityStates.removeValue(forKey: panelId)
surfaceTTYNames.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId)
surfaceListeningPorts.removeValue(forKey: panelId) surfaceListeningPorts.removeValue(forKey: panelId)
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId) restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
@ -4861,7 +4962,7 @@ extension Workspace: BonsplitDelegate {
if forceCloseTabIds.contains(tab.id) { continue } if forceCloseTabIds.contains(tab.id) { continue }
if let panelId = panelIdFromSurfaceId(tab.id), if let panelId = panelIdFromSurfaceId(tab.id),
let terminalPanel = terminalPanel(for: panelId), let terminalPanel = terminalPanel(for: panelId),
terminalPanel.needsConfirmClose() { panelNeedsConfirmClose(panelId: panelId, fallbackNeedsConfirmClose: terminalPanel.needsConfirmClose()) {
pendingPaneClosePanelIds.removeValue(forKey: pane.id) pendingPaneClosePanelIds.removeValue(forKey: pane.id)
return false return false
} }

View file

@ -452,8 +452,9 @@ struct cmuxApp: App {
Divider() Divider()
// Terminal semantics: // Terminal semantics:
// Cmd+W closes the focused tab (with confirmation if needed). If this is the last // Cmd+W closes the focused tab/surface (with confirmation if needed). When that
// tab in the last workspace, it closes the window. // was the last surface in the workspace, cmux removes the workspace and closes
// the window if it was also the last workspace.
Button(String(localized: "menu.file.closeTab", defaultValue: "Close Tab")) { Button(String(localized: "menu.file.closeTab", defaultValue: "Close Tab")) {
closePanelOrWindow() closePanelOrWindow()
} }
@ -881,11 +882,7 @@ struct cmuxApp: App {
in manager: TabManager, in manager: TabManager,
allowPinned: Bool allowPinned: Bool
) { ) {
for workspaceId in workspaceIds { manager.closeWorkspacesWithConfirmation(workspaceIds, allowPinned: allowPinned)
guard let workspace = manager.tabs.first(where: { $0.id == workspaceId }) else { continue }
guard allowPinned || !workspace.isPinned else { continue }
manager.closeWorkspaceWithConfirmation(workspace)
}
} }
private func closeOtherSelectedWorkspacePeers(in manager: TabManager) { private func closeOtherSelectedWorkspacePeers(in manager: TabManager) {
@ -3070,6 +3067,9 @@ struct SettingsView: View {
private var notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath private var notificationSoundCustomFilePath = NotificationSoundSettings.defaultCustomFilePath
@AppStorage(NotificationSoundSettings.customCommandKey) private var notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand @AppStorage(NotificationSoundSettings.customCommandKey) private var notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
@AppStorage(NotificationPaneRingSettings.enabledKey) private var notificationPaneRingEnabled = NotificationPaneRingSettings.defaultEnabled
@AppStorage(NotificationPaneFlashSettings.enabledKey) private var notificationPaneFlashEnabled = NotificationPaneFlashSettings.defaultEnabled
@AppStorage(MenuBarExtraSettings.showInMenuBarKey) private var showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar
@AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
@ -3426,14 +3426,6 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 14) {
SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App")) SettingsSectionHeader(title: String(localized: "settings.section.app", defaultValue: "App"))
SettingsCard { SettingsCard {
SettingsPickerRow(String(localized: "settings.app.theme", defaultValue: "Theme"), controlWidth: pickerColumnWidth, selection: $appearanceMode) {
ForEach(AppearanceMode.visibleCases) { mode in
Text(mode.displayName).tag(mode.rawValue)
}
}
SettingsCardDivider()
SettingsCardRow( SettingsCardRow(
String(localized: "settings.app.language", defaultValue: "Language"), String(localized: "settings.app.language", defaultValue: "Language"),
subtitle: appLanguage != LanguageSettings.languageAtLaunch.rawValue subtitle: appLanguage != LanguageSettings.languageAtLaunch.rawValue
@ -3465,6 +3457,15 @@ struct SettingsView: View {
SettingsCardDivider() SettingsCardDivider()
ThemePickerRow(
selectedMode: appearanceMode,
onSelect: { mode in
appearanceMode = mode.rawValue
}
)
SettingsCardDivider()
AppIconPickerRow( AppIconPickerRow(
selectedMode: appIconMode, selectedMode: appIconMode,
onSelect: { mode in onSelect: { mode in
@ -3510,6 +3511,48 @@ struct SettingsView: View {
SettingsCardDivider() SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar"),
subtitle: String(localized: "settings.app.showInMenuBar.subtitle", defaultValue: "Keep cmux in the menu bar for unread notifications and quick actions.")
) {
Toggle("", isOn: $showMenuBarExtra)
.labelsHidden()
.controlSize(.small)
.accessibilityLabel(
String(localized: "settings.app.showInMenuBar", defaultValue: "Show in Menu Bar")
)
}
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.notifications.paneRing.title", defaultValue: "Unread Pane Ring"),
subtitle: String(localized: "settings.notifications.paneRing.subtitle", defaultValue: "Show a blue ring around panes with unread notifications.")
) {
Toggle("", isOn: $notificationPaneRingEnabled)
.labelsHidden()
.controlSize(.small)
.accessibilityLabel(
String(localized: "settings.notifications.paneRing.title", defaultValue: "Unread Pane Ring")
)
}
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.notifications.paneFlash.title", defaultValue: "Pane Flash"),
subtitle: String(localized: "settings.notifications.paneFlash.subtitle", defaultValue: "Briefly flash a blue outline when cmux highlights a pane.")
) {
Toggle("", isOn: $notificationPaneFlashEnabled)
.labelsHidden()
.controlSize(.small)
.accessibilityLabel(
String(localized: "settings.notifications.paneFlash.title", defaultValue: "Pane Flash")
)
}
SettingsCardDivider()
SettingsCardRow( SettingsCardRow(
"Desktop Notifications", "Desktop Notifications",
subtitle: notificationPermissionSubtitle subtitle: notificationPermissionSubtitle
@ -4407,6 +4450,9 @@ struct SettingsView: View {
notificationCustomSoundErrorAlertMessage = "" notificationCustomSoundErrorAlertMessage = ""
notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand notificationCustomCommand = NotificationSoundSettings.defaultCustomCommand
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
notificationPaneRingEnabled = NotificationPaneRingSettings.defaultEnabled
notificationPaneFlashEnabled = NotificationPaneFlashSettings.defaultEnabled
showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
ShortcutHintDebugSettings.resetVisibilityDefaults() ShortcutHintDebugSettings.resetVisibilityDefaults()
@ -4683,6 +4729,193 @@ private struct SettingsCardNote: View {
} }
} }
private struct ThemeWindowThumbnail: View {
let isDark: Bool
var body: some View {
GeometryReader { geo in
let width = geo.size.width
let height = geo.size.height
ZStack {
// Wallpaper background
if isDark {
LinearGradient(
colors: [Color(red: 0.1, green: 0.1, blue: 0.3), Color(red: 0.05, green: 0.05, blue: 0.1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Path { path in
path.move(to: CGPoint(x: 0, y: height * 0.5))
path.addQuadCurve(to: CGPoint(x: width, y: height), control: CGPoint(x: width * 0.5, y: height * 0.2))
path.addLine(to: CGPoint(x: width, y: 0))
path.addLine(to: CGPoint(x: 0, y: 0))
}
.fill(LinearGradient(colors: [Color(red: 0.2, green: 0.2, blue: 0.6).opacity(0.5), .clear], startPoint: .topLeading, endPoint: .bottomTrailing))
} else {
LinearGradient(
colors: [Color(red: 0.6, green: 0.8, blue: 0.95), Color(red: 0.2, green: 0.4, blue: 0.8)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Path { path in
path.move(to: CGPoint(x: 0, y: height * 0.5))
path.addQuadCurve(to: CGPoint(x: width, y: height), control: CGPoint(x: width * 0.5, y: height * 0.2))
path.addLine(to: CGPoint(x: width, y: 0))
path.addLine(to: CGPoint(x: 0, y: 0))
}
.fill(LinearGradient(colors: [Color(red: 0.8, green: 0.9, blue: 1.0).opacity(0.6), .clear], startPoint: .topLeading, endPoint: .bottomTrailing))
}
// Menu bar
VStack(spacing: 0) {
HStack {
Image(systemName: "applelogo")
.font(.system(size: max(height * 0.08, 6)))
.foregroundColor(isDark ? .white : .black)
.opacity(0.8)
Spacer()
}
.padding(.horizontal, max(width * 0.04, 4))
.frame(height: max(height * 0.12, 8))
.background(.ultraThinMaterial)
Spacer()
}
// Back window
VStack(spacing: 0) {
Rectangle()
.fill(isDark ? Color(white: 0.2) : Color(white: 0.9))
.frame(height: max(height * 0.15, 8))
ZStack(alignment: .top) {
Rectangle()
.fill(isDark ? Color(white: 0.15) : Color(white: 0.98))
RoundedRectangle(cornerRadius: max(width * 0.02, 2), style: .continuous)
.fill(Color.accentColor)
.frame(height: max(height * 0.12, 6))
.padding(max(width * 0.04, 4))
}
}
.clipShape(RoundedRectangle(cornerRadius: max(width * 0.04, 4), style: .continuous))
.frame(width: width * 0.65, height: height * 0.45)
.shadow(color: .black.opacity(isDark ? 0.4 : 0.15), radius: 4, x: 0, y: 2)
.offset(x: -width * 0.08, y: -height * 0.1)
// Front window with traffic lights
VStack(spacing: 0) {
ZStack {
Rectangle()
.fill(isDark ? Color(white: 0.18) : Color(white: 0.92))
HStack(spacing: max(width * 0.025, 2)) {
Circle().fill(Color(red: 1.0, green: 0.37, blue: 0.34)).frame(width: max(width * 0.04, 3))
Circle().fill(Color(red: 1.0, green: 0.74, blue: 0.18)).frame(width: max(width * 0.04, 3))
Circle().fill(Color(red: 0.15, green: 0.79, blue: 0.25)).frame(width: max(width * 0.04, 3))
Spacer()
}
.padding(.horizontal, max(width * 0.04, 4))
}
.frame(height: max(height * 0.18, 10))
Rectangle()
.fill(isDark ? Color(white: 0.1) : .white)
}
.clipShape(RoundedRectangle(cornerRadius: max(width * 0.05, 5), style: .continuous))
.shadow(color: .black.opacity(isDark ? 0.5 : 0.2), radius: 6, x: 0, y: 3)
.frame(width: width * 0.75, height: height * 0.55)
.offset(x: width * 0.12, y: height * 0.2)
}
}
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
)
}
}
private struct ThemePickerRow: View {
let selectedMode: String
let onSelect: (AppearanceMode) -> Void
private let thumbWidth: CGFloat = 76
private let thumbHeight: CGFloat = 50
var body: some View {
HStack(alignment: .top, spacing: 12) {
Text(String(localized: "settings.app.theme", defaultValue: "Theme"))
.font(.system(size: 13, weight: .medium))
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 8) {
ForEach(AppearanceMode.visibleCases) { mode in
let isSelected = selectedMode == mode.rawValue
Button {
onSelect(mode)
} label: {
VStack(spacing: 4) {
Group {
if mode == .system {
ZStack {
ThemeWindowThumbnail(isDark: false)
.mask(
GeometryReader { geo in
Rectangle()
.frame(width: geo.size.width / 2, height: geo.size.height)
.position(x: geo.size.width / 4, y: geo.size.height / 2)
}
)
ThemeWindowThumbnail(isDark: true)
.mask(
GeometryReader { geo in
Rectangle()
.frame(width: geo.size.width / 2, height: geo.size.height)
.position(x: geo.size.width * 0.75, y: geo.size.height / 2)
}
)
GeometryReader { geo in
Rectangle()
.fill(Color.primary.opacity(0.15))
.frame(width: 1, height: geo.size.height)
.position(x: geo.size.width / 2, y: geo.size.height / 2)
}
}
} else {
ThemeWindowThumbnail(isDark: mode == .dark)
}
}
.frame(width: thumbWidth, height: thumbHeight)
Text(mode.displayName)
.font(.system(size: 10))
.fontWeight(isSelected ? .semibold : .regular)
.foregroundColor(isSelected ? .primary : .secondary)
}
.padding(.vertical, 8)
.padding(.horizontal, 10)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(isSelected
? Color.accentColor.opacity(0.12)
: Color.clear)
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
)
}
.buttonStyle(.plain)
.focusable(false)
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
.layoutPriority(1)
}
.padding(.horizontal, 14)
.padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct AppIconPickerRow: View { private struct AppIconPickerRow: View {
let selectedMode: String let selectedMode: String
let onSelect: (AppIconMode) -> Void let onSelect: (AppIconMode) -> Void
@ -4691,20 +4924,25 @@ private struct AppIconPickerRow: View {
private let autoIconSize: CGFloat = 36 private let autoIconSize: CGFloat = 36
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top, spacing: 12) {
Text(String(localized: "settings.app.appIcon", defaultValue: "App Icon")) VStack(alignment: .leading, spacing: 3) {
.font(.system(size: 13, weight: .medium)) Text(String(localized: "settings.app.appIcon", defaultValue: "App Icon"))
.font(.system(size: 13, weight: .medium))
Text(String(localized: "settings.app.appIcon.subtitle", defaultValue: "Dock and app switcher"))
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 12) { HStack(spacing: 8) {
ForEach(AppIconMode.allCases) { mode in ForEach(AppIconMode.allCases) { mode in
let isSelected = selectedMode == mode.rawValue let isSelected = selectedMode == mode.rawValue
Button { Button {
onSelect(mode) onSelect(mode)
} label: { } label: {
VStack(spacing: 6) { VStack(spacing: 4) {
Group { Group {
if mode == .automatic { if mode == .automatic {
// Show both icons overlapping
ZStack { ZStack {
Image("AppIconLight") Image("AppIconLight")
.resizable() .resizable()
@ -4730,25 +4968,29 @@ private struct AppIconPickerRow: View {
} }
Text(mode.displayName) Text(mode.displayName)
.font(.system(size: 11)) .font(.system(size: 10))
.foregroundColor(isSelected ? .primary : .secondary) .foregroundColor(isSelected ? .primary : .secondary)
} }
.padding(.vertical, 8) .padding(.vertical, 8)
.padding(.horizontal, 12) .padding(.horizontal, 10)
.contentShape(Rectangle())
.background( .background(
RoundedRectangle(cornerRadius: 10, style: .continuous) RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(isSelected .fill(isSelected
? Color.accentColor.opacity(0.12) ? Color.accentColor.opacity(0.12)
: Color.clear) : Color.clear)
) )
.overlay( .overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous) RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.focusable(false)
.accessibilityAddTraits(isSelected ? .isSelected : [])
} }
} }
.layoutPriority(1)
} }
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 9) .padding(.vertical, 9)

View file

@ -235,6 +235,113 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Menu-driven add workspace should still route to key window context when object-key lookup misses") XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Menu-driven add workspace should still route to key window context when object-key lookup misses")
} }
func testAddWorkspaceInPreferredMainWindowPrunesOrphanedContextWithoutLiveWindow() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let orphanWindowId = UUID()
let orphanManager = TabManager()
let orphanSidebarState = SidebarState()
let orphanSidebarSelectionState = SidebarSelectionState()
autoreleasepool {
var orphanWindow: NSWindow? = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
orphanWindow?.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(orphanWindowId.uuidString)")
appDelegate.registerMainWindow(
orphanWindow!,
windowId: orphanWindowId,
tabManager: orphanManager,
sidebarState: orphanSidebarState,
sidebarSelectionState: orphanSidebarSelectionState
)
orphanWindow = nil
}
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
XCTAssertNil(appDelegate.mainWindow(for: orphanWindowId), "Test precondition: orphaned context should not have a live window")
let orphanCount = orphanManager.tabs.count
XCTAssertNil(
appDelegate.addWorkspaceInPreferredMainWindow(),
"Workspace creation should refuse orphaned contexts with no live window"
)
XCTAssertEqual(orphanManager.tabs.count, orphanCount, "Orphaned manager must not receive a new workspace")
XCTAssertNil(appDelegate.tabManagerFor(windowId: orphanWindowId), "Orphaned context should be pruned after failed resolution")
}
func testCustomCmdTNewWorkspacePrunesOrphanedContextWithoutLiveWindow() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let existingWindowIds = mainWindowIds()
let orphanWindowId = UUID()
let orphanManager = TabManager()
let orphanSidebarState = SidebarState()
let orphanSidebarSelectionState = SidebarSelectionState()
autoreleasepool {
var orphanWindow: NSWindow? = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false
)
orphanWindow?.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(orphanWindowId.uuidString)")
appDelegate.registerMainWindow(
orphanWindow!,
windowId: orphanWindowId,
tabManager: orphanManager,
sidebarState: orphanSidebarState,
sidebarSelectionState: orphanSidebarSelectionState
)
orphanWindow = nil
}
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
XCTAssertNil(appDelegate.mainWindow(for: orphanWindowId), "Test precondition: orphaned context should not have a live window")
let orphanCount = orphanManager.tabs.count
let remappedCmdT = StoredShortcut(key: "t", command: true, shift: false, option: false, control: false)
withTemporaryShortcut(action: .newTab, shortcut: remappedCmdT) {
guard let event = makeKeyDownEvent(
key: "t",
modifiers: [.command],
keyCode: 17, // kVK_ANSI_T
windowNumber: 0
) else {
XCTFail("Failed to construct remapped 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(orphanManager.tabs.count, orphanCount, "Orphaned manager must not receive a new workspace from remapped Cmd+T")
XCTAssertNil(appDelegate.tabManagerFor(windowId: orphanWindowId), "Remapped Cmd+T should prune the orphaned context after failed resolution")
let createdWindowIds = mainWindowIds().subtracting(existingWindowIds)
for windowId in createdWindowIds {
closeWindow(withId: windowId)
}
}
func testCmdDigitRoutesToEventWindowWhenActiveManagerIsStale() { func testCmdDigitRoutesToEventWindowWhenActiveManagerIsStale() {
guard let appDelegate = AppDelegate.shared else { guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared") XCTFail("Expected AppDelegate.shared")
@ -422,6 +529,48 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertNil(self.window(withId: windowId), "Confirming Cmd+Ctrl+W should close the window") XCTAssertNil(self.window(withId: windowId), "Confirming Cmd+Ctrl+W should close the window")
} }
func testCmdWClosesWindowWhenClosingLastSurfaceInLastWorkspace() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let targetWindow = window(withId: windowId),
let manager = appDelegate.tabManagerFor(windowId: windowId) else {
XCTFail("Expected test window and manager")
return
}
XCTAssertEqual(manager.tabs.count, 1)
XCTAssertEqual(manager.tabs[0].panels.count, 1)
guard let event = makeKeyDownEvent(
key: "w",
modifiers: [.command],
keyCode: 13,
windowNumber: targetWindow.windowNumber
) else {
XCTFail("Failed to construct Cmd+W 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))
XCTAssertNil(
self.window(withId: windowId),
"Cmd+W on the last surface in the last workspace should close the window"
)
}
func testCmdPhysicalIWithDvorakCharactersDoesNotTriggerShowNotifications() { func testCmdPhysicalIWithDvorakCharactersDoesNotTriggerShowNotifications() {
guard let appDelegate = AppDelegate.shared else { guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared") XCTFail("Expected AppDelegate.shared")
@ -2336,6 +2485,16 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier }) return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier })
} }
private func mainWindowIds() -> Set<UUID> {
Set(NSApp.windows.compactMap { window in
guard let raw = window.identifier?.rawValue,
raw.hasPrefix("cmux.main.") else {
return nil
}
return UUID(uuidString: String(raw.dropFirst("cmux.main.".count)))
})
}
private func closeWindow(withId windowId: UUID) { private func closeWindow(withId windowId: UUID) {
guard let window = window(withId: windowId) else { return } guard let window = window(withId: windowId) else { return }
window.performClose(nil) window.performClose(nil)

View file

@ -1,6 +1,7 @@
import XCTest import XCTest
import AppKit import AppKit
import SwiftUI import SwiftUI
import UniformTypeIdentifiers
import WebKit import WebKit
import SwiftUI import SwiftUI
import ObjectiveC.runtime import ObjectiveC.runtime
@ -56,6 +57,14 @@ private func installCmuxUnitTestInspectorOverride() {
cmuxUnitTestInspectorOverrideInstalled = true cmuxUnitTestInspectorOverrideInstalled = true
} }
private func drainMainQueue() {
let expectation = XCTestExpectation(description: "drain main queue")
DispatchQueue.main.async {
expectation.fulfill()
}
XCTWaiter().wait(for: [expectation], timeout: 1.0)
}
final class SplitShortcutTransientFocusGuardTests: XCTestCase { final class SplitShortcutTransientFocusGuardTests: XCTestCase {
func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsTiny() { func testSuppressesWhenFirstResponderFallsBackAndHostedViewIsTiny() {
XCTAssertTrue( XCTAssertTrue(
@ -864,6 +873,163 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
} }
} }
@MainActor
final class GhosttyPasteboardHelperTests: XCTestCase {
func testHTMLOnlyPasteboardExtractsPlainText() {
let pasteboard = NSPasteboard(name: .init("cmux-test-html-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.setString("<p>Hello <strong>world</strong></p>", forType: .html)
XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello world")
XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
}
func testImageHTMLClipboardFallsBackToImagePath() throws {
let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.setString("<meta charset='utf-8'><img src=\"https://example.com/keyboard.png\">", forType: .html)
let image = NSImage(size: NSSize(width: 1, height: 1))
image.lockFocus()
NSColor.red.setFill()
NSRect(x: 0, y: 0, width: 1, height: 1).fill()
image.unlockFocus()
let tiffData = try XCTUnwrap(image.tiffRepresentation)
let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData))
let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:]))
pasteboard.setData(pngData, forType: .png)
XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard))
let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard))
defer { try? FileManager.default.removeItem(atPath: imagePath) }
XCTAssertTrue(imagePath.hasSuffix(".png"))
XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath))
}
func testImageHTMLClipboardWithVisibleTextPrefersText() throws {
let pasteboard = NSPasteboard(name: .init("cmux-test-image-html-text-\(UUID().uuidString)"))
pasteboard.clearContents()
pasteboard.setString("<p>Hello <img src=\"https://example.com/keyboard.png\"></p>", forType: .html)
let image = NSImage(size: NSSize(width: 1, height: 1))
image.lockFocus()
NSColor.blue.setFill()
NSRect(x: 0, y: 0, width: 1, height: 1).fill()
image.unlockFocus()
let tiffData = try XCTUnwrap(image.tiffRepresentation)
let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData))
let pngData = try XCTUnwrap(bitmap.representation(using: .png, properties: [:]))
pasteboard.setData(pngData, forType: .png)
XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello")
XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
}
func testJPEGClipboardFallsBackToImagePath() throws {
let pasteboard = NSPasteboard(name: .init("cmux-test-jpeg-\(UUID().uuidString)"))
pasteboard.clearContents()
let image = NSImage(size: NSSize(width: 1, height: 1))
image.lockFocus()
NSColor.green.setFill()
NSRect(x: 0, y: 0, width: 1, height: 1).fill()
image.unlockFocus()
let tiffData = try XCTUnwrap(image.tiffRepresentation)
let bitmap = try XCTUnwrap(NSBitmapImageRep(data: tiffData))
let jpegData = try XCTUnwrap(
bitmap.representation(
using: .jpeg,
properties: [.compressionFactor: 1.0]
)
)
pasteboard.setData(
jpegData,
forType: NSPasteboard.PasteboardType(UTType.jpeg.identifier)
)
let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard))
defer { try? FileManager.default.removeItem(atPath: imagePath) }
XCTAssertTrue(imagePath.hasSuffix(".jpeg"))
XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath))
}
func testAttachmentOnlyRTFDClipboardFallsBackToImagePath() throws {
let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-attachment-\(UUID().uuidString)"))
pasteboard.clearContents()
let image = NSImage(size: NSSize(width: 1, height: 1))
image.lockFocus()
NSColor.orange.setFill()
NSRect(x: 0, y: 0, width: 1, height: 1).fill()
image.unlockFocus()
let attachment = NSTextAttachment()
attachment.image = image
let attributed = NSAttributedString(attachment: attachment)
let data = try attributed.data(
from: NSRange(location: 0, length: attributed.length),
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
)
pasteboard.setData(data, forType: .rtfd)
XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard))
let imagePath = try XCTUnwrap(cmuxPasteboardImagePathForTesting(pasteboard))
defer { try? FileManager.default.removeItem(atPath: imagePath) }
XCTAssertTrue(imagePath.hasSuffix(".tiff"))
XCTAssertTrue(FileManager.default.fileExists(atPath: imagePath))
}
func testAttachmentOnlyRTFDNonImageClipboardDoesNotFallBackToImagePath() throws {
let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-non-image-\(UUID().uuidString)"))
pasteboard.clearContents()
let wrapper = FileWrapper(regularFileWithContents: Data("hello".utf8))
wrapper.preferredFilename = "note.txt"
let attachment = NSTextAttachment(fileWrapper: wrapper)
let attributed = NSAttributedString(attachment: attachment)
let data = try attributed.data(
from: NSRange(location: 0, length: attributed.length),
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
)
pasteboard.setData(data, forType: .rtfd)
XCTAssertNil(cmuxPasteboardStringContentsForTesting(pasteboard))
XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
}
func testRTFDClipboardWithVisibleTextPrefersText() throws {
let pasteboard = NSPasteboard(name: .init("cmux-test-rtfd-text-\(UUID().uuidString)"))
pasteboard.clearContents()
let image = NSImage(size: NSSize(width: 1, height: 1))
image.lockFocus()
NSColor.purple.setFill()
NSRect(x: 0, y: 0, width: 1, height: 1).fill()
image.unlockFocus()
let attachment = NSTextAttachment()
attachment.image = image
let attributed = NSMutableAttributedString(string: "Hello ")
attributed.append(NSAttributedString(attachment: attachment))
let data = try attributed.data(
from: NSRange(location: 0, length: attributed.length),
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
)
pasteboard.setData(data, forType: .rtfd)
XCTAssertEqual(cmuxPasteboardStringContentsForTesting(pasteboard), "Hello")
XCTAssertNil(cmuxPasteboardImagePathForTesting(pasteboard))
}
}
@MainActor @MainActor
final class AppDelegateWindowContextRoutingTests: XCTestCase { final class AppDelegateWindowContextRoutingTests: XCTestCase {
private func makeMainWindow(id: UUID) -> NSWindow { private func makeMainWindow(id: UUID) -> NSWindow {
@ -5063,6 +5229,54 @@ final class WorkspaceTeardownTests: XCTestCase {
} }
} }
@MainActor
final class WorkspaceSplitWorkingDirectoryTests: XCTestCase {
func testNewTerminalSplitFallsBackToRequestedWorkingDirectoryWhenReportedDirectoryIsStale() {
let workspace = Workspace()
guard let sourcePaneId = workspace.bonsplitController.focusedPaneId else {
XCTFail("Expected focused pane in new workspace")
return
}
let staleCurrentDirectory = workspace.currentDirectory
let requestedDirectory = "/tmp/cmux-requested-split-cwd-\(UUID().uuidString)"
guard let sourcePanel = workspace.newTerminalSurface(
inPane: sourcePaneId,
focus: false,
workingDirectory: requestedDirectory
) else {
XCTFail("Expected source terminal panel to be created")
return
}
XCTAssertEqual(sourcePanel.requestedWorkingDirectory, requestedDirectory)
XCTAssertNil(
workspace.panelDirectories[sourcePanel.id],
"Expected requested cwd to exist before shell integration reports a live cwd"
)
XCTAssertEqual(
workspace.currentDirectory,
staleCurrentDirectory,
"Expected focused workspace cwd to remain stale before panel directory updates"
)
guard let splitPanel = workspace.newTerminalSplit(
from: sourcePanel.id,
orientation: .horizontal,
focus: false
) else {
XCTFail("Expected split terminal panel to be created")
return
}
XCTAssertEqual(
splitPanel.requestedWorkingDirectory,
requestedDirectory,
"Expected split to inherit the source terminal's requested cwd when no reported cwd exists yet"
)
}
}
@MainActor @MainActor
final class TabManagerWorkspaceOwnershipTests: XCTestCase { final class TabManagerWorkspaceOwnershipTests: XCTestCase {
func testCloseWorkspaceIgnoresWorkspaceNotOwnedByManager() { func testCloseWorkspaceIgnoresWorkspaceNotOwnedByManager() {
@ -5084,6 +5298,297 @@ final class TabManagerWorkspaceOwnershipTests: XCTestCase {
} }
} }
@MainActor
final class TabManagerCloseWorkspacesWithConfirmationTests: XCTestCase {
func testCloseWorkspacesWithConfirmationPromptsOnceAndClosesAcceptedWorkspaces() {
let manager = TabManager()
let second = manager.addWorkspace()
let third = manager.addWorkspace()
manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha")
manager.setCustomTitle(tabId: second.id, title: "Beta")
manager.setCustomTitle(tabId: third.id, title: "Gamma")
var prompts: [(title: String, message: String, acceptCmdD: Bool)] = []
manager.confirmCloseHandler = { title, message, acceptCmdD in
prompts.append((title, message, acceptCmdD))
return true
}
manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true)
let expectedMessage = String(
format: String(
localized: "dialog.closeWorkspaces.message",
defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@"
),
locale: .current,
Int64(2),
"• Alpha\n• Beta"
)
XCTAssertEqual(prompts.count, 1, "Expected a single confirmation prompt for multi-close")
XCTAssertEqual(
prompts.first?.title,
String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?")
)
XCTAssertEqual(prompts.first?.message, expectedMessage)
XCTAssertEqual(prompts.first?.acceptCmdD, false)
XCTAssertEqual(manager.tabs.map(\.title), ["Gamma"])
}
func testCloseWorkspacesWithConfirmationKeepsWorkspacesWhenCancelled() {
let manager = TabManager()
let second = manager.addWorkspace()
manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha")
manager.setCustomTitle(tabId: second.id, title: "Beta")
var prompts: [(title: String, message: String, acceptCmdD: Bool)] = []
manager.confirmCloseHandler = { title, message, acceptCmdD in
prompts.append((title, message, acceptCmdD))
return false
}
manager.closeWorkspacesWithConfirmation([manager.tabs[0].id, second.id], allowPinned: true)
let expectedMessage = String(
format: String(
localized: "dialog.closeWorkspacesWindow.message",
defaultValue: "This will close the current window, its %1$lld workspaces, and all of their panels:\n%2$@"
),
locale: .current,
Int64(2),
"• Alpha\n• Beta"
)
XCTAssertEqual(prompts.count, 1)
XCTAssertEqual(
prompts.first?.title,
String(localized: "dialog.closeWindow.title", defaultValue: "Close window?")
)
XCTAssertEqual(prompts.first?.message, expectedMessage)
XCTAssertEqual(prompts.first?.acceptCmdD, true)
XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta"])
}
func testCloseCurrentWorkspaceWithConfirmationUsesSidebarMultiSelection() {
let manager = TabManager()
let second = manager.addWorkspace()
let third = manager.addWorkspace()
manager.setCustomTitle(tabId: manager.tabs[0].id, title: "Alpha")
manager.setCustomTitle(tabId: second.id, title: "Beta")
manager.setCustomTitle(tabId: third.id, title: "Gamma")
manager.selectWorkspace(second)
manager.setSidebarSelectedWorkspaceIds([manager.tabs[0].id, second.id])
var prompts: [(title: String, message: String, acceptCmdD: Bool)] = []
manager.confirmCloseHandler = { title, message, acceptCmdD in
prompts.append((title, message, acceptCmdD))
return false
}
manager.closeCurrentWorkspaceWithConfirmation()
let expectedMessage = String(
format: String(
localized: "dialog.closeWorkspaces.message",
defaultValue: "This will close %1$lld workspaces and all of their panels:\n%2$@"
),
locale: .current,
Int64(2),
"• Alpha\n• Beta"
)
XCTAssertEqual(prompts.count, 1, "Expected Cmd+Shift+W path to reuse the multi-close summary dialog")
XCTAssertEqual(
prompts.first?.title,
String(localized: "dialog.closeWorkspaces.title", defaultValue: "Close workspaces?")
)
XCTAssertEqual(prompts.first?.message, expectedMessage)
XCTAssertEqual(prompts.first?.acceptCmdD, false)
XCTAssertEqual(manager.tabs.map(\.title), ["Alpha", "Beta", "Gamma"])
}
}
@MainActor
final class TabManagerCloseCurrentPanelTests: XCTestCase {
func testRuntimeCloseSkipsConfirmationWhenShellReportsPromptIdle() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let panelId = workspace.focusedPanelId,
let terminalPanel = workspace.terminalPanel(for: panelId) else {
XCTFail("Expected selected workspace and focused terminal panel")
return
}
terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(true)
workspace.updatePanelShellActivityState(panelId: panelId, state: .promptIdle)
var promptCount = 0
manager.confirmCloseHandler = { _, _, _ in
promptCount += 1
return false
}
manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId)
drainMainQueue()
drainMainQueue()
XCTAssertEqual(promptCount, 0, "Runtime closes should honor prompt-idle shell state")
XCTAssertNil(workspace.panels[panelId], "Expected the original panel to close")
XCTAssertEqual(workspace.panels.count, 1, "Expected a replacement surface after closing the last panel")
}
func testRuntimeClosePromptsWhenShellReportsRunningCommand() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let panelId = workspace.focusedPanelId,
let terminalPanel = workspace.terminalPanel(for: panelId) else {
XCTFail("Expected selected workspace and focused terminal panel")
return
}
terminalPanel.surface.setNeedsConfirmCloseOverrideForTesting(false)
workspace.updatePanelShellActivityState(panelId: panelId, state: .commandRunning)
var promptCount = 0
manager.confirmCloseHandler = { _, _, _ in
promptCount += 1
return false
}
manager.closeRuntimeSurfaceWithConfirmation(tabId: workspace.id, surfaceId: panelId)
XCTAssertEqual(promptCount, 1, "Running commands should still require confirmation")
XCTAssertNotNil(workspace.panels[panelId], "Prompt rejection should keep the original panel open")
}
func testCloseCurrentPanelClosesWorkspaceWhenItOwnsTheLastSurface() {
let manager = TabManager()
let firstWorkspace = manager.tabs[0]
let secondWorkspace = manager.addWorkspace()
manager.selectWorkspace(secondWorkspace)
guard let secondPanelId = secondWorkspace.focusedPanelId else {
XCTFail("Expected focused panel in selected workspace")
return
}
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
XCTAssertEqual(secondWorkspace.panels.count, 1)
manager.closeCurrentPanelWithConfirmation()
drainMainQueue()
drainMainQueue()
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
XCTAssertNil(secondWorkspace.panels[secondPanelId])
XCTAssertTrue(secondWorkspace.panels.isEmpty)
}
func testClosePanelButtonClosesWorkspaceWhenItOwnsTheLastSurface() {
let manager = TabManager()
let firstWorkspace = manager.tabs[0]
let secondWorkspace = manager.addWorkspace()
manager.selectWorkspace(secondWorkspace)
guard let secondPanelId = secondWorkspace.focusedPanelId else {
XCTFail("Expected focused panel in selected workspace")
return
}
XCTAssertEqual(manager.selectedTabId, secondWorkspace.id)
XCTAssertEqual(secondWorkspace.panels.count, 1)
guard let secondSurfaceId = secondWorkspace.surfaceIdFromPanelId(secondPanelId) else {
XCTFail("Expected bonsplit surface ID for focused panel")
return
}
secondWorkspace.markExplicitClose(surfaceId: secondSurfaceId)
XCTAssertFalse(secondWorkspace.closePanel(secondPanelId))
drainMainQueue()
drainMainQueue()
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id])
XCTAssertEqual(manager.selectedTabId, firstWorkspace.id)
XCTAssertNil(secondWorkspace.panels[secondPanelId])
XCTAssertTrue(secondWorkspace.panels.isEmpty)
}
func testGenericClosePanelKeepsWorkspaceOpenWithoutExplicitCloseMarker() {
let manager = TabManager()
guard let workspace = manager.selectedWorkspace,
let initialPanelId = workspace.focusedPanelId else {
XCTFail("Expected selected workspace and focused panel")
return
}
let initialWorkspaceId = workspace.id
XCTAssertEqual(manager.tabs.count, 1)
XCTAssertEqual(workspace.panels.count, 1)
XCTAssertTrue(workspace.closePanel(initialPanelId))
drainMainQueue()
drainMainQueue()
XCTAssertEqual(manager.tabs.count, 1)
XCTAssertEqual(manager.selectedTabId, initialWorkspaceId)
XCTAssertEqual(manager.tabs.first?.id, initialWorkspaceId)
XCTAssertNil(workspace.panels[initialPanelId])
XCTAssertEqual(workspace.panels.count, 1)
XCTAssertNotEqual(workspace.focusedPanelId, initialPanelId)
}
func testCloseCurrentPanelIgnoresStaleSurfaceId() {
let manager = TabManager()
let firstWorkspace = manager.tabs[0]
let secondWorkspace = manager.addWorkspace()
manager.closePanelWithConfirmation(tabId: secondWorkspace.id, surfaceId: UUID())
XCTAssertEqual(manager.tabs.map(\.id), [firstWorkspace.id, secondWorkspace.id])
}
func testCloseCurrentPanelClearsNotificationsForClosedSurface() {
let appDelegate = AppDelegate.shared ?? AppDelegate()
let manager = TabManager()
let store = TerminalNotificationStore.shared
let originalTabManager = appDelegate.tabManager
let originalNotificationStore = appDelegate.notificationStore
store.replaceNotificationsForTesting([])
store.configureNotificationDeliveryHandlerForTesting { _, _ in }
appDelegate.tabManager = manager
appDelegate.notificationStore = store
defer {
store.replaceNotificationsForTesting([])
store.resetNotificationDeliveryHandlerForTesting()
appDelegate.tabManager = originalTabManager
appDelegate.notificationStore = originalNotificationStore
}
guard let workspace = manager.selectedWorkspace,
let initialPanelId = workspace.focusedPanelId else {
XCTFail("Expected selected workspace and focused panel")
return
}
store.addNotification(
tabId: workspace.id,
surfaceId: initialPanelId,
title: "Unread",
subtitle: "",
body: ""
)
XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId))
manager.closeCurrentPanelWithConfirmation()
drainMainQueue()
drainMainQueue()
XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: initialPanelId))
}
}
@MainActor @MainActor
final class TabManagerPendingUnfocusPolicyTests: XCTestCase { final class TabManagerPendingUnfocusPolicyTests: XCTestCase {
func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() { func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() {
@ -7793,6 +8298,44 @@ final class NotificationDockBadgeTests: XCTestCase {
XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults)) XCTAssertTrue(NotificationBadgeSettings.isDockBadgeEnabled(defaults: defaults))
} }
func testNotificationPaneFlashPreferenceDefaultsToEnabled() {
let suiteName = "NotificationPaneFlashSettingsTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer {
defaults.removePersistentDomain(forName: suiteName)
}
XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults))
defaults.set(false, forKey: NotificationPaneFlashSettings.enabledKey)
XCTAssertFalse(NotificationPaneFlashSettings.isEnabled(defaults: defaults))
defaults.set(true, forKey: NotificationPaneFlashSettings.enabledKey)
XCTAssertTrue(NotificationPaneFlashSettings.isEnabled(defaults: defaults))
}
func testMenuBarExtraPreferenceDefaultsToVisible() {
let suiteName = "MenuBarExtraVisibilityTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer {
defaults.removePersistentDomain(forName: suiteName)
}
XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults))
defaults.set(false, forKey: MenuBarExtraSettings.showInMenuBarKey)
XCTAssertFalse(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults))
defaults.set(true, forKey: MenuBarExtraSettings.showInMenuBarKey)
XCTAssertTrue(MenuBarExtraSettings.showsMenuBarExtra(defaults: defaults))
}
func testNotificationSoundUsesSystemSoundForDefaultAndNamedSounds() { func testNotificationSoundUsesSystemSoundForDefaultAndNamedSounds() {
let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)" let suiteName = "NotificationDockBadgeTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else { guard let defaults = UserDefaults(suiteName: suiteName) else {
@ -10480,6 +11023,27 @@ final class InternalTabDragConfigurationTests: XCTestCase {
) )
} }
} }
@MainActor
final class InternalTabDragBundleDeclarationTests: XCTestCase {
private func exportedTypeIdentifiers(bundle: Bundle) -> Set<String> {
let declarations = (bundle.object(forInfoDictionaryKey: "UTExportedTypeDeclarations") as? [[String: Any]]) ?? []
return Set(declarations.compactMap { $0["UTTypeIdentifier"] as? String })
}
func testAppBundleExportsInternalDragTypes() {
let exported = exportedTypeIdentifiers(bundle: Bundle(for: AppDelegate.self))
XCTAssertTrue(
exported.contains("com.splittabbar.tabtransfer"),
"Expected app bundle to export bonsplit tab-transfer type, got \(exported)"
)
XCTAssertTrue(
exported.contains("com.cmux.sidebar-tab-reorder"),
"Expected app bundle to export sidebar tab-reorder type, got \(exported)"
)
}
}
#endif #endif
@MainActor @MainActor

View file

@ -0,0 +1,282 @@
import XCTest
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
@MainActor
final class WorkspaceStressProfileTests: XCTestCase {
private struct StressConfig {
let workspaceCount: Int
let tabsPerWorkspace: Int
let switchPasses: Int
let createP95BudgetMs: Double?
let switchP95BudgetMs: Double?
static func current(environment: [String: String] = ProcessInfo.processInfo.environment) -> StressConfig {
StressConfig(
workspaceCount: parseInt(environment["CMUX_WORKSPACE_STRESS_WORKSPACES"], default: 48, minimum: 2),
tabsPerWorkspace: parseInt(environment["CMUX_WORKSPACE_STRESS_TABS_PER_WORKSPACE"], default: 10, minimum: 1),
switchPasses: parseInt(environment["CMUX_WORKSPACE_STRESS_SWITCH_PASSES"], default: 6, minimum: 1),
createP95BudgetMs: parseDouble(environment["CMUX_WORKSPACE_STRESS_CREATE_P95_BUDGET_MS"]),
switchP95BudgetMs: parseDouble(environment["CMUX_WORKSPACE_STRESS_SWITCH_P95_BUDGET_MS"])
)
}
private static func parseInt(_ value: String?, default defaultValue: Int, minimum: Int) -> Int {
guard let value, let parsed = Int(value) else { return defaultValue }
return max(minimum, parsed)
}
private static func parseDouble(_ value: String?) -> Double? {
guard let value, let parsed = Double(value) else { return nil }
return parsed
}
}
private struct TimedSample {
let label: String
let elapsedMs: Double
}
private struct TimingSummary {
let count: Int
let averageMs: Double
let medianMs: Double
let p95Ms: Double
let maxMs: Double
let totalMs: Double
init(samples: [TimedSample]) {
let sorted = samples.map(\.elapsedMs).sorted()
count = sorted.count
totalMs = sorted.reduce(0, +)
averageMs = count > 0 ? totalMs / Double(count) : 0
medianMs = Self.percentile(0.50, in: sorted)
p95Ms = Self.percentile(0.95, in: sorted)
maxMs = sorted.last ?? 0
}
private static func percentile(_ percentile: Double, in sortedValues: [Double]) -> Double {
guard !sortedValues.isEmpty else { return 0 }
let clamped = min(max(percentile, 0), 1)
let index = Int((Double(sortedValues.count - 1) * clamped).rounded(.up))
return sortedValues[min(sortedValues.count - 1, max(0, index))]
}
}
func testWorkspaceCreationAndSwitchingStressProfile() {
let config = StressConfig.current()
let welcomeWasShown = UserDefaults.standard.object(forKey: WelcomeSettings.shownKey)
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey)
defer {
if let welcomeWasShown {
UserDefaults.standard.set(welcomeWasShown, forKey: WelcomeSettings.shownKey)
} else {
UserDefaults.standard.removeObject(forKey: WelcomeSettings.shownKey)
}
}
var creationSamples: [TimedSample] = []
var populationSamples: [TimedSample] = []
var switchSamples: [TimedSample] = []
var switchDispatchSamples: [TimedSample] = []
var switchFirstDrainSamples: [TimedSample] = []
var switchUnfocusSamples: [TimedSample] = []
var switchSecondDrainSamples: [TimedSample] = []
let manager = timed("workspace-000-create", collectInto: &creationSamples) {
TabManager()
}
guard let bootstrapWorkspace = manager.selectedWorkspace else {
XCTFail("Expected bootstrap workspace")
return
}
timed("workspace-000-populate", collectInto: &populationSamples) {
populate(workspace: bootstrapWorkspace, tabsPerWorkspace: config.tabsPerWorkspace)
}
settleWorkspaceSelection(manager)
for workspaceIndex in 1..<config.workspaceCount {
let workspace = timed("workspace-\(label(for: workspaceIndex))-create", collectInto: &creationSamples) {
manager.addWorkspace(
select: true,
eagerLoadTerminal: false,
autoWelcomeIfNeeded: false
)
}
settleWorkspaceSelection(manager)
timed("workspace-\(label(for: workspaceIndex))-populate", collectInto: &populationSamples) {
populate(workspace: workspace, tabsPerWorkspace: config.tabsPerWorkspace)
}
settleWorkspaceSelection(manager)
}
XCTAssertEqual(manager.tabs.count, config.workspaceCount)
XCTAssertTrue(manager.tabs.allSatisfy { $0.panels.count == config.tabsPerWorkspace })
for pass in 0..<config.switchPasses {
for switchIndex in 0..<manager.tabs.count {
timed("pass-\(label(for: pass))-next-\(label(for: switchIndex))", collectInto: &switchSamples) {
timed("pass-\(label(for: pass))-next-dispatch-\(label(for: switchIndex))", collectInto: &switchDispatchSamples) {
manager.selectNextTab()
}
timed("pass-\(label(for: pass))-next-drain1-\(label(for: switchIndex))", collectInto: &switchFirstDrainSamples) {
drainMainQueue()
}
timed("pass-\(label(for: pass))-next-unfocus-\(label(for: switchIndex))", collectInto: &switchUnfocusSamples) {
manager.completePendingWorkspaceUnfocus(reason: "workspace_stress_profile")
}
timed("pass-\(label(for: pass))-next-drain2-\(label(for: switchIndex))", collectInto: &switchSecondDrainSamples) {
drainMainQueue()
}
}
}
for switchIndex in 0..<manager.tabs.count {
timed("pass-\(label(for: pass))-prev-\(label(for: switchIndex))", collectInto: &switchSamples) {
timed("pass-\(label(for: pass))-prev-dispatch-\(label(for: switchIndex))", collectInto: &switchDispatchSamples) {
manager.selectPreviousTab()
}
timed("pass-\(label(for: pass))-prev-drain1-\(label(for: switchIndex))", collectInto: &switchFirstDrainSamples) {
drainMainQueue()
}
timed("pass-\(label(for: pass))-prev-unfocus-\(label(for: switchIndex))", collectInto: &switchUnfocusSamples) {
manager.completePendingWorkspaceUnfocus(reason: "workspace_stress_profile")
}
timed("pass-\(label(for: pass))-prev-drain2-\(label(for: switchIndex))", collectInto: &switchSecondDrainSamples) {
drainMainQueue()
}
}
}
}
XCTAssertNotNil(manager.selectedWorkspace)
let creationSummary = TimingSummary(samples: creationSamples)
let populationSummary = TimingSummary(samples: populationSamples)
let switchSummary = TimingSummary(samples: switchSamples)
let switchDispatchSummary = TimingSummary(samples: switchDispatchSamples)
let switchFirstDrainSummary = TimingSummary(samples: switchFirstDrainSamples)
let switchUnfocusSummary = TimingSummary(samples: switchUnfocusSamples)
let switchSecondDrainSummary = TimingSummary(samples: switchSecondDrainSamples)
let report = [
"Workspace stress config workspaces=\(config.workspaceCount) tabsPerWorkspace=\(config.tabsPerWorkspace) switchPasses=\(config.switchPasses)",
reportLine(title: "create", summary: creationSummary, slowest: slowest(creationSamples)),
reportLine(title: "populate", summary: populationSummary, slowest: slowest(populationSamples)),
reportLine(title: "switch", summary: switchSummary, slowest: slowest(switchSamples)),
reportLine(title: "switch.dispatch", summary: switchDispatchSummary, slowest: slowest(switchDispatchSamples)),
reportLine(title: "switch.drain1", summary: switchFirstDrainSummary, slowest: slowest(switchFirstDrainSamples)),
reportLine(title: "switch.unfocus", summary: switchUnfocusSummary, slowest: slowest(switchUnfocusSamples)),
reportLine(title: "switch.drain2", summary: switchSecondDrainSummary, slowest: slowest(switchSecondDrainSamples))
].joined(separator: "\n")
print(report)
let attachment = XCTAttachment(string: report)
attachment.name = "workspace-stress-profile"
attachment.lifetime = .keepAlways
add(attachment)
if let createP95BudgetMs = config.createP95BudgetMs {
XCTAssertLessThanOrEqual(
creationSummary.p95Ms,
createP95BudgetMs,
"Workspace creation p95 exceeded budget"
)
}
if let switchP95BudgetMs = config.switchP95BudgetMs {
XCTAssertLessThanOrEqual(
switchSummary.p95Ms,
switchP95BudgetMs,
"Workspace switch p95 exceeded budget"
)
}
}
private func populate(workspace: Workspace, tabsPerWorkspace: Int) {
guard tabsPerWorkspace > 0 else { return }
while workspace.panels.count < tabsPerWorkspace {
let created = workspace.newTerminalSurfaceInFocusedPane(focus: false)
guard created != nil else {
XCTFail("Expected terminal tab creation to succeed")
return
}
}
}
private func settleWorkspaceSelection(_ manager: TabManager) {
drainMainQueue()
manager.completePendingWorkspaceUnfocus(reason: "workspace_stress_profile")
drainMainQueue()
}
private func drainMainQueue() {
let deadline = Date(timeIntervalSinceNow: 1.0)
var drained = false
DispatchQueue.main.async {
drained = true
}
while !drained {
if Date() >= deadline {
XCTFail("Timed out draining main queue")
return
}
let sliceDeadline = min(deadline, Date(timeIntervalSinceNow: 0.001))
_ = RunLoop.main.run(mode: .default, before: sliceDeadline)
}
}
@discardableResult
private func timed<T>(
_ label: String,
collectInto samples: inout [TimedSample],
operation: () -> T
) -> T {
let startedAt = ProcessInfo.processInfo.systemUptime
let value = operation()
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0
samples.append(TimedSample(label: label, elapsedMs: elapsedMs))
return value
}
private func slowest(_ samples: [TimedSample], count: Int = 5) -> String {
samples
.sorted { lhs, rhs in
if lhs.elapsedMs == rhs.elapsedMs {
return lhs.label < rhs.label
}
return lhs.elapsedMs > rhs.elapsedMs
}
.prefix(count)
.map { "\($0.label)=\(formatMs($0.elapsedMs))" }
.joined(separator: ", ")
}
private func reportLine(title: String, summary: TimingSummary, slowest: String) -> String {
[
"\(title):",
"count=\(summary.count)",
"avg=\(formatMs(summary.averageMs))",
"median=\(formatMs(summary.medianMs))",
"p95=\(formatMs(summary.p95Ms))",
"max=\(formatMs(summary.maxMs))",
"total=\(formatMs(summary.totalMs))",
"slowest=[\(slowest)]"
].joined(separator: " ")
}
private func formatMs(_ value: Double) -> String {
String(format: "%.2fms", value)
}
private func label(for index: Int) -> String {
String(format: "%03d", index)
}
}

View file

@ -27,23 +27,32 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
) )
} }
func testCmdDConfirmsCloseWhenClosingLastTabClosesWindow() { func testCmdWClosingLastTabKeepsWorkspaceWindowOpen() {
let app = XCUIApplication() let app = XCUIApplication()
// Closing the last tab should also present a confirmation and accept Cmd+D when it would close the window. let keyequivPath = "/tmp/cmux-ui-test-keyequiv-\(UUID().uuidString).json"
app.launchEnvironment["CMUX_UI_TEST_FORCE_CONFIRM_CLOSE_WORKSPACE"] = "1" try? FileManager.default.removeItem(atPath: keyequivPath)
app.launchEnvironment["CMUX_UI_TEST_KEYEQUIV_PATH"] = keyequivPath
app.launch() app.launch()
app.activate() app.activate()
// Close current tab (Cmd+W). With a single workspace and a single tab, this will close the window after confirmation. let baseline = loadJSON(atPath: keyequivPath)?["closePanelInvocations"].flatMap(Int.init) ?? 0
app.typeKey("w", modifierFlags: [.command]) app.typeKey("w", modifierFlags: [.command])
XCTAssertTrue(waitForCloseTabAlert(app: app, timeout: 5.0)) XCTAssertTrue(
waitForKeyequivInt("closePanelInvocations", toBeAtLeast: baseline + 1, atPath: keyequivPath, timeout: 5.0),
"Expected Cmd+W to route through the close-current-tab action"
)
// Cmd+D should accept the destructive close and close the window. if waitForCloseTabAlert(app: app, timeout: 5.0) {
app.typeKey("d", modifierFlags: [.command]) clickCloseOnCloseTabAlert(app: app)
XCTAssertFalse(
isCloseTabAlertPresent(app: app),
"Expected close tab confirmation to dismiss after confirming the close"
)
}
XCTAssertTrue( XCTAssertTrue(
waitForNoWindowsOrAppNotRunningForeground(app: app, timeout: 6.0), waitForWindowCount(app: app, atLeast: 1, timeout: 6.0),
"Expected Cmd+D to confirm close and close the last window" "Expected Cmd+W on the last tab to keep the workspace window open"
) )
} }
@ -608,12 +617,37 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
private func waitForCloseTabAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool { private func waitForCloseTabAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline { while Date() < deadline {
if app.dialogs.containing(.staticText, identifier: "Close tab?").firstMatch.exists { return true } if isCloseTabAlertPresent(app: app) { return true }
if app.alerts.containing(.staticText, identifier: "Close tab?").firstMatch.exists { return true }
if app.staticTexts["Close tab?"].exists { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.05)) RunLoop.current.run(until: Date().addingTimeInterval(0.05))
} }
return false return isCloseTabAlertPresent(app: app)
}
// Must match the defaultValue for dialog.closeTab.title in TabManager.
private func isCloseTabAlertPresent(app: XCUIApplication) -> Bool {
if app.dialogs.containing(.staticText, identifier: "Close tab?").firstMatch.exists { return true }
if app.alerts.containing(.staticText, identifier: "Close tab?").firstMatch.exists { return true }
return app.staticTexts["Close tab?"].exists
}
// Must match the defaultValue for dialog.closeTab.title in TabManager.
private func clickCloseOnCloseTabAlert(app: XCUIApplication) {
let dialog = app.dialogs.containing(.staticText, identifier: "Close tab?").firstMatch
if dialog.exists {
dialog.buttons["Close"].firstMatch.click()
return
}
let alert = app.alerts.containing(.staticText, identifier: "Close tab?").firstMatch
if alert.exists {
alert.buttons["Close"].firstMatch.click()
return
}
let anyDialog = app.dialogs.firstMatch
if anyDialog.exists, anyDialog.buttons["Close"].exists {
anyDialog.buttons["Close"].firstMatch.click()
}
} }
private func waitForWindowCount(app: XCUIApplication, toBe count: Int, timeout: TimeInterval) -> Bool { private func waitForWindowCount(app: XCUIApplication, toBe count: Int, timeout: TimeInterval) -> Bool {
@ -644,6 +678,17 @@ final class CloseWorkspaceCmdDUITests: XCTestCase {
return app.state != .runningForeground || app.windows.count == 0 return app.state != .runningForeground || app.windows.count == 0
} }
private func waitForKeyequivInt(_ key: String, toBeAtLeast expected: Int, atPath path: String, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let value = loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0
if value >= expected { return true }
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
let value = loadJSON(atPath: path)?[key].flatMap(Int.init) ?? 0
return value >= expected
}
private func waitForAnyJSON(atPath path: String, timeout: TimeInterval) -> Bool { private func waitForAnyJSON(atPath path: String, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout) let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline { while Date() < deadline {

View file

@ -0,0 +1,219 @@
import XCTest
import Foundation
final class CloseWorkspacesConfirmDialogUITests: XCTestCase {
private var socketPath = ""
override func setUp() {
super.setUp()
continueAfterFailure = false
socketPath = "/tmp/cmux-ui-test-close-workspaces-\(UUID().uuidString).sock"
try? FileManager.default.removeItem(atPath: socketPath)
}
func testCommandPaletteCloseOtherWorkspacesShowsSingleSummaryDialog() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_UI_TEST_FORCE_CONFIRM_CLOSE_WORKSPACE"] = "1"
app.launch()
XCTAssertTrue(
ensureForegroundAfterLaunch(app, timeout: 12.0),
"Expected app to launch for close-workspaces confirmation test. state=\(app.state.rawValue)"
)
XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket to respond at \(socketPath)")
XCTAssertEqual(socketCommand("new_workspace")?.prefix(2), "OK")
XCTAssertEqual(socketCommand("new_workspace")?.prefix(2), "OK")
XCTAssertTrue(
waitForWorkspaceCount(3, timeout: 5.0),
"Expected 3 workspaces before running the close-other-workspaces command. list=\(socketCommand("list_workspaces") ?? "<nil>")"
)
XCTAssertEqual(socketCommand("select_workspace 1"), "OK")
app.typeKey("p", modifierFlags: [.command, .shift])
let searchField = app.textFields["CommandPaletteSearchField"]
XCTAssertTrue(searchField.waitForExistence(timeout: 5.0), "Expected command palette search field")
searchField.click()
searchField.typeText("Close Other Workspaces")
let resultButton = app.buttons["Close Other Workspaces"].firstMatch
if resultButton.waitForExistence(timeout: 5.0) {
resultButton.click()
} else {
app.typeKey(.return, modifierFlags: [])
}
XCTAssertTrue(
waitForCloseWorkspacesAlert(app: app, timeout: 5.0),
"Expected a single aggregated close-workspaces alert"
)
clickCancelOnCloseWorkspacesAlert(app: app)
XCTAssertFalse(
isCloseWorkspacesAlertPresent(app: app),
"Expected aggregated close-workspaces alert to dismiss after clicking Cancel"
)
XCTAssertTrue(
waitForWorkspaceCount(3, timeout: 5.0),
"Expected all workspaces to remain after cancelling multi-close. list=\(socketCommand("list_workspaces") ?? "<nil>")"
)
}
func testCmdShiftWUsesSidebarMultiSelectionSummaryDialog() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_UI_TEST_FORCE_CONFIRM_CLOSE_WORKSPACE"] = "1"
app.launchEnvironment["CMUX_UI_TEST_SIDEBAR_SELECTED_WORKSPACE_INDICES"] = "0,1"
app.launch()
XCTAssertTrue(
ensureForegroundAfterLaunch(app, timeout: 12.0),
"Expected app to launch for close-workspaces shortcut test. state=\(app.state.rawValue)"
)
XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket to respond at \(socketPath)")
XCTAssertEqual(socketCommand("new_workspace")?.prefix(2), "OK")
XCTAssertTrue(
waitForWorkspaceCount(2, timeout: 5.0),
"Expected 2 workspaces before running Cmd+Shift+W. list=\(socketCommand("list_workspaces") ?? "<nil>")"
)
app.typeKey("w", modifierFlags: [.command, .shift])
XCTAssertTrue(
waitForCloseWorkspacesAlert(app: app, timeout: 5.0),
"Expected Cmd+Shift+W to use the aggregated close-workspaces alert for sidebar multi-selection"
)
clickCancelOnCloseWorkspacesAlert(app: app)
XCTAssertFalse(
isCloseWorkspacesAlertPresent(app: app),
"Expected aggregated close-workspaces alert to dismiss after clicking Cancel"
)
XCTAssertTrue(
waitForWorkspaceCount(2, timeout: 5.0),
"Expected both workspaces to remain after cancelling Cmd+Shift+W multi-close. list=\(socketCommand("list_workspaces") ?? "<nil>")"
)
}
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
if app.wait(for: .runningForeground, timeout: timeout) {
return true
}
if app.state == .runningBackground {
app.activate()
return app.wait(for: .runningForeground, timeout: 6.0)
}
return false
}
private func waitForSocketPong(timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if socketCommand("ping") == "PONG" {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return socketCommand("ping") == "PONG"
}
private func waitForWorkspaceCount(_ expectedCount: Int, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if workspaceCount() == expectedCount {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return workspaceCount() == expectedCount
}
private func workspaceCount() -> Int {
guard let response = socketCommand("list_workspaces") else { return -1 }
if response == "No workspaces" {
return 0
}
return response
.split(separator: "\n")
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
.count
}
private func socketCommand(_ cmd: String) -> String? {
let nc = "/usr/bin/nc"
guard FileManager.default.isExecutableFile(atPath: nc) else { return nil }
let proc = Process()
proc.executableURL = URL(fileURLWithPath: nc)
proc.arguments = ["-U", socketPath, "-w", "2"]
let inPipe = Pipe()
let outPipe = Pipe()
let errPipe = Pipe()
proc.standardInput = inPipe
proc.standardOutput = outPipe
proc.standardError = errPipe
do {
try proc.run()
} catch {
return nil
}
if let data = (cmd + "\n").data(using: .utf8) {
inPipe.fileHandleForWriting.write(data)
}
inPipe.fileHandleForWriting.closeFile()
proc.waitUntilExit()
let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
guard let outStr = String(data: outData, encoding: .utf8) else { return nil }
return outStr.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func isCloseWorkspacesAlertPresent(app: XCUIApplication) -> Bool {
if closeWorkspacesDialog(app: app).exists { return true }
if closeWorkspacesAlert(app: app).exists { return true }
return app.staticTexts["Close workspaces?"].exists
}
private func waitForCloseWorkspacesAlert(app: XCUIApplication, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if isCloseWorkspacesAlertPresent(app: app) {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
return isCloseWorkspacesAlertPresent(app: app)
}
private func clickCancelOnCloseWorkspacesAlert(app: XCUIApplication) {
let dialog = closeWorkspacesDialog(app: app)
if dialog.exists {
dialog.buttons["Cancel"].firstMatch.click()
return
}
let alert = closeWorkspacesAlert(app: app)
if alert.exists {
alert.buttons["Cancel"].firstMatch.click()
return
}
let anyDialog = app.dialogs.firstMatch
if anyDialog.exists, anyDialog.buttons["Cancel"].exists {
anyDialog.buttons["Cancel"].firstMatch.click()
}
}
private func closeWorkspacesDialog(app: XCUIApplication) -> XCUIElement {
app.dialogs.containing(.staticText, identifier: "Close workspaces?").firstMatch
}
private func closeWorkspacesAlert(app: XCUIApplication) -> XCUIElement {
app.alerts.containing(.staticText, identifier: "Close workspaces?").firstMatch
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Before After
Before After

View file

@ -77,14 +77,16 @@ touch the same stale-frame mitigation path and tend to conflict in the same file
- Commits: - Commits:
- `0cf559581` (zsh: fix Pure-style multiline prompt redraws) - `0cf559581` (zsh: fix Pure-style multiline prompt redraws)
- `312c7b23a` (zsh: avoid extra Pure continuation markers) - `312c7b23a` (zsh: avoid extra Pure continuation markers)
- `404a3f175` (Fix Pure prompt redraw markers)
- Files: - Files:
- `src/shell-integration/zsh/ghostty-integration` - `src/shell-integration/zsh/ghostty-integration`
- Summary: - Summary:
- Handles multiline prompts that use `\n%{\r%}` to return to column 0 before the visible prompt line. - Handles multiline prompts that use `\n%{\r%}` to return to column 0 before the visible prompt line.
- Keeps redraw-safe prompt-start markers for async themes. - Keeps redraw-safe prompt-start markers for async themes.
- Avoids inserting an explicit continuation marker after Pure's hidden carriage return, because Ghostty already tracks the newline as prompt continuation and the extra marker duplicates the preprompt row. - Avoids inserting an explicit continuation marker after Pure's hidden carriage return, because Ghostty already tracks the newline as prompt continuation and the extra marker duplicates the preprompt row.
- Restores that prompt-marker behavior on top of the current Ghostty `main` base after the older redraw fix drifted out during later submodule updates.
The fork branch HEAD is now the section 6 zsh redraw commit. The fork branch HEAD is now the section 6 zsh redraw follow-up commit.
### 7) cmux theme picker helper hooks ### 7) cmux theme picker helper hooks

@ -1 +1 @@
Subproject commit 80cca8a12ebd554953fc6b35235135a3e61fe20c Subproject commit bc9be90a21997a4e5f06bf15ae2ec0f937c2dc42

View file

@ -5,3 +5,4 @@
a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d
0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de 0cf5595817794466e3a60abe6bf97f8494dedcfe 1c6ae53ea549740bd45e59fe92714a292fb0d71a41ff915eb6b2e644468152de
312c7b23a7c8dc0704431940d76ba5dc32a46afb ae73cb18a9d6efec42126a1d99e0e9d12022403d7dc301dfa21ed9f7c89c9e30 312c7b23a7c8dc0704431940d76ba5dc32a46afb ae73cb18a9d6efec42126a1d99e0e9d12022403d7dc301dfa21ed9f7c89c9e30
404a3f175ba6baafabc46cac807194883e040980 bcbd2954f4746fe5bcb4bfca6efeddd3ea355fda2836371f4c7150271c58acbd

View file

@ -59,24 +59,29 @@ def _wait_for_focused_cwd(
client: cmux, client: cmux,
expected: str, expected: str,
timeout: float = 12.0, timeout: float = 12.0,
exclude_panel: str | None = None, panel: str | None = None,
tab: str | None = None,
) -> dict[str, str]: ) -> dict[str, str]:
"""Wait for focused_cwd to match expected. """Wait for focused_cwd to match expected.
If exclude_panel is given, also require that focused_panel differs from If panel is given, also require that focused_panel matches that panel.
that value ensuring we're checking the *new* pane, not the original. If tab is given, also require that the selected tab matches that tab.
""" """
def pred(): def pred():
state = _parse_sidebar_state(client.sidebar_state()) state = _parse_sidebar_state(client.sidebar_state())
cwd = state.get("focused_cwd", "") cwd = state.get("focused_cwd", "")
if cwd != expected: if cwd != expected:
return None return None
if exclude_panel and state.get("focused_panel", "") == exclude_panel: if panel and state.get("focused_panel", "") != panel:
return None
if tab and state.get("tab", "") != tab:
return None return None
return state return state
label = f"focused_cwd={expected!r}" label = f"focused_cwd={expected!r}"
if exclude_panel: if panel:
label += f" (panel != {exclude_panel})" label += f" (panel == {panel})"
if tab:
label += f" (tab == {tab})"
return _wait_for(pred, timeout=timeout, interval=0.3, label=label) return _wait_for(pred, timeout=timeout, interval=0.3, label=label)
@ -84,12 +89,25 @@ def _send_cd_and_wait(
client: cmux, client: cmux,
target: str, target: str,
timeout: float = 12.0, timeout: float = 12.0,
surface: str | int | None = None,
) -> dict[str, str]: ) -> dict[str, str]:
"""cd to target and wait for sidebar focused_cwd to reflect it.""" """cd to target and wait for sidebar focused_cwd to reflect it."""
client.send(f"cd {target}\n") if surface is None:
client.send(f"cd {target}\n")
else:
client.send_surface(surface, f"cd {target}\n")
return _wait_for_focused_cwd(client, target, timeout=timeout) return _wait_for_focused_cwd(client, target, timeout=timeout)
def _focus_first_surface(client: cmux) -> str:
surfaces = client.list_surfaces()
if not surfaces:
raise AssertionError("Current tab has no surfaces")
surface_id = surfaces[0][1]
client.focus_surface(surface_id)
return surface_id
def main() -> int: def main() -> int:
tag = os.environ.get("CMUX_TAG", "") tag = os.environ.get("CMUX_TAG", "")
@ -119,17 +137,22 @@ def main() -> int:
print("=== Split CWD Inheritance Tests ===") print("=== Split CWD Inheritance Tests ===")
print(" [setup] creating isolated workspace tab...")
setup_tab = client.new_tab()
client.select_tab(setup_tab)
time.sleep(1.0)
setup_surface = _focus_first_surface(client)
time.sleep(0.5)
# --- Setup: cd to test_dir_a in workspace 1 --- # --- Setup: cd to test_dir_a in workspace 1 ---
print(" [setup] cd to test_dir_a and wait for shell integration...") print(" [setup] cd to test_dir_a and wait for shell integration...")
_send_cd_and_wait(client, test_dir_a) _send_cd_and_wait(client, test_dir_a, surface=setup_surface)
state = _parse_sidebar_state(client.sidebar_state()) state = _parse_sidebar_state(client.sidebar_state())
check("setup: focused_cwd is test_dir_a", state.get("focused_cwd") == test_dir_a, check("setup: focused_cwd is test_dir_a", state.get("focused_cwd") == test_dir_a,
f"got {state.get('focused_cwd')!r}") f"got {state.get('focused_cwd')!r}")
# --- Test 1: New split inherits test_dir_a --- # --- Test 1: New split inherits test_dir_a ---
print(" [test1] creating right split from test_dir_a...") print(" [test1] creating right split from test_dir_a...")
# Record the original panel so we can verify focus moves to the NEW pane.
original_panel = state.get("focused_panel", "")
split_result = client.new_split("right") split_result = client.new_split("right")
if not split_result: if not split_result:
check("split created", False) check("split created", False)
@ -138,15 +161,15 @@ def main() -> int:
return 1 return 1
check("split created", True) check("split created", True)
# Wait for the NEW pane (different panel ID) to report test_dir_a. # Socket split commands should not steal focus; focus the returned pane
# explicitly, then assert that pane inherited the source cwd.
new_panel = split_result.strip()
client.focus_surface_by_panel(new_panel)
time.sleep(4) # wait for new bash to start + run PROMPT_COMMAND time.sleep(4) # wait for new bash to start + run PROMPT_COMMAND
try: try:
state = _wait_for_focused_cwd( state = _wait_for_focused_cwd(
client, test_dir_a, timeout=15.0, exclude_panel=original_panel, client, test_dir_a, timeout=15.0, panel=new_panel,
) )
new_panel = state.get("focused_panel", "")
check("test1: focus moved to new pane", new_panel != original_panel,
f"original={original_panel!r}, current={new_panel!r}")
check("test1: split inherited test_dir_a", check("test1: split inherited test_dir_a",
state.get("focused_cwd") == test_dir_a, state.get("focused_cwd") == test_dir_a,
f"focused_cwd={state.get('focused_cwd')!r}") f"focused_cwd={state.get('focused_cwd')!r}")
@ -159,8 +182,6 @@ def main() -> int:
# First cd to test_dir_b so we have a different dir to inherit # First cd to test_dir_b so we have a different dir to inherit
print(" [test2] cd to test_dir_b, then creating new workspace tab...") print(" [test2] cd to test_dir_b, then creating new workspace tab...")
_send_cd_and_wait(client, test_dir_b) _send_cd_and_wait(client, test_dir_b)
state = _parse_sidebar_state(client.sidebar_state())
original_tab = state.get("tab", "")
tab_result = client.new_tab() tab_result = client.new_tab()
if not tab_result: if not tab_result:
@ -170,23 +191,14 @@ def main() -> int:
return 1 return 1
check("new tab created", True) check("new tab created", True)
# New workspace should be a different tab AND inherit test_dir_b # Focus the returned workspace explicitly, then assert it inherited cwd.
new_tab = tab_result.strip()
client.select_tab(new_tab)
time.sleep(4) time.sleep(4)
try: try:
def _new_tab_with_cwd(): state = _wait_for_focused_cwd(
s = _parse_sidebar_state(client.sidebar_state()) client, test_dir_b, timeout=15.0, tab=new_tab,
tab_id = s.get("tab", "")
cwd = s.get("focused_cwd", "")
if tab_id != original_tab and cwd == test_dir_b:
return s
return None
state = _wait_for(
_new_tab_with_cwd, timeout=15.0, interval=0.3,
label=f"new tab with focused_cwd={test_dir_b!r}",
) )
check("test2: focus moved to new tab", state.get("tab") != original_tab,
f"original={original_tab!r}, current={state.get('tab')!r}")
check("test2: new workspace inherited test_dir_b", check("test2: new workspace inherited test_dir_b",
state.get("focused_cwd") == test_dir_b, state.get("focused_cwd") == test_dir_b,
f"focused_cwd={state.get('focused_cwd')!r}") f"focused_cwd={state.get('focused_cwd')!r}")

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit fa452db181f361514087558a29204bda7e38218f Subproject commit 73c1ef2df9a6c8a2837212ecce900794d0f21826

View file

@ -16,6 +16,7 @@ export async function SiteFooter() {
links: [ links: [
{ label: t("blog"), href: "/blog" }, { label: t("blog"), href: "/blog" },
{ label: t("community"), href: "/community" }, { label: t("community"), href: "/community" },
{ label: t("nightly"), href: "/nightly" },
], ],
}, },
{ {

View file

@ -0,0 +1,99 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import { SiteHeader } from "../components/site-header";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "nightly" });
return {
title: t("metaTitle"),
description: t("metaDescription"),
};
}
const linkClass =
"underline underline-offset-2 decoration-border hover:decoration-foreground transition-colors";
export default function NightlyPage() {
const t = useTranslations("nightly");
return (
<div className="min-h-screen">
<SiteHeader section={t("title")} />
<main className="w-full max-w-2xl mx-auto px-6 py-10">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<img
src="/logo-nightly.png"
alt="cmux NIGHTLY icon"
width={48}
height={48}
className="rounded-xl"
/>
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{t("title")}
</h1>
</div>
</div>
{/* Description */}
<p
className="text-[15px] text-muted mb-8"
style={{ lineHeight: 1.5 }}
>
{t("description")}
</p>
{/* Download button */}
<a
href="https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg"
className="inline-flex items-center gap-2.5 rounded-full font-medium bg-foreground hover:opacity-85 transition-opacity px-5 py-2.5 text-[15px]"
style={{ color: "var(--background)", textDecoration: "none" }}
>
<svg
width={16}
height={19}
viewBox="0 0 814 1000"
fill="currentColor"
>
<path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57.8-155.5-127.4c-58.3-81.6-105.6-208.4-105.6-328.6 0-193 125.6-295.5 249.2-295.5 65.7 0 120.5 43.1 161.7 43.1 39.2 0 100.4-45.8 175.1-45.8 28.3 0 130.3 2.6 197.2 99.2zM554.1 159.4c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.9 32.4-57.2 83.6-57.2 135.4 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 137.6-71.2z" />
</svg>
{t("download")}
</a>
<p
className="text-[15px] text-muted mt-8"
style={{ lineHeight: 1.5 }}
>
{t.rich("warning", {
githubLink: (chunks) => (
<a
href="https://github.com/manaflow-ai/cmux/issues"
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
{chunks}
</a>
),
discordLink: (chunks) => (
<a
href="https://discord.gg/xsgFEVrWCZ"
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
{chunks}
</a>
),
})}
</p>
</main>
</div>
);
}

View file

@ -21,6 +21,7 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ path: "/docs/browser-automation", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.8 }, { path: "/docs/browser-automation", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.8 },
{ path: "/community", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.5 }, { path: "/community", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.5 },
{ path: "/wall-of-love", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.5 }, { path: "/wall-of-love", lastModified: new Date(), changeFrequency: "monthly" as const, priority: 0.5 },
{ path: "/nightly", lastModified: new Date(), changeFrequency: "weekly" as const, priority: 0.6 },
]; ];
const entries: MetadataRoute.Sitemap = []; const entries: MetadataRoute.Sitemap = [];

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "تواصل معنا", "contact": "تواصل معنا",
"nightly": "إصدار ليلي",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "اللغة" "language": "اللغة"
}, },
@ -581,6 +582,15 @@
"connorelsea": "أستخدمه منذ أسبوع وهو رائع. علامة تبويب عمودية لكل مهمة قيد التنفيذ. بالداخل، Claude على جانب والمتصفح مع PR والموارد على الجانب الآخر، أتنقل بين المهام وأبقى منظماً. امزج ذلك مع المهارات لجعل Claude يراقب CI بشكل متكرر وما إلى ذلك. أشعر بالتنوير بصراحة", "connorelsea": "أستخدمه منذ أسبوع وهو رائع. علامة تبويب عمودية لكل مهمة قيد التنفيذ. بالداخل، Claude على جانب والمتصفح مع PR والموارد على الجانب الآخر، أتنقل بين المهام وأبقى منظماً. امزج ذلك مع المهارات لجعل Claude يراقب CI بشكل متكرر وما إلى ذلك. أشعر بالتنوير بصراحة",
"tonkotsuboy": "انتقلت من Warp إلى Ghostty في بداية السنة، لكن الآن انتقلت إلى cmux. علامات التبويب العمودية مريحة، وأقدر الإشعارات عندما تنتهي مهام Claude Code. هو مبني على Ghostty لذا الأداء السريع ينتقل معه. عرض الفرع والإكمالات التي أعددتها في Ghostty لا تزال تعمل أيضاً." "tonkotsuboy": "انتقلت من Warp إلى Ghostty في بداية السنة، لكن الآن انتقلت إلى cmux. علامات التبويب العمودية مريحة، وأقدر الإشعارات عندما تنتهي مهام Claude Code. هو مبني على Ghostty لذا الأداء السريع ينتقل معه. عرض الفرع والإكمالات التي أعددتها في Ghostty لا تزال تعمل أيضاً."
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "أحدث الإصدارات من الفرع الرئيسي",
"metaTitle": "cmux NIGHTLY — إصدارات ليلية",
"metaDescription": "حمّل cmux NIGHTLY، تطبيق مستقل يُبنى تلقائياً من أحدث commit على main. يعمل بجانب النسخة المستقرة مع تحديثات تلقائية خاصة به.",
"description": "يُبنى cmux NIGHTLY تلقائياً من أحدث commit على main. يمتلك معرّف حزمة خاص به، لذا يعمل بجانب النسخة المستقرة دون تعارض. استخدمه لاختبار الميزات الجديدة قبل إصدارها.",
"download": "تحميل NIGHTLY لنظام Mac",
"warning": "قد تحتوي الإصدارات الليلية على أخطاء أو ميزات غير مكتملة. إذا حدثت مشكلة، أبلغ عنها على <githubLink>GitHub</githubLink> أو في <discordLink>#nightly-bugs على Discord</discordLink> وارجع إلى النسخة المستقرة."
},
"languageSwitcher": { "languageSwitcher": {
"label": "اللغة" "label": "اللغة"
} }

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "Kontakt", "contact": "Kontakt",
"nightly": "Nightly",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "Jezik" "language": "Jezik"
}, },
@ -581,6 +582,15 @@
"connorelsea": "Koristim ovo sedmicu i fantastično je. Vertikalni tab za svaki zadatak u toku. Unutra, Claude na jednoj strani a preglednik sa PR-ovima i resursima na drugoj, prebacujem se između zadataka i ostajam organizovan. Pomiješajte to sa skillovima da Claude prati CI rekurzivno itd. osjećam se prosvijećenim iskreno", "connorelsea": "Koristim ovo sedmicu i fantastično je. Vertikalni tab za svaki zadatak u toku. Unutra, Claude na jednoj strani a preglednik sa PR-ovima i resursima na drugoj, prebacujem se između zadataka i ostajam organizovan. Pomiješajte to sa skillovima da Claude prati CI rekurzivno itd. osjećam se prosvijećenim iskreno",
"tonkotsuboy": "Prešao sam sa Warpa na Ghostty početkom godine, ali sad sam prešao na cmux. Vertikalni tabovi su praktični, i cijenim što dobijem notifikaciju kada Claude Code zadaci završe. Baziran je na Ghostty-ju tako da munjevite performanse ostaju. Prikaz grane i completioni koje sam podesio u Ghostty-ju i dalje rade." "tonkotsuboy": "Prešao sam sa Warpa na Ghostty početkom godine, ali sad sam prešao na cmux. Vertikalni tabovi su praktični, i cijenim što dobijem notifikaciju kada Claude Code zadaci završe. Baziran je na Ghostty-ju tako da munjevite performanse ostaju. Prikaz grane i completioni koje sam podesio u Ghostty-ju i dalje rade."
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Najnovije verzije iz main grane",
"metaTitle": "cmux NIGHTLY — Nightly verzije",
"metaDescription": "Preuzmite cmux NIGHTLY, zasebnu aplikaciju koja se automatski kompajlira iz posljednjeg commita na main. Radi uporedo sa stabilnom verzijom s vlastitim automatskim ažuriranjima.",
"description": "cmux NIGHTLY se automatski kompajlira iz posljednjeg commita na main. Ima vlastiti bundle ID, pa radi uporedo sa stabilnom verzijom bez konflikata. Koristite ga za testiranje novih funkcija prije objavljivanja.",
"download": "Preuzmi NIGHTLY za Mac",
"warning": "Nightly verzije mogu sadržavati greške ili nepotpune funkcije. Ako nešto ne radi, prijavite to na <githubLink>GitHubu</githubLink> ili u <discordLink>#nightly-bugs na Discordu</discordLink> i prebacite se na stabilnu verziju."
},
"languageSwitcher": { "languageSwitcher": {
"label": "Jezik" "label": "Jezik"
} }

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "Kontakt", "contact": "Kontakt",
"nightly": "Nightly",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "Sprog" "language": "Sprog"
}, },
@ -581,6 +582,15 @@
"connorelsea": "Har brugt det i en uge og det er fantastisk. Vertikal fane for hver igangværende opgave. Indeni, Claude på den ene side og browser med PR og ressourcer på den anden, skift mellem opgaver og hold orden. Bland det med skills så Claude kan overvåge CI rekursivt, osv. føler mig oplyst ærlig talt", "connorelsea": "Har brugt det i en uge og det er fantastisk. Vertikal fane for hver igangværende opgave. Indeni, Claude på den ene side og browser med PR og ressourcer på den anden, skift mellem opgaver og hold orden. Bland det med skills så Claude kan overvåge CI rekursivt, osv. føler mig oplyst ærlig talt",
"tonkotsuboy": "Jeg skiftede fra Warp til Ghostty i starten af året, men nu er jeg skiftet til cmux. De vertikale faner er praktiske, og jeg sætter pris på at blive notificeret når Claude Code-opgaver er færdige. Det er Ghostty-baseret så den lynhurtige ydeevne følger med. Branch-visning og completions jeg satte op i Ghostty virker stadig." "tonkotsuboy": "Jeg skiftede fra Warp til Ghostty i starten af året, men nu er jeg skiftet til cmux. De vertikale faner er praktiske, og jeg sætter pris på at blive notificeret når Claude Code-opgaver er færdige. Det er Ghostty-baseret så den lynhurtige ydeevne følger med. Branch-visning og completions jeg satte op i Ghostty virker stadig."
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Seneste builds fra main",
"metaTitle": "cmux NIGHTLY — Nightly Builds",
"metaDescription": "Download cmux NIGHTLY, en separat app bygget automatisk fra det seneste main-commit. Kører ved siden af den stabile version med egne automatiske opdateringer.",
"description": "cmux NIGHTLY bygges automatisk fra det seneste commit på main. Den har sit eget bundle-ID, så den kører ved siden af den stabile version uden konflikter. Brug den til at teste nye funktioner før de udkommer.",
"download": "Download NIGHTLY til Mac",
"warning": "Nightly builds kan indeholde fejl eller ufærdige funktioner. Hvis noget går galt, rapportér det på <githubLink>GitHub</githubLink> eller i <discordLink>#nightly-bugs på Discord</discordLink> og skift tilbage til den stabile version."
},
"languageSwitcher": { "languageSwitcher": {
"label": "Sprog" "label": "Sprog"
} }

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "Kontakt", "contact": "Kontakt",
"nightly": "Nightly",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "Sprache" "language": "Sprache"
}, },
@ -581,6 +582,15 @@
"connorelsea": "Nutze das seit einer Woche und es ist fantastisch. Ein vertikaler Tab pro WIP-Aufgabe. Darin Claude auf einer Seite und Browser mit PR und Ressourcen auf der anderen. Zwischen Aufgaben wechseln und organisiert bleiben. Dazu Skills, damit Claude CI rekursiv überwacht usw. Fühle mich ehrlich gesagt erleuchtet.", "connorelsea": "Nutze das seit einer Woche und es ist fantastisch. Ein vertikaler Tab pro WIP-Aufgabe. Darin Claude auf einer Seite und Browser mit PR und Ressourcen auf der anderen. Zwischen Aufgaben wechseln und organisiert bleiben. Dazu Skills, damit Claude CI rekursiv überwacht usw. Fühle mich ehrlich gesagt erleuchtet.",
"tonkotsuboy": "Anfang des Jahres bin ich von Warp zu Ghostty gewechselt, aber jetzt bin ich bei cmux. Die vertikalen Tabs sind praktisch, und ich schätze die Benachrichtigungen, wenn Claude-Code-Aufgaben fertig sind. Da es auf Ghostty basiert, bleibt die blitzschnelle Performance erhalten. Branch-Anzeige und Vervollständigungen, die ich in Ghostty eingerichtet hatte, funktionieren auch weiterhin." "tonkotsuboy": "Anfang des Jahres bin ich von Warp zu Ghostty gewechselt, aber jetzt bin ich bei cmux. Die vertikalen Tabs sind praktisch, und ich schätze die Benachrichtigungen, wenn Claude-Code-Aufgaben fertig sind. Da es auf Ghostty basiert, bleibt die blitzschnelle Performance erhalten. Branch-Anzeige und Vervollständigungen, die ich in Ghostty eingerichtet hatte, funktionieren auch weiterhin."
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Aktuelle Builds vom main-Branch",
"metaTitle": "cmux NIGHTLY — Nightly Builds",
"metaDescription": "Laden Sie cmux NIGHTLY herunter, eine separate App, die automatisch aus dem neuesten main-Commit erstellt wird. Läuft neben der stabilen Version mit eigenen Auto-Updates.",
"description": "cmux NIGHTLY wird automatisch aus dem neuesten Commit auf main erstellt. Es hat eine eigene Bundle-ID und läuft daher ohne Konflikte neben der stabilen Version. Damit können Sie neue Funktionen testen, bevor sie veröffentlicht werden.",
"download": "NIGHTLY für Mac herunterladen",
"warning": "Nightly Builds können Fehler oder unfertige Funktionen enthalten. Falls Probleme auftreten, melden Sie diese auf <githubLink>GitHub</githubLink> oder in <discordLink>#nightly-bugs auf Discord</discordLink> und wechseln Sie zur stabilen Version."
},
"languageSwitcher": { "languageSwitcher": {
"label": "Sprache" "label": "Sprache"
} }

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "Contact", "contact": "Contact",
"nightly": "Nightly",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "Language" "language": "Language"
}, },
@ -583,6 +584,15 @@
"connorelsea": "Been using this for a week and it's fantastic. Vert tab for each WIP task. Inside, claudes on one side and browser with PR and resources on the other, switch between tasks and stay organized. Mix that with skills to have Claude watch CI recursively, etc. feeling enlightened tbh", "connorelsea": "Been using this for a week and it's fantastic. Vert tab for each WIP task. Inside, claudes on one side and browser with PR and resources on the other, switch between tasks and stay organized. Mix that with skills to have Claude watch CI recursively, etc. feeling enlightened tbh",
"tonkotsuboy": "I switched from Warp to Ghostty at the start of the year, but now I've switched to cmux. The vertical tabs are convenient, and I appreciate getting notified when Claude Code tasks finish. It's Ghostty-based so the blazing fast performance carries over. Branch display and completions I set up in Ghostty still work too." "tonkotsuboy": "I switched from Warp to Ghostty at the start of the year, but now I've switched to cmux. The vertical tabs are convenient, and I appreciate getting notified when Claude Code tasks finish. It's Ghostty-based so the blazing fast performance carries over. Branch display and completions I set up in Ghostty still work too."
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Bleeding-edge builds from main",
"metaTitle": "cmux NIGHTLY — Nightly Builds",
"metaDescription": "Download cmux NIGHTLY, a separate app built automatically from the latest main commit. Runs alongside the stable version with its own auto-updates.",
"description": "cmux NIGHTLY is built automatically from the latest commit on main. It has its own bundle ID, so it runs alongside the stable version without conflicts. Use it to test new features before they ship.",
"download": "Download NIGHTLY for Mac",
"warning": "Nightly builds may contain bugs or incomplete features. If something breaks, report it on <githubLink>GitHub</githubLink> or in <discordLink>#nightly-bugs on Discord</discordLink>, and switch back to the stable release."
},
"languageSwitcher": { "languageSwitcher": {
"label": "Language" "label": "Language"
} }

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "Contacto", "contact": "Contacto",
"nightly": "Nightly",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "Idioma" "language": "Idioma"
}, },
@ -581,6 +582,15 @@
"connorelsea": "Lo llevo usando una semana y es fantástico. Una pestaña vertical por cada tarea WIP. Dentro, Claude a un lado y navegador con PR y recursos al otro. Cambiar entre tareas y mantener todo organizado. Combinado con skills para que Claude vigile CI recursivamente, etc. Sinceramente me siento iluminado.", "connorelsea": "Lo llevo usando una semana y es fantástico. Una pestaña vertical por cada tarea WIP. Dentro, Claude a un lado y navegador con PR y recursos al otro. Cambiar entre tareas y mantener todo organizado. Combinado con skills para que Claude vigile CI recursivamente, etc. Sinceramente me siento iluminado.",
"tonkotsuboy": "A principios de año cambié de Warp a Ghostty, pero ahora me cambié a cmux. Las pestañas verticales son cómodas y agradezco las notificaciones cuando terminan las tareas de Claude Code. Al estar basado en Ghostty, el rendimiento ultrarrápido se mantiene. La visualización de ramas y las completaciones que configuré en Ghostty siguen funcionando." "tonkotsuboy": "A principios de año cambié de Warp a Ghostty, pero ahora me cambié a cmux. Las pestañas verticales son cómodas y agradezco las notificaciones cuando terminan las tareas de Claude Code. Al estar basado en Ghostty, el rendimiento ultrarrápido se mantiene. La visualización de ramas y las completaciones que configuré en Ghostty siguen funcionando."
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Compilaciones de última hora desde main",
"metaTitle": "cmux NIGHTLY — Compilaciones Nightly",
"metaDescription": "Descarga cmux NIGHTLY, una app independiente compilada automáticamente desde el último commit en main. Funciona junto a la versión estable con sus propias actualizaciones automáticas.",
"description": "cmux NIGHTLY se compila automáticamente desde el último commit en main. Tiene su propio bundle ID, así que funciona junto a la versión estable sin conflictos. Úsala para probar nuevas funciones antes de su lanzamiento.",
"download": "Descargar NIGHTLY para Mac",
"warning": "Las compilaciones nightly pueden contener errores o funciones incompletas. Si algo falla, repórtalo en <githubLink>GitHub</githubLink> o en <discordLink>#nightly-bugs en Discord</discordLink> y cambia a la versión estable."
},
"languageSwitcher": { "languageSwitcher": {
"label": "Idioma" "label": "Idioma"
} }

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "Contact", "contact": "Contact",
"nightly": "Nightly",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "Langue" "language": "Langue"
}, },
@ -581,6 +582,15 @@
"connorelsea": "Je l'utilise depuis une semaine et c'est fantastique. Un onglet vertical par tache en cours. A l'interieur, Claude d'un cote et le navigateur avec la PR et les ressources de l'autre. Basculer entre les taches en restant organise. En combinant avec les skills pour que Claude surveille le CI recursivement, etc. Franchement, je me sens eclaire.", "connorelsea": "Je l'utilise depuis une semaine et c'est fantastique. Un onglet vertical par tache en cours. A l'interieur, Claude d'un cote et le navigateur avec la PR et les ressources de l'autre. Basculer entre les taches en restant organise. En combinant avec les skills pour que Claude surveille le CI recursivement, etc. Franchement, je me sens eclaire.",
"tonkotsuboy": "J'etais passe de Warp a Ghostty en debut d'annee, mais maintenant je suis passe a cmux. Les onglets verticaux sont pratiques, et j'apprecie les notifications quand les taches Claude Code sont terminees. Comme c'est base sur Ghostty, les performances ultra-rapides sont conservees. L'affichage des branches et les completions que j'avais configures dans Ghostty fonctionnent toujours." "tonkotsuboy": "J'etais passe de Warp a Ghostty en debut d'annee, mais maintenant je suis passe a cmux. Les onglets verticaux sont pratiques, et j'apprecie les notifications quand les taches Claude Code sont terminees. Comme c'est base sur Ghostty, les performances ultra-rapides sont conservees. L'affichage des branches et les completions que j'avais configures dans Ghostty fonctionnent toujours."
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Builds de pointe depuis main",
"metaTitle": "cmux NIGHTLY — Builds Nightly",
"metaDescription": "Téléchargez cmux NIGHTLY, une app séparée compilée automatiquement depuis le dernier commit sur main. Fonctionne à côté de la version stable avec ses propres mises à jour automatiques.",
"description": "cmux NIGHTLY est compilé automatiquement depuis le dernier commit sur main. Il possède son propre bundle ID et fonctionne donc à côté de la version stable sans conflit. Utilisez-le pour tester les nouvelles fonctionnalités avant leur sortie.",
"download": "Télécharger NIGHTLY pour Mac",
"warning": "Les builds nightly peuvent contenir des bugs ou des fonctionnalités incomplètes. En cas de problème, signalez-le sur <githubLink>GitHub</githubLink> ou dans <discordLink>#nightly-bugs sur Discord</discordLink> et revenez à la version stable."
},
"languageSwitcher": { "languageSwitcher": {
"label": "Langue" "label": "Langue"
} }

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "Contatti", "contact": "Contatti",
"nightly": "Nightly",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "Lingua" "language": "Lingua"
}, },
@ -581,6 +582,15 @@
"connorelsea": "Lo uso da una settimana ed è fantastico. Un tab verticale per ogni task in corso. Dentro, Claude da un lato e il browser con PR e risorse dall'altro, passo tra i task e resto organizzato. Combinalo con le skill per far monitorare la CI a Claude ricorsivamente, ecc. mi sento illuminato onestamente", "connorelsea": "Lo uso da una settimana ed è fantastico. Un tab verticale per ogni task in corso. Dentro, Claude da un lato e il browser con PR e risorse dall'altro, passo tra i task e resto organizzato. Combinalo con le skill per far monitorare la CI a Claude ricorsivamente, ecc. mi sento illuminato onestamente",
"tonkotsuboy": "A inizio anno sono passato da Warp a Ghostty, ma ora sono passato a cmux. I tab verticali sono comodi e apprezzo le notifiche quando i task di Claude Code finiscono. È basato su Ghostty quindi le prestazioni fulminee restano. Anche la visualizzazione del branch e i completamenti che avevo impostato su Ghostty funzionano ancora." "tonkotsuboy": "A inizio anno sono passato da Warp a Ghostty, ma ora sono passato a cmux. I tab verticali sono comodi e apprezzo le notifiche quando i task di Claude Code finiscono. È basato su Ghostty quindi le prestazioni fulminee restano. Anche la visualizzazione del branch e i completamenti che avevo impostato su Ghostty funzionano ancora."
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Build di ultima generazione dal branch main",
"metaTitle": "cmux NIGHTLY — Build Nightly",
"metaDescription": "Scarica cmux NIGHTLY, un'app separata compilata automaticamente dall'ultimo commit su main. Funziona accanto alla versione stabile con aggiornamenti automatici propri.",
"description": "cmux NIGHTLY viene compilata automaticamente dall'ultimo commit su main. Ha un proprio bundle ID, quindi funziona accanto alla versione stabile senza conflitti. Usala per testare le nuove funzionalità prima del rilascio.",
"download": "Scarica NIGHTLY per Mac",
"warning": "Le build nightly possono contenere bug o funzionalità incomplete. In caso di problemi, segnalali su <githubLink>GitHub</githubLink> o in <discordLink>#nightly-bugs su Discord</discordLink> e torna alla versione stabile."
},
"languageSwitcher": { "languageSwitcher": {
"label": "Lingua" "label": "Lingua"
} }

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "お問い合わせ", "contact": "お問い合わせ",
"nightly": "ナイトリー",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "言語" "language": "言語"
}, },
@ -581,6 +582,15 @@
"connorelsea": "1週間使ってるけど最高。WIPタスクごとに縦タブ。中にはClaudeを片側に、PRやリソースのブラウザをもう片側に。タスクを切り替えながら整理できる。スキルでClaudeにCIを再帰的に監視させたり。正直、悟りを開いた気分。", "connorelsea": "1週間使ってるけど最高。WIPタスクごとに縦タブ。中にはClaudeを片側に、PRやリソースのブラウザをもう片側に。タスクを切り替えながら整理できる。スキルでClaudeにCIを再帰的に監視させたり。正直、悟りを開いた気分。",
"tonkotsuboy": "年初にWarpからGhosttyに乗り換えたけど、今はcmuxに乗り換えた💻 垂直タブが便利で、Claude Codeのタスクの終了が通知されるのがありがたい。Ghosttyベースだから爆速動作はそのまま。ghosttyでやったブランチ表示や補完もそのまま使える" "tonkotsuboy": "年初にWarpからGhosttyに乗り換えたけど、今はcmuxに乗り換えた💻 垂直タブが便利で、Claude Codeのタスクの終了が通知されるのがありがたい。Ghosttyベースだから爆速動作はそのまま。ghosttyでやったブランチ表示や補完もそのまま使える"
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "mainブランチからの最新ビルド",
"metaTitle": "cmux NIGHTLY — ナイトリービルド",
"metaDescription": "cmux NIGHTLYをダウンロード。mainの最新コミットから自動ビルドされる独立アプリ。安定版と並行して動作し、独自の自動アップデート機能付き。",
"description": "cmux NIGHTLYはmainの最新コミットから自動ビルドされます。独自のバンドルIDを持つため、安定版と競合せず並行して動作します。新機能をリリース前にテストできます。",
"download": "Mac版 NIGHTLYをダウンロード",
"warning": "ナイトリービルドにはバグや未完成の機能が含まれる場合があります。問題が発生した場合は<githubLink>GitHub</githubLink>または<discordLink>Discordの#nightly-bugs</discordLink>で報告し、安定版に切り替えてください。"
},
"languageSwitcher": { "languageSwitcher": {
"label": "言語" "label": "言語"
} }

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "ទំនាក់ទំនង", "contact": "ទំនាក់ទំនង",
"nightly": "Nightly",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "ភាសា" "language": "ភាសា"
}, },
@ -577,6 +578,15 @@
"connorelsea": "ប្រើមកមួយសប្តាហ៍ហើយ វាល្អខ្លាំង។ ផ្ទាំងបញ្ឈរសម្រាប់កិច្ចការនីមួយៗ។ ខាងក្នុង Claude នៅម្ខាង កម្មវិធីរុករកជាមួយ PR និងធនធាននៅម្ខាង ប្ដូររវាងកិច្ចការហើយរក្សាការរៀបចំ។ ផ្សំជាមួយ skills ឱ្យ Claude តាមដាន CI ដដែលៗ ។ រឹតតែស្រស់បំព្រង", "connorelsea": "ប្រើមកមួយសប្តាហ៍ហើយ វាល្អខ្លាំង។ ផ្ទាំងបញ្ឈរសម្រាប់កិច្ចការនីមួយៗ។ ខាងក្នុង Claude នៅម្ខាង កម្មវិធីរុករកជាមួយ PR និងធនធាននៅម្ខាង ប្ដូររវាងកិច្ចការហើយរក្សាការរៀបចំ។ ផ្សំជាមួយ skills ឱ្យ Claude តាមដាន CI ដដែលៗ ។ រឹតតែស្រស់បំព្រង",
"tonkotsuboy": "ខ្ញុំប្ដូរពី Warp មក Ghostty ដើមឆ្នាំ ប៉ុន្តែឥឡូវខ្ញុំប្ដូរមក cmux។ ផ្ទាំងបញ្ឈរងាយស្រួល ហើយខ្ញុំពេញចិត្តដែលទទួលបានជូនដំណឹងពេល Claude Code បានបញ្ចប់។ វាផ្អែកលើ Ghostty ដូច្នេះល្បឿនលឿនប្រែកៗនៅតែមាន។ ការបង្ហាញ branch និង completion ដែលខ្ញុំបានកំណត់ក្នុង Ghostty នៅតែដំណើរការដែរ។" "tonkotsuboy": "ខ្ញុំប្ដូរពី Warp មក Ghostty ដើមឆ្នាំ ប៉ុន្តែឥឡូវខ្ញុំប្ដូរមក cmux។ ផ្ទាំងបញ្ឈរងាយស្រួល ហើយខ្ញុំពេញចិត្តដែលទទួលបានជូនដំណឹងពេល Claude Code បានបញ្ចប់។ វាផ្អែកលើ Ghostty ដូច្នេះល្បឿនលឿនប្រែកៗនៅតែមាន។ ការបង្ហាញ branch និង completion ដែលខ្ញុំបានកំណត់ក្នុង Ghostty នៅតែដំណើរការដែរ។"
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "កំណែចុងក្រោយពីសាខា main",
"metaTitle": "cmux NIGHTLY — កំណែ Nightly",
"metaDescription": "ទាញយក cmux NIGHTLY កម្មវិធីដាច់ដោយឡែកដែលត្រូវបានបង្កើតដោយស្វ័យប្រវត្តិពី commit ចុងក្រោយនៅលើ main។ ដំណើរការស្របជាមួយកំណែស្ថិរភាពជាមួយការអាប់ដេតស្វ័យប្រវត្តិផ្ទាល់ខ្លួន។",
"description": "cmux NIGHTLY ត្រូវបានបង្កើតដោយស្វ័យប្រវត្តិពី commit ចុងក្រោយនៅលើ main។ វាមាន bundle ID ផ្ទាល់ខ្លួន ដូច្នេះវាដំណើរការស្របជាមួយកំណែស្ថិរភាពដោយគ្មានជម្លោះ។ ប្រើវាដើម្បីសាកល្បងមុខងារថ្មីមុនពេលចេញផ្សាយ។",
"download": "ទាញយក NIGHTLY សម្រាប់ Mac",
"warning": "កំណែ nightly អាចមានកំហុស ឬមុខងារមិនទាន់ពេញលេញ។ ប្រសិនបើមានបញ្ហា សូមរាយការណ៍នៅលើ <githubLink>GitHub</githubLink> ឬក្នុង <discordLink>#nightly-bugs នៅលើ Discord</discordLink> ហើយប្តូរទៅកំណែស្ថិរភាពវិញ។"
},
"languageSwitcher": { "languageSwitcher": {
"label": "ភាសា" "label": "ភាសា"
}, },

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "문의", "contact": "문의",
"nightly": "나이틀리",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "언어" "language": "언어"
}, },
@ -581,6 +582,15 @@
"connorelsea": "일주일째 쓰고 있는데 환상적이에요. WIP 작업마다 세로 탭 하나씩. 안에는 한쪽에 Claude, 다른 쪽에 PR과 리소스 브라우저. 작업 전환하면서 정리가 돼요. 스킬로 Claude에게 CI를 재귀적으로 감시시키는 것도 가능. 솔직히 깨달음을 얻은 기분.", "connorelsea": "일주일째 쓰고 있는데 환상적이에요. WIP 작업마다 세로 탭 하나씩. 안에는 한쪽에 Claude, 다른 쪽에 PR과 리소스 브라우저. 작업 전환하면서 정리가 돼요. 스킬로 Claude에게 CI를 재귀적으로 감시시키는 것도 가능. 솔직히 깨달음을 얻은 기분.",
"tonkotsuboy": "연초에 Warp에서 Ghostty로 갈아탔는데, 이제는 cmux로 갈아탔어요. 세로 탭이 편하고, Claude Code 작업이 끝나면 알림이 와서 좋아요. Ghostty 기반이라 빠른 성능은 그대로. Ghostty에서 설정한 브랜치 표시랑 자동완성도 그대로 쓸 수 있어요." "tonkotsuboy": "연초에 Warp에서 Ghostty로 갈아탔는데, 이제는 cmux로 갈아탔어요. 세로 탭이 편하고, Claude Code 작업이 끝나면 알림이 와서 좋아요. Ghostty 기반이라 빠른 성능은 그대로. Ghostty에서 설정한 브랜치 표시랑 자동완성도 그대로 쓸 수 있어요."
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "main 브랜치의 최신 빌드",
"metaTitle": "cmux NIGHTLY — 나이틀리 빌드",
"metaDescription": "cmux NIGHTLY를 다운로드하세요. main의 최신 커밋에서 자동으로 빌드되는 독립 앱입니다. 안정 버전과 나란히 실행되며 독자적인 자동 업데이트를 제공합니다.",
"description": "cmux NIGHTLY는 main의 최신 커밋에서 자동으로 빌드됩니다. 자체 번들 ID를 가지고 있어 안정 버전과 충돌 없이 나란히 실행됩니다. 출시 전에 새로운 기능을 테스트할 수 있습니다.",
"download": "Mac용 NIGHTLY 다운로드",
"warning": "나이틀리 빌드에는 버그나 미완성 기능이 포함될 수 있습니다. 문제가 발생하면 <githubLink>GitHub</githubLink> 또는 <discordLink>Discord의 #nightly-bugs</discordLink>에서 보고하고 안정 버전으로 전환하세요."
},
"languageSwitcher": { "languageSwitcher": {
"label": "언어" "label": "언어"
} }

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "Kontakt", "contact": "Kontakt",
"nightly": "Nightly",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "Språk" "language": "Språk"
}, },
@ -581,6 +582,15 @@
"connorelsea": "Har brukt dette i en uke og det er fantastisk. Vertikal fane for hver pågående oppgave. Inni har jeg Claude på den ene siden og nettleser med PR og ressurser på den andre, bytter mellom oppgaver og holder orden. Bland det med skills for å la Claude overvåke CI rekursivt, osv. Føler meg opplyst tbh", "connorelsea": "Har brukt dette i en uke og det er fantastisk. Vertikal fane for hver pågående oppgave. Inni har jeg Claude på den ene siden og nettleser med PR og ressurser på den andre, bytter mellom oppgaver og holder orden. Bland det med skills for å la Claude overvåke CI rekursivt, osv. Føler meg opplyst tbh",
"tonkotsuboy": "Jeg byttet fra Warp til Ghostty i begynnelsen av året, men nå har jeg byttet til cmux. De vertikale fanene er praktiske, og jeg setter pris på å bli varslet når Claude Code-oppgaver er ferdige. Det er Ghostty-basert, så den lynraske ytelsen følger med. Grenvisning og autofullføringer jeg satte opp i Ghostty fungerer fortsatt også." "tonkotsuboy": "Jeg byttet fra Warp til Ghostty i begynnelsen av året, men nå har jeg byttet til cmux. De vertikale fanene er praktiske, og jeg setter pris på å bli varslet når Claude Code-oppgaver er ferdige. Det er Ghostty-basert, så den lynraske ytelsen følger med. Grenvisning og autofullføringer jeg satte opp i Ghostty fungerer fortsatt også."
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Nyeste bygg fra main",
"metaTitle": "cmux NIGHTLY — Nightly-bygg",
"metaDescription": "Last ned cmux NIGHTLY, en separat app som bygges automatisk fra siste main-commit. Kjører ved siden av den stabile versjonen med egne automatiske oppdateringer.",
"description": "cmux NIGHTLY bygges automatisk fra siste commit på main. Den har sin egen bundle-ID, så den kjører ved siden av den stabile versjonen uten konflikter. Bruk den til å teste nye funksjoner før de lanseres.",
"download": "Last ned NIGHTLY for Mac",
"warning": "Nightly-bygg kan inneholde feil eller uferdige funksjoner. Hvis noe går galt, rapporter det på <githubLink>GitHub</githubLink> eller i <discordLink>#nightly-bugs på Discord</discordLink> og bytt tilbake til den stabile versjonen."
},
"languageSwitcher": { "languageSwitcher": {
"label": "Språk" "label": "Språk"
} }

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "Kontakt", "contact": "Kontakt",
"nightly": "Nightly",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "Język" "language": "Język"
}, },
@ -581,6 +582,15 @@
"connorelsea": "Używam tego od tygodnia i jest fantastyczne. Pionowa karta dla każdego zadania w toku. Wewnątrz, Claude po jednej stronie a przeglądarka z PR i zasobami po drugiej, przełączam się między zadaniami i utrzymuję porządek. Połącz to ze skillami żeby Claude monitorował CI rekursywnie itp. czuję się oświecony szczerze mówiąc", "connorelsea": "Używam tego od tygodnia i jest fantastyczne. Pionowa karta dla każdego zadania w toku. Wewnątrz, Claude po jednej stronie a przeglądarka z PR i zasobami po drugiej, przełączam się między zadaniami i utrzymuję porządek. Połącz to ze skillami żeby Claude monitorował CI rekursywnie itp. czuję się oświecony szczerze mówiąc",
"tonkotsuboy": "Na początku roku przeszedłem z Warpa na Ghostty, ale teraz przeszedłem na cmux. Pionowe karty są wygodne i doceniam powiadomienia gdy zadania Claude Code się kończą. Jest oparty na Ghostty więc błyskawiczna wydajność zostaje. Wyświetlanie brancha i uzupełniania które skonfigurowałem w Ghostty nadal działają." "tonkotsuboy": "Na początku roku przeszedłem z Warpa na Ghostty, ale teraz przeszedłem na cmux. Pionowe karty są wygodne i doceniam powiadomienia gdy zadania Claude Code się kończą. Jest oparty na Ghostty więc błyskawiczna wydajność zostaje. Wyświetlanie brancha i uzupełniania które skonfigurowałem w Ghostty nadal działają."
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Najnowsze buildy z gałęzi main",
"metaTitle": "cmux NIGHTLY — Buildy Nightly",
"metaDescription": "Pobierz cmux NIGHTLY, osobną aplikację budowaną automatycznie z najnowszego commita na main. Działa obok wersji stabilnej z własnymi aktualizacjami automatycznymi.",
"description": "cmux NIGHTLY jest budowany automatycznie z najnowszego commita na main. Ma własne bundle ID, więc działa obok wersji stabilnej bez konfliktów. Używaj go, aby testować nowe funkcje przed ich wydaniem.",
"download": "Pobierz NIGHTLY na Maca",
"warning": "Buildy nightly mogą zawierać błędy lub niekompletne funkcje. W razie problemów zgłoś je na <githubLink>GitHubie</githubLink> lub w <discordLink>#nightly-bugs na Discordzie</discordLink> i przełącz się na wersję stabilną."
},
"languageSwitcher": { "languageSwitcher": {
"label": "Język" "label": "Język"
} }

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "Contato", "contact": "Contato",
"nightly": "Nightly",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "Idioma" "language": "Idioma"
}, },
@ -581,6 +582,15 @@
"connorelsea": "Usando há uma semana e é fantástico. Aba vertical para cada tarefa em andamento. Dentro, claudes de um lado e navegador com PR e recursos do outro, alterno entre tarefas e mantenho tudo organizado. Misture com skills para o Claude monitorar CI recursivamente, etc. me sinto iluminado pra ser honesto", "connorelsea": "Usando há uma semana e é fantástico. Aba vertical para cada tarefa em andamento. Dentro, claudes de um lado e navegador com PR e recursos do outro, alterno entre tarefas e mantenho tudo organizado. Misture com skills para o Claude monitorar CI recursivamente, etc. me sinto iluminado pra ser honesto",
"tonkotsuboy": "Mudei do Warp para o Ghostty no início do ano, mas agora migrei para o cmux. As abas verticais são práticas e gosto de ser notificado quando tarefas do Claude Code terminam. É baseado no Ghostty, então a performance ultrarrápida se mantém. A exibição de branches e completions que configurei no Ghostty continuam funcionando também." "tonkotsuboy": "Mudei do Warp para o Ghostty no início do ano, mas agora migrei para o cmux. As abas verticais são práticas e gosto de ser notificado quando tarefas do Claude Code terminam. É baseado no Ghostty, então a performance ultrarrápida se mantém. A exibição de branches e completions que configurei no Ghostty continuam funcionando também."
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Builds de ponta do branch main",
"metaTitle": "cmux NIGHTLY — Builds Nightly",
"metaDescription": "Baixe o cmux NIGHTLY, um app separado compilado automaticamente do commit mais recente no main. Funciona ao lado da versão estável com suas próprias atualizações automáticas.",
"description": "O cmux NIGHTLY é compilado automaticamente do commit mais recente no main. Ele tem seu próprio bundle ID, então funciona ao lado da versão estável sem conflitos. Use-o para testar novos recursos antes do lançamento.",
"download": "Baixar NIGHTLY para Mac",
"warning": "Builds nightly podem conter bugs ou recursos incompletos. Se algo quebrar, reporte no <githubLink>GitHub</githubLink> ou em <discordLink>#nightly-bugs no Discord</discordLink> e volte para a versão estável."
},
"languageSwitcher": { "languageSwitcher": {
"label": "Idioma" "label": "Idioma"
} }

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "Контакты", "contact": "Контакты",
"nightly": "Ночные сборки",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "Язык" "language": "Язык"
}, },
@ -581,6 +582,15 @@
"connorelsea": "Использую неделю и это фантастика. Вертикальная вкладка для каждой текущей задачи. Внутри Claude с одной стороны и браузер с PR и ресурсами с другой, переключаюсь между задачами и остаюсь организованным. Сочетай это со скиллами чтобы Claude рекурсивно следил за CI и т.д. чувствую себя просветлённым честно говоря", "connorelsea": "Использую неделю и это фантастика. Вертикальная вкладка для каждой текущей задачи. Внутри Claude с одной стороны и браузер с PR и ресурсами с другой, переключаюсь между задачами и остаюсь организованным. Сочетай это со скиллами чтобы Claude рекурсивно следил за CI и т.д. чувствую себя просветлённым честно говоря",
"tonkotsuboy": "В начале года перешёл с Warp на Ghostty, а теперь перешёл на cmux. Вертикальные вкладки удобны, и ценю уведомления когда задачи Claude Code завершаются. Он на базе Ghostty, так что молниеносная скорость сохраняется. Отображение веток и автодополнения, которые я настроил в Ghostty, тоже работают." "tonkotsuboy": "В начале года перешёл с Warp на Ghostty, а теперь перешёл на cmux. Вертикальные вкладки удобны, и ценю уведомления когда задачи Claude Code завершаются. Он на базе Ghostty, так что молниеносная скорость сохраняется. Отображение веток и автодополнения, которые я настроил в Ghostty, тоже работают."
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "Актуальные сборки из ветки main",
"metaTitle": "cmux NIGHTLY — Ночные сборки",
"metaDescription": "Скачайте cmux NIGHTLY — отдельное приложение, автоматически собираемое из последнего коммита в main. Работает параллельно со стабильной версией с собственными автообновлениями.",
"description": "cmux NIGHTLY автоматически собирается из последнего коммита в main. У него собственный bundle ID, поэтому он работает параллельно со стабильной версией без конфликтов. Используйте его для тестирования новых функций до релиза.",
"download": "Скачать NIGHTLY для Mac",
"warning": "Ночные сборки могут содержать ошибки или незавершённые функции. Если что-то сломалось, сообщите на <githubLink>GitHub</githubLink> или в <discordLink>#nightly-bugs в Discord</discordLink> и переключитесь на стабильную версию."
},
"languageSwitcher": { "languageSwitcher": {
"label": "Язык" "label": "Язык"
} }

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "ติดต่อ", "contact": "ติดต่อ",
"nightly": "Nightly",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "ภาษา" "language": "ภาษา"
}, },
@ -577,6 +578,15 @@
"connorelsea": "ใช้มาสัปดาห์นึงแล้ว เยี่ยมมาก แท็บแนวตั้งสำหรับแต่ละงานที่ทำอยู่ ข้างในมี Claude อยู่ด้านนึงและเบราว์เซอร์กับ PR และทรัพยากรอยู่อีกด้าน สลับไปมาระหว่างงานได้อย่างเป็นระเบียบ ผสมกับ skills ให้ Claude คอยดู CI แบบ recursive ฯลฯ รู้สึกตาสว่างเลย", "connorelsea": "ใช้มาสัปดาห์นึงแล้ว เยี่ยมมาก แท็บแนวตั้งสำหรับแต่ละงานที่ทำอยู่ ข้างในมี Claude อยู่ด้านนึงและเบราว์เซอร์กับ PR และทรัพยากรอยู่อีกด้าน สลับไปมาระหว่างงานได้อย่างเป็นระเบียบ ผสมกับ skills ให้ Claude คอยดู CI แบบ recursive ฯลฯ รู้สึกตาสว่างเลย",
"tonkotsuboy": "ผมเปลี่ยนจาก Warp มา Ghostty ตอนต้นปี แต่ตอนนี้เปลี่ยนมา cmux แล้ว แท็บแนวตั้งสะดวกดี และชอบที่แจ้งเตือนเมื่องาน Claude Code เสร็จ มันใช้ Ghostty เป็นฐานก็เลยเร็วเหมือนเดิม การแสดง branch และ completion ที่ตั้งไว้ใน Ghostty ก็ยังใช้ได้อยู่" "tonkotsuboy": "ผมเปลี่ยนจาก Warp มา Ghostty ตอนต้นปี แต่ตอนนี้เปลี่ยนมา cmux แล้ว แท็บแนวตั้งสะดวกดี และชอบที่แจ้งเตือนเมื่องาน Claude Code เสร็จ มันใช้ Ghostty เป็นฐานก็เลยเร็วเหมือนเดิม การแสดง branch และ completion ที่ตั้งไว้ใน Ghostty ก็ยังใช้ได้อยู่"
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "บิลด์ล่าสุดจาก main",
"metaTitle": "cmux NIGHTLY — บิลด์ Nightly",
"metaDescription": "ดาวน์โหลด cmux NIGHTLY แอปแยกที่สร้างอัตโนมัติจาก commit ล่าสุดบน main ทำงานควบคู่กับเวอร์ชันเสถียรพร้อมอัปเดตอัตโนมัติของตัวเอง",
"description": "cmux NIGHTLY สร้างอัตโนมัติจาก commit ล่าสุดบน main มี bundle ID เป็นของตัวเอง จึงทำงานควบคู่กับเวอร์ชันเสถียรได้โดยไม่ขัดแย้ง ใช้เพื่อทดสอบฟีเจอร์ใหม่ก่อนเปิดตัว",
"download": "ดาวน์โหลด NIGHTLY สำหรับ Mac",
"warning": "บิลด์ nightly อาจมีบั๊กหรือฟีเจอร์ที่ยังไม่สมบูรณ์ หากพบปัญหา รายงานบน <githubLink>GitHub</githubLink> หรือใน <discordLink>#nightly-bugs บน Discord</discordLink> แล้วสลับกลับไปใช้เวอร์ชันเสถียร"
},
"languageSwitcher": { "languageSwitcher": {
"label": "ภาษา" "label": "ภาษา"
}, },

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "İletişim", "contact": "İletişim",
"nightly": "Nightly",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "Dil" "language": "Dil"
}, },
@ -581,6 +582,15 @@
"connorelsea": "Bir haftadır kullanıyorum ve harika. Her devam eden görev için dikey sekme. İçinde bir tarafta Claude'lar, diğer tarafta PR ve kaynaklarla tarayıcı, görevler arasında geçiş yapıp düzenli kalıyorum. Bunu Claude'un CI'ı özyinelemeli izlemesi için skill'lerle birleştirin, vs. aydınlanmış hissediyorum açıkçası", "connorelsea": "Bir haftadır kullanıyorum ve harika. Her devam eden görev için dikey sekme. İçinde bir tarafta Claude'lar, diğer tarafta PR ve kaynaklarla tarayıcı, görevler arasında geçiş yapıp düzenli kalıyorum. Bunu Claude'un CI'ı özyinelemeli izlemesi için skill'lerle birleştirin, vs. aydınlanmış hissediyorum açıkçası",
"tonkotsuboy": "Yılın başında Warp'tan Ghostty'ye geçtim ama şimdi cmux'a geçtim. Dikey sekmeler kullanışlı ve Claude Code görevleri bittiğinde bildirim almayı takdir ediyorum. Ghostty tabanlı olduğu için çok hızlı performans aynen devam ediyor. Ghostty'de ayarladığım dal gösterimi ve tamamlamalar da hâlâ çalışıyor." "tonkotsuboy": "Yılın başında Warp'tan Ghostty'ye geçtim ama şimdi cmux'a geçtim. Dikey sekmeler kullanışlı ve Claude Code görevleri bittiğinde bildirim almayı takdir ediyorum. Ghostty tabanlı olduğu için çok hızlı performans aynen devam ediyor. Ghostty'de ayarladığım dal gösterimi ve tamamlamalar da hâlâ çalışıyor."
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "main dalından güncel derlemeler",
"metaTitle": "cmux NIGHTLY — Nightly Derlemeler",
"metaDescription": "cmux NIGHTLY indirin. main deki en son commit ten otomatik olarak derlenen bağımsız bir uygulama. Kararlı sürümle yan yana çalışır ve kendi otomatik güncellemelerine sahiptir.",
"description": "cmux NIGHTLY, main deki en son commit ten otomatik olarak derlenir. Kendi bundle ID sine sahip olduğundan kararlı sürümle çakışmadan yan yana çalışır. Yeni özellikleri yayınlanmadan önce test etmek için kullanın.",
"download": "Mac için NIGHTLY indir",
"warning": "Nightly derlemeler hatalar veya tamamlanmamış özellikler içerebilir. Bir sorun oluşursa <githubLink>GitHub</githubLink> veya <discordLink>Discord daki #nightly-bugs</discordLink> kanalında bildirin ve kararlı sürüme geçin."
},
"languageSwitcher": { "languageSwitcher": {
"label": "Dil" "label": "Dil"
} }

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "联系我们", "contact": "联系我们",
"nightly": "每夜构建",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "语言" "language": "语言"
}, },
@ -581,6 +582,15 @@
"connorelsea": "用了一周,非常棒。每个进行中的任务一个垂直标签页。里面一边是 Claude另一边是浏览器看 PR 和资料,在任务之间切换保持有序。配合 skill 让 Claude 递归监控 CI 等等。感觉开悟了。", "connorelsea": "用了一周,非常棒。每个进行中的任务一个垂直标签页。里面一边是 Claude另一边是浏览器看 PR 和资料,在任务之间切换保持有序。配合 skill 让 Claude 递归监控 CI 等等。感觉开悟了。",
"tonkotsuboy": "年初从 Warp 换到 Ghostty现在又换到了 cmux。垂直标签页很方便Claude Code 任务完成时收到通知很实用。基于 Ghostty 所以依然飞快。之前在 Ghostty 里设置的分支显示和补全也都能用。" "tonkotsuboy": "年初从 Warp 换到 Ghostty现在又换到了 cmux。垂直标签页很方便Claude Code 任务完成时收到通知很实用。基于 Ghostty 所以依然飞快。之前在 Ghostty 里设置的分支显示和补全也都能用。"
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "来自 main 分支的最新构建",
"metaTitle": "cmux NIGHTLY — 每夜构建",
"metaDescription": "下载 cmux NIGHTLY从最新 main 提交自动构建的独立应用。与稳定版并行运行,拥有独立的自动更新。",
"description": "cmux NIGHTLY 从 main 的最新提交自动构建。它拥有独立的 Bundle ID因此可以与稳定版并行运行互不冲突。用它来测试尚未发布的新功能。",
"download": "下载 Mac 版 NIGHTLY",
"warning": "每夜构建可能包含错误或不完整的功能。如果遇到问题,请在 <githubLink>GitHub</githubLink> 或 <discordLink>Discord 的 #nightly-bugs</discordLink> 上报告,并切换回稳定版。"
},
"languageSwitcher": { "languageSwitcher": {
"label": "语言" "label": "语言"
} }

View file

@ -37,6 +37,7 @@
"twitter": "X / Twitter", "twitter": "X / Twitter",
"discord": "Discord", "discord": "Discord",
"contact": "聯絡我們", "contact": "聯絡我們",
"nightly": "每夜建置",
"copyright": "© {year} Manaflow", "copyright": "© {year} Manaflow",
"language": "語言" "language": "語言"
}, },
@ -581,6 +582,15 @@
"connorelsea": "用了一週,非常棒。每個進行中的任務一個垂直分頁。裡面一邊是 Claude另一邊是瀏覽器看 PR 和資料,在任務之間切換保持有序。搭配 skill 讓 Claude 遞迴監控 CI 等等。感覺開悟了。", "connorelsea": "用了一週,非常棒。每個進行中的任務一個垂直分頁。裡面一邊是 Claude另一邊是瀏覽器看 PR 和資料,在任務之間切換保持有序。搭配 skill 讓 Claude 遞迴監控 CI 等等。感覺開悟了。",
"tonkotsuboy": "年初從 Warp 換到 Ghostty現在又換到了 cmux。垂直分頁很方便Claude Code 任務完成時收到通知很實用。基於 Ghostty 所以依然飛快。之前在 Ghostty 裡設定的分支顯示和補全也都能用。" "tonkotsuboy": "年初從 Warp 換到 Ghostty現在又換到了 cmux。垂直分頁很方便Claude Code 任務完成時收到通知很實用。基於 Ghostty 所以依然飛快。之前在 Ghostty 裡設定的分支顯示和補全也都能用。"
}, },
"nightly": {
"title": "cmux NIGHTLY",
"subtitle": "來自 main 分支的最新建置",
"metaTitle": "cmux NIGHTLY — 每夜建置",
"metaDescription": "下載 cmux NIGHTLY從最新 main 提交自動建置的獨立應用。與穩定版並行運行,擁有獨立的自動更新。",
"description": "cmux NIGHTLY 從 main 的最新提交自動建置。它擁有獨立的 Bundle ID因此可以與穩定版並行運行互不衝突。用它來測試尚未發佈的新功能。",
"download": "下載 Mac 版 NIGHTLY",
"warning": "每夜建置可能包含錯誤或不完整的功能。如果遇到問題,請在 <githubLink>GitHub</githubLink> 或 <discordLink>Discord 的 #nightly-bugs</discordLink> 上回報,並切換回穩定版。"
},
"languageSwitcher": { "languageSwitcher": {
"label": "語言" "label": "語言"
} }

BIN
web/public/logo-nightly.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB