Merge branch 'main' into issue-151-ssh-remote-port-proxying

# Conflicts:
#	CLI/cmux.swift
#	Sources/ContentView.swift
#	Sources/GhosttyTerminalView.swift
#	Sources/Panels/BrowserPanel.swift
#	Sources/Panels/BrowserPanelView.swift
#	Sources/TabManager.swift
#	Sources/TerminalController.swift
#	Sources/Workspace.swift
#	Sources/WorkspaceContentView.swift
#	ghostty
This commit is contained in:
Lawrence Chen 2026-03-09 18:36:59 -07:00
commit bdebc8ecc9
205 changed files with 107859 additions and 6333 deletions

View file

@ -46,6 +46,18 @@ body:
validations:
required: true
- type: dropdown
id: nightly-repro
attributes:
label: Can you reproduce this on cmux NIGHTLY?
description: "Please test with the latest NIGHTLY build first: https://github.com/manaflow-ai/cmux?tab=readme-ov-file#nightly-builds"
options:
- Yes, it still reproduces on NIGHTLY
- No, it does not reproduce on NIGHTLY
- I could not test NIGHTLY
validations:
required: true
- type: textarea
id: description
attributes:

33
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,33 @@
## Summary
- What changed?
- Why?
## Testing
- How did you test this change?
- What did you verify manually?
## Demo Video
For UI or behavior changes, include a short demo video (GitHub upload, Loom, or other direct link).
- Video URL or attachment:
## Review Trigger (Copy/Paste as PR comment)
```text
@codex review
@coderabbitai review
@greptile-apps review
@cubic-dev-ai review
```
## Checklist
- [ ] I tested the change locally
- [ ] I added or updated tests for behavior changes
- [ ] I updated docs/changelog if needed
- [ ] I requested bot reviews after my latest commit (copy/paste block above or equivalent)
- [ ] All code review bot comments are resolved
- [ ] All human review comments are resolved

View file

@ -8,12 +8,9 @@ on:
jobs:
build-ghosttykit:
# Never run self-hosted jobs for fork pull requests.
# Never run Depot jobs for fork pull requests (avoid billing on external PRs).
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: self-hosted
concurrency:
group: self-hosted-build
cancel-in-progress: false
runs-on: depot-macos-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@ -72,7 +69,7 @@ jobs:
exit 1
fi
fi
cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false -Doptimize=ReleaseFast
cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=universal -Doptimize=ReleaseFast
- name: Package xcframework
if: steps.check-release.outputs.exists == 'false'

171
.github/workflows/ci-macos-compat.yml vendored Normal file
View file

@ -0,0 +1,171 @@
name: macOS Compatibility
on:
push:
branches:
- main
pull_request:
jobs:
compat-tests:
# Only run for the repo itself, not forks (GhosttyKit download needs repo access).
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
strategy:
fail-fast: false
matrix:
os: [macos-14, macos-15]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
- name: Select Xcode
run: |
set -euo pipefail
# Pick the latest Xcode installed on the runner. GitHub-hosted macos-14
# defaults to Xcode 15.4, but the project needs Xcode 16+ (Swift tools
# version 6.0 required by sentry-cocoa).
XCODE_APP="$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort | tail -n 1)"
if [ -z "$XCODE_APP" ]; then
XCODE_APP="/Applications/Xcode.app"
fi
XCODE_DIR="$XCODE_APP/Contents/Developer"
if [ ! -d "$XCODE_DIR" ]; then
echo "No Xcode found under /Applications" >&2
exit 1
fi
echo "Selected: $XCODE_APP"
echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV"
export DEVELOPER_DIR="$XCODE_DIR"
xcodebuild -version
xcrun --sdk macosx --show-sdk-path
sw_vers
- name: Download pre-built GhosttyKit.xcframework
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD)
TAG="xcframework-$GHOSTTY_SHA"
URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz"
echo "Downloading xcframework for ghostty $GHOSTTY_SHA"
MAX_RETRIES=30
RETRY_DELAY=20
for i in $(seq 1 $MAX_RETRIES); do
if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then
echo "Download succeeded on attempt $i"
break
fi
if [ "$i" -eq "$MAX_RETRIES" ]; then
echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2
exit 1
fi
echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..."
sleep $RETRY_DELAY
done
tar xzf GhosttyKit.xcframework.tar.gz
rm GhosttyKit.xcframework.tar.gz
test -d GhosttyKit.xcframework
- name: Clean DerivedData
run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
- name: Cache Swift packages
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: .ci-source-packages
key: spm-${{ matrix.os }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-${{ matrix.os }}-
- name: Resolve Swift packages
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
mkdir -p "$SOURCE_PACKAGES_DIR"
for attempt in 1 2 3; do
if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-resolvePackageDependencies; then
exit 0
fi
if [ "$attempt" -eq 3 ]; then
echo "Failed to resolve Swift packages after 3 attempts" >&2
exit 1
fi
echo "Package resolution failed on attempt $attempt, retrying..."
sleep $((attempt * 5))
done
- name: Run unit tests
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
run_unit_tests() {
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" test 2>&1
}
set +e
OUTPUT=$(run_unit_tests)
EXIT_CODE=$?
set -e
# SwiftPM binary artifact resolution can occasionally fail on ephemeral
# runners. Retry once after clearing caches.
if [ "$EXIT_CODE" -ne 0 ] && echo "$OUTPUT" | grep -q "Could not resolve package dependencies"; then
echo "SwiftPM package resolution failed, clearing caches and retrying once"
rm -rf ~/Library/Caches/org.swift.swiftpm
mkdir -p ~/Library/Caches/org.swift.swiftpm
rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
set +e
OUTPUT=$(run_unit_tests)
EXIT_CODE=$?
set -e
fi
echo "$OUTPUT"
if [ "$EXIT_CODE" -ne 0 ]; then
SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1)
if echo "$SUMMARY" | grep -q "(0 unexpected)"; then
echo "All failures are expected, treating as pass"
else
echo "Unexpected test failures detected"
exit 1
fi
fi
- name: Create virtual display
run: |
set -euo pipefail
echo "=== Display before ==="
system_profiler SPDisplaysDataType 2>/dev/null || echo "(no display info)"
echo ""
clang -framework Foundation -framework CoreGraphics \
-o /tmp/create-virtual-display scripts/create-virtual-display.m
/tmp/create-virtual-display &
VDISPLAY_PID=$!
echo "VDISPLAY_PID=$VDISPLAY_PID" >> "$GITHUB_ENV"
sleep 3
echo "=== Display after ==="
system_profiler SPDisplaysDataType 2>/dev/null || echo "(no display info)"
- name: Build app for smoke test
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" build
- name: Smoke test
run: |
set -euo pipefail
chmod +x scripts/smoke-test-ci.sh
scripts/smoke-test-ci.sh

View file

@ -13,7 +13,7 @@ jobs:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Validate self-hosted runner guards
- name: Validate Depot runner guards
run: ./tests/test_ci_self_hosted_guard.sh
- name: Validate create-dmg version pinning
@ -25,6 +25,9 @@ jobs:
- name: Validate cmux scheme test configuration
run: ./tests/test_ci_scheme_testaction_debug.sh
- name: Validate GhosttyKit checksum verification
run: ./tests/test_ci_ghosttykit_checksum_verification.sh
web-typecheck:
runs-on: ubuntu-latest
defaults:
@ -44,12 +47,7 @@ jobs:
run: bun tsc --noEmit
tests:
# Never run self-hosted jobs for fork pull requests.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: self-hosted
concurrency:
group: self-hosted-build
cancel-in-progress: false
runs-on: macos-15
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@ -59,51 +57,41 @@ jobs:
- name: Select Xcode
run: |
set -euo pipefail
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
XCODE_DIR="/Applications/Xcode.app/Contents/Developer"
else
XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -n 1 || true)"
if [ -n "$XCODE_APP" ]; then
XCODE_APP="$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort | tail -n 1 || true)"
if [ -z "$XCODE_APP" ]; then
XCODE_APP="/Applications/Xcode.app"
fi
XCODE_DIR="$XCODE_APP/Contents/Developer"
else
echo "No Xcode.app found under /Applications" >&2
if [ ! -d "$XCODE_DIR" ]; then
echo "No Xcode found under /Applications" >&2
exit 1
fi
fi
echo "Selected: $XCODE_APP"
echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV"
export DEVELOPER_DIR="$XCODE_DIR"
xcodebuild -version
xcrun --sdk macosx --show-sdk-path
- name: Download Metal Toolchain
run: xcodebuild -downloadComponent MetalToolchain
- name: Build GhosttyKit.xcframework
- name: Download pre-built GhosttyKit.xcframework
run: |
set -euo pipefail
if ! command -v zig >/dev/null 2>&1; then
if command -v brew >/dev/null 2>&1; then
brew install zig
else
echo "zig is required to build GhosttyKit.xcframework. Install zig and retry." >&2
exit 1
fi
fi
(cd ghostty && zig build -Demit-xcframework=true -Demit-macos-app=false)
rm -rf GhosttyKit.xcframework
cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework
test -d GhosttyKit.xcframework
./scripts/download-prebuilt-ghosttykit.sh
- name: Clean DerivedData
run: |
# Remove stale build cache to avoid incremental build errors
rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
- name: Cache Swift packages
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: .ci-source-packages
key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-
- name: Resolve Swift packages
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
rm -rf "$SOURCE_PACKAGES_DIR"
mkdir -p "$SOURCE_PACKAGES_DIR"
for attempt in 1 2 3; do
@ -138,12 +126,13 @@ jobs:
EXIT_CODE=$?
set -e
# SwiftPM binary artifact resolution can occasionally fail on self-hosted
# SwiftPM binary artifact resolution can occasionally fail on ephemeral
# runners with "Could not resolve package dependencies". Retry once after
# clearing SwiftPM/DerivedData caches to recover from transient corruption.
if [ "$EXIT_CODE" -ne 0 ] && echo "$OUTPUT" | grep -q "Could not resolve package dependencies"; then
echo "SwiftPM package resolution failed, clearing caches and retrying once"
rm -rf ~/Library/Caches/org.swift.swiftpm
mkdir -p ~/Library/Caches/org.swift.swiftpm
rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
set +e
OUTPUT=$(run_unit_tests)
@ -162,12 +151,151 @@ jobs:
fi
fi
tests-depot:
# Never run Depot jobs for fork pull requests (avoid billing on external PRs).
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: depot-macos-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
submodules: recursive
- name: Select Xcode
run: |
set -euo pipefail
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
XCODE_DIR="/Applications/Xcode.app/Contents/Developer"
else
XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | sort | tail -n 1 || true)"
if [ -n "$XCODE_APP" ]; then
XCODE_DIR="$XCODE_APP/Contents/Developer"
else
echo "No Xcode.app found under /Applications" >&2
exit 1
fi
fi
echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV"
export DEVELOPER_DIR="$XCODE_DIR"
xcodebuild -version
- name: Download pre-built GhosttyKit.xcframework
run: |
./scripts/download-prebuilt-ghosttykit.sh
- name: Clean DerivedData
run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
- name: Cache Swift packages
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: .ci-source-packages
key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-
- name: Resolve Swift packages
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
mkdir -p "$SOURCE_PACKAGES_DIR"
for attempt in 1 2 3; do
if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-resolvePackageDependencies; then
exit 0
fi
if [ "$attempt" -eq 3 ]; then
echo "Failed to resolve Swift packages after 3 attempts" >&2
exit 1
fi
echo "Package resolution failed on attempt $attempt, retrying..."
sleep $((attempt * 5))
done
- name: Create virtual display
run: |
set -euo pipefail
clang -framework Foundation -framework CoreGraphics \
-o /tmp/create-virtual-display scripts/create-virtual-display.m
/tmp/create-virtual-display &
VDISPLAY_PID=$!
echo "VDISPLAY_PID=$VDISPLAY_PID" >> "$GITHUB_ENV"
sleep 3
- name: Run UI tests
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
# SidebarResizeUITests hangs on headless runners (mouse drag simulation
# doesn't work without a physical display, even with virtual display).
# Skip it in CI; it runs fine on local machines.
run_ui_tests() {
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
-only-testing:cmuxUITests/UpdatePillUITests test
-maximum-test-execution-time-allowance 120 \
-only-testing:cmuxUITests \
-skip-testing:cmuxUITests/SidebarResizeUITests test 2>&1
}
# xcodebuild exits 65 even for expected failures (XCTExpectFailure).
# Capture output and fail only if there are unexpected failures.
set +e
OUTPUT=$(run_ui_tests)
EXIT_CODE=$?
set -e
echo "$OUTPUT"
if [ "$EXIT_CODE" -ne 0 ]; then
SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1)
if echo "$SUMMARY" | grep -q "(0 unexpected)"; then
echo "All failures are expected, treating as pass"
else
echo "Unexpected test failures detected"
exit 1
fi
fi
- name: Run workspace churn typing-lag regression
run: |
set -euo pipefail
APP="$(find "$HOME/Library/Developer/Xcode/DerivedData" -path "*/Build/Products/Debug/cmux DEV.app" -print -quit)"
if [ -z "${APP:-}" ] || [ ! -d "$APP" ]; then
echo "cmux DEV.app not found in DerivedData" >&2
exit 1
fi
TAG="ci-lag"
SOCK="/tmp/cmux-debug-${TAG}.sock"
BUNDLE_ID="$(
/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP/Contents/Info.plist" 2>/dev/null \
|| echo 'com.cmuxterm.app.debug'
)"
pkill -x "cmux DEV" || true
rm -f "$SOCK" "/tmp/cmux-${TAG}.sock" || true
defaults write "$BUNDLE_ID" socketControlMode -string full >/dev/null 2>&1 || true
CMUX_TAG="$TAG" CMUX_SOCKET_PATH="$SOCK" CMUX_UI_TEST_MODE=1 "$APP/Contents/MacOS/cmux DEV" >/tmp/cmux-ci-lag.log 2>&1 &
APP_PID=$!
trap 'kill "$APP_PID" >/dev/null 2>&1 || true' EXIT
for _ in {1..240}; do
[ -S "$SOCK" ] && break
sleep 0.25
done
[ -S "$SOCK" ] || { echo "Socket not ready at $SOCK" >&2; exit 1; }
CMUX_SOCKET_PATH="$SOCK" \
CMUX_LAG_MAX_P95_RATIO=1.70 \
CMUX_LAG_MAX_AVG_RATIO=1.70 \
CMUX_LAG_MIN_BASELINE_P95_MS_FOR_RATIO=6.0 \
CMUX_LAG_MIN_BASELINE_AVG_MS_FOR_RATIO=4.0 \
CMUX_LAG_MAX_P95_DELTA_MS=20.0 \
CMUX_LAG_MAX_AVG_DELTA_MS=12.0 \
CMUX_LAG_MAX_CHURN_P95_MS=35 \
CMUX_LAG_KEY_EVENTS=180 \
python3 tests/test_workspace_churn_up_arrow_lag.py

50
.github/workflows/claude.yml vendored Normal file
View file

@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'

View file

@ -1,9 +1,8 @@
name: Nightly macOS build
on:
schedule:
# Every hour at :30. The 'decide' job skips if main has no new commits.
- cron: "30 * * * *"
push:
branches: [main]
workflow_dispatch:
inputs:
force:
@ -12,6 +11,10 @@ on:
default: false
type: boolean
concurrency:
group: nightly-build-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: write
@ -25,6 +28,7 @@ jobs:
should_build: ${{ steps.decide.outputs.should_build }}
head_sha: ${{ steps.decide.outputs.head_sha }}
short_sha: ${{ steps.decide.outputs.short_sha }}
should_publish: ${{ steps.decide.outputs.should_publish }}
steps:
- name: Decide whether a nightly build is needed
id: decide
@ -35,15 +39,23 @@ jobs:
script: |
const forceBuild = process.env.FORCE_BUILD === 'true';
const { owner, repo } = context.repo;
const requestedRef = context.ref.startsWith('refs/heads/')
? context.ref.replace('refs/heads/', '')
: 'main';
const isMainRef = requestedRef === 'main';
let headSha = context.sha;
if (isMainRef) {
const branch = await github.rest.repos.getBranch({
owner,
repo,
branch: 'main',
});
const headSha = branch.data.commit.sha;
headSha = branch.data.commit.sha;
}
let nightlySha = null;
if (isMainRef) {
try {
const ref = await github.rest.git.getRef({
owner,
@ -63,30 +75,31 @@ jobs:
} catch (error) {
if (error.status !== 404) throw error;
}
}
const shouldBuild = forceBuild || nightlySha !== headSha;
const shouldBuild = !isMainRef || forceBuild || nightlySha !== headSha;
core.setOutput('should_build', shouldBuild ? 'true' : 'false');
core.setOutput('head_sha', headSha);
core.setOutput('short_sha', headSha.slice(0, 7));
core.setOutput('should_publish', isMainRef ? 'true' : 'false');
core.summary
.addHeading('Nightly build decision')
.addTable([
[{data: 'main HEAD', header: true}, headSha],
[{data: 'requested ref', header: true}, requestedRef],
[{data: 'build HEAD', header: true}, headSha],
[{data: 'nightly tag', header: true}, nightlySha ?? '(missing)'],
[{data: 'force build', header: true}, String(forceBuild)],
[{data: 'should build', header: true}, String(shouldBuild)],
[{data: 'should publish', header: true}, String(isMainRef)],
])
.write();
build-sign-notarize-nightly:
needs: decide
if: needs.decide.outputs.should_build == 'true'
runs-on: self-hosted
concurrency:
group: self-hosted-build
cancel-in-progress: false
runs-on: macos-15
steps:
- name: Checkout main
- name: Checkout build ref
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ needs.decide.outputs.head_sha }}
@ -113,30 +126,18 @@ jobs:
- name: Install build deps
run: |
brew update
brew install zig
npm install --global "create-dmg@${CREATE_DMG_VERSION}"
- name: Build GhosttyKit.xcframework
- name: Download pre-built GhosttyKit.xcframework
run: |
cd ghostty
zig build -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=native -Doptimize=ReleaseFast
cd ..
rm -rf GhosttyKit.xcframework
cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework
./scripts/download-prebuilt-ghosttykit.sh
- name: Clear SPM cache
run: |
rm -rf ~/Library/Caches/org.swift.swiftpm
rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
- name: Configure SwiftPM cache
run: |
set -euo pipefail
CACHE_DIR="${RUNNER_TEMP}/swiftpm-cache/${GITHUB_RUN_ID}"
rm -rf "$CACHE_DIR"
mkdir -p "$CACHE_DIR"
echo "SWIFTPM_CACHE_PATH=$CACHE_DIR" >> "$GITHUB_ENV"
- name: Cache Swift packages
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: .spm-cache
key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-
- name: Derive Sparkle public key from private key
env:
@ -150,38 +151,53 @@ jobs:
echo "Derived Sparkle public key: $DERIVED_PUBLIC_KEY"
echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV"
- name: Build app (Release)
- name: Build Apple Silicon app (Release)
run: |
xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build
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: Inject nightly identity and metadata
- name: Build universal app (Release)
run: |
xcodebuild -scheme cmux -configuration Release -derivedDataPath build-universal \
-destination 'generic/platform=macOS' \
-clonedSourcePackagesDirPath .spm-cache \
ARCHS="arm64 x86_64" \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=NO ASSETCATALOG_COMPILER_APPICON_NAME=AppIcon-Nightly build
- name: Verify nightly binary architectures
run: |
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"
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")"
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 "CLI binary architectures: $CLI_ARCHS"
[[ "$ARM_APP_ARCHS" == "arm64" ]]
[[ "$ARM_CLI_ARCHS" == "arm64" ]]
[[ "$APP_ARCHS" == *arm64* && "$APP_ARCHS" == *x86_64* ]]
[[ "$CLI_ARCHS" == *arm64* && "$CLI_ARCHS" == *x86_64* ]]
- name: Inject nightly identities and metadata
run: |
set -euo pipefail
APP_DIR="build/Build/Products/Release"
APP_PLIST="${APP_DIR}/cmux.app/Contents/Info.plist"
SHORT_SHA="${{ needs.decide.outputs.short_sha }}"
ARM_APP_DIR="build-arm/Build/Products/Release"
UNIVERSAL_APP_DIR="build-universal/Build/Products/Release"
# --- Separate app identity: "cmux NIGHTLY" with its own bundle ID ---
/usr/libexec/PlistBuddy -c "Set :CFBundleName cmux NIGHTLY" "$APP_PLIST"
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName cmux NIGHTLY" "$APP_PLIST"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier com.cmuxterm.app.nightly" "$APP_PLIST"
# Rename the .app bundle to match the display name
mv "${APP_DIR}/cmux.app" "${APP_DIR}/cmux NIGHTLY.app"
# Update plist path after rename
APP_PLIST="${APP_DIR}/cmux NIGHTLY.app/Contents/Info.plist"
# --- Sparkle: point at the nightly appcast ---
/usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$APP_PLIST" >/dev/null 2>&1 || true
/usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$APP_PLIST" >/dev/null 2>&1 || true
/usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_KEY}" "$APP_PLIST"
/usr/libexec/PlistBuddy -c "Add :SUFeedURL string https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml" "$APP_PLIST"
# Marketing version: append -nightly.YYYYMMDD so users can identify the channel and date
BASE_MARKETING=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PLIST")
BASE_MARKETING=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "${ARM_APP_DIR}/cmux.app/Contents/Info.plist")
NIGHTLY_DATE=$(date -u +%Y%m%d)
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" "$APP_PLIST"
# Build number: unique/monotonic per workflow run attempt so same-day
# nightlies and reruns still compare as newer in Sparkle.
@ -191,23 +207,49 @@ jobs:
else
NIGHTLY_BUILD="${NIGHTLY_DATE}000000"
fi
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${NIGHTLY_BUILD}" "$APP_PLIST"
# Use an immutable DMG filename in appcast URLs so old appcasts keep
# pointing at matching archives while nightly assets roll forward.
NIGHTLY_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg"
echo "NIGHTLY_BUILD=${NIGHTLY_BUILD}" >> "$GITHUB_ENV"
echo "NIGHTLY_DMG_IMMUTABLE=${NIGHTLY_DMG_IMMUTABLE}" >> "$GITHUB_ENV"
# Embed commit SHA for bug reports
/usr/libexec/PlistBuddy -c "Delete :CMUXCommit" "$APP_PLIST" >/dev/null 2>&1 || true
/usr/libexec/PlistBuddy -c "Add :CMUXCommit string ${SHORT_SHA}" "$APP_PLIST"
ARM_DMG_IMMUTABLE="cmux-nightly-macos-${NIGHTLY_BUILD}.dmg"
UNIVERSAL_DMG_IMMUTABLE="cmux-nightly-universal-macos-${NIGHTLY_BUILD}.dmg"
echo "NIGHTLY_DMG_IMMUTABLE=${ARM_DMG_IMMUTABLE}" >> "$GITHUB_ENV"
echo "NIGHTLY_UNIVERSAL_DMG_IMMUTABLE=${UNIVERSAL_DMG_IMMUTABLE}" >> "$GITHUB_ENV"
prepare_variant() {
local app_dir="$1"
local bundle_id="$2"
local feed_url="$3"
local app_plist="$app_dir/cmux.app/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleName cmux NIGHTLY" "$app_plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName cmux NIGHTLY" "$app_plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${bundle_id}" "$app_plist"
/usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$app_plist" >/dev/null 2>&1 || true
/usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$app_plist" >/dev/null 2>&1 || true
/usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_KEY}" "$app_plist"
/usr/libexec/PlistBuddy -c "Add :SUFeedURL string ${feed_url}" "$app_plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${BASE_MARKETING}-nightly.${NIGHTLY_DATE}" "$app_plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${NIGHTLY_BUILD}" "$app_plist"
/usr/libexec/PlistBuddy -c "Delete :CMUXCommit" "$app_plist" >/dev/null 2>&1 || true
/usr/libexec/PlistBuddy -c "Add :CMUXCommit string ${SHORT_SHA}" "$app_plist"
mv "$app_dir/cmux.app" "$app_dir/cmux NIGHTLY.app"
}
prepare_variant \
"$ARM_APP_DIR" \
"com.cmuxterm.app.nightly" \
"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 bundle ID: com.cmuxterm.app.nightly"
echo "Nightly arm64 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 build number: ${NIGHTLY_BUILD}"
echo "Nightly immutable DMG: ${NIGHTLY_DMG_IMMUTABLE}"
echo "Nightly arm64 immutable DMG: ${ARM_DMG_IMMUTABLE}"
echo "Nightly universal immutable DMG: ${UNIVERSAL_DMG_IMMUTABLE}"
echo "Commit SHA: ${SHORT_SHA}"
- name: Import signing cert
@ -233,7 +275,7 @@ jobs:
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
security list-keychains -d user -s build.keychain
- name: Codesign app
- name: Codesign apps
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: |
@ -241,16 +283,20 @@ jobs:
echo "Missing APPLE_SIGNING_IDENTITY secret" >&2
exit 1
fi
APP_PATH="build/Build/Products/Release/cmux NIGHTLY.app"
ENTITLEMENTS="cmux.entitlements"
for APP_PATH in \
"build-arm/Build/Products/Release/cmux NIGHTLY.app" \
"build-universal/Build/Products/Release/cmux NIGHTLY.app"
do
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
if [ -f "$CLI_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH"
fi
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH"
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
done
- name: Notarize app and dmg
- name: Notarize apps and dmgs
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
@ -261,41 +307,62 @@ jobs:
echo "Missing notarization secrets (APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID)" >&2
exit 1
fi
APP_PATH="build/Build/Products/Release/cmux NIGHTLY.app"
ZIP_SUBMIT="cmux-nightly-notary.zip"
DMG_RELEASE="cmux-nightly-macos.dmg"
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ZIP_SUBMIT"
APP_SUBMIT_JSON="$(xcrun notarytool submit "$ZIP_SUBMIT" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)"
notarize_and_package() {
local app_path="$1"
local dmg_release="$2"
local dmg_immutable="$3"
local zip_submit="${dmg_release%.dmg}-notary.zip"
local dmg_tmp_dir
local created_dmg
ditto -c -k --sequesterRsrc --keepParent "$app_path" "$zip_submit"
APP_SUBMIT_JSON="$(xcrun notarytool submit "$zip_submit" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)"
APP_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$APP_SUBMIT_JSON")"
APP_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$APP_SUBMIT_JSON")"
if [ "$APP_STATUS" != "Accepted" ]; then
echo "App notarization failed with status: $APP_STATUS" >&2
echo "App notarization failed for $app_path with status: $APP_STATUS" >&2
xcrun notarytool log "$APP_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true
exit 1
fi
xcrun stapler staple "$APP_PATH"
xcrun stapler validate "$APP_PATH"
spctl -a -vv --type execute "$APP_PATH"
rm -f "$ZIP_SUBMIT"
xcrun stapler staple "$app_path"
xcrun stapler validate "$app_path"
spctl -a -vv --type execute "$app_path"
rm -f "$zip_submit"
dmg_tmp_dir="$(mktemp -d)"
create-dmg \
--identity="$APPLE_SIGNING_IDENTITY" \
"$APP_PATH" \
./
mv ./"cmux NIGHTLY"*.dmg "$DMG_RELEASE" 2>/dev/null || mv ./cmux*.dmg "$DMG_RELEASE"
DMG_SUBMIT_JSON="$(xcrun notarytool submit "$DMG_RELEASE" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)"
"$app_path" \
"$dmg_tmp_dir"
created_dmg="$(find "$dmg_tmp_dir" -maxdepth 1 -name '*.dmg' | head -n 1)"
if [ -z "$created_dmg" ]; then
echo "Failed to locate created DMG for $app_path" >&2
exit 1
fi
mv "$created_dmg" "$dmg_release"
rm -rf "$dmg_tmp_dir"
DMG_SUBMIT_JSON="$(xcrun notarytool submit "$dmg_release" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)"
DMG_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$DMG_SUBMIT_JSON")"
DMG_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$DMG_SUBMIT_JSON")"
if [ "$DMG_STATUS" != "Accepted" ]; then
echo "DMG notarization failed with status: $DMG_STATUS" >&2
echo "DMG notarization failed for $dmg_release with status: $DMG_STATUS" >&2
xcrun notarytool log "$DMG_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true
exit 1
fi
xcrun stapler staple "$DMG_RELEASE"
xcrun stapler validate "$DMG_RELEASE"
xcrun stapler staple "$dmg_release"
xcrun stapler validate "$dmg_release"
cp "$dmg_release" "$dmg_immutable"
}
# Keep a stable filename for humans and an immutable filename used
# by appcast URLs to prevent signature/asset mismatch races.
cp "$DMG_RELEASE" "$NIGHTLY_DMG_IMMUTABLE"
notarize_and_package \
"build-arm/Build/Products/Release/cmux NIGHTLY.app" \
"cmux-nightly-macos.dmg" \
"$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
env:
@ -308,9 +375,11 @@ jobs:
exit 0
fi
brew install getsentry/tools/sentry-cli || true
sentry-cli debug-files upload --include-sources build/Build/Products/Release/
sentry-cli debug-files upload --include-sources \
build-arm/Build/Products/Release/ \
build-universal/Build/Products/Release/
- name: Generate Sparkle appcast (nightly)
- name: Generate Sparkle appcasts (nightly)
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: |
@ -319,8 +388,22 @@ jobs:
exit 1
fi
./scripts/sparkle_generate_appcast.sh "$NIGHTLY_DMG_IMMUTABLE" nightly appcast.xml
./scripts/sparkle_generate_appcast.sh "$NIGHTLY_UNIVERSAL_DMG_IMMUTABLE" nightly appcast-universal.xml
- name: Upload branch nightly artifacts
if: needs.decide.outputs.should_publish != 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: cmux-nightly-${{ needs.decide.outputs.short_sha }}
path: |
cmux-nightly-macos*.dmg
cmux-nightly-universal-macos*.dmg
appcast.xml
appcast-universal.xml
if-no-files-found: error
- name: Move nightly tag to built commit
if: needs.decide.outputs.should_publish == 'true'
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
@ -329,6 +412,7 @@ jobs:
git push origin refs/tags/nightly --force
- name: Publish nightly release assets
if: needs.decide.outputs.should_publish == 'true'
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
with:
tag_name: nightly
@ -338,13 +422,19 @@ jobs:
body: |
Automated nightly build for `${{ needs.decide.outputs.short_sha }}`.
**cmux NIGHTLY** is a separate app (bundle ID `com.cmuxterm.app.nightly`) that can be installed alongside the stable release. It receives nightly updates automatically via its own Sparkle feed.
**cmux NIGHTLY** has two update tracks:
- Apple Silicon: bundle ID `com.cmuxterm.app.nightly`, feed `appcast.xml`
- Universal: bundle ID `com.cmuxterm.app.nightly.universal`, feed `appcast-universal.xml`
[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: |
cmux-nightly-macos-${{ github.run_id }}*.dmg
cmux-nightly-macos.dmg
cmux-nightly-universal-macos-${{ github.run_id }}*.dmg
cmux-nightly-universal-macos.dmg
appcast.xml
appcast-universal.xml
overwrite_files: true
- name: Cleanup keychain
@ -352,12 +442,3 @@ jobs:
run: |
security delete-keychain build.keychain >/dev/null 2>&1 || true
rm -f /tmp/cert.p12
skipped:
needs: decide
if: needs.decide.outputs.should_build != 'true'
runs-on: ubuntu-latest
steps:
- name: No nightly build needed
run: |
echo "No changes on main since last nightly tag; skipping build."

View file

@ -14,10 +14,7 @@ env:
jobs:
build-sign-notarize:
runs-on: self-hosted
concurrency:
group: self-hosted-build
cancel-in-progress: false
runs-on: depot-macos-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@ -102,37 +99,20 @@ jobs:
- name: Install build deps
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: |
brew update
brew install zig
npm install --global "create-dmg@${CREATE_DMG_VERSION}"
- name: Download Metal Toolchain
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: xcodebuild -downloadComponent MetalToolchain
- name: Build GhosttyKit.xcframework
- name: Download pre-built GhosttyKit.xcframework
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: |
cd ghostty
zig build -Demit-xcframework=true -Demit-macos-app=false -Doptimize=ReleaseFast
cd ..
rm -rf GhosttyKit.xcframework
cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework
./scripts/download-prebuilt-ghosttykit.sh
- name: Clear SPM cache
- name: Cache Swift packages
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: |
rm -rf ~/Library/Caches/org.swift.swiftpm
rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
- name: Configure SwiftPM cache
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: |
set -euo pipefail
CACHE_DIR="${RUNNER_TEMP}/swiftpm-cache/${GITHUB_RUN_ID}"
rm -rf "$CACHE_DIR"
mkdir -p "$CACHE_DIR"
echo "SWIFTPM_CACHE_PATH=$CACHE_DIR" >> "$GITHUB_ENV"
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: .spm-cache
key: spm-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-
- name: Derive Sparkle public key from private key
if: steps.guard_release_assets.outputs.skip_all != 'true'
@ -150,7 +130,9 @@ jobs:
- name: Build app (Release)
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: |
xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
xcodebuild -scheme cmux -configuration Release -derivedDataPath build \
-clonedSourcePackagesDirPath .spm-cache \
CODE_SIGNING_ALLOWED=NO build
- name: Inject Sparkle keys into Info.plist
if: steps.guard_release_assets.outputs.skip_all != 'true'

187
.github/workflows/test-depot.yml vendored Normal file
View file

@ -0,0 +1,187 @@
name: Run tests on Depot
on:
workflow_dispatch:
inputs:
ref:
description: Branch or SHA to test
required: false
default: ""
skip_unit_tests:
description: Skip unit tests (run only UI tests)
required: false
default: false
type: boolean
skip_ui_tests:
description: Skip UI tests (run only unit tests)
required: false
default: false
type: boolean
test_filter:
description: "Run specific UI test class (e.g. UpdatePillUITests) or empty for all"
required: false
default: ""
test_timeout:
description: "Per-test timeout in seconds (default: 120)"
required: false
default: "120"
jobs:
tests:
runs-on: depot-macos-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ github.event.inputs.ref || github.ref }}
submodules: recursive
- name: Select Xcode
run: |
set -euo pipefail
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
XCODE_DIR="/Applications/Xcode.app/Contents/Developer"
else
XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -n 1 || true)"
if [ -n "$XCODE_APP" ]; then
XCODE_DIR="$XCODE_APP/Contents/Developer"
else
echo "No Xcode.app found under /Applications" >&2
exit 1
fi
fi
echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV"
export DEVELOPER_DIR="$XCODE_DIR"
xcodebuild -version
xcrun --sdk macosx --show-sdk-path
- name: Download pre-built GhosttyKit.xcframework
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD)
TAG="xcframework-$GHOSTTY_SHA"
URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz"
echo "Downloading xcframework for ghostty $GHOSTTY_SHA"
MAX_RETRIES=30
RETRY_DELAY=20
for i in $(seq 1 $MAX_RETRIES); do
if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then
echo "Download succeeded on attempt $i"
break
fi
if [ "$i" -eq "$MAX_RETRIES" ]; then
echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2
exit 1
fi
echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..."
sleep $RETRY_DELAY
done
tar xzf GhosttyKit.xcframework.tar.gz
rm GhosttyKit.xcframework.tar.gz
test -d GhosttyKit.xcframework
- name: Create virtual display
run: |
set -euo pipefail
echo "=== Display before ==="
system_profiler SPDisplaysDataType 2>/dev/null || echo "(none)"
echo ""
clang -framework Foundation -framework CoreGraphics \
-o /tmp/create-virtual-display scripts/create-virtual-display.m
/tmp/create-virtual-display &
VDISPLAY_PID=$!
echo "VDISPLAY_PID=$VDISPLAY_PID" >> "$GITHUB_ENV"
sleep 3
echo "=== Display after ==="
system_profiler SPDisplaysDataType 2>/dev/null || echo "(none)"
- name: Clean DerivedData
run: |
rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
- name: Resolve Swift packages
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
rm -rf "$SOURCE_PACKAGES_DIR"
mkdir -p "$SOURCE_PACKAGES_DIR"
for attempt in 1 2 3; do
if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-resolvePackageDependencies; then
exit 0
fi
if [ "$attempt" -eq 3 ]; then
echo "Failed to resolve Swift packages after 3 attempts" >&2
exit 1
fi
echo "Package resolution failed on attempt $attempt, retrying..."
sleep $((attempt * 5))
done
- name: Run unit tests
if: ${{ !inputs.skip_unit_tests }}
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
run_unit_tests() {
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" test 2>&1
}
set +e
OUTPUT=$(run_unit_tests)
EXIT_CODE=$?
set -e
# SwiftPM binary artifact resolution can occasionally fail with
# "Could not resolve package dependencies". Retry once after clearing
# SwiftPM/DerivedData caches to recover from transient corruption.
if [ "$EXIT_CODE" -ne 0 ] && echo "$OUTPUT" | grep -q "Could not resolve package dependencies"; then
echo "SwiftPM package resolution failed, clearing caches and retrying once"
rm -rf ~/Library/Caches/org.swift.swiftpm
rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
set +e
OUTPUT=$(run_unit_tests)
EXIT_CODE=$?
set -e
fi
echo "$OUTPUT"
if [ "$EXIT_CODE" -ne 0 ]; then
SUMMARY=$(echo "$OUTPUT" | grep "Executed.*tests.*with.*failures" | tail -1)
if echo "$SUMMARY" | grep -q "(0 unexpected)"; then
echo "All failures are expected, treating as pass"
else
echo "Unexpected test failures detected"
exit 1
fi
fi
- name: Run UI tests
if: ${{ !inputs.skip_ui_tests }}
env:
TEST_FILTER: ${{ inputs.test_filter }}
TEST_TIMEOUT: ${{ inputs.test_timeout || '120' }}
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
# Build the -only-testing argument
if [ -n "$TEST_FILTER" ]; then
ONLY_TESTING="-only-testing:cmuxUITests/$TEST_FILTER"
else
ONLY_TESTING="-only-testing:cmuxUITests"
fi
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
-maximum-test-execution-time-allowance "$TEST_TIMEOUT" \
$ONLY_TESTING test

363
.github/workflows/test-e2e.yml vendored Normal file
View file

@ -0,0 +1,363 @@
name: E2E test with video recording
on:
workflow_dispatch:
inputs:
ref:
description: Branch or SHA to test
required: false
default: ""
test_filter:
description: "Test class or class/method (e.g. UpdatePillUITests or UpdatePillUITests/testSomething)"
required: true
test_timeout:
description: "Per-test timeout in seconds"
required: false
default: "120"
record_video:
description: Record the virtual display during tests
required: false
default: true
type: boolean
runner:
description: "Runner OS (macos-15 or macos-26)"
required: false
default: "macos-15"
type: choice
options:
- macos-15
- macos-26
jobs:
e2e:
runs-on: ${{ inputs.runner || 'macos-15' }}
env:
TEST_REF: ${{ inputs.ref || github.ref }}
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ inputs.ref || github.ref }}
submodules: recursive
- name: Capture SHA
id: sha
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Select Xcode
run: |
set -euo pipefail
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
XCODE_DIR="/Applications/Xcode.app/Contents/Developer"
else
XCODE_APP="$(ls -d /Applications/Xcode*.app 2>/dev/null | head -n 1 || true)"
if [ -n "$XCODE_APP" ]; then
XCODE_DIR="$XCODE_APP/Contents/Developer"
else
echo "No Xcode.app found under /Applications" >&2
exit 1
fi
fi
echo "DEVELOPER_DIR=$XCODE_DIR" >> "$GITHUB_ENV"
export DEVELOPER_DIR="$XCODE_DIR"
xcodebuild -version
xcrun --sdk macosx --show-sdk-path
- name: Download pre-built GhosttyKit.xcframework
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD)
TAG="xcframework-$GHOSTTY_SHA"
URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz"
echo "Downloading xcframework for ghostty $GHOSTTY_SHA"
MAX_RETRIES=30
RETRY_DELAY=20
for i in $(seq 1 $MAX_RETRIES); do
if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then
echo "Download succeeded on attempt $i"
break
fi
if [ "$i" -eq "$MAX_RETRIES" ]; then
echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2
exit 1
fi
echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..."
sleep $RETRY_DELAY
done
tar xzf GhosttyKit.xcframework.tar.gz
rm GhosttyKit.xcframework.tar.gz
test -d GhosttyKit.xcframework
- name: Create virtual display
run: |
set -euo pipefail
echo "=== Display before ==="
system_profiler SPDisplaysDataType 2>/dev/null || echo "(none)"
echo ""
clang -framework Foundation -framework CoreGraphics \
-o /tmp/create-virtual-display scripts/create-virtual-display.m
/tmp/create-virtual-display &
VDISPLAY_PID=$!
echo "VDISPLAY_PID=$VDISPLAY_PID" >> "$GITHUB_ENV"
sleep 3
echo "=== Display after ==="
system_profiler SPDisplaysDataType 2>/dev/null || echo "(none)"
- name: Install ffmpeg
if: ${{ inputs.record_video }}
run: |
brew install --quiet ffmpeg
FFMPEG_PATH=$(which ffmpeg)
echo "ffmpeg: $FFMPEG_PATH"
ffmpeg -version | head -1
echo "FFMPEG_PATH=$FFMPEG_PATH" >> "$GITHUB_ENV"
- name: Grant TCC screen recording permission
if: ${{ inputs.record_video }}
continue-on-error: true
run: |
FFMPEG_BIN="${FFMPEG_PATH:-/opt/homebrew/bin/ffmpeg}"
# System-level TCC database (where kTCCServiceScreenCapture lives)
SYS_TCC="/Library/Application Support/com.apple.TCC/TCC.db"
if [ -f "$SYS_TCC" ]; then
echo "Granting screen capture in system TCC database"
for client in "$FFMPEG_BIN" /opt/homebrew/bin/ffmpeg /usr/local/bin/ffmpeg /usr/sbin/screencapture; do
sudo sqlite3 "$SYS_TCC" \
"INSERT OR REPLACE INTO access (service, client, client_type, auth_value, auth_reason, auth_version, csreq, policy_id, indirect_object_identifier_type, indirect_object_identifier, indirect_object_code_identity, flags, last_modified) VALUES ('kTCCServiceScreenCapture', '$client', 1, 2, 4, 1, NULL, NULL, 0, 'UNUSED', NULL, 0, $(date +%s));" 2>&1 || echo " (failed for $client)"
done
fi
# User-level TCC database (fallback)
USER_TCC="$HOME/Library/Application Support/com.apple.TCC/TCC.db"
if [ -f "$USER_TCC" ]; then
echo "Granting screen capture in user TCC database"
for client in "$FFMPEG_BIN" /opt/homebrew/bin/ffmpeg /usr/local/bin/ffmpeg; do
sqlite3 "$USER_TCC" \
"INSERT OR REPLACE INTO access (service, client, client_type, auth_value, auth_reason, auth_version, csreq, policy_id, indirect_object_identifier_type, indirect_object_identifier, indirect_object_code_identity, flags, last_modified) VALUES ('kTCCServiceScreenCapture', '$client', 1, 2, 4, 1, NULL, NULL, 0, 'UNUSED', NULL, 0, $(date +%s));" 2>&1 || echo " (failed for $client)"
done
fi
# Suppress Sequoia's ScreenCaptureApprovals prompt by pre-dating approval
APPROVALS_PLIST="$HOME/Library/Group Containers/group.com.apple.replayd/ScreenCaptureApprovals.plist"
if [ -d "$(dirname "$APPROVALS_PLIST")" ]; then
echo "Pre-dating ScreenCaptureApprovals"
# Set approval date far in the future so the monthly prompt never fires
defaults write "$APPROVALS_PLIST" "$FFMPEG_BIN" -date "3000-01-01T00:00:00Z" 2>&1 || echo " (failed)"
fi
- name: Clean DerivedData
run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*
- name: Cache Swift packages
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
with:
path: .ci-source-packages
key: spm-${{ inputs.runner || 'macos-15' }}-${{ hashFiles('GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: spm-${{ inputs.runner || 'macos-15' }}-
- name: Resolve Swift packages
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
mkdir -p "$SOURCE_PACKAGES_DIR"
for attempt in 1 2 3; do
if xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux-unit -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-resolvePackageDependencies; then
exit 0
fi
if [ "$attempt" -eq 3 ]; then
echo "Failed to resolve Swift packages after 3 attempts" >&2
exit 1
fi
echo "Package resolution failed on attempt $attempt, retrying..."
sleep $((attempt * 5))
done
- name: Run UI tests
id: tests
env:
TEST_FILTER: ${{ inputs.test_filter }}
TEST_TIMEOUT: ${{ inputs.test_timeout || '120' }}
RECORD_VIDEO: ${{ inputs.record_video }}
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
ONLY_TESTING="-only-testing:cmuxUITests/$TEST_FILTER"
# Start recording right before the test (after build/resolve)
if [ "$RECORD_VIDEO" = "true" ]; then
DEVLIST=$( ffmpeg -f avfoundation -list_devices true -i "" 2>&1 || true )
echo "Available devices:"
echo "$DEVLIST" | grep -E "AVFoundation|Capture screen"
SCREEN_INDEX=$( echo "$DEVLIST" | grep "Capture screen" | head -1 \
| sed 's/.*\[\([0-9]*\)\].*/\1/' )
SCREEN_INDEX="${SCREEN_INDEX:-0}"
echo "Using screen device index: $SCREEN_INDEX"
ffmpeg -f avfoundation -framerate 10 -capture_cursor 1 \
-i "${SCREEN_INDEX}:none" \
-c:v libx264 -preset ultrafast -pix_fmt yuv420p \
/tmp/test-recording-raw.mp4 </dev/null >/tmp/ffmpeg.log 2>&1 &
RECORD_PID=$!
echo "RECORD_PID=$RECORD_PID" >> "$GITHUB_ENV"
sleep 2
if kill -0 "$RECORD_PID" 2>/dev/null; then
echo "Recording started (PID $RECORD_PID)"
else
echo "::warning::ffmpeg screen recording failed to start"
cat /tmp/ffmpeg.log
fi
fi
set +e
OUTPUT=$(xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
-maximum-test-execution-time-allowance "$TEST_TIMEOUT" \
$ONLY_TESTING test 2>&1)
EXIT_CODE=$?
set -e
echo "$OUTPUT"
# Save summary for the issue
SUMMARY=$(echo "$OUTPUT" | grep -E "(Test Suite|Executed|FAIL|PASS)" | tail -20)
{
echo "test_summary<<EOFSUM"
echo "$SUMMARY"
echo "EOFSUM"
} >> "$GITHUB_OUTPUT"
if [ "$EXIT_CODE" -eq 0 ]; then
echo "test_result=passed" >> "$GITHUB_OUTPUT"
else
echo "test_result=failed" >> "$GITHUB_OUTPUT"
# Save full output for the issue body
{
echo "test_output<<EOFOUT"
echo "$OUTPUT" | tail -200
echo "EOFOUT"
} >> "$GITHUB_OUTPUT"
exit 1
fi
- name: Stop recording and trim
if: ${{ always() && inputs.record_video && env.RECORD_PID != '' }}
run: |
# Stop ffmpeg cleanly
kill -INT "$RECORD_PID" 2>/dev/null || true
for i in $(seq 1 15); do
if ! kill -0 "$RECORD_PID" 2>/dev/null; then
echo "Recording stopped after ${i}s"
break
fi
sleep 1
done
kill -9 "$RECORD_PID" 2>/dev/null || true
echo "=== raw recording ==="
ls -lh /tmp/test-recording-raw.mp4 2>/dev/null || { echo "No recording file"; exit 0; }
# Trim: detect first non-black frame and cut from there
BLACK_END=$(ffmpeg -i /tmp/test-recording-raw.mp4 \
-vf "blackdetect=d=0.3:pic_th=0.95:pix_th=0.1" \
-an -f null - 2>&1 \
| grep "black_end" | tail -1 \
| sed 's/.*black_end:\([0-9.]*\).*/\1/' || true)
if [ -n "$BLACK_END" ] && [ "$BLACK_END" != "0" ]; then
echo "Trimming ${BLACK_END}s of black frames from start"
ffmpeg -y -i /tmp/test-recording-raw.mp4 -ss "$BLACK_END" \
-c:v libx264 -preset ultrafast -pix_fmt yuv420p \
/tmp/test-recording.mp4 2>/dev/null
else
echo "No black frames detected, using raw recording"
mv /tmp/test-recording-raw.mp4 /tmp/test-recording.mp4
fi
echo "=== final recording ==="
ls -lh /tmp/test-recording.mp4
# Print duration
ffprobe -v error -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 /tmp/test-recording.mp4 2>/dev/null \
| xargs -I{} echo "Duration: {}s"
- name: Upload recording artifact
if: ${{ always() && inputs.record_video }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: test-recording
path: /tmp/test-recording.mp4
if-no-files-found: warn
- name: Post results to cmux-dev-artifacts
if: always()
env:
GH_TOKEN: ${{ secrets.DEV_ARTIFACTS_TOKEN }}
TEST_RESULT: ${{ steps.tests.outputs.test_result || 'failed' }}
TEST_SUMMARY: ${{ steps.tests.outputs.test_summary }}
TEST_OUTPUT: ${{ steps.tests.outputs.test_output }}
TEST_FILTER: ${{ inputs.test_filter }}
COMMIT_SHA: ${{ steps.sha.outputs.sha }}
RUN_ID: ${{ github.run_id }}
RECORD_VIDEO: ${{ inputs.record_video }}
run: |
set -euo pipefail
LABEL="$TEST_RESULT"
if [ "$TEST_RESULT" = "passed" ]; then
STATUS_EMOJI="PASSED"
else
STATUS_EMOJI="FAILED"
fi
REF_DISPLAY="${{ inputs.ref || github.ref_name }}"
RUN_URL="https://github.com/${{ github.repository }}/actions/runs/$RUN_ID"
ARTIFACT_URL="$RUN_URL#artifacts"
BODY="**Status:** $STATUS_EMOJI
**Ref:** \`$REF_DISPLAY\`
**SHA:** [\`${COMMIT_SHA:0:12}\`](https://github.com/${{ github.repository }}/commit/$COMMIT_SHA)
**Test:** \`$TEST_FILTER\`
**Workflow run:** $RUN_URL"
if [ "$RECORD_VIDEO" = "true" ]; then
BODY="$BODY
**Recording:** [Download from artifacts]($ARTIFACT_URL)"
fi
if [ -n "$TEST_OUTPUT" ]; then
BODY="$BODY
<details><summary>Test output (last 200 lines)</summary>
\`\`\`
$TEST_OUTPUT
\`\`\`
</details>"
fi
if [ -n "$TEST_SUMMARY" ]; then
BODY="$BODY
\`\`\`
$TEST_SUMMARY
\`\`\`"
fi
ISSUE_URL=$(gh issue create \
--repo manaflow-ai/cmux-dev-artifacts \
--title "[$STATUS_EMOJI] $TEST_FILTER @ ${COMMIT_SHA:0:7} ($REF_DISPLAY)" \
--body "$BODY" \
--label "$LABEL")
echo "Issue posted: $ISSUE_URL"
echo "::notice title=Test Result Issue::$ISSUE_URL"

View file

@ -94,6 +94,7 @@ jobs:
depends_on macos: ">= :sonoma"
app "cmux.app"
binary "#{appdir}/cmux.app/Contents/Resources/bin/cmux"
zap trash: [
"~/Library/Application Support/cmux",

Binary file not shown.

After

Width:  |  Height:  |  Size: 486 KiB

35
AppIcon.icon/icon.json Normal file
View file

@ -0,0 +1,35 @@
{
"fill" : "automatic",
"groups" : [
{
"layers" : [
{
"glass" : false,
"image-name" : "cmux-icon-chevron 2.png",
"name" : "cmux-icon-chevron 2",
"position" : {
"scale" : 1,
"translation-in-points" : [
37.357790031201375,
-0.5
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 738 B

After

Width:  |  Height:  |  Size: 622 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 385 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 B

After

Width:  |  Height:  |  Size: 587 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 404 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View file

@ -1,68 +1,188 @@
{
"images" : [
"images": [
{
"filename" : "16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
"filename": "16.png",
"idiom": "mac",
"scale": "1x",
"size": "16x16"
},
{
"filename" : "16@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
"appearances": [
{
"filename" : "32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "32@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "128@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "256@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "512@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
"appearance": "luminosity",
"value": "dark"
}
],
"info" : {
"author" : "xcode",
"version" : 1
"filename": "16_dark.png",
"idiom": "mac",
"scale": "1x",
"size": "16x16"
},
{
"filename": "16@2x.png",
"idiom": "mac",
"scale": "2x",
"size": "16x16"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "16@2x_dark.png",
"idiom": "mac",
"scale": "2x",
"size": "16x16"
},
{
"filename": "32.png",
"idiom": "mac",
"scale": "1x",
"size": "32x32"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "32_dark.png",
"idiom": "mac",
"scale": "1x",
"size": "32x32"
},
{
"filename": "32@2x.png",
"idiom": "mac",
"scale": "2x",
"size": "32x32"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "32@2x_dark.png",
"idiom": "mac",
"scale": "2x",
"size": "32x32"
},
{
"filename": "128.png",
"idiom": "mac",
"scale": "1x",
"size": "128x128"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "128_dark.png",
"idiom": "mac",
"scale": "1x",
"size": "128x128"
},
{
"filename": "128@2x.png",
"idiom": "mac",
"scale": "2x",
"size": "128x128"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "128@2x_dark.png",
"idiom": "mac",
"scale": "2x",
"size": "128x128"
},
{
"filename": "256.png",
"idiom": "mac",
"scale": "1x",
"size": "256x256"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "256_dark.png",
"idiom": "mac",
"scale": "1x",
"size": "256x256"
},
{
"filename": "256@2x.png",
"idiom": "mac",
"scale": "2x",
"size": "256x256"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "256@2x_dark.png",
"idiom": "mac",
"scale": "2x",
"size": "256x256"
},
{
"filename": "512.png",
"idiom": "mac",
"scale": "1x",
"size": "512x512"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "512_dark.png",
"idiom": "mac",
"scale": "1x",
"size": "512x512"
},
{
"filename": "512@2x.png",
"idiom": "mac",
"scale": "2x",
"size": "512x512"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"filename": "512@2x_dark.png",
"idiom": "mac",
"scale": "2x",
"size": "512x512"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 KiB

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "AppIconDark.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

View file

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "AppIconLight.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -2,6 +2,103 @@
All notable changes to cmux are documented here.
## [0.62.0] - 2026-03-07
### Added
- Markdown viewer panel with live file watching ([#883](https://github.com/manaflow-ai/cmux/pull/883))
- Find-in-page (Cmd+F) for browser panels ([#837](https://github.com/manaflow-ai/cmux/issues/837), [#875](https://github.com/manaflow-ai/cmux/pull/875))
- Keyboard copy mode for terminal scrollback with vi-style navigation ([#792](https://github.com/manaflow-ai/cmux/pull/792))
- Custom notification sounds with file picker support ([#839](https://github.com/manaflow-ai/cmux/pull/839), [#869](https://github.com/manaflow-ai/cmux/pull/869))
- Browser camera and microphone permission support ([#760](https://github.com/manaflow-ai/cmux/issues/760), [#913](https://github.com/manaflow-ai/cmux/pull/913))
- Language setting for per-app locale override ([#886](https://github.com/manaflow-ai/cmux/pull/886))
- Japanese localization ([#819](https://github.com/manaflow-ai/cmux/pull/819))
- 16 new languages added to localization ([#895](https://github.com/manaflow-ai/cmux/pull/895))
- Kagi as a search provider option ([#561](https://github.com/manaflow-ai/cmux/pull/561))
- Open Folder command (Cmd+O) ([#656](https://github.com/manaflow-ai/cmux/pull/656))
- Dark mode app icon for macOS Sequoia ([#702](https://github.com/manaflow-ai/cmux/pull/702))
- Close other pane tabs with confirmation ([#475](https://github.com/manaflow-ai/cmux/pull/475))
- Flash Focused Panel command palette action ([#638](https://github.com/manaflow-ai/cmux/pull/638))
- Zoom/maximize focused pane in splits ([#634](https://github.com/manaflow-ai/cmux/pull/634))
- `cmux tree` command for full CLI hierarchy view ([#592](https://github.com/manaflow-ai/cmux/pull/592))
- Install or uninstall the `cmux` CLI from the command palette ([#626](https://github.com/manaflow-ai/cmux/pull/626))
- Clipboard image paste in terminal with Cmd+V ([#562](https://github.com/manaflow-ai/cmux/pull/562), [#853](https://github.com/manaflow-ai/cmux/pull/853))
- Middle-click X11-style selection paste in terminal ([#369](https://github.com/manaflow-ai/cmux/pull/369))
- Honor Ghostty `background-opacity` across all cmux chrome ([#667](https://github.com/manaflow-ai/cmux/pull/667))
- Setting to hide Cmd-hold shortcut hints ([#765](https://github.com/manaflow-ai/cmux/pull/765))
- Focus-follows-mouse on terminal hover ([#519](https://github.com/manaflow-ai/cmux/pull/519))
- Sidebar help menu in the footer ([#958](https://github.com/manaflow-ai/cmux/pull/958))
- External URL bypass rules for the embedded browser ([#768](https://github.com/manaflow-ai/cmux/pull/768))
- Telemetry opt-out setting ([#610](https://github.com/manaflow-ai/cmux/pull/610))
- Browser automation docs page ([#622](https://github.com/manaflow-ai/cmux/pull/622))
### Changed
- Command palette search is now async and decoupled from typing for reduced lag
- Fuzzy matching improved with single-edit and omitted-character word matches
- Replaced keychain password storage with file-based storage ([#576](https://github.com/manaflow-ai/cmux/pull/576))
- Fullscreen shortcut changed to Cmd+Ctrl+F, and Cmd+Enter also toggles fullscreen ([#530](https://github.com/manaflow-ai/cmux/pull/530))
- Workspace rename shortcut Cmd+Shift+R now uses the command palette flow
- Renamed tab color to workspace color in user-facing strings ([#637](https://github.com/manaflow-ai/cmux/pull/637))
- Feedback recipient changed to `feedback@manaflow.com` ([#1007](https://github.com/manaflow-ai/cmux/pull/1007))
- Regenerated app icons from Icon Composer ([#1005](https://github.com/manaflow-ai/cmux/pull/1005))
- Moved update logs into the Debug menu ([#1008](https://github.com/manaflow-ai/cmux/pull/1008))
### Fixed
- Frozen blank launch from session restore race condition ([#399](https://github.com/manaflow-ai/cmux/issues/399), [#565](https://github.com/manaflow-ai/cmux/pull/565))
- Crash on launch from an exclusive access violation in drag-handle hit testing ([#490](https://github.com/manaflow-ai/cmux/issues/490))
- Use-after-free in `ghostty_surface_refresh` after sleep/wake ([#432](https://github.com/manaflow-ai/cmux/issues/432), [#619](https://github.com/manaflow-ai/cmux/pull/619))
- Startup SIGSEGV by pre-warming locale before `SentrySDK.start` ([#927](https://github.com/manaflow-ai/cmux/pull/927))
- IME issues: Shift+Space toggle inserting a space ([#641](https://github.com/manaflow-ai/cmux/issues/641), [#670](https://github.com/manaflow-ai/cmux/pull/670)), Ctrl fast path blocking IME events, browser address bar Japanese IME ([#789](https://github.com/manaflow-ai/cmux/issues/789), [#867](https://github.com/manaflow-ai/cmux/pull/867)), and Cmd shortcuts during IME composition
- CLI socket autodiscovery for tagged sockets ([#832](https://github.com/manaflow-ai/cmux/pull/832))
- Flaky CLI socket listener recovery ([#952](https://github.com/manaflow-ai/cmux/issues/952), [#954](https://github.com/manaflow-ai/cmux/pull/954))
- Side-docked dev tools resize ([#712](https://github.com/manaflow-ai/cmux/pull/712))
- Dvorak Cmd+C colliding with the notifications shortcut ([#762](https://github.com/manaflow-ai/cmux/pull/762))
- Terminal drag hover overlay flicker
- Titlebar controls clipped at the bottom edge ([#1016](https://github.com/manaflow-ai/cmux/pull/1016))
- Sidebar git branch recovery after sleep/wake and agent checkout ([#494](https://github.com/manaflow-ai/cmux/issues/494), [#671](https://github.com/manaflow-ai/cmux/pull/671), [#905](https://github.com/manaflow-ai/cmux/pull/905))
- Browser portal routing, uploads, and click focus regressions ([#908](https://github.com/manaflow-ai/cmux/pull/908), [#961](https://github.com/manaflow-ai/cmux/pull/961))
- Notification unread persistence on workspace focus
- Escape propagation when the command palette is visible ([#847](https://github.com/manaflow-ai/cmux/pull/847))
- Cmd+Shift+Enter pane zoom regression in browser focus ([#826](https://github.com/manaflow-ai/cmux/pull/826))
- Cross-window theme background after jump-to-unread ([#861](https://github.com/manaflow-ai/cmux/pull/861))
- `window.open()` and `target=_blank` not opening in a new tab ([#693](https://github.com/manaflow-ai/cmux/pull/693))
- Terminal wrap width for the overlay scrollbar ([#522](https://github.com/manaflow-ai/cmux/pull/522))
- Orphaned child processes when closing workspace tabs ([#889](https://github.com/manaflow-ai/cmux/pull/889))
- Cmd+F Escape passthrough into terminal ([#918](https://github.com/manaflow-ai/cmux/pull/918))
- Terminal link opens staying in the source workspace ([#912](https://github.com/manaflow-ai/cmux/pull/912))
- Ghost terminal surface rebind after close ([#808](https://github.com/manaflow-ai/cmux/pull/808))
- Cmd+plus zoom handling on non-US keyboard layouts ([#680](https://github.com/manaflow-ai/cmux/pull/680))
- Menubar icon invisible in light mode ([#741](https://github.com/manaflow-ai/cmux/pull/741))
- Various drag-handle crash fixes and reentrancy guards
- Background workspace git metadata refresh after external checkout
- Markdown panel text click focus ([#991](https://github.com/manaflow-ai/cmux/pull/991))
- Browser Cmd+F overlay clipping in portal mode ([#916](https://github.com/manaflow-ai/cmux/pull/916))
- Voice dictation text insertion ([#857](https://github.com/manaflow-ai/cmux/pull/857))
- Browser panel lifecycle after WebContent process termination ([#892](https://github.com/manaflow-ai/cmux/pull/892))
- Typing lag reduction by hiding invisible views from the accessibility tree ([#862](https://github.com/manaflow-ai/cmux/pull/862))
### Thanks to 21 contributors!
- [@afxjzs](https://github.com/afxjzs)
- [@AI-per](https://github.com/AI-per)
- [@atani](https://github.com/atani)
- [@austinywang](https://github.com/austinywang)
- [@cheulyop](https://github.com/cheulyop)
- [@ConnorCallison](https://github.com/ConnorCallison)
- [@harukitosa](https://github.com/harukitosa)
- [@homanp](https://github.com/homanp)
- [@JLeeChan](https://github.com/JLeeChan)
- [@josemasri](https://github.com/josemasri)
- [@lawrencecchen](https://github.com/lawrencecchen)
- [@novarii](https://github.com/novarii)
- [@orkhanrz](https://github.com/orkhanrz)
- [@qianwan](https://github.com/qianwan)
- [@rjwittams](https://github.com/rjwittams)
- [@sminamot](https://github.com/sminamot)
- [@tmcarr](https://github.com/tmcarr)
- [@trydis](https://github.com/trydis)
- [@ukoasis](https://github.com/ukoasis)
- [@y-agatsuma](https://github.com/y-agatsuma)
- [@yasunogithub](https://github.com/yasunogithub)
## [0.61.0] - 2026-02-25
### Added

View file

@ -16,16 +16,40 @@ After making code changes, always run the reload script with a tag to launch the
./scripts/reload.sh --tag fix-zsh-autosuggestions
```
After making code changes, always run the build:
When reporting a tagged reload result in chat, use the format for your agent type:
**Claude Code** (markdown link with correct derived-data path, cmd+clickable):
```markdown
=======================================================
[cmux DEV <tag-name>.app](file:///Users/lawrencechen/Library/Developer/Xcode/DerivedData/cmux-<tag-name>/Build/Products/Debug/cmux%20DEV%20<tag-name>.app)
=======================================================
```
**Codex** (plain text format):
```
=======================================================
[<tag-name>: file:///Users/lawrencechen/Library/Developer/Xcode/DerivedData/cmux-<tag-name>/Build/Products/Debug/cmux%20DEV%20<tag-name>.app](file:///Users/lawrencechen/Library/Developer/Xcode/DerivedData/cmux-<tag-name>/Build/Products/Debug/cmux%20DEV%20<tag-name>.app)
=======================================================
```
Never use `/tmp/cmux-<tag>/...` app links in chat output. If the expected DerivedData path is missing, resolve the real `.app` path and report that `file://` URL.
After making code changes, always use `reload.sh --tag` to build and launch. **Never run bare `xcodebuild` or `open` an untagged `cmux DEV.app`.** Untagged builds share the default debug socket and bundle ID with other agents, causing conflicts and stealing focus.
```bash
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build
./scripts/reload.sh --tag <your-branch-slug>
```
If you only need to verify the build compiles (no launch), use a tagged derivedDataPath:
```bash
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/cmux-<your-tag> build
```
When rebuilding GhosttyKit.xcframework, always use Release optimizations:
```bash
cd ghostty && zig build -Demit-xcframework=true -Doptimize=ReleaseFast
cd ghostty && zig build -Demit-xcframework=true -Dxcframework-target=universal -Doptimize=ReleaseFast
```
When rebuilding cmuxd for release/bundling, always use ReleaseFast:
@ -91,12 +115,31 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug
- Focus events: `focus.panel`, `focus.bonsplit`, `focus.firstResponder`, `focus.moveFocus`
- Bonsplit events: `tab.select`, `tab.close`, `tab.dragStart`, `tab.drop`, `pane.focus`, `pane.drop`, `divider.dragStart`
## Regression test commit policy
When adding a regression test for a bug fix, use a two-commit structure so CI proves the test catches the bug:
1. **Commit 1:** Add the failing test only (no fix). CI should go red.
2. **Commit 2:** Add the fix. CI should go green.
This makes it visible in the GitHub PR UI (Commits tab, check statuses) that the test genuinely fails without the fix.
## Pitfalls
- **Custom UTTypes** for drag-and-drop must be declared in `Resources/Info.plist` under `UTExportedTypeDeclarations` (e.g. `com.splittabbar.tabtransfer`, `com.cmux.sidebar-tab-reorder`).
- Do not add an app-level display link or manual `ghostty_surface_draw` loop; rely on Ghostty wakeups/renderer to avoid typing lag.
- **Terminal find layering contract:** `SurfaceSearchOverlay` must be mounted from `GhosttySurfaceScrollView` in `Sources/GhosttyTerminalView.swift` (AppKit portal layer), not from SwiftUI panel containers such as `Sources/Panels/TerminalPanelView.swift`. Portal-hosted terminal views can sit above SwiftUI during split/workspace churn.
- **Submodule safety:** When modifying a submodule (ghostty, vendor/bonsplit, etc.), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd <submodule> && git merge-base --is-ancestor HEAD origin/main`.
- **All user-facing strings must be localized.** Use `String(localized: "key.name", defaultValue: "English text")` for every string shown in the UI (labels, buttons, menus, dialogs, tooltips, error messages). Keys go in `Resources/Localizable.xcstrings` with translations for all supported languages (currently English and Japanese). Never use bare string literals in SwiftUI `Text()`, `Button()`, alert titles, etc.
## Test quality policy
- Do not add tests that only verify source code text, method signatures, AST fragments, or grep-style patterns.
- Do not add tests that read checked-in metadata or project files such as `Resources/Info.plist`, `project.pbxproj`, `.xcconfig`, or source files only to assert that a key, string, plist entry, or snippet exists.
- Tests must verify observable runtime behavior through executable paths (unit/integration/e2e/CLI), not implementation shape.
- For metadata changes, prefer verifying the built app bundle or the runtime behavior that depends on that metadata, not the checked-in source file.
- If a behavior cannot be exercised end-to-end yet, add a small runtime seam or harness first, then test through that seam.
- If no meaningful behavioral or artifact-level test is practical, skip the fake regression test and state that explicitly.
## Socket command threading policy
@ -114,21 +157,14 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug
- Only explicit focus-intent commands may mutate in-app focus/selection (`window.focus`, `workspace.select/next/previous/last`, `surface.focus`, `pane.focus/last`, browser focus commands, and v1 focus equivalents).
- All non-focus commands should preserve current user focus context while still applying data/model changes.
## E2E mac UI tests
## Testing policy
Run UI tests on the UTM macOS VM (never on the host machine). Always run e2e UI tests via `ssh cmux-vm`:
**Never run tests locally.** All tests (E2E, UI, python socket tests) run via GitHub Actions or on the VM.
```bash
ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test'
```
## Basic tests
Run basic automated tests on the UTM macOS VM (never on the host machine):
```bash
ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" build && pkill -x "cmux DEV" || true && APP=$(find /Users/cmux/Library/Developer/Xcode/DerivedData -path "*/Build/Products/Debug/cmux DEV.app" -print -quit) && open "$APP" --env CMUX_SOCKET_MODE=allowAll && for i in {1..20}; do [ -S /tmp/cmux-debug.sock ] && break; sleep 0.5; done && python3 tests/test_update_timing.py && python3 tests/test_signals_auto.py && python3 tests/test_ctrl_socket.py && python3 tests/test_notifications.py'
```
- **E2E / UI tests:** trigger via `gh workflow run test-e2e.yml` (see cmuxterm-hq CLAUDE.md for details)
- **Unit tests:** `xcodebuild -scheme cmux-unit` is safe (no app launch), but prefer CI
- **Python socket tests (tests_v2/):** these connect to a running cmux instance's socket. Never launch an untagged `cmux DEV.app` to run them. If you must test locally, use a tagged build's socket (`/tmp/cmux-debug-<tag>.sock`) with `CMUX_SOCKET=/tmp/cmux-debug-<tag>.sock`
- **Never `open` an untagged `cmux DEV.app`** from DerivedData. It conflicts with the user's running debug instance.
## Ghostty submodule workflow

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,9 @@
A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; };
A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; };
A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; };
A5001420 /* MarkdownPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001418 /* MarkdownPanel.swift */; };
A5001421 /* MarkdownPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001419 /* MarkdownPanelView.swift */; };
A5001290 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A5001291 /* MarkdownUI */; };
A5001405 /* PanelContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001415 /* PanelContentView.swift */; };
A5001406 /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001416 /* Workspace.swift */; };
A5001407 /* WorkspaceContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001417 /* WorkspaceContentView.swift */; };
@ -38,6 +41,8 @@
B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; };
A5001270 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = A5001271 /* PostHog */; };
A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; };
A5008371 /* BrowserSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008370 /* BrowserSearchOverlay.swift */; };
A5008373 /* BrowserFindJavaScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008372 /* BrowserFindJavaScript.swift */; };
A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; };
A50012F3 /* KeyboardShortcutSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F2 /* KeyboardShortcutSettings.swift */; };
A50012F5 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F4 /* KeyboardLayout.swift */; };
@ -68,6 +73,7 @@
A5002000 /* THIRD_PARTY_LICENSES.md in Resources */ = {isa = PBXBuildFile; fileRef = A5002001 /* THIRD_PARTY_LICENSES.md */; };
B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; };
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; };
B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */; };
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; };
B9000014A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000013A1B2C3D4E5F60719 /* JumpToUnreadUITests.swift */; };
B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */; };
@ -84,6 +90,10 @@
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; };
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; };
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; };
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; };
DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; };
DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
@ -165,12 +175,16 @@
A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = "<group>"; };
A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = "<group>"; };
A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = "<group>"; };
A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = "<group>"; };
A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = "<group>"; };
A5001416 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = "<group>"; };
A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = "<group>"; };
A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
A5001091 /* NotificationsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPage.swift; sourceTree = "<group>"; };
A5001092 /* TerminalNotificationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationStore.swift; sourceTree = "<group>"; };
A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = "<group>"; };
A5008370 /* BrowserSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserSearchOverlay.swift; sourceTree = "<group>"; };
A5008372 /* BrowserFindJavaScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserFindJavaScript.swift; sourceTree = "<group>"; };
A50012F0 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
A50012F2 /* KeyboardShortcutSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutSettings.swift; sourceTree = "<group>"; };
A50012F4 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = "<group>"; };
@ -191,8 +205,10 @@
A5001241 /* WindowDecorationsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDecorationsController.swift; sourceTree = "<group>"; };
A5001611 /* SessionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistence.swift; sourceTree = "<group>"; };
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = "<group>"; };
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = "<group>"; };
A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
IC000002 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = AppIcon.icon; sourceTree = "<group>"; };
B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "ghostty/terminfo/78/xterm-ghostty"; sourceTree = "<group>"; };
C1ADE00001A1B2C3D4E5F719 /* claude */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/claude"; sourceTree = SOURCE_ROOT; };
D1BEF00001A1B2C3D4E5F719 /* open */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "Resources/bin/open"; sourceTree = SOURCE_ROOT; };
@ -215,7 +231,11 @@
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; };
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; };
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; };
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
A5001030 /* Frameworks */ = {
@ -226,6 +246,7 @@
A5001230 /* Sparkle in Frameworks */,
A5001250 /* Sentry in Frameworks */,
A5001270 /* PostHog in Frameworks */,
A5001290 /* MarkdownUI in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -261,6 +282,8 @@
A5001100 /* Assets.xcassets in Resources */,
84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */,
A5002000 /* THIRD_PARTY_LICENSES.md in Resources */,
DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */,
DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -308,6 +331,7 @@
B9000003A1B2C3D4E5F60719 /* CLI */,
087C454FFF74443AB06942C3 /* Resources */,
A5001101 /* Assets.xcassets */,
IC000002 /* AppIcon.icon */,
A5001016 /* GhosttyKit.xcframework */,
A5001017 /* ghostty.h */,
A5001018 /* cmux-Bridging-Header.h */,
@ -344,11 +368,15 @@
A5001091 /* NotificationsPage.swift */,
A5001092 /* TerminalNotificationStore.swift */,
A5001301 /* SurfaceSearchOverlay.swift */,
A5008370 /* BrowserSearchOverlay.swift */,
A5008372 /* BrowserFindJavaScript.swift */,
A5001410 /* Panel.swift */,
A5001411 /* TerminalPanel.swift */,
A5001412 /* BrowserPanel.swift */,
A5001413 /* TerminalPanelView.swift */,
A5001414 /* BrowserPanelView.swift */,
A5001418 /* MarkdownPanel.swift */,
A5001419 /* MarkdownPanelView.swift */,
A5001510 /* CmuxWebView.swift */,
A5001415 /* PanelContentView.swift */,
A5001211 /* UpdateController.swift */,
@ -385,6 +413,8 @@
B2E7294509CC42FE9191870E /* xterm-ghostty */,
A5002001 /* THIRD_PARTY_LICENSES.md */,
C1ADE00001A1B2C3D4E5F719 /* claude */,
DA7A10CA710E000000000001 /* Localizable.xcstrings */,
DA7A10CA710E000000000002 /* InfoPlist.xcstrings */,
);
path = Resources;
sourceTree = "<group>";
@ -409,6 +439,7 @@
B9000019A1B2C3D4E5F60719 /* CloseWorkspaceConfirmDialogUITests.swift */,
B9000016A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift */,
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */,
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */,
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */,
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */,
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
@ -428,6 +459,8 @@
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */,
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
A5008380 /* BrowserFindJavaScriptTests.swift */,
A5008382 /* CommandPaletteSearchEngineTests.swift */,
);
path = cmuxTests;
sourceTree = "<group>";
@ -456,6 +489,7 @@
A5001251 /* Sentry */,
A5001271 /* PostHog */,
A5001261 /* Bonsplit */,
A5001291 /* MarkdownUI */,
);
name = GhosttyTabs;
productName = GhosttyTabs;
@ -534,12 +568,30 @@
knownRegions = (
en,
Base,
ja,
ar,
bs,
da,
de,
es,
fr,
it,
ko,
nb,
pl,
pt-BR,
ru,
th,
tr,
zh-Hans,
zh-Hant,
);
mainGroup = A5001040;
packageReferences = (
A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */,
A5001252 /* XCRemoteSwiftPackageReference "sentry-cocoa" */,
A5001272 /* XCRemoteSwiftPackageReference "posthog-ios" */,
A5001292 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
A5001260 /* XCLocalSwiftPackageReference "bonsplit" */,
);
productRefGroup = A5001042 /* Products */;
@ -583,11 +635,15 @@
A5001094 /* NotificationsPage.swift in Sources */,
A5001095 /* TerminalNotificationStore.swift in Sources */,
A5001303 /* SurfaceSearchOverlay.swift in Sources */,
A5008371 /* BrowserSearchOverlay.swift in Sources */,
A5008373 /* BrowserFindJavaScript.swift in Sources */,
A5001400 /* Panel.swift in Sources */,
A5001401 /* TerminalPanel.swift in Sources */,
A5001402 /* BrowserPanel.swift in Sources */,
A5001403 /* TerminalPanelView.swift in Sources */,
A5001404 /* BrowserPanelView.swift in Sources */,
A5001420 /* MarkdownPanel.swift in Sources */,
A5001421 /* MarkdownPanelView.swift in Sources */,
A5001500 /* CmuxWebView.swift in Sources */,
A5001405 /* PanelContentView.swift in Sources */,
A5001201 /* UpdateController.swift in Sources */,
@ -619,6 +675,7 @@
B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */,
B9000015A1B2C3D4E5F60719 /* MultiWindowNotificationsUITests.swift in Sources */,
B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */,
B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */,
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */,
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */,
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */,
@ -638,6 +695,8 @@
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */,
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */,
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -725,6 +784,7 @@
MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
@ -738,7 +798,7 @@
CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 73;
CURRENT_PROJECT_VERSION = 74;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = NO;
GENERATE_INFOPLIST_FILE = NO;
@ -747,7 +807,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.61.0;
MARKETING_VERSION = 0.62.0;
OTHER_LDFLAGS = (
"-lc++",
"-framework",
@ -777,7 +837,7 @@
CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 73;
CURRENT_PROJECT_VERSION = 74;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = NO;
GENERATE_INFOPLIST_FILE = NO;
@ -786,7 +846,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.61.0;
MARKETING_VERSION = 0.62.0;
OTHER_LDFLAGS = (
"-lc++",
"-framework",
@ -800,7 +860,7 @@
"-framework",
Carbon,
);
ONLY_ACTIVE_ARCH = YES;
ONLY_ACTIVE_ARCH = NO;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.app;
PRODUCT_NAME = cmux;
SPARKLE_PUBLIC_KEY = "avjcgKibf1FTvhIjLBxhd+0HSpsXU4D0IGlVk8cgqRc=";
@ -842,6 +902,7 @@
MACOSX_DEPLOYMENT_TARGET = 14.0;
PRODUCT_NAME = cmux;
PRODUCT_MODULE_NAME = cmux_cli;
ONLY_ACTIVE_ARCH = NO;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
@ -852,10 +913,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 73;
CURRENT_PROJECT_VERSION = 74;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.61.0;
MARKETING_VERSION = 0.62.0;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -869,11 +930,11 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 73;
CURRENT_PROJECT_VERSION = 74;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.61.0;
ONLY_ACTIVE_ARCH = YES;
MARKETING_VERSION = 0.62.0;
ONLY_ACTIVE_ARCH = NO;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
@ -886,10 +947,10 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 73;
CURRENT_PROJECT_VERSION = 74;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.61.0;
MARKETING_VERSION = 0.62.0;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -905,11 +966,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 73;
CURRENT_PROJECT_VERSION = 74;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.61.0;
ONLY_ACTIVE_ARCH = YES;
MARKETING_VERSION = 0.62.0;
ONLY_ACTIVE_ARCH = NO;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
@ -945,6 +1006,14 @@
minimumVersion = 3.41.0;
};
};
A5001292 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.4.1;
};
};
A5001260 /* XCLocalSwiftPackageReference "bonsplit" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = vendor/bonsplit;
@ -972,6 +1041,11 @@
package = A5001260 /* XCLocalSwiftPackageReference "bonsplit" */;
productName = Bonsplit;
};
A5001291 /* MarkdownUI */ = {
isa = XCSwiftPackageProductDependency;
package = A5001292 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
productName = MarkdownUI;
};
/* End XCSwiftPackageProductDependency section */
/* Begin XCConfigurationList section */

View file

@ -1,6 +1,15 @@
{
"originHash" : "a1df212ee81645b29368e6cc39c83aebbbafb5c592f726afc990bab228304987",
"originHash" : "b66d812c506be67c70b46c63421ab2eb2db013613c74252ad1205f662ada079b",
"pins" : [
{
"identity" : "networkimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/NetworkImage",
"state" : {
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
"version" : "6.0.1"
}
},
{
"identity" : "posthog-ios",
"kind" : "remoteSourceControl",
@ -27,6 +36,24 @@
"revision" : "5581748cef2bae787496fe6d61139aebe0a451f6",
"version" : "2.8.1"
}
},
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark",
"state" : {
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
"version" : "0.7.1"
}
},
{
"identity" : "swift-markdown-ui",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
"state" : {
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
"version" : "2.4.1"
}
}
],
"version" : 3

View file

@ -1,9 +1,9 @@
> 이 번역은 Claude에 의해 생성되었습니다. 개선 사항이 있으면 PR을 제출해 주세요.
> 이 문서는 Claude가 번역했어요. 개선할 부분이 있다면 PR을 보내주세요.
<p align="center"><a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | 한국어 | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a></p>
<h1 align="center">cmux</h1>
<p align="center">AI 코딩 에이전트를 위한 세로 탭과 알림 기능을 갖춘 Ghostty 기반 macOS 터미널</p>
<p align="center">세로 탭과 알림을 지원하는 AI 코딩 에이전트용 Ghostty 기반 macOS 터미널</p>
<p align="center">
<a href="https://github.com/manaflow-ai/cmux/releases/latest/download/cmux-macos.dmg">
@ -17,17 +17,17 @@
## 기능
- **세로 탭** — 사이드바에 git 브랜치, 작업 디렉토리, 리스닝 포트, 최신 알림 텍스트 표시
- **알림 링** — AI 에이전트(Claude Code, OpenCode)가 사용자의 주의를 필요로 할 때 패널에 파란색 링이 표시되고 탭이 강조됨
- **알림 패널** 모든 대기 중인 알림을 한 곳에서 확인하고, 가장 최근 읽지 않은 알림으로 바로 이동
- **분할 패널** — 수평 및 수직 분할 지원
- **내장 브라우저** — [agent-browser](https://github.com/vercel-labs/agent-browser)에서 포팅된 스크립트 가능한 API를 갖춘 브라우저를 터미널 옆에 분할하여 사용
- **스크립트 가능** — CLI와 socket API로 워크스페이스 생성, 패널 분할, 키 입력 전송, 브라우저 자동화 가능
- **네이티브 macOS 앱**Swift와 AppKit으로 구축, Electron이 아닙니다. 빠른 시작, 낮은 메모리 사용량.
- **Ghostty 호환** — 기존 `~/.config/ghostty/config`에서 테마, 글꼴, 색상 설정을 읽어옴
- **GPU 가속** — libghostty로 구동되어 부드러운 렌더링 제공
- **세로 탭** — 사이드바에서 git 브랜치, 작업 디렉토리, 수신 포트, 최근 알림 텍스트를 한눈에 볼 수 있어요.
- **알림 링** — AI 에이전트(Claude Code, OpenCode)가 입력을 기다리면 패널에 파란색 링이 뜨고 탭이 강조돼요.
- **알림 패널** — 대기 중인 알림을 한곳에서 확인하고, 가장 최근 읽지 않은 알림으로 바로 이동할 수 있어요.
- **분할 패널** — 수평·수직 분할을 지원해요.
- **내장 브라우저** — [agent-browser](https://github.com/vercel-labs/agent-browser)에서 포팅된 스크립팅 API를 갖춘 브라우저를 터미널 옆에 띄울 수 있어요.
- **스크립** — CLI와 socket API로 워크스페이스 생성, 패널 분할, 키 입력 전송, 브라우저 자동화 가능해요.
- **네이티브 macOS 앱**Electron이 아닌 Swift와 AppKit으로 만들었어요. 빠르게 실행되고 메모리도 적게 써요.
- **Ghostty 호환** — 기존 `~/.config/ghostty/config`에서 테마, 글꼴, 색상 설정을 그대로 읽어와요.
- **GPU 가속** — libghostty 기반이라 렌더링이 부드러워요.
## 설치
## 설치하기
### DMG (권장)
@ -35,7 +35,7 @@
<img src="./docs/assets/macos-badge.png" alt="macOS용 cmux 다운로드" width="180" />
</a>
`.dmg` 파일을 열고 cmux를 응용 프로그램 폴더로 드래그하세요. cmux는 Sparkle을 통해 자동 업데이트되므로, 한 번만 다운로드하면 됩니다.
`.dmg` 파일을 열고 cmux를 응용 프로그램 폴더로 드래그하면 돼요. Sparkle을 통해 자동 업데이트되니 한 번만 다운로드하면 돼요.
### Homebrew
@ -44,25 +44,25 @@ brew tap manaflow-ai/cmux
brew install --cask cmux
```
나중에 업데이트하려면:
나중에 업데이트하려면 아래 명령어를 실행해주세요:
```bash
brew upgrade --cask cmux
```
처음 실행 시, macOS가 확인된 개발자의 앱을 여는 것을 확인하도록 요청할 수 있습니다. **열기**를 클릭하여 계속 진행하세요.
처음 실행할 때 macOS에서 개발자 확인 팝업이 뜰 수 있어요. **열기**를 클릭하면 돼요.
## 왜 cmux를 만들었나요?
저는 Claude Code와 Codex 세션을 대량으로 병렬 실행합니다. 이전에는 Ghostty에서 분할 패널을 여러 개 열어놓고, 에이전트가 저를 필요로 할 때 macOS 기본 알림에 의존했습니다. 하지만 Claude Code의 알림 내용은 항상 "Claude is waiting for your input"이라는 맥락 없는 동일한 메시지뿐이었고, 탭이 많아지면 제목조차 읽을 수 없었습니다.
저는 Claude Code와 Codex 세션을 여러 개 동시에 돌려요. 예전에는 Ghostty에서 분할 패널을 여러 개 열어놓고, 에이전트가 입력을 기다릴 때 macOS 기본 알림에 의존했어요. 그런데 Claude Code 알림은 항상 "Claude is waiting for your input"이라는 아무 맥락 없이 똑같은 메시지뿐이었고, 탭이 많아지면 제목조차 읽을 수가 없었어요.
몇 가지 코딩 오케스트레이터를 시도해봤지만, 대부분 Electron/Tauri 앱이어서 성능이 마음에 들지 않았습니다. 또한 GUI 오케스트레이터는 특정 워크플로우에 갇히게 되므로 터미널을 더 선호합니다. 그래서 Swift/AppKit으로 네이티브 macOS 앱인 cmux를 만들었습니다. 터미널 렌더링에 libghostty를 사용하고, 기존 Ghostty 설정에서 테마, 글꼴, 색상을 읽어옵니다.
여러 코딩 오케스트레이터를 써봤는데, 대부분 Electron/Tauri 앱이라 성능이 별로였어요. GUI 오케스트레이터는 특정 워크플로우에 갇히게 돼서 터미널이 더 낫다고 생각했고요. 그래서 Swift/AppKit으로 네이티브 macOS 앱인 cmux를 직접 만들었어요. 터미널 렌더링에는 libghostty를 쓰고, 기존 Ghostty 설정에서 테마, 글꼴, 색상을 그대로 가져와요.
주요 추가 기능은 사이드바와 알림 시스템입니다. 사이드바에는 각 워크스페이스의 git 브랜치, 작업 디렉토리, 리스닝 포트, 최신 알림 텍스트를 보여주는 세로 탭이 있습니다. 알림 시스템은 터미널 시퀀스(OSC 9/99/777)를 감지하고, Claude Code, OpenCode 등의 에이전트 훅에 연결할 수 있는 CLI(`cmux notify`)를 제공합니다. 에이전트가 대기 중일 때 해당 패널에 파란색 링이 표시되고 사이드바에서 탭이 강조되어, 여러 분할 패널과 탭에서 어떤 것이 저를 필요로 하는지 한눈에 알 수 있습니다. ⌘⇧U로 가장 최근의 읽지 않은 알림으로 이동합니다.
핵심은 사이드바와 알림 시스템이에요. 사이드바에는 각 워크스페이스의 git 브랜치, 작업 디렉토리, 수신 포트, 최근 알림 텍스트를 보여주는 세로 탭이 있어요. 알림 시스템은 터미널 시퀀스(OSC 9/99/777)를 감지하고, Claude Code나 OpenCode 같은 에이전트 훅에 연결할 수 있는 CLI(`cmux notify`)를 제공해요. 에이전트가 대기 중이면 해당 패널에 파란색 링이 뜨고 사이드바 탭이 강조되니까, 여러 패널과 탭 중에서 어디서 입력을 기다리는지 바로 알 수 있어요. ⌘⇧U를 누르면 가장 최근 읽지 않은 알림으로 이동해요.
내장 브라우저는 [agent-browser](https://github.com/vercel-labs/agent-browser)에서 포팅된 스크립트 가능한 API를 갖추고 있습니다. 에이전트가 접근성 트리 스냅샷을 가져오고, 요소 참조를 얻고, 클릭하고, 양식을 작성하고, JS를 실행할 수 있습니다. 터미널 옆에 브라우저 패널을 분할하여 Claude Code가 개발 서버와 직접 상호작용하도록 할 수 있습니다.
내장 브라우저는 [agent-browser](https://github.com/vercel-labs/agent-browser)에서 포팅한 스크립팅 API를 제공해요. 에이전트가 접근성 트리 스냅샷을 가져오고, 요소를 참조·클릭하고, 양식을 채우고, JS를 실행할 수 있어요. 터미널 옆에 브라우저 패널을 띄워서 Claude Code가 개발 서버와 직접 상호작용하게 할 수 있어요.
모든 것은 CLI와 socket API를 통해 스크립트 가능합니다 — 워크스페이스/탭 생성, 패널 분할, 키 입력 전송, 브라우저에서 URL 열기.
CLI와 socket API로 모든 걸 자동화할 수 있어요 — 워크스페이스/탭 생성, 패널 분할, 키 입력 전송, 브라우저에서 URL 열기까지요.
## 키보드 단축키
@ -98,13 +98,13 @@ brew upgrade --cask cmux
| ⌘ D | 오른쪽으로 분할 |
| ⌘ ⇧ D | 아래로 분할 |
| ⌥ ⌘ ← → ↑ ↓ | 방향키로 패널 포커스 이동 |
| ⌘ ⇧ H | 포커스된 패널 깜빡임 |
| ⌘ ⇧ H | 현재 패널 깜빡임 |
### 브라우저
| 단축키 | 동작 |
|----------|--------|
| ⌘ ⇧ L | 분할에서 브라우저 열기 |
| ⌘ ⇧ L | 분할 패널로 브라우저 열기 |
| ⌘ L | 주소창 포커스 |
| ⌘ [ | 뒤로 |
| ⌘ ] | 앞으로 |
@ -116,7 +116,7 @@ brew upgrade --cask cmux
| 단축키 | 동작 |
|----------|--------|
| ⌘ I | 알림 패널 표시 |
| ⌘ ⇧ U | 최 읽지 않은 알림으로 이동 |
| ⌘ ⇧ U | 최 읽지 않은 알림으로 이동 |
### 찾기
@ -125,7 +125,7 @@ brew upgrade --cask cmux
| ⌘ F | 찾기 |
| ⌘ G / ⌘ ⇧ G | 다음 찾기 / 이전 찾기 |
| ⌘ ⇧ F | 찾기 바 숨기기 |
| ⌘ E | 선택 영역으로 찾기 |
| ⌘ E | 선택한 텍스트로 찾기 |
### 터미널
@ -148,6 +148,6 @@ brew upgrade --cask cmux
## 라이선스
이 프로젝트는 GNU Affero 일반 공중 사용 허가서 v3.0 이상(`AGPL-3.0-or-later`)에 따라 라이선스가 부여됩니다.
이 프로젝트는 GNU Affero General Public License v3.0 이상(`AGPL-3.0-or-later`)으로 배포돼요.
전체 라이선스 텍스트는 `LICENSE` 파일을 참조하세요.
자세한 내용은 `LICENSE` 파일을 확인해주세요.

View file

@ -117,6 +117,10 @@ The best developers have always built their own tools. Nobody has figured out th
Give a million developers composable primitives and they'll collectively find the most efficient workflows faster than any product team could design top-down.
## Documentation
For more info on how to configure cmux, [head over to our docs](https://cmux.dev/docs/getting-started?utm_source=readme).
## Keyboard Shortcuts
### Workspaces
@ -233,10 +237,10 @@ cmux does **not** restore live process state inside terminal apps. For example,
Ways to get involved:
- Follow us on X for updates [@manaflowai](https://x.com/manaflowai) or [@lawrencecchen](https://x.com/lawrencecchen)
- Follow us on X for updates [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen), and [@austinywang](https://x.com/austinywang)
- Join the conversation on [Discord](https://discord.gg/xsgFEVrWCZ)
- Create and participate in [GitHub issues](https://github.com/manaflow-ai/cmux/issues) and [discussions](https://github.com/manaflow-ai/cmux/discussions)
- Let me know what you're building with cmux
- Let us know what you're building with cmux
## Community
@ -245,6 +249,7 @@ Ways to get involved:
- [X / Twitter](https://twitter.com/manaflowai)
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
- [Reddit](https://www.reddit.com/r/cmux/)
## Founder's Edition

View file

@ -28,6 +28,24 @@
<string></string>
<key>NSMicrophoneUsageDescription</key>
<string>A program running within cmux would like to use your microphone.</string>
<key>NSCameraUsageDescription</key>
<string>A program running within cmux would like to use your camera.</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLName</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).web</string>
<key>LSHandlerRank</key>
<string>Default</string>
<key>CFBundleURLSchemes</key>
<array>
<string>http</string>
<string>https</string>
</array>
</dict>
</array>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSServices</key>

View file

@ -0,0 +1,362 @@
{
"sourceLanguage": "en",
"version": "1.0",
"strings": {
"NSCameraUsageDescription": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "A program running within cmux would like to use your camera."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "cmux 内で実行中のプログラムがカメラの使用を求めています。"
}
}
}
},
"NSMicrophoneUsageDescription": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "A program running within cmux would like to use your microphone."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "cmux 内で実行中のプログラムがマイクの使用を求めています。"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "在 cmux 中运行的程序想要使用您的麦克风。"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "在 cmux 中執行的程式想要使用您的麥克風。"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "cmux 내에서 실행 중인 프로그램이 마이크를 사용하려고 합니다."
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Ein in cmux ausgeführtes Programm möchte Ihr Mikrofon verwenden."
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Un programa en ejecución dentro de cmux desea usar tu micrófono."
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Un programme s'exécutant dans cmux souhaite utiliser votre microphone."
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Un programma in esecuzione in cmux desidera utilizzare il microfono."
}
},
"da": {
"stringUnit": {
"state": "translated",
"value": "Et program, der kører i cmux, vil gerne bruge din mikrofon."
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Program działający w cmux chciałby użyć Twojego mikrofonu."
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Программа, запущенная в cmux, хотела бы использовать ваш микрофон."
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "Program koji se izvršava unutar cmux želi koristiti vaš mikrofon."
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "يرغب برنامج يعمل داخل cmux في استخدام الميكروفون."
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "Et program som kjører i cmux ønsker å bruke mikrofonen din."
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Um programa em execução no cmux gostaria de usar seu microfone."
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "โปรแกรมที่ทำงานภายใน cmux ต้องการใช้ไมโครโฟนของคุณ"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "cmux içinde çalışan bir program mikrofonunuzu kullanmak istiyor."
}
}
}
},
"New $(PRODUCT_NAME) Workspace Here": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "New $(PRODUCT_NAME) Workspace Here"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ここに新規 $(PRODUCT_NAME) ワークスペースを作成"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "在此新建 $(PRODUCT_NAME) 工作区"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "在此新增 $(PRODUCT_NAME) 工作區"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "여기에 새 $(PRODUCT_NAME) 작업 공간"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Neuer $(PRODUCT_NAME)-Arbeitsbereich hier"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Nuevo espacio de trabajo de $(PRODUCT_NAME) aquí"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Nouvel espace de travail $(PRODUCT_NAME) ici"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Nuova area di lavoro $(PRODUCT_NAME) qui"
}
},
"da": {
"stringUnit": {
"state": "translated",
"value": "Nyt $(PRODUCT_NAME)-arbejdsområde her"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Nowa przestrzeń robocza $(PRODUCT_NAME) tutaj"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Новое рабочее пространство $(PRODUCT_NAME) здесь"
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "Novi $(PRODUCT_NAME) radni prostor ovdje"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "مساحة عمل $(PRODUCT_NAME) جديدة هنا"
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "Nytt $(PRODUCT_NAME)-arbeidsområde her"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Nova Área de Trabalho do $(PRODUCT_NAME) Aqui"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "เวิร์กสเปซ $(PRODUCT_NAME) ใหม่ที่นี่"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Buraya Yeni $(PRODUCT_NAME) Çalışma Alanı"
}
}
}
},
"New $(PRODUCT_NAME) Window Here": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "New $(PRODUCT_NAME) Window Here"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ここに新規 $(PRODUCT_NAME) ウインドウを作成"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "在此新建 $(PRODUCT_NAME) 窗口"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "在此新增 $(PRODUCT_NAME) 視窗"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "여기에 새 $(PRODUCT_NAME) 윈도우"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Neues $(PRODUCT_NAME)-Fenster hier"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Nueva ventana de $(PRODUCT_NAME) aquí"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Nouvelle fenêtre $(PRODUCT_NAME) ici"
}
},
"it": {
"stringUnit": {
"state": "translated",
"value": "Nuova finestra $(PRODUCT_NAME) qui"
}
},
"da": {
"stringUnit": {
"state": "translated",
"value": "Nyt $(PRODUCT_NAME)-vindue her"
}
},
"pl": {
"stringUnit": {
"state": "translated",
"value": "Nowe okno $(PRODUCT_NAME) tutaj"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Новое окно $(PRODUCT_NAME) здесь"
}
},
"bs": {
"stringUnit": {
"state": "translated",
"value": "Novi $(PRODUCT_NAME) prozor ovdje"
}
},
"ar": {
"stringUnit": {
"state": "translated",
"value": "نافذة $(PRODUCT_NAME) جديدة هنا"
}
},
"nb": {
"stringUnit": {
"state": "translated",
"value": "Nytt $(PRODUCT_NAME)-vindu her"
}
},
"pt-BR": {
"stringUnit": {
"state": "translated",
"value": "Nova Janela do $(PRODUCT_NAME) Aqui"
}
},
"th": {
"stringUnit": {
"state": "translated",
"value": "หน้าต่าง $(PRODUCT_NAME) ใหม่ที่นี่"
}
},
"tr": {
"stringUnit": {
"state": "translated",
"value": "Buraya Yeni $(PRODUCT_NAME) Penceresi"
}
}
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -18,8 +18,36 @@ find_real_claude() {
return 1
}
# Pass through if not in a cmux terminal or hooks are disabled.
if [[ -z "$CMUX_SURFACE_ID" || "$CMUX_CLAUDE_HOOKS_DISABLED" == "1" ]]; then
# Return 0 only when CMUX_SOCKET_PATH points to a live cmux socket.
cmux_socket_available() {
local socket="${CMUX_SOCKET_PATH:-}"
[[ -n "$socket" && -S "$socket" ]] || return 1
local self_dir cmux_bin
self_dir="$(cd "$(dirname "$0")" && pwd)"
cmux_bin="$self_dir/cmux"
[[ -x "$cmux_bin" ]] || cmux_bin="$(command -v cmux || true)"
[[ -n "$cmux_bin" ]] || return 1
# Keep stale/hung socket checks bounded so claude startup does not block
# behind the CLI default timeout (15s).
CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC=0.75 \
"$cmux_bin" --socket "$socket" ping >/dev/null 2>&1
}
# Pass through if not in a cmux terminal, hooks are disabled, or the cmux
# socket is unavailable (stale env / app not running).
IN_CMUX=0
if [[ -n "$CMUX_SURFACE_ID" ]]; then
IN_CMUX=1
fi
if [[ "$IN_CMUX" == "0" || "$CMUX_CLAUDE_HOOKS_DISABLED" == "1" ]] || ! cmux_socket_available; then
# In cmux-launched shells, preserve old behavior and always clear nested
# Claude session markers, even when we must pass through due to stale socket.
if [[ "$IN_CMUX" == "1" ]]; then
unset CLAUDECODE
fi
REAL_CLAUDE="$(find_real_claude)" || { echo "Error: claude not found in PATH" >&2; exit 127; }
exec "$REAL_CLAUDE" "$@"
fi

View file

@ -105,6 +105,169 @@ is_http_url() {
return 1
}
is_file_url() {
local value="$1"
case "$value" in
[Ff][Ii][Ll][Ee]://*)
return 0
;;
esac
return 1
}
has_uri_scheme() {
local value="$1"
[[ "$value" =~ ^[A-Za-z][A-Za-z0-9+.-]*: ]]
}
is_html_extension() {
local value
value="$(to_lower_ascii "$(trim "$1")")"
case "$value" in
*.html|*.htm)
return 0
;;
esac
return 1
}
is_explicit_local_path() {
local value="$1"
case "$value" in
/*|./*|../*|~|~/*)
return 0
;;
esac
return 1
}
file_url_points_to_html() {
local value="$1"
if [[ -n "$PYTHON3_BIN" ]]; then
"$PYTHON3_BIN" - "$value" <<'PY' >/dev/null 2>&1
import sys
from urllib.parse import unquote, urlsplit
value = sys.argv[1].strip()
if not value:
raise SystemExit(1)
parts = urlsplit(value)
path = unquote(parts.path or "")
lower = path.lower()
if lower.endswith(".html") or lower.endswith(".htm"):
raise SystemExit(0)
raise SystemExit(1)
PY
return $?
fi
local without_fragment="${value%%\#*}"
local without_query="${without_fragment%%\?*}"
local remainder path_part
case "$without_query" in
[Ff][Ii][Ll][Ee]://*)
remainder="${without_query#*://}"
;;
*)
return 1
;;
esac
if [[ "$remainder" == /* ]]; then
path_part="$remainder"
elif [[ "$remainder" == */* ]]; then
path_part="/${remainder#*/}"
else
return 1
fi
is_html_extension "$path_part"
}
path_to_file_url_without_python() {
local raw="$1"
local expanded="$raw"
case "$expanded" in
"~")
expanded="$HOME"
;;
"~/"*)
expanded="$HOME/${expanded#~/}"
;;
esac
local absolute
if [[ "$expanded" == /* ]]; then
absolute="$expanded"
else
absolute="$(pwd)/$expanded"
fi
local directory="$absolute"
local basename=""
if [[ "$absolute" == */* ]]; then
directory="${absolute%/*}"
basename="${absolute##*/}"
fi
local resolved_directory
if resolved_directory="$(cd "$directory" 2>/dev/null && pwd -P)"; then
absolute="$resolved_directory"
if [[ -n "$basename" ]]; then
absolute="$absolute/$basename"
fi
fi
local encoded=""
local length=${#absolute}
local index char hex
local LC_ALL=C
for ((index = 0; index < length; index++)); do
char="${absolute:index:1}"
case "$char" in
[a-zA-Z0-9.~_-]|/)
encoded+="$char"
;;
*)
printf -v hex '%02X' "'$char"
encoded+="%$hex"
;;
esac
done
printf 'file://%s\n' "$encoded"
}
path_to_file_url() {
local raw="$1"
if [[ -n "$PYTHON3_BIN" ]]; then
local converted
if converted="$("$PYTHON3_BIN" - "$raw" <<'PY' 2>/dev/null
import pathlib
import sys
raw = sys.argv[1]
if not raw:
raise SystemExit(1)
path = pathlib.Path(raw).expanduser()
if path.is_absolute():
resolved = path.resolve(strict=False)
else:
resolved = (pathlib.Path.cwd() / path).resolve(strict=False)
sys.stdout.write(resolved.as_uri())
PY
)"; then
printf '%s\n' "$converted"
return 0
fi
fi
path_to_file_url_without_python "$raw"
}
normalize_host() {
local value
value="$(trim "$1")"
@ -212,9 +375,10 @@ if [[ $# -eq 0 ]]; then
fi
# Scan for flags that indicate explicit user intent → pass through.
# Also collect non-flag arguments (potential URLs/files).
# Also collect non-flag arguments and route eligible browser targets to cmux.
passthrough=false
urls=()
cmux_targets=()
passthrough_args=()
for arg in "$@"; do
case "$arg" in
-a|-b|-R|-e|-t|-f|-W|-g|-n|-h|-s|-j|-u|--env|--stdin|--stdout|--stderr)
@ -228,17 +392,33 @@ for arg in "$@"; do
;;
*)
if is_http_url "$arg"; then
urls+=("$arg")
cmux_targets+=("$arg")
elif is_file_url "$arg"; then
if file_url_points_to_html "$arg"; then
cmux_targets+=("$arg")
else
# Non-URL, non-flag argument (file path, etc.) → pass through all
passthrough=true
break
passthrough_args+=("$arg")
fi
elif has_uri_scheme "$arg"; then
passthrough_args+=("$arg")
elif is_html_extension "$arg"; then
if is_explicit_local_path "$arg" || [[ -e "$arg" ]]; then
if local_file_url="$(path_to_file_url "$arg")"; then
cmux_targets+=("$local_file_url")
else
passthrough_args+=("$arg")
fi
else
passthrough_args+=("$arg")
fi
else
passthrough_args+=("$arg")
fi
;;
esac
done
if [[ "$passthrough" == true ]] || [[ ${#urls[@]} -eq 0 ]]; then
if [[ "$passthrough" == true ]] || [[ ${#cmux_targets[@]} -eq 0 ]]; then
system_open "$@"
fi
@ -257,6 +437,7 @@ if [[ -n "$settings_domain" ]]; then
if [[ -n "$whitelist_raw" ]]; then
load_whitelist_patterns "$whitelist_raw"
fi
fi
# Find cmux CLI (same directory as this script).
@ -268,16 +449,18 @@ if [[ ! -x "$CMUX_CLI" ]]; then
fi
# Open each URL in cmux's in-app browser; track failures individually.
# External-open pattern rules are evaluated in-app (NSRegularExpression) so
# terminal link clicks and intercepted `open` commands share one regex dialect.
failed_urls=()
for url in "${urls[@]}"; do
if ! host_matches_whitelist "$url"; then
for url in "${cmux_targets[@]}"; do
if is_http_url "$url" && ! host_matches_whitelist "$url"; then
failed_urls+=("$url")
continue
fi
"$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url")
CMUX_RESPECT_EXTERNAL_OPEN_RULES=1 "$CMUX_CLI" browser open "$url" 2>/dev/null || failed_urls+=("$url")
done
# Fall back to system open only for URLs that failed.
if [[ ${#failed_urls[@]} -gt 0 ]]; then
system_open "${failed_urls[@]}"
# Fall back to system open for unmatched args and URLs that failed.
if [[ ${#passthrough_args[@]} -gt 0 ]] || [[ ${#failed_urls[@]} -gt 0 ]]; then
system_open "${passthrough_args[@]}" "${failed_urls[@]}"
fi

View file

@ -13,7 +13,9 @@
# - CMUX_ZSH_ZDOTDIR (set by cmux when it overwrote a user-provided ZDOTDIR)
# - unset (zsh treats unset ZDOTDIR as $HOME)
builtin typeset _cmux_had_ghostty_zdotdir=0
if [[ -n "${GHOSTTY_ZSH_ZDOTDIR+X}" ]]; then
_cmux_had_ghostty_zdotdir=1
builtin export ZDOTDIR="$GHOSTTY_ZSH_ZDOTDIR"
builtin unset GHOSTTY_ZSH_ZDOTDIR
elif [[ -n "${CMUX_ZSH_ZDOTDIR+X}" ]]; then
@ -31,7 +33,9 @@ fi
if [[ -o interactive ]]; then
# We overwrote GhosttyKit's injected ZDOTDIR, so manually load Ghostty's
# zsh integration if available.
if [[ -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then
# Guard on GHOSTTY_ZSH_ZDOTDIR being set by Ghostty. When users configure
# shell-integration=none, Ghostty does not set this and we must skip.
if [[ "$_cmux_had_ghostty_zdotdir" == "1" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then
builtin typeset _cmux_ghostty="$GHOSTTY_RESOURCES_DIR/shell-integration/zsh/ghostty-integration"
[[ -r "$_cmux_ghostty" ]] && builtin source -- "$_cmux_ghostty"
fi
@ -43,5 +47,5 @@ fi
fi
fi
builtin unset _cmux_file _cmux_ghostty _cmux_integ
builtin unset _cmux_file _cmux_ghostty _cmux_integ _cmux_had_ghostty_zdotdir
}

View file

@ -41,6 +41,9 @@ _CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}"
_CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}"
_CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}"
_CMUX_GIT_JOB_STARTED_AT="${_CMUX_GIT_JOB_STARTED_AT:-0}"
_CMUX_GIT_HEAD_LAST_PWD="${_CMUX_GIT_HEAD_LAST_PWD:-}"
_CMUX_GIT_HEAD_PATH="${_CMUX_GIT_HEAD_PATH:-}"
_CMUX_GIT_HEAD_SIGNATURE="${_CMUX_GIT_HEAD_SIGNATURE:-}"
_CMUX_PR_LAST_PWD="${_CMUX_PR_LAST_PWD:-}"
_CMUX_PR_LAST_RUN="${_CMUX_PR_LAST_RUN:-0}"
_CMUX_PR_JOB_PID="${_CMUX_PR_JOB_PID:-}"
@ -51,6 +54,41 @@ _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
_CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}"
_CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}"
_cmux_git_resolve_head_path() {
# Resolve the HEAD file path without invoking git (fast; works for worktrees).
local dir="$PWD"
while :; do
if [[ -d "$dir/.git" ]]; then
printf '%s\n' "$dir/.git/HEAD"
return 0
fi
if [[ -f "$dir/.git" ]]; then
local line gitdir
IFS= read -r line < "$dir/.git" || line=""
if [[ "$line" == gitdir:* ]]; then
gitdir="${line#gitdir:}"
gitdir="${gitdir## }"
gitdir="${gitdir%% }"
[[ -n "$gitdir" ]] || return 1
[[ "$gitdir" != /* ]] && gitdir="$dir/$gitdir"
printf '%s\n' "$gitdir/HEAD"
return 0
fi
fi
[[ "$dir" == "/" || -z "$dir" ]] && break
dir="$(dirname "$dir")"
done
return 1
}
_cmux_git_head_signature() {
local head_path="$1"
[[ -n "$head_path" && -r "$head_path" ]] || return 1
local line
IFS= read -r line < "$head_path" || return 1
printf '%s\n' "$line"
}
_cmux_report_tty_once() {
# Send the TTY name to the app once per session so the batched port scanner
# knows which TTY belongs to this panel.
@ -62,7 +100,7 @@ _cmux_report_tty_once() {
_CMUX_TTY_REPORTED=1
{
_cmux_send "report_tty $_CMUX_TTY_NAME --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 &
} >/dev/null 2>&1 & disown
}
_cmux_ports_kick() {
@ -74,7 +112,7 @@ _cmux_ports_kick() {
_CMUX_PORTS_LAST_RUN=$SECONDS
{
_cmux_send "ports_kick --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 &
} >/dev/null 2>&1 & disown
}
_cmux_prompt_command() {
@ -123,7 +161,26 @@ _cmux_prompt_command() {
{
local qpwd="${pwd//\"/\\\"}"
_cmux_send "report_pwd \"${qpwd}\" --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 &
} >/dev/null 2>&1 & disown
fi
# Branch can change via aliases/tools while an older probe is still in flight.
# Track .git/HEAD content so we can restart stale probes immediately.
local git_head_changed=0
if [[ "$pwd" != "$_CMUX_GIT_HEAD_LAST_PWD" ]]; then
_CMUX_GIT_HEAD_LAST_PWD="$pwd"
_CMUX_GIT_HEAD_PATH="$(_cmux_git_resolve_head_path 2>/dev/null || true)"
_CMUX_GIT_HEAD_SIGNATURE=""
fi
if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then
local head_signature
head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)"
if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
git_head_changed=1
# Also invalidate the PR probe so it refreshes with the new branch.
_CMUX_PR_LAST_RUN=0
fi
fi
# Git branch/dirty can change without a directory change (e.g. `git checkout`),
@ -131,7 +188,7 @@ _cmux_prompt_command() {
# When pwd changes (cd into a different repo), kill the old probe and start fresh
# so the sidebar picks up the new branch immediately.
if [[ -n "$_CMUX_GIT_JOB_PID" ]] && kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then
if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]]; then
if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" || "$git_head_changed" == "1" ]]; then
kill "$_CMUX_GIT_JOB_PID" >/dev/null 2>&1 || true
_CMUX_GIT_JOB_PID=""
_CMUX_GIT_JOB_STARTED_AT=0
@ -154,20 +211,21 @@ _cmux_prompt_command() {
fi
} >/dev/null 2>&1 &
_CMUX_GIT_JOB_PID=$!
disown
_CMUX_GIT_JOB_STARTED_AT=$now
fi
# Pull request metadata (number/state/url):
# refresh on cwd change and periodically to avoid stale status.
# refresh on cwd change, HEAD change, and periodically to avoid stale status.
if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" || "$git_head_changed" == "1" ]]; then
kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true
_CMUX_PR_JOB_PID=""
_CMUX_PR_JOB_STARTED_AT=0
fi
fi
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" || "$git_head_changed" == "1" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then
if [[ -z "$_CMUX_PR_JOB_PID" ]] || ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
_CMUX_PR_LAST_PWD="$pwd"
_CMUX_PR_LAST_RUN=$now
@ -197,6 +255,7 @@ _cmux_prompt_command() {
fi
} >/dev/null 2>&1 &
_CMUX_PR_JOB_PID=$!
disown
_CMUX_PR_JOB_STARTED_AT=$now
fi
fi
@ -205,6 +264,7 @@ _cmux_prompt_command() {
if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then
_cmux_ports_kick
fi
}
_cmux_install_prompt_command() {

View file

@ -45,8 +45,8 @@ typeset -g _CMUX_GIT_JOB_STARTED_AT=0
typeset -g _CMUX_GIT_FORCE=0
typeset -g _CMUX_GIT_HEAD_LAST_PWD=""
typeset -g _CMUX_GIT_HEAD_PATH=""
typeset -g _CMUX_GIT_HEAD_MTIME=0
typeset -g _CMUX_HAVE_ZSTAT=0
typeset -g _CMUX_GIT_HEAD_SIGNATURE=""
typeset -g _CMUX_GIT_HEAD_WATCH_PID=""
typeset -g _CMUX_PR_LAST_PWD=""
typeset -g _CMUX_PR_LAST_RUN=0
typeset -g _CMUX_PR_JOB_PID=""
@ -155,19 +155,6 @@ _cmux_install_winch_guard() {
}
_cmux_install_winch_guard
_cmux_ensure_zstat() {
# zstat is substantially cheaper than spawning external `stat`.
if (( _CMUX_HAVE_ZSTAT != 0 )); then
return 0
fi
if zmodload -F zsh/stat b:zstat 2>/dev/null; then
_CMUX_HAVE_ZSTAT=1
return 0
fi
_CMUX_HAVE_ZSTAT=-1
return 1
}
_cmux_git_resolve_head_path() {
# Resolve the HEAD file path without invoking git (fast; works for worktrees).
local dir="$PWD"
@ -195,27 +182,15 @@ _cmux_git_resolve_head_path() {
return 1
}
_cmux_git_head_mtime() {
_cmux_git_head_signature() {
local head_path="$1"
[[ -n "$head_path" && -f "$head_path" ]] || { print -r -- 0; return 0; }
if _cmux_ensure_zstat; then
typeset -A st
if zstat -H st +mtime -- "$head_path" 2>/dev/null; then
print -r -- "${st[mtime]:-0}"
[[ -n "$head_path" && -r "$head_path" ]] || return 1
local line=""
if IFS= read -r line < "$head_path"; then
print -r -- "$line"
return 0
fi
fi
# Fallback for environments where zsh/stat isn't available.
if command -v stat >/dev/null 2>&1; then
local mtime
mtime="$(stat -f %m "$head_path" 2>/dev/null || stat -c %Y "$head_path" 2>/dev/null || echo 0)"
print -r -- "$mtime"
return 0
fi
print -r -- 0
return 1
}
_cmux_report_tty_once() {
@ -244,6 +219,65 @@ _cmux_ports_kick() {
} >/dev/null 2>&1 &!
}
_cmux_report_git_branch_for_path() {
local repo_path="$1"
[[ -n "$repo_path" ]] || return 0
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
local branch dirty_opt="" first
branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)"
if [[ -n "$branch" ]]; then
first="$(git -C "$repo_path" status --porcelain -uno 2>/dev/null | head -1)"
[[ -n "$first" ]] && dirty_opt="--status=dirty"
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
else
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
fi
}
_cmux_stop_git_head_watch() {
if [[ -n "$_CMUX_GIT_HEAD_WATCH_PID" ]]; then
kill "$_CMUX_GIT_HEAD_WATCH_PID" >/dev/null 2>&1 || true
_CMUX_GIT_HEAD_WATCH_PID=""
fi
}
_cmux_start_git_head_watch() {
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
local watch_pwd="$PWD"
local watch_head_path
watch_head_path="$(_cmux_git_resolve_head_path 2>/dev/null || true)"
[[ -n "$watch_head_path" ]] || return 0
local watch_head_signature
watch_head_signature="$(_cmux_git_head_signature "$watch_head_path" 2>/dev/null || true)"
_CMUX_GIT_HEAD_LAST_PWD="$watch_pwd"
_CMUX_GIT_HEAD_PATH="$watch_head_path"
_CMUX_GIT_HEAD_SIGNATURE="$watch_head_signature"
_cmux_stop_git_head_watch
{
local last_signature="$watch_head_signature"
while true; do
sleep 1
local signature
signature="$(_cmux_git_head_signature "$watch_head_path" 2>/dev/null || true)"
if [[ -n "$signature" && "$signature" != "$last_signature" ]]; then
last_signature="$signature"
_cmux_report_git_branch_for_path "$watch_pwd"
fi
done
} >/dev/null 2>&1 &!
_CMUX_GIT_HEAD_WATCH_PID=$!
}
_cmux_preexec() {
if [[ -z "$_CMUX_TTY_NAME" ]]; then
local t
@ -265,9 +299,12 @@ _cmux_preexec() {
# Register TTY + kick batched port scan for foreground commands (servers).
_cmux_report_tty_once
_cmux_ports_kick
_cmux_start_git_head_watch
}
_cmux_precmd() {
_cmux_stop_git_head_watch
# Skip if socket doesn't exist yet
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
@ -328,6 +365,8 @@ _cmux_precmd() {
fi
# Git branch/dirty: update immediately on directory change, otherwise every ~3s.
# While a foreground command is running, _cmux_start_git_head_watch probes HEAD
# once per second so agent-initiated git checkouts still surface quickly.
local should_git=0
# Git branch can change without a `git ...`-prefixed command (aliases like `gco`,
@ -335,13 +374,13 @@ _cmux_precmd() {
if [[ "$pwd" != "$_CMUX_GIT_HEAD_LAST_PWD" ]]; then
_CMUX_GIT_HEAD_LAST_PWD="$pwd"
_CMUX_GIT_HEAD_PATH="$(_cmux_git_resolve_head_path 2>/dev/null || true)"
_CMUX_GIT_HEAD_MTIME=0
_CMUX_GIT_HEAD_SIGNATURE=""
fi
if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then
local head_mtime
head_mtime="$(_cmux_git_head_mtime "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || echo 0)"
if [[ -n "$head_mtime" && "$head_mtime" != 0 && "$head_mtime" != "$_CMUX_GIT_HEAD_MTIME" ]]; then
_CMUX_GIT_HEAD_MTIME="$head_mtime"
local head_signature
head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)"
if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
# Treat HEAD file change like a git command — force-replace any
# running probe so the sidebar picks up the new branch immediately.
_CMUX_GIT_FORCE=1
@ -381,16 +420,7 @@ _cmux_precmd() {
_CMUX_GIT_LAST_PWD="$pwd"
_CMUX_GIT_LAST_RUN=$now
{
local branch dirty_opt=""
branch=$(git branch --show-current 2>/dev/null)
if [[ -n "$branch" ]]; then
local first
first=$(git status --porcelain -uno 2>/dev/null | head -1)
[[ -n "$first" ]] && dirty_opt="--status=dirty"
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
else
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
fi
_cmux_report_git_branch_for_path "$pwd"
} >/dev/null 2>&1 &!
_CMUX_GIT_JOB_PID=$!
_CMUX_GIT_JOB_STARTED_AT=$now
@ -488,7 +518,12 @@ _cmux_fix_path() {
add-zsh-hook -d precmd _cmux_fix_path
}
_cmux_zshexit() {
_cmux_stop_git_head_watch
}
autoload -Uz add-zsh-hook
add-zsh-hook preexec _cmux_preexec
add-zsh-hook precmd _cmux_precmd
add-zsh-hook precmd _cmux_fix_path
add-zsh-hook zshexit _cmux_zshexit

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,207 @@
import Foundation
/// JavaScript snippets for find-in-page in WKWebView.
///
/// Uses TreeWalker to scan text nodes and wraps matches with `<mark>` elements.
/// The current match gets an additional `.current` class and is scrolled into view.
enum BrowserFindJavaScript {
// MARK: - Public API
/// Returns JS that highlights all occurrences of `query` in the document body.
/// The script evaluates to a JSON string `{"total":N,"current":0}`.
static func searchScript(query: String) -> String {
let escaped = jsStringEscape(query)
return """
(() => {
const MARK_CLASS = '__cmux-find';
const CURRENT_CLASS = '__cmux-find-current';
// Remove previous highlights first.
\(clearBody)
const query = "\(escaped)";
if (!query) return JSON.stringify({total: 0, current: 0});
const lowerQuery = query.toLowerCase();
const SKIP_TAGS = new Set(['SCRIPT','STYLE','NOSCRIPT','TEMPLATE','IFRAME','SVG']);
const isVisible = (el) => {
while (el && el !== document.body) {
if (SKIP_TAGS.has(el.tagName)) return false;
if (el.getAttribute('aria-hidden') === 'true') return false;
const st = getComputedStyle(el);
if (st.display === 'none' || st.visibility === 'hidden') return false;
el = el.parentElement;
}
return true;
};
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{ acceptNode(node) { return isVisible(node.parentElement) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } }
);
const matches = [];
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
for (const node of textNodes) {
const text = node.textContent || '';
const lowerText = text.toLowerCase();
let startIndex = 0;
const parts = [];
let lastEnd = 0;
while (true) {
const idx = lowerText.indexOf(lowerQuery, startIndex);
if (idx === -1) break;
parts.push({ start: idx, end: idx + query.length });
startIndex = idx + query.length;
}
if (parts.length === 0) continue;
const parent = node.parentNode;
if (!parent) continue;
const frag = document.createDocumentFragment();
let pos = 0;
for (const part of parts) {
if (part.start > pos) {
frag.appendChild(document.createTextNode(text.substring(pos, part.start)));
}
const mark = document.createElement('mark');
mark.className = MARK_CLASS;
mark.textContent = text.substring(part.start, part.end);
frag.appendChild(mark);
matches.push(mark);
pos = part.end;
}
if (pos < text.length) {
frag.appendChild(document.createTextNode(text.substring(pos)));
}
parent.replaceChild(frag, node);
}
window.__cmuxFindMatches = matches;
window.__cmuxFindIndex = 0;
if (matches.length > 0) {
matches[0].classList.add(CURRENT_CLASS);
matches[0].scrollIntoView({ block: 'center', behavior: 'smooth' });
}
// Inject highlight styles if not already present.
if (!document.getElementById('__cmux-find-style')) {
const style = document.createElement('style');
style.id = '__cmux-find-style';
style.textContent = `
mark.__cmux-find { background: #facc15; color: #000; border-radius: 2px; }
mark.__cmux-find.__cmux-find-current { background: #f97316; color: #fff; }
`;
document.head.appendChild(style);
}
return JSON.stringify({ total: matches.length, current: 0 });
})()
"""
}
/// Returns JS that moves to the next match. Evaluates to `{"total":N,"current":M}`.
static func nextScript() -> String {
"""
(() => {
const matches = window.__cmuxFindMatches || [];
if (matches.length === 0) return JSON.stringify({ total: 0, current: 0 });
let idx = window.__cmuxFindIndex || 0;
if (!matches[idx] || !matches[idx].isConnected) {
window.__cmuxFindMatches = [];
window.__cmuxFindIndex = 0;
return JSON.stringify({ total: 0, current: 0 });
}
matches[idx].classList.remove('__cmux-find-current');
idx = (idx + 1) % matches.length;
if (!matches[idx] || !matches[idx].isConnected) {
window.__cmuxFindMatches = [];
window.__cmuxFindIndex = 0;
return JSON.stringify({ total: 0, current: 0 });
}
matches[idx].classList.add('__cmux-find-current');
matches[idx].scrollIntoView({ block: 'center', behavior: 'smooth' });
window.__cmuxFindIndex = idx;
return JSON.stringify({ total: matches.length, current: idx });
})()
"""
}
/// Returns JS that moves to the previous match. Evaluates to `{"total":N,"current":M}`.
static func previousScript() -> String {
"""
(() => {
const matches = window.__cmuxFindMatches || [];
if (matches.length === 0) return JSON.stringify({ total: 0, current: 0 });
let idx = window.__cmuxFindIndex || 0;
if (!matches[idx] || !matches[idx].isConnected) {
window.__cmuxFindMatches = [];
window.__cmuxFindIndex = 0;
return JSON.stringify({ total: 0, current: 0 });
}
matches[idx].classList.remove('__cmux-find-current');
idx = (idx - 1 + matches.length) % matches.length;
if (!matches[idx] || !matches[idx].isConnected) {
window.__cmuxFindMatches = [];
window.__cmuxFindIndex = 0;
return JSON.stringify({ total: 0, current: 0 });
}
matches[idx].classList.add('__cmux-find-current');
matches[idx].scrollIntoView({ block: 'center', behavior: 'smooth' });
window.__cmuxFindIndex = idx;
return JSON.stringify({ total: matches.length, current: idx });
})()
"""
}
/// Returns JS that removes all find highlights and restores the DOM.
static func clearScript() -> String {
"""
(() => {
\(clearBody)
window.__cmuxFindMatches = [];
window.__cmuxFindIndex = 0;
const style = document.getElementById('__cmux-find-style');
if (style) style.remove();
return 'ok';
})()
"""
}
// MARK: - Internal
/// JS snippet (no wrapping IIFE) that removes existing mark highlights.
private static let clearBody = """
document.querySelectorAll('mark.__cmux-find').forEach(mark => {
const parent = mark.parentNode;
if (!parent) return;
const text = document.createTextNode(mark.textContent || '');
parent.replaceChild(text, mark);
parent.normalize();
});
"""
/// Escape a Swift string for safe embedding inside a JS double-quoted string literal.
static func jsStringEscape(_ string: String) -> String {
var result = ""
result.reserveCapacity(string.count)
for scalar in string.unicodeScalars {
switch scalar {
case "\\": result += "\\\\"
case "\"": result += "\\\""
case "\n": result += "\\n"
case "\r": result += "\\r"
case "\t": result += "\\t"
case "\0": result += "\\0"
case "\u{2028}": result += "\\u2028"
case "\u{2029}": result += "\\u2029"
default:
result.append(Character(scalar))
}
}
return result
}
}

View file

@ -0,0 +1,193 @@
import Bonsplit
import SwiftUI
struct BrowserSearchOverlay: View {
let panelId: UUID
@ObservedObject var searchState: BrowserSearchState
let onNext: () -> Void
let onPrevious: () -> Void
let onClose: () -> Void
@State private var corner: Corner = .topRight
@State private var dragOffset: CGSize = .zero
@State private var barSize: CGSize = .zero
@FocusState private var isSearchFieldFocused: Bool
private let padding: CGFloat = 8
private func requestSearchFieldFocus(maxAttempts: Int = 3) {
guard maxAttempts > 0 else { return }
isSearchFieldFocused = true
guard maxAttempts > 1 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
requestSearchFieldFocus(maxAttempts: maxAttempts - 1)
}
}
var body: some View {
GeometryReader { geo in
HStack(spacing: 4) {
TextField("Search", text: $searchState.needle)
.textFieldStyle(.plain)
.accessibilityIdentifier("BrowserFindSearchTextField")
.frame(width: 180)
.padding(.leading, 8)
.padding(.trailing, 50)
.padding(.vertical, 6)
.background(Color.primary.opacity(0.1))
.cornerRadius(6)
.focused($isSearchFieldFocused)
.overlay(alignment: .trailing) {
if let selected = searchState.selected {
let totalText = searchState.total.map { String($0) } ?? "?"
Text("\(selected + 1)/\(totalText)")
.font(.caption)
.foregroundColor(.secondary)
.monospacedDigit()
.padding(.trailing, 8)
} else if let total = searchState.total {
Text(total == 0 ? "0/0" : "-/\(total)")
.font(.caption)
.foregroundColor(.secondary)
.monospacedDigit()
.padding(.trailing, 8)
}
}
.onExitCommand {
onClose()
}
.onSubmit {
// onSubmit fires only after IME composition is committed.
if NSEvent.modifierFlags.contains(.shift) {
onPrevious()
} else {
onNext()
}
}
Button(action: {
#if DEBUG
dlog("browser.findbar.next panel=\(panelId.uuidString.prefix(5))")
#endif
onNext()
}) {
Image(systemName: "chevron.up")
}
.buttonStyle(SearchButtonStyle())
.safeHelp("Next match (Return)")
Button(action: {
#if DEBUG
dlog("browser.findbar.prev panel=\(panelId.uuidString.prefix(5))")
#endif
onPrevious()
}) {
Image(systemName: "chevron.down")
}
.buttonStyle(SearchButtonStyle())
.safeHelp("Previous match (Shift+Return)")
Button(action: {
#if DEBUG
dlog("browser.findbar.close panel=\(panelId.uuidString.prefix(5))")
#endif
onClose()
}) {
Image(systemName: "xmark")
}
.buttonStyle(SearchButtonStyle())
.safeHelp("Close (Esc)")
}
.padding(8)
.background(.background)
.clipShape(clipShape)
.shadow(radius: 4)
.onAppear {
#if DEBUG
dlog("browser.findbar.appear panel=\(panelId.uuidString.prefix(5))")
#endif
requestSearchFieldFocus()
}
.onReceive(NotificationCenter.default.publisher(for: .browserSearchFocus)) { notification in
guard let notifiedPanelId = notification.object as? UUID,
notifiedPanelId == panelId else { return }
DispatchQueue.main.async {
requestSearchFieldFocus()
}
}
.background(
GeometryReader { barGeo in
Color.clear.onAppear {
barSize = barGeo.size
}
}
)
.padding(padding)
.offset(dragOffset)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: corner.alignment)
.gesture(
DragGesture()
.onChanged { value in
dragOffset = value.translation
}
.onEnded { value in
let centerPos = centerPosition(for: corner, in: geo.size, barSize: barSize)
let newCenter = CGPoint(
x: centerPos.x + value.translation.width,
y: centerPos.y + value.translation.height
)
let newCorner = closestCorner(to: newCenter, in: geo.size)
withAnimation(.easeOut(duration: 0.2)) {
corner = newCorner
dragOffset = .zero
}
}
)
}
}
private var clipShape: some Shape {
RoundedRectangle(cornerRadius: 8)
}
enum Corner {
case topLeft
case topRight
case bottomLeft
case bottomRight
var alignment: Alignment {
switch self {
case .topLeft: return .topLeading
case .topRight: return .topTrailing
case .bottomLeft: return .bottomLeading
case .bottomRight: return .bottomTrailing
}
}
}
private func centerPosition(for corner: Corner, in containerSize: CGSize, barSize: CGSize) -> CGPoint {
let halfWidth = barSize.width / 2 + padding
let halfHeight = barSize.height / 2 + padding
switch corner {
case .topLeft:
return CGPoint(x: halfWidth, y: halfHeight)
case .topRight:
return CGPoint(x: containerSize.width - halfWidth, y: halfHeight)
case .bottomLeft:
return CGPoint(x: halfWidth, y: containerSize.height - halfHeight)
case .bottomRight:
return CGPoint(x: containerSize.width - halfWidth, y: containerSize.height - halfHeight)
}
}
private func closestCorner(to point: CGPoint, in containerSize: CGSize) -> Corner {
let midX = containerSize.width / 2
let midY = containerSize.height / 2
if point.x < midX {
return point.y < midY ? .topLeft : .bottomLeft
}
return point.y < midY ? .topRight : .bottomRight
}
}

View file

@ -1,32 +1,67 @@
import AppKit
import Bonsplit
import SwiftUI
private extension NSView {
func cmuxAncestor<T: NSView>(of type: T.Type) -> T? {
var current: NSView? = self
while let view = current {
if let target = view as? T {
return target
}
current = view.superview
}
return nil
}
}
struct SurfaceSearchOverlay: View {
let tabId: UUID
let surfaceId: UUID
@ObservedObject var searchState: TerminalSurface.SearchState
let onMoveFocusToTerminal: () -> Void
let onNavigateSearch: (_ action: String) -> Void
let onFieldDidFocus: () -> Void
let onClose: () -> Void
@State private var corner: Corner = .topRight
@State private var dragOffset: CGSize = .zero
@State private var barSize: CGSize = .zero
@FocusState private var isSearchFieldFocused: Bool
@State private var isSearchFieldFocused: Bool = true
private let padding: CGFloat = 8
var body: some View {
GeometryReader { geo in
HStack(spacing: 4) {
TextField("Search", text: $searchState.needle)
.textFieldStyle(.plain)
SearchTextFieldRepresentable(
text: $searchState.needle,
isFocused: $isSearchFieldFocused,
surfaceId: surfaceId,
onFieldDidFocus: onFieldDidFocus,
onEscape: {
#if DEBUG
dlog("find.nativeField.escape surface=\(surfaceId.uuidString.prefix(5)) needleEmpty=\(searchState.needle.isEmpty)")
#endif
if searchState.needle.isEmpty {
onClose()
} else {
onMoveFocusToTerminal()
}
},
onReturn: { isShift in
let action = isShift
? "navigate_search:previous"
: "navigate_search:next"
onNavigateSearch(action)
}
)
.accessibilityIdentifier("TerminalFindSearchTextField")
.frame(width: 180)
.padding(.leading, 8)
.padding(.trailing, 50)
.padding(.vertical, 6)
.background(Color.primary.opacity(0.1))
.cornerRadius(6)
.focused($isSearchFieldFocused)
.overlay(alignment: .trailing) {
if let selected = searchState.selected {
let totalText = searchState.total.map { String($0) } ?? "?"
@ -43,20 +78,6 @@ struct SurfaceSearchOverlay: View {
.padding(.trailing, 8)
}
}
.onExitCommand {
if searchState.needle.isEmpty {
onClose()
} else {
onMoveFocusToTerminal()
}
}
.backport.onKeyPress(.return) { modifiers in
let action = modifiers.contains(.shift)
? "navigate_search:previous"
: "navigate_search:next"
onNavigateSearch(action)
return .handled
}
Button(action: {
#if DEBUG
@ -67,7 +88,7 @@ struct SurfaceSearchOverlay: View {
Image(systemName: "chevron.up")
}
.buttonStyle(SearchButtonStyle())
.help("Next match (Return)")
.safeHelp(String(localized: "search.nextMatch.help", defaultValue: "Next match (Return)"))
Button(action: {
#if DEBUG
@ -78,7 +99,7 @@ struct SurfaceSearchOverlay: View {
Image(systemName: "chevron.down")
}
.buttonStyle(SearchButtonStyle())
.help("Previous match (Shift+Return)")
.safeHelp(String(localized: "search.previousMatch.help", defaultValue: "Previous match (Shift+Return)"))
Button(action: {
#if DEBUG
@ -89,24 +110,18 @@ struct SurfaceSearchOverlay: View {
Image(systemName: "xmark")
}
.buttonStyle(SearchButtonStyle())
.help("Close (Esc)")
.safeHelp(String(localized: "search.close.help", defaultValue: "Close (Esc)"))
}
.padding(8)
.background(.background)
.clipShape(clipShape)
.shadow(radius: 4)
.onAppear {
NSLog("Find: overlay appear tab=%@ surface=%@", tabId.uuidString, surfaceId.uuidString)
#if DEBUG
dlog("find.overlay.appear tab=\(tabId.uuidString.prefix(5)) surface=\(surfaceId.uuidString.prefix(5))")
#endif
isSearchFieldFocused = true
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in
guard let focusedSurface = notification.object as? TerminalSurface,
focusedSurface.id == surfaceId else { return }
NSLog("Find: overlay focus tab=%@ surface=%@", tabId.uuidString, surfaceId.uuidString)
DispatchQueue.main.async {
isSearchFieldFocused = true
}
}
.background(
GeometryReader { barGeo in
Color.clear.onAppear {
@ -185,6 +200,194 @@ struct SurfaceSearchOverlay: View {
}
}
// MARK: - Native Search Text Field (AppKit)
/// NSTextField subclass for the terminal find bar.
/// Strips visual chrome so SwiftUI handles the background/border appearance.
private final class SearchNativeTextField: NSTextField {
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
isBordered = false
isBezeled = false
drawsBackground = false
focusRingType = .none
usesSingleLineMode = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// NSViewRepresentable wrapping SearchNativeTextField.
/// Handles Escape and Return at the AppKit delegate level, eliminating the
/// SwiftUI @FocusState / AppKit first-responder mismatch that broke focus
/// after window switching.
private struct SearchTextFieldRepresentable: NSViewRepresentable {
@Binding var text: String
@Binding var isFocused: Bool
let surfaceId: UUID
let onFieldDidFocus: () -> Void
let onEscape: () -> Void
let onReturn: (_ isShift: Bool) -> Void
final class Coordinator: NSObject, NSTextFieldDelegate {
var parent: SearchTextFieldRepresentable
var isProgrammaticMutation = false
weak var parentField: SearchNativeTextField?
var pendingFocusRequest: Bool?
var searchFocusObserver: NSObjectProtocol?
init(parent: SearchTextFieldRepresentable) {
self.parent = parent
}
deinit {
if let searchFocusObserver {
NotificationCenter.default.removeObserver(searchFocusObserver)
}
}
func controlTextDidChange(_ obj: Notification) {
guard !isProgrammaticMutation else { return }
guard let field = obj.object as? NSTextField else { return }
parent.text = field.stringValue
}
func controlTextDidBeginEditing(_ obj: Notification) {
#if DEBUG
dlog("find.nativeField.beginEditing surface=\(parent.surfaceId.uuidString.prefix(5))")
#endif
parent.onFieldDidFocus()
if !parent.isFocused {
DispatchQueue.main.async {
self.parent.isFocused = true
}
}
}
func controlTextDidEndEditing(_ obj: Notification) {
#if DEBUG
dlog("find.nativeField.endEditing surface=\(parent.surfaceId.uuidString.prefix(5))")
#endif
if parent.isFocused {
DispatchQueue.main.async {
self.parent.isFocused = false
}
}
}
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
switch commandSelector {
case #selector(NSResponder.cancelOperation(_:)):
// Don't intercept Escape during CJK IME composition (issue #118)
if textView.hasMarkedText() { return false }
control.cmuxAncestor(of: GhosttySurfaceScrollView.self)?.beginFindEscapeSuppression()
parent.onEscape()
return true
case #selector(NSResponder.insertNewline(_:)):
if textView.hasMarkedText() { return false }
let isShift = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false
parent.onReturn(isShift)
return true
default:
return false
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(parent: self)
}
func makeNSView(context: Context) -> SearchNativeTextField {
let field = SearchNativeTextField(frame: .zero)
field.font = .systemFont(ofSize: NSFont.systemFontSize)
field.placeholderString = String(localized: "search.placeholder", defaultValue: "Search")
field.setAccessibilityIdentifier("TerminalFindSearchTextField")
field.delegate = context.coordinator
field.stringValue = text
context.coordinator.parentField = field
// Observe .ghosttySearchFocus to immediately focus from AppKit level.
// This is the primary mechanism for restoring focus after window switches.
context.coordinator.searchFocusObserver = NotificationCenter.default.addObserver(
forName: .ghosttySearchFocus,
object: nil,
queue: .main
) { [weak field, weak coordinator = context.coordinator] notification in
guard let field, let coordinator else { return }
guard let surface = notification.object as? TerminalSurface,
surface.id == coordinator.parent.surfaceId else { return }
guard let window = field.window else { return }
// Don't re-focus if already first responder. makeFirstResponder on an
// already-editing NSTextField ends the editing session and restarts it
// with all text selected, causing typed characters to replace each other.
let fr = window.firstResponder
let alreadyFocused = fr === field ||
field.currentEditor() != nil ||
((fr as? NSTextView)?.delegate as? NSTextField) === field
#if DEBUG
dlog("find.nativeField.searchFocusNotification surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) alreadyFocused=\(alreadyFocused)")
#endif
guard !alreadyFocused else { return }
window.makeFirstResponder(field)
}
return field
}
func updateNSView(_ nsView: SearchNativeTextField, context: Context) {
context.coordinator.parent = self
context.coordinator.parentField = nsView
// Sync text from binding to field (skip during active IME composition)
if let editor = nsView.currentEditor() as? NSTextView {
if editor.string != text, !editor.hasMarkedText() {
context.coordinator.isProgrammaticMutation = true
editor.string = text
nsView.stringValue = text
context.coordinator.isProgrammaticMutation = false
}
} else if nsView.stringValue != text {
nsView.stringValue = text
}
// Sync focus from binding to AppKit
if let window = nsView.window {
let fr = window.firstResponder
let isFirstResponder =
fr === nsView ||
nsView.currentEditor() != nil ||
((fr as? NSTextView)?.delegate as? NSTextField) === nsView
if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true {
context.coordinator.pendingFocusRequest = true
DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
coordinator?.pendingFocusRequest = nil
guard let coordinator, coordinator.parent.isFocused else { return }
guard let nsView, let window = nsView.window else { return }
let fr = window.firstResponder
let alreadyFocused = fr === nsView ||
nsView.currentEditor() != nil ||
((fr as? NSTextView)?.delegate as? NSTextField) === nsView
guard !alreadyFocused else { return }
window.makeFirstResponder(nsView)
}
}
}
}
static func dismantleNSView(_ nsView: SearchNativeTextField, coordinator: Coordinator) {
if let observer = coordinator.searchFocusObserver {
NotificationCenter.default.removeObserver(observer)
coordinator.searchFocusObserver = nil
}
nsView.delegate = nil
coordinator.parentField = nil
}
}
struct SearchButtonStyle: ButtonStyle {
@State private var isHovered = false

View file

@ -21,6 +21,7 @@ struct GhosttyConfig {
// Colors (from theme or config)
var backgroundColor: NSColor = NSColor(hex: "#272822")!
var backgroundOpacity: Double = 1.0
var foregroundColor: NSColor = NSColor(hex: "#fdfff1")!
var cursorColor: NSColor = NSColor(hex: "#c0c1b5")!
var cursorTextColor: NSColor = NSColor(hex: "#8d8e82")!
@ -148,6 +149,10 @@ struct GhosttyConfig {
if let color = NSColor(hex: value) {
backgroundColor = color
}
case "background-opacity":
if let opacity = Double(value) {
backgroundOpacity = opacity
}
case "foreground":
if let color = NSColor(hex: value) {
foregroundColor = color

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,4 @@
import AppKit
import Carbon
class KeyboardLayout {
@ -12,8 +13,12 @@ class KeyboardLayout {
return nil
}
/// Translate a physical keyCode to the unmodified character under the current keyboard layout.
static func character(forKeyCode keyCode: UInt16) -> String? {
/// Translate a physical keyCode to the character AppKit would use for shortcut matching,
/// preserving command-aware layouts such as "Dvorak - QWERTY Command".
static func character(
forKeyCode keyCode: UInt16,
modifierFlags: NSEvent.ModifierFlags = []
) -> String? {
guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(),
let layoutDataPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else {
return nil
@ -31,7 +36,7 @@ class KeyboardLayout {
keyboardLayout,
keyCode,
UInt16(kUCKeyActionDisplay),
0,
translationModifierKeyState(for: modifierFlags),
UInt32(LMGetKbdType()),
UInt32(kUCKeyTranslateNoDeadKeysBit),
&deadKeyState,
@ -43,4 +48,20 @@ class KeyboardLayout {
guard status == noErr, length > 0 else { return nil }
return String(utf16CodeUnits: chars, count: length).lowercased()
}
private static func translationModifierKeyState(for modifierFlags: NSEvent.ModifierFlags) -> UInt32 {
let normalized = modifierFlags
.intersection(.deviceIndependentFlagsMask)
.intersection([.shift, .command])
var carbonModifiers: Int = 0
if normalized.contains(.shift) {
carbonModifiers |= shiftKey
}
if normalized.contains(.command) {
carbonModifiers |= cmdKey
}
return UInt32((carbonModifiers >> 8) & 0xFF)
}
}

View file

@ -10,6 +10,7 @@ enum KeyboardShortcutSettings {
case newWindow
case closeWindow
case openFolder
case sendFeedback
case showNotifications
case jumpToUnread
case triggerFlash
@ -23,6 +24,7 @@ enum KeyboardShortcutSettings {
case renameWorkspace
case closeWorkspace
case newSurface
case toggleTerminalCopyMode
// Panes / splits
case focusLeft
@ -44,34 +46,36 @@ enum KeyboardShortcutSettings {
var label: String {
switch self {
case .toggleSidebar: return "Toggle Sidebar"
case .newTab: return "New Workspace"
case .newWindow: return "New Window"
case .closeWindow: return "Close Window"
case .openFolder: return "Open Folder"
case .showNotifications: return "Show Notifications"
case .jumpToUnread: return "Jump to Latest Unread"
case .triggerFlash: return "Flash Focused Panel"
case .nextSurface: return "Next Surface"
case .prevSurface: return "Previous Surface"
case .nextSidebarTab: return "Next Workspace"
case .prevSidebarTab: return "Previous Workspace"
case .renameTab: return "Rename Tab"
case .renameWorkspace: return "Rename Workspace"
case .closeWorkspace: return "Close Workspace"
case .newSurface: return "New Surface"
case .focusLeft: return "Focus Pane Left"
case .focusRight: return "Focus Pane Right"
case .focusUp: return "Focus Pane Up"
case .focusDown: return "Focus Pane Down"
case .splitRight: return "Split Right"
case .splitDown: return "Split Down"
case .toggleSplitZoom: return "Toggle Pane Zoom"
case .splitBrowserRight: return "Split Browser Right"
case .splitBrowserDown: return "Split Browser Down"
case .openBrowser: return "Open Browser"
case .toggleBrowserDeveloperTools: return "Toggle Browser Developer Tools"
case .showBrowserJavaScriptConsole: return "Show Browser JavaScript Console"
case .toggleSidebar: return String(localized: "shortcut.toggleSidebar.label", defaultValue: "Toggle Sidebar")
case .newTab: return String(localized: "shortcut.newWorkspace.label", defaultValue: "New Workspace")
case .newWindow: return String(localized: "shortcut.newWindow.label", defaultValue: "New Window")
case .closeWindow: return String(localized: "shortcut.closeWindow.label", defaultValue: "Close Window")
case .openFolder: return String(localized: "shortcut.openFolder.label", defaultValue: "Open Folder")
case .sendFeedback: return String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback")
case .showNotifications: return String(localized: "shortcut.showNotifications.label", defaultValue: "Show Notifications")
case .jumpToUnread: return String(localized: "shortcut.jumpToUnread.label", defaultValue: "Jump to Latest Unread")
case .triggerFlash: return String(localized: "shortcut.flashFocusedPanel.label", defaultValue: "Flash Focused Panel")
case .nextSurface: return String(localized: "shortcut.nextSurface.label", defaultValue: "Next Surface")
case .prevSurface: return String(localized: "shortcut.previousSurface.label", defaultValue: "Previous Surface")
case .nextSidebarTab: return String(localized: "shortcut.nextWorkspace.label", defaultValue: "Next Workspace")
case .prevSidebarTab: return String(localized: "shortcut.previousWorkspace.label", defaultValue: "Previous Workspace")
case .renameTab: return String(localized: "shortcut.renameTab.label", defaultValue: "Rename Tab")
case .renameWorkspace: return String(localized: "shortcut.renameWorkspace.label", defaultValue: "Rename Workspace")
case .closeWorkspace: return String(localized: "shortcut.closeWorkspace.label", defaultValue: "Close Workspace")
case .newSurface: return String(localized: "shortcut.newSurface.label", defaultValue: "New Surface")
case .toggleTerminalCopyMode: return String(localized: "shortcut.toggleTerminalCopyMode.label", defaultValue: "Toggle Terminal Copy Mode")
case .focusLeft: return String(localized: "shortcut.focusPaneLeft.label", defaultValue: "Focus Pane Left")
case .focusRight: return String(localized: "shortcut.focusPaneRight.label", defaultValue: "Focus Pane Right")
case .focusUp: return String(localized: "shortcut.focusPaneUp.label", defaultValue: "Focus Pane Up")
case .focusDown: return String(localized: "shortcut.focusPaneDown.label", defaultValue: "Focus Pane Down")
case .splitRight: return String(localized: "shortcut.splitRight.label", defaultValue: "Split Right")
case .splitDown: return String(localized: "shortcut.splitDown.label", defaultValue: "Split Down")
case .toggleSplitZoom: return String(localized: "shortcut.togglePaneZoom.label", defaultValue: "Toggle Pane Zoom")
case .splitBrowserRight: return String(localized: "shortcut.splitBrowserRight.label", defaultValue: "Split Browser Right")
case .splitBrowserDown: return String(localized: "shortcut.splitBrowserDown.label", defaultValue: "Split Browser Down")
case .openBrowser: return String(localized: "shortcut.openBrowser.label", defaultValue: "Open Browser")
case .toggleBrowserDeveloperTools: return String(localized: "shortcut.toggleBrowserDevTools.label", defaultValue: "Toggle Browser Developer Tools")
case .showBrowserJavaScriptConsole: return String(localized: "shortcut.showBrowserJSConsole.label", defaultValue: "Show Browser JavaScript Console")
}
}
@ -82,6 +86,7 @@ enum KeyboardShortcutSettings {
case .newWindow: return "shortcut.newWindow"
case .closeWindow: return "shortcut.closeWindow"
case .openFolder: return "shortcut.openFolder"
case .sendFeedback: return "shortcut.sendFeedback"
case .showNotifications: return "shortcut.showNotifications"
case .jumpToUnread: return "shortcut.jumpToUnread"
case .triggerFlash: return "shortcut.triggerFlash"
@ -102,6 +107,7 @@ enum KeyboardShortcutSettings {
case .nextSurface: return "shortcut.nextSurface"
case .prevSurface: return "shortcut.prevSurface"
case .newSurface: return "shortcut.newSurface"
case .toggleTerminalCopyMode: return "shortcut.toggleTerminalCopyMode"
case .openBrowser: return "shortcut.openBrowser"
case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools"
case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole"
@ -120,6 +126,8 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "w", command: true, shift: false, option: false, control: true)
case .openFolder:
return StoredShortcut(key: "o", command: true, shift: false, option: false, control: false)
case .sendFeedback:
return StoredShortcut(key: "f", command: true, shift: false, option: true, control: false)
case .showNotifications:
return StoredShortcut(key: "i", command: true, shift: false, option: false, control: false)
case .jumpToUnread:
@ -160,6 +168,8 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "[", command: true, shift: true, option: false, control: false)
case .newSurface:
return StoredShortcut(key: "t", command: true, shift: false, option: false, control: false)
case .toggleTerminalCopyMode:
return StoredShortcut(key: "m", command: true, shift: true, option: false, control: false)
case .openBrowser:
return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false)
case .toggleBrowserDeveloperTools:
@ -469,7 +479,7 @@ private class ShortcutRecorderNSButton: NSButton {
func updateTitle() {
if isRecording {
title = "Press shortcut…"
title = String(localized: "shortcut.pressShortcut.prompt", defaultValue: "Press shortcut…")
} else {
title = shortcut.displayString
}

View file

@ -1,3 +1,4 @@
import Bonsplit
import SwiftUI
struct NotificationsPage: View {
@ -67,7 +68,7 @@ struct NotificationsPage: View {
private var header: some View {
HStack {
Text("Notifications")
Text(String(localized: "notifications.title", defaultValue: "Notifications"))
.font(.title2)
.fontWeight(.semibold)
@ -76,7 +77,7 @@ struct NotificationsPage: View {
if !notificationStore.notifications.isEmpty {
jumpToUnreadButton
Button("Clear All") {
Button(String(localized: "notifications.clearAll", defaultValue: "Clear All")) {
notificationStore.clearAll()
}
.buttonStyle(.bordered)
@ -91,9 +92,9 @@ struct NotificationsPage: View {
Image(systemName: "bell.slash")
.font(.system(size: 32))
.foregroundColor(.secondary)
Text("No notifications yet")
Text(String(localized: "notifications.empty.title", defaultValue: "No notifications yet"))
.font(.headline)
Text("Desktop notifications will appear here for quick review.")
Text(String(localized: "notifications.empty.description", defaultValue: "Desktop notifications will appear here for quick review."))
.font(.subheadline)
.foregroundColor(.secondary)
}
@ -107,25 +108,25 @@ struct NotificationsPage: View {
AppDelegate.shared?.jumpToLatestUnread()
}) {
HStack(spacing: 6) {
Text("Jump to Latest Unread")
Text(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"))
ShortcutAnnotation(text: jumpToUnreadShortcut.displayString)
}
}
.buttonStyle(.bordered)
.keyboardShortcut(key, modifiers: jumpToUnreadShortcut.eventModifiers)
.help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip("Jump to Latest Unread"))
.safeHelp(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread")))
.disabled(!hasUnreadNotifications)
} else {
Button(action: {
AppDelegate.shared?.jumpToLatestUnread()
}) {
HStack(spacing: 6) {
Text("Jump to Latest Unread")
Text(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"))
ShortcutAnnotation(text: jumpToUnreadShortcut.displayString)
}
}
.buttonStyle(.bordered)
.help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip("Jump to Latest Unread"))
.safeHelp(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread")))
.disabled(!hasUnreadNotifications)
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -93,7 +93,7 @@ final class CmuxWebView: WKWebView {
/// Temporarily permits focus acquisition for explicit pointer-driven interactions
/// (mouse click into this webview) while keeping background autofocus blocked.
func withPointerFocusAllowance(_ body: () -> Void) {
func withPointerFocusAllowance<T>(_ body: () -> T) -> T {
pointerFocusAllowanceDepth += 1
#if DEBUG
dlog(
@ -110,7 +110,7 @@ final class CmuxWebView: WKWebView {
)
#endif
}
body()
return body()
}
override func performKeyEquivalent(with event: NSEvent) -> Bool {
@ -1113,6 +1113,11 @@ final class CmuxWebView: WKWebView {
NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder"),
]
static func shouldRejectInternalPaneDrag(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool {
DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes)
|| DragOverlayRoutingPolicy.hasSidebarTabReorder(pasteboardTypes)
}
override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) {
let filtered = newTypes.filter { !Self.blockedDragTypes.contains($0) }
if !filtered.isEmpty {
@ -1120,6 +1125,21 @@ final class CmuxWebView: WKWebView {
}
}
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return [] }
return super.draggingEntered(sender)
}
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return [] }
return super.draggingUpdated(sender)
}
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return false }
return super.performDragOperation(sender)
}
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
super.willOpenMenu(menu, with: event)
lastContextMenuPoint = convert(event.locationInWindow, from: nil)
@ -1133,7 +1153,7 @@ final class CmuxWebView: WKWebView {
debugLogContextMenuDownloadCandidate(item, index: index)
if !hasDefaultBrowserOpenLinkItem,
(item.action == #selector(contextMenuOpenLinkInDefaultBrowser(_:))
|| item.title == "Open Link in Default Browser") {
|| item.title == String(localized: "browser.contextMenu.openLinkInDefaultBrowser", defaultValue: "Open Link in Default Browser")) {
hasDefaultBrowserOpenLinkItem = true
}
@ -1148,7 +1168,7 @@ final class CmuxWebView: WKWebView {
// by opening the link as a new surface in the same pane.
if item.identifier?.rawValue == "WKMenuItemIdentifierOpenLinkInNewWindow"
|| item.title.contains("Open Link in New Window") {
item.title = "Open Link in New Tab"
item.title = String(localized: "browser.contextMenu.openLinkInNewTab", defaultValue: "Open Link in New Tab")
}
if isDownloadImageMenuItem(item) {
@ -1188,7 +1208,7 @@ final class CmuxWebView: WKWebView {
if let openLinkInsertionIndex, !hasDefaultBrowserOpenLinkItem {
let item = NSMenuItem(
title: "Open Link in Default Browser",
title: String(localized: "browser.contextMenu.openLinkInDefaultBrowser", defaultValue: "Open Link in Default Browser"),
action: #selector(contextMenuOpenLinkInDefaultBrowser(_:)),
keyEquivalent: ""
)

View file

@ -0,0 +1,182 @@
import Foundation
import Combine
/// A panel that renders a markdown file with live file-watching.
/// When the file changes on disk, the content is automatically reloaded.
@MainActor
final class MarkdownPanel: Panel, ObservableObject {
let id: UUID
let panelType: PanelType = .markdown
/// Absolute path to the markdown file being displayed.
let filePath: String
/// The workspace this panel belongs to.
private(set) var workspaceId: UUID
/// Current markdown content read from the file.
@Published private(set) var content: String = ""
/// Title shown in the tab bar (filename).
@Published private(set) var displayTitle: String = ""
/// SF Symbol icon for the tab bar.
var displayIcon: String? { "doc.richtext" }
/// Whether the file has been deleted or is unreadable.
@Published private(set) var isFileUnavailable: Bool = false
/// Token incremented to trigger focus flash animation.
@Published private(set) var focusFlashToken: Int = 0
// MARK: - File watching
// nonisolated(unsafe) because deinit is not guaranteed to run on the
// main actor, but DispatchSource.cancel() is thread-safe.
private nonisolated(unsafe) var fileWatchSource: DispatchSourceFileSystemObject?
private var fileDescriptor: Int32 = -1
private var isClosed: Bool = false
private let watchQueue = DispatchQueue(label: "com.cmux.markdown-file-watch", qos: .utility)
/// Maximum number of reattach attempts after a file delete/rename event.
private static let maxReattachAttempts = 6
/// Delay between reattach attempts (total window: attempts * delay = 3s).
private static let reattachDelay: TimeInterval = 0.5
// MARK: - Init
init(workspaceId: UUID, filePath: String) {
self.id = UUID()
self.workspaceId = workspaceId
self.filePath = filePath
self.displayTitle = (filePath as NSString).lastPathComponent
loadFileContent()
startFileWatcher()
if isFileUnavailable && fileWatchSource == nil {
// Session restore can create a panel before the file is recreated.
// Retry briefly so atomic-rename recreations can reconnect.
scheduleReattach(attempt: 1)
}
}
// MARK: - Panel protocol
func focus() {
// Markdown panel is read-only; no first responder to manage.
}
func unfocus() {
// No-op for read-only panel.
}
func close() {
isClosed = true
stopFileWatcher()
}
func triggerFlash() {
focusFlashToken += 1
}
// MARK: - File I/O
private func loadFileContent() {
do {
let newContent = try String(contentsOfFile: filePath, encoding: .utf8)
content = newContent
isFileUnavailable = false
} catch {
// Fallback: try ISO Latin-1, which accepts all 256 byte values,
// covering legacy encodings like Windows-1252.
if let data = FileManager.default.contents(atPath: filePath),
let decoded = String(data: data, encoding: .isoLatin1) {
content = decoded
isFileUnavailable = false
} else {
isFileUnavailable = true
}
}
}
// MARK: - File watcher via DispatchSource
private func startFileWatcher() {
let fd = open(filePath, O_EVTONLY)
guard fd >= 0 else { return }
fileDescriptor = fd
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.write, .delete, .rename, .extend],
queue: watchQueue
)
source.setEventHandler { [weak self] in
guard let self else { return }
let flags = source.data
if flags.contains(.delete) || flags.contains(.rename) {
// File was deleted or renamed. The old file descriptor points to
// a stale inode, so we must always stop and reattach the watcher
// even if the new file is already readable (atomic save case).
DispatchQueue.main.async {
self.stopFileWatcher()
self.loadFileContent()
if self.isFileUnavailable {
// File not yet replaced retry until it reappears.
self.scheduleReattach(attempt: 1)
} else {
// File already replaced reattach to the new inode immediately.
self.startFileWatcher()
}
}
} else {
// Content changed reload.
DispatchQueue.main.async {
self.loadFileContent()
}
}
}
source.setCancelHandler {
Darwin.close(fd)
}
source.resume()
fileWatchSource = source
}
/// Retry reattaching the file watcher up to `maxReattachAttempts` times.
/// Each attempt checks if the file has reappeared. Bails out early if
/// the panel has been closed.
private func scheduleReattach(attempt: Int) {
guard attempt <= Self.maxReattachAttempts else { return }
watchQueue.asyncAfter(deadline: .now() + Self.reattachDelay) { [weak self] in
guard let self else { return }
DispatchQueue.main.async {
guard !self.isClosed else { return }
if FileManager.default.fileExists(atPath: self.filePath) {
self.isFileUnavailable = false
self.loadFileContent()
self.startFileWatcher()
} else {
self.scheduleReattach(attempt: attempt + 1)
}
}
}
}
private func stopFileWatcher() {
if let source = fileWatchSource {
source.cancel()
fileWatchSource = nil
}
// File descriptor is closed by the cancel handler.
fileDescriptor = -1
}
deinit {
// DispatchSource cancel is safe from any thread.
fileWatchSource?.cancel()
}
}

View file

@ -0,0 +1,355 @@
import AppKit
import SwiftUI
import MarkdownUI
/// SwiftUI view that renders a MarkdownPanel's content using MarkdownUI.
struct MarkdownPanelView: View {
@ObservedObject var panel: MarkdownPanel
let isFocused: Bool
let isVisibleInUI: Bool
let portalPriority: Int
let onRequestPanelFocus: () -> Void
@State private var focusFlashOpacity: Double = 0.0
@State private var focusFlashAnimationGeneration: Int = 0
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Group {
if panel.isFileUnavailable {
fileUnavailableView
} else {
markdownContentView
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(backgroundColor)
.overlay {
RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius)
.stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3)
.shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10)
.padding(FocusFlashPattern.ringInset)
.allowsHitTesting(false)
}
.overlay {
if isVisibleInUI {
// Observe left-clicks without intercepting them so markdown text
// selection and link activation continue to use the native path.
MarkdownPointerObserver(onPointerDown: onRequestPanelFocus)
}
}
.onChange(of: panel.focusFlashToken) { _ in
triggerFocusFlashAnimation()
}
}
// MARK: - Content
private var markdownContentView: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
// File path breadcrumb
filePathHeader
.padding(.horizontal, 24)
.padding(.top, 16)
.padding(.bottom, 8)
Divider()
.padding(.horizontal, 16)
// Rendered markdown
Markdown(panel.content)
.markdownTheme(cmuxMarkdownTheme)
.textSelection(.enabled)
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
}
}
private var filePathHeader: some View {
HStack(spacing: 6) {
Image(systemName: "doc.richtext")
.foregroundColor(.secondary)
.font(.system(size: 12))
Text(panel.filePath)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
}
}
private var fileUnavailableView: some View {
VStack(spacing: 12) {
Image(systemName: "doc.questionmark")
.font(.system(size: 40))
.foregroundColor(.secondary)
Text(String(localized: "markdown.fileUnavailable.title", defaultValue: "File unavailable"))
.font(.headline)
.foregroundColor(.primary)
Text(panel.filePath)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 24)
Text(String(localized: "markdown.fileUnavailable.message", defaultValue: "The file may have been moved or deleted."))
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Theme
private var backgroundColor: Color {
colorScheme == .dark
? Color(nsColor: NSColor(white: 0.12, alpha: 1.0))
: Color(nsColor: NSColor(white: 0.98, alpha: 1.0))
}
private var cmuxMarkdownTheme: Theme {
let isDark = colorScheme == .dark
return Theme()
// Text
.text {
ForegroundColor(isDark ? .white.opacity(0.9) : .primary)
FontSize(14)
}
// Headings
.heading1 { configuration in
VStack(alignment: .leading, spacing: 8) {
configuration.label
.markdownTextStyle {
FontWeight(.bold)
FontSize(28)
ForegroundColor(isDark ? .white : .primary)
}
Divider()
}
.markdownMargin(top: 24, bottom: 16)
}
.heading2 { configuration in
VStack(alignment: .leading, spacing: 6) {
configuration.label
.markdownTextStyle {
FontWeight(.bold)
FontSize(22)
ForegroundColor(isDark ? .white : .primary)
}
Divider()
}
.markdownMargin(top: 20, bottom: 12)
}
.heading3 { configuration in
configuration.label
.markdownTextStyle {
FontWeight(.semibold)
FontSize(18)
ForegroundColor(isDark ? .white : .primary)
}
.markdownMargin(top: 16, bottom: 8)
}
.heading4 { configuration in
configuration.label
.markdownTextStyle {
FontWeight(.semibold)
FontSize(16)
ForegroundColor(isDark ? .white : .primary)
}
.markdownMargin(top: 12, bottom: 6)
}
.heading5 { configuration in
configuration.label
.markdownTextStyle {
FontWeight(.medium)
FontSize(14)
ForegroundColor(isDark ? .white : .primary)
}
.markdownMargin(top: 10, bottom: 4)
}
.heading6 { configuration in
configuration.label
.markdownTextStyle {
FontWeight(.medium)
FontSize(13)
ForegroundColor(isDark ? .white.opacity(0.7) : .secondary)
}
.markdownMargin(top: 8, bottom: 4)
}
// Code blocks
.codeBlock { configuration in
ScrollView(.horizontal, showsIndicators: true) {
configuration.label
.markdownTextStyle {
FontFamilyVariant(.monospaced)
FontSize(13)
ForegroundColor(isDark ? Color(red: 0.9, green: 0.9, blue: 0.9) : Color(red: 0.2, green: 0.2, blue: 0.2))
}
.padding(12)
}
.background(isDark
? Color(nsColor: NSColor(white: 0.08, alpha: 1.0))
: Color(nsColor: NSColor(white: 0.93, alpha: 1.0)))
.clipShape(RoundedRectangle(cornerRadius: 6))
.markdownMargin(top: 8, bottom: 8)
}
// Inline code
.code {
FontFamilyVariant(.monospaced)
FontSize(13)
ForegroundColor(isDark ? Color(red: 0.85, green: 0.6, blue: 0.95) : Color(red: 0.6, green: 0.2, blue: 0.7))
BackgroundColor(isDark
? Color(nsColor: NSColor(white: 0.18, alpha: 1.0))
: Color(nsColor: NSColor(white: 0.92, alpha: 1.0)))
}
// Block quotes
.blockquote { configuration in
HStack(spacing: 0) {
RoundedRectangle(cornerRadius: 1.5)
.fill(isDark ? Color.white.opacity(0.2) : Color.gray.opacity(0.4))
.frame(width: 3)
configuration.label
.markdownTextStyle {
ForegroundColor(isDark ? .white.opacity(0.6) : .secondary)
FontSize(14)
}
.padding(.leading, 12)
}
.markdownMargin(top: 8, bottom: 8)
}
// Links
.link {
ForegroundColor(Color.accentColor)
}
// Strong
.strong {
FontWeight(.semibold)
}
// Tables
.table { configuration in
configuration.label
.markdownTableBorderStyle(.init(color: isDark ? .white.opacity(0.15) : .gray.opacity(0.3)))
.markdownTableBackgroundStyle(
.alternatingRows(
isDark
? Color(nsColor: NSColor(white: 0.14, alpha: 1.0))
: Color(nsColor: NSColor(white: 0.96, alpha: 1.0)),
isDark
? Color(nsColor: NSColor(white: 0.10, alpha: 1.0))
: Color(nsColor: NSColor(white: 1.0, alpha: 1.0))
)
)
.markdownMargin(top: 8, bottom: 8)
}
// Thematic break (horizontal rule)
.thematicBreak {
Divider()
.markdownMargin(top: 16, bottom: 16)
}
// List items
.listItem { configuration in
configuration.label
.markdownMargin(top: 4, bottom: 4)
}
// Paragraphs
.paragraph { configuration in
configuration.label
.markdownMargin(top: 4, bottom: 8)
}
}
// MARK: - Focus Flash
private func triggerFocusFlashAnimation() {
focusFlashAnimationGeneration &+= 1
let generation = focusFlashAnimationGeneration
focusFlashOpacity = FocusFlashPattern.values.first ?? 0
for segment in FocusFlashPattern.segments {
DispatchQueue.main.asyncAfter(deadline: .now() + segment.delay) {
guard focusFlashAnimationGeneration == generation else { return }
withAnimation(focusFlashAnimation(for: segment.curve, duration: segment.duration)) {
focusFlashOpacity = segment.targetOpacity
}
}
}
}
private func focusFlashAnimation(for curve: FocusFlashCurve, duration: TimeInterval) -> Animation {
switch curve {
case .easeIn:
return .easeIn(duration: duration)
case .easeOut:
return .easeOut(duration: duration)
}
}
}
private struct MarkdownPointerObserver: NSViewRepresentable {
let onPointerDown: () -> Void
func makeNSView(context: Context) -> MarkdownPanelPointerObserverView {
let view = MarkdownPanelPointerObserverView()
view.onPointerDown = onPointerDown
return view
}
func updateNSView(_ nsView: MarkdownPanelPointerObserverView, context: Context) {
nsView.onPointerDown = onPointerDown
}
}
final class MarkdownPanelPointerObserverView: NSView {
var onPointerDown: (() -> Void)?
private var eventMonitor: Any?
override var mouseDownCanMoveWindow: Bool { false }
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
installEventMonitorIfNeeded()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
if let eventMonitor {
NSEvent.removeMonitor(eventMonitor)
}
}
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
func shouldHandle(_ event: NSEvent) -> Bool {
guard event.type == .leftMouseDown,
let window,
event.window === window,
!isHiddenOrHasHiddenAncestor else { return false }
let point = convert(event.locationInWindow, from: nil)
return bounds.contains(point)
}
func handleEventIfNeeded(_ event: NSEvent) -> NSEvent {
guard shouldHandle(event) else { return event }
DispatchQueue.main.async { [weak self] in
self?.onPointerDown?()
}
return event
}
private func installEventMonitorIfNeeded() {
guard eventMonitor == nil else { return }
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in
self?.handleEventIfNeeded(event) ?? event
}
}
}

View file

@ -5,6 +5,7 @@ import Combine
public enum PanelType: String, Codable, Sendable {
case terminal
case browser
case markdown
}
enum FocusFlashCurve: Equatable {

View file

@ -1,9 +1,11 @@
import SwiftUI
import Foundation
import Bonsplit
/// View that renders the appropriate panel view based on panel type
struct PanelContentView: View {
let panel: any Panel
let paneId: PaneID
let isFocused: Bool
let isSelectedInPane: Bool
let isVisibleInUI: Bool
@ -35,6 +37,17 @@ struct PanelContentView: View {
if let browserPanel = panel as? BrowserPanel {
BrowserPanelView(
panel: browserPanel,
paneId: paneId,
isFocused: isFocused,
isVisibleInUI: isVisibleInUI,
portalPriority: portalPriority,
onRequestPanelFocus: onRequestPanelFocus
)
}
case .markdown:
if let markdownPanel = panel as? MarkdownPanel {
MarkdownPanelView(
panel: markdownPanel,
isFocused: isFocused,
isVisibleInUI: isVisibleInUI,
portalPriority: portalPriority,

View file

@ -1,6 +1,7 @@
import Foundation
import Combine
import AppKit
import Bonsplit
/// TerminalPanel wraps an existing TerminalSurface and conforms to the Panel protocol.
/// This allows TerminalSurface to be used within the bonsplit-based layout system.
@ -164,9 +165,28 @@ final class TerminalPanel: Panel, ObservableObject {
// The surface will be cleaned up by its deinit
// Detach from the window portal on real close so stale hosted views
// cannot remain above browser panes after split close.
surface.beginPortalCloseLifecycle(reason: "panel.close")
#if DEBUG
let frame = String(format: "%.1fx%.1f", hostedView.frame.width, hostedView.frame.height)
let bounds = String(format: "%.1fx%.1f", hostedView.bounds.width, hostedView.bounds.height)
dlog(
"surface.panel.close.begin panel=\(id.uuidString.prefix(5)) " +
"workspace=\(workspaceId.uuidString.prefix(5)) runtimeSurface=\(surface.surface != nil ? 1 : 0) " +
"inWindow=\(hostedView.window != nil ? 1 : 0) hasSuperview=\(hostedView.superview != nil ? 1 : 0) " +
"hidden=\(hostedView.isHidden ? 1 : 0) frame=\(frame) bounds=\(bounds)"
)
#endif
unfocus()
hostedView.setVisibleInUI(false)
TerminalWindowPortalRegistry.detach(hostedView: hostedView)
#if DEBUG
dlog(
"surface.panel.close.end panel=\(id.uuidString.prefix(5)) " +
"inWindow=\(hostedView.window != nil ? 1 : 0) hasSuperview=\(hostedView.superview != nil ? 1 : 0) " +
"hidden=\(hostedView.isHidden ? 1 : 0)"
)
#endif
surface.teardownSurface()
}
func requestViewReattach() {
@ -195,6 +215,10 @@ final class TerminalPanel: Panel, ObservableObject {
hostedView.triggerFlash()
}
func triggerNotificationDismissFlash() {
hostedView.triggerFlash(style: .notificationDismiss)
}
func applyWindowBackgroundIfActive() {
surface.applyWindowBackgroundIfActive()
}

View file

@ -2,7 +2,6 @@ import AppKit
import Foundation
import PostHog
@MainActor
final class PostHogAnalytics {
static let shared = PostHogAnalytics()
@ -12,12 +11,27 @@ final class PostHogAnalytics {
// PostHog Cloud US default (matches other cmux properties).
private let host = "https://us.i.posthog.com"
private let dailyActiveEvent = "cmux_daily_active"
private let hourlyActiveEvent = "cmux_hourly_active"
private let lastActiveDayUTCKey = "posthog.lastActiveDayUTC"
private let lastActiveHourUTCKey = "posthog.lastActiveHourUTC"
private let workQueue: DispatchQueue
private let workQueueSpecificKey = DispatchSpecificKey<Void>()
private let utcHourFormatter: DateFormatter
private let utcDayFormatter: DateFormatter
private var didStart = false
private var activeCheckTimer: Timer?
private init() {
workQueue = DispatchQueue(label: "com.cmux.posthog.analytics", qos: .utility)
utcHourFormatter = Self.makeUTCFormatter("yyyy-MM-dd'T'HH")
utcDayFormatter = Self.makeUTCFormatter("yyyy-MM-dd")
workQueue.setSpecific(key: workQueueSpecificKey, value: ())
}
private var isEnabled: Bool {
guard TelemetrySettings.enabledForCurrentLaunch else { return false }
#if DEBUG
@ -29,6 +43,44 @@ final class PostHogAnalytics {
}
func startIfNeeded() {
dispatchAsyncOnWorkQueue { [weak self] in
self?.startIfNeededOnWorkQueue()
}
}
func trackActive(reason: String) {
dispatchAsyncOnWorkQueue { [weak self] in
guard let self else { return }
let didCaptureDaily = self.trackDailyActiveOnWorkQueue(reason: reason, flush: false)
let didCaptureHourly = self.trackHourlyActiveOnWorkQueue(reason: reason, flush: false)
if didCaptureDaily || didCaptureHourly {
// On app focus we can capture both events; flush once to reduce extra work.
PostHogSDK.shared.flush()
}
}
}
func trackDailyActive(reason: String) {
dispatchAsyncOnWorkQueue { [weak self] in
self?.trackDailyActiveOnWorkQueue(reason: reason, flush: true)
}
}
func trackHourlyActive(reason: String) {
dispatchAsyncOnWorkQueue { [weak self] in
self?.trackHourlyActiveOnWorkQueue(reason: reason, flush: true)
}
}
func flush() {
dispatchSyncOnWorkQueue {
guard didStart else { return }
PostHogSDK.shared.flush()
}
}
private func startIfNeededOnWorkQueue() {
guard !didStart else { return }
guard isEnabled else { return }
@ -49,31 +101,40 @@ final class PostHogAnalytics {
didStart = true
scheduleActiveCheckTimer()
}
private func scheduleActiveCheckTimer() {
// If the app stays in the foreground across midnight, `applicationDidBecomeActive`
// won't fire again, so a periodic check avoids undercounting those users.
activeCheckTimer?.invalidate()
activeCheckTimer = Timer.scheduledTimer(withTimeInterval: 30 * 60, repeats: true) { [weak self] _ in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.activeCheckTimer?.invalidate()
self.activeCheckTimer = Timer.scheduledTimer(withTimeInterval: 30 * 60, repeats: true) { [weak self] _ in
guard let self else { return }
guard NSApp.isActive else { return }
self.trackDailyActive(reason: "activeTimer")
self.trackHourlyActive(reason: "activeTimer")
self.trackActive(reason: "activeTimer")
}
}
}
func trackDailyActive(reason: String) {
startIfNeeded()
guard didStart else { return }
@discardableResult
private func trackDailyActiveOnWorkQueue(reason: String, flush: Bool) -> Bool {
startIfNeededOnWorkQueue()
guard didStart else { return false }
let today = utcDayString(Date())
let defaults = UserDefaults.standard
if defaults.string(forKey: lastActiveDayUTCKey) == today {
return
return false
}
defaults.set(today, forKey: lastActiveDayUTCKey)
let event = dailyActiveEvent
PostHogSDK.shared.capture(
"cmux_daily_active",
event,
properties: Self.dailyActiveProperties(
dayUTC: today,
reason: reason,
@ -81,53 +142,77 @@ final class PostHogAnalytics {
)
)
// For DAU we care more about delivery than batching.
if flush && Self.shouldFlushAfterCapture(event: event) {
// For active metrics we care more about delivery than batching.
PostHogSDK.shared.flush()
}
func trackHourlyActive(reason: String) {
startIfNeeded()
guard didStart else { return }
return true
}
@discardableResult
private func trackHourlyActiveOnWorkQueue(reason: String, flush: Bool) -> Bool {
startIfNeededOnWorkQueue()
guard didStart else { return false }
let hour = utcHourString(Date())
let defaults = UserDefaults.standard
if defaults.string(forKey: lastActiveHourUTCKey) == hour {
return
return false
}
defaults.set(hour, forKey: lastActiveHourUTCKey)
let event = hourlyActiveEvent
PostHogSDK.shared.capture(
"cmux_hourly_active",
event,
properties: Self.hourlyActiveProperties(
hourUTC: hour,
reason: reason,
infoDictionary: Bundle.main.infoDictionary ?? [:]
)
)
}
func flush() {
guard didStart else { return }
if flush && Self.shouldFlushAfterCapture(event: event) {
// Keep hourly freshness and avoid losing a deduped hour on abrupt exits.
PostHogSDK.shared.flush()
}
return true
}
private func dispatchAsyncOnWorkQueue(_ block: @escaping () -> Void) {
if DispatchQueue.getSpecific(key: workQueueSpecificKey) != nil {
block()
return
}
workQueue.async(execute: block)
}
private func dispatchSyncOnWorkQueue(_ block: () -> Void) {
if DispatchQueue.getSpecific(key: workQueueSpecificKey) != nil {
block()
return
}
workQueue.sync(execute: block)
}
private func utcHourString(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .iso8601)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd'T'HH"
return formatter.string(from: date)
utcHourFormatter.string(from: date)
}
private func utcDayString(_ date: Date) -> String {
utcDayFormatter.string(from: date)
}
private static func makeUTCFormatter(_ dateFormat: String) -> DateFormatter {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .iso8601)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
formatter.dateFormat = dateFormat
return formatter
}
nonisolated static func superProperties(infoDictionary: [String: Any]) -> [String: Any] {
@ -162,6 +247,15 @@ final class PostHogAnalytics {
return properties
}
nonisolated static func shouldFlushAfterCapture(event: String) -> Bool {
switch event {
case "cmux_daily_active", "cmux_hourly_active":
return true
default:
return false
}
}
nonisolated private static func versionProperties(infoDictionary: [String: Any]) -> [String: Any] {
var properties: [String: Any] = [:]
if let value = infoDictionary["CFBundleShortVersionString"] as? String, !value.isEmpty {

View file

@ -235,6 +235,10 @@ struct SessionBrowserPanelSnapshot: Codable, Sendable {
var forwardHistoryURLStrings: [String]?
}
struct SessionMarkdownPanelSnapshot: Codable, Sendable {
var filePath: String
}
struct SessionPanelSnapshot: Codable, Sendable {
var id: UUID
var type: PanelType
@ -248,6 +252,7 @@ struct SessionPanelSnapshot: Codable, Sendable {
var ttyName: String?
var terminal: SessionTerminalPanelSnapshot?
var browser: SessionBrowserPanelSnapshot?
var markdown: SessionMarkdownPanelSnapshot?
}
enum SessionSplitOrientation: String, Codable, Sendable {

View file

@ -18,30 +18,30 @@ enum SocketControlMode: String, CaseIterable, Identifiable {
var displayName: String {
switch self {
case .off:
return "Off"
return String(localized: "socketControl.off.name", defaultValue: "Off")
case .cmuxOnly:
return "cmux processes only"
return String(localized: "socketControl.cmuxOnly.name", defaultValue: "cmux processes only")
case .automation:
return "Automation mode"
return String(localized: "socketControl.automation.name", defaultValue: "Automation mode")
case .password:
return "Password mode"
return String(localized: "socketControl.password.name", defaultValue: "Password mode")
case .allowAll:
return "Full open access"
return String(localized: "socketControl.allowAll.name", defaultValue: "Full open access")
}
}
var description: String {
switch self {
case .off:
return "Disable the local control socket."
return String(localized: "socketControl.off.description", defaultValue: "Disable the local control socket.")
case .cmuxOnly:
return "Only processes started inside cmux terminals can send commands."
return String(localized: "socketControl.cmuxOnly.description", defaultValue: "Only processes started inside cmux terminals can send commands.")
case .automation:
return "Allow external local automation clients from this macOS user (no ancestry check)."
return String(localized: "socketControl.automation.description", defaultValue: "Allow external local automation clients from this macOS user (no ancestry check).")
case .password:
return "Require socket authentication with a password stored in a local file."
return String(localized: "socketControl.password.description", defaultValue: "Require socket authentication with a password stored in a local file.")
case .allowAll:
return "Allow any local process and user to connect with no auth. Unsafe."
return String(localized: "socketControl.allowAll.description", defaultValue: "Allow any local process and user to connect with no auth. Unsafe.")
}
}
@ -183,7 +183,7 @@ enum SocketControlPasswordStore {
throw NSError(
domain: NSCocoaErrorDomain,
code: NSFileNoSuchFileError,
userInfo: [NSLocalizedDescriptionKey: "Unable to resolve socket password file path."]
userInfo: [NSLocalizedDescriptionKey: String(localized: "socketControl.error.passwordFilePath", defaultValue: "Unable to resolve socket password file path.")]
)
}
let directory = fileURL.deletingLastPathComponent()

View file

@ -19,22 +19,22 @@ enum NewWorkspacePlacement: String, CaseIterable, Identifiable {
var displayName: String {
switch self {
case .top:
return "Top"
return String(localized: "workspace.placement.top", defaultValue: "Top")
case .afterCurrent:
return "After current"
return String(localized: "workspace.placement.afterCurrent", defaultValue: "After current")
case .end:
return "End"
return String(localized: "workspace.placement.end", defaultValue: "End")
}
}
var description: String {
switch self {
case .top:
return "Insert new workspaces at the top of the list."
return String(localized: "workspace.placement.top.description", defaultValue: "Insert new workspaces at the top of the list.")
case .afterCurrent:
return "Insert new workspaces directly after the active workspace."
return String(localized: "workspace.placement.afterCurrent.description", defaultValue: "Insert new workspaces directly after the active workspace.")
case .end:
return "Append new workspaces to the bottom of the list."
return String(localized: "workspace.placement.end.description", defaultValue: "Append new workspaces to the bottom of the list.")
}
}
}
@ -72,9 +72,9 @@ enum SidebarActiveTabIndicatorStyle: String, CaseIterable, Identifiable {
var displayName: String {
switch self {
case .leftRail:
return "Left Rail"
return String(localized: "sidebar.indicator.leftRail", defaultValue: "Left Rail")
case .solidFill:
return "Solid Fill"
return String(localized: "sidebar.indicator.solidFill", defaultValue: "Solid Fill")
}
}
}
@ -558,13 +558,23 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback(
@MainActor
class TabManager: ObservableObject {
private struct InitialWorkspaceGitMetadataSnapshot: Equatable {
let branch: String?
let isDirty: Bool
}
/// The window that owns this TabManager. Set by AppDelegate.registerMainWindow().
/// Used to apply title updates to the correct window instead of NSApp.keyWindow.
weak var window: NSWindow?
@Published var tabs: [Workspace] = []
@Published private(set) var isWorkspaceCycleHot: Bool = false
weak var window: NSWindow?
@Published private(set) var pendingBackgroundWorkspaceLoadIds: Set<UUID> = []
/// Global monotonically increasing counter for CMUX_PORT ordinal assignment.
/// Static so port ranges don't overlap across multiple windows (each window has its own TabManager).
private static var nextPortOrdinal: Int = 0
private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0]
@Published var selectedTabId: UUID? {
didSet {
guard selectedTabId != oldValue else { return }
@ -617,6 +627,12 @@ class TabManager: ObservableObject {
private var pendingPanelTitleUpdates: [PanelTitleUpdateKey: String] = [:]
private let panelTitleUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
private var recentlyClosedBrowsers = RecentlyClosedBrowserStack(capacity: 20)
private let initialWorkspaceGitProbeQueue = DispatchQueue(
label: "com.cmux.initial-workspace-git-probe",
qos: .utility
)
private var initialWorkspaceGitProbeGenerationByWorkspace: [UUID: UUID] = [:]
private var initialWorkspaceGitProbeTimersByWorkspace: [UUID: [DispatchSourceTimer]] = [:]
// Recent tab history for back/forward navigation (like browser history)
private var tabHistory: [UUID] = []
@ -712,19 +728,34 @@ class TabManager: ObservableObject {
}
var isFindVisible: Bool {
selectedTerminalPanel?.searchState != nil
if selectedTerminalPanel?.searchState != nil { return true }
if focusedBrowserPanel?.searchState != nil { return true }
return false
}
var canUseSelectionForFind: Bool {
selectedTerminalPanel?.hasSelection() == true
if focusedBrowserPanel != nil { return false }
return selectedTerminalPanel?.hasSelection() == true
}
func startSearch() {
guard let panel = selectedTerminalPanel else { return }
if panel.searchState == nil {
if let browser = focusedBrowserPanel {
browser.startFind()
return
}
guard let panel = selectedTerminalPanel else {
#if DEBUG
dlog("find.startSearch SKIPPED no selectedTerminalPanel")
#endif
return
}
let wasNil = panel.searchState == nil
if wasNil {
panel.searchState = TerminalSurface.SearchState()
}
NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString)
#if DEBUG
dlog("find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5)) created=\(wasNil ? "yes" : "no(reuse)") firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))")
#endif
NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
_ = panel.performBindingAction("start_search")
}
@ -734,20 +765,43 @@ class TabManager: ObservableObject {
if panel.searchState == nil {
panel.searchState = TerminalSurface.SearchState()
}
NSLog("Find: searchSelection workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString)
#if DEBUG
dlog("find.searchSelection workspace=\(panel.workspaceId.uuidString.prefix(5)) panel=\(panel.id.uuidString.prefix(5))")
#endif
NotificationCenter.default.post(name: .ghosttySearchFocus, object: panel.surface)
_ = panel.performBindingAction("search_selection")
}
func findNext() {
if let browser = focusedBrowserPanel, browser.searchState != nil {
browser.findNext()
return
}
_ = selectedTerminalPanel?.performBindingAction("search:next")
}
func findPrevious() {
if let browser = focusedBrowserPanel, browser.searchState != nil {
browser.findPrevious()
return
}
_ = selectedTerminalPanel?.performBindingAction("search:previous")
}
@discardableResult
func toggleFocusedTerminalCopyMode() -> Bool {
guard let panel = selectedTerminalPanel else { return false }
return panel.surface.toggleKeyboardCopyMode()
}
func hideFind() {
if let browser = focusedBrowserPanel, browser.searchState != nil {
browser.hideFind()
return
}
#if DEBUG
dlog("find.hideFind panel=\(selectedTerminalPanel?.id.uuidString.prefix(5) ?? "nil")")
#endif
selectedTerminalPanel?.searchState = nil
}
@ -757,10 +811,12 @@ class TabManager: ObservableObject {
initialTerminalCommand: String? = nil,
initialTerminalEnvironment: [String: String] = [:],
select: Bool = true,
eagerLoadTerminal: Bool = false,
placementOverride: NewWorkspacePlacement? = nil
) -> Workspace {
sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1])
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
let explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory)
let workingDirectory = explicitWorkingDirectory ?? preferredWorkingDirectoryForNewTab()
let inheritedConfig = inheritedTerminalConfigForNewWorkspace()
let ordinal = Self.nextPortOrdinal
Self.nextPortOrdinal += 1
@ -779,6 +835,18 @@ class TabManager: ObservableObject {
} else {
tabs.append(newWorkspace)
}
if let explicitWorkingDirectory,
let terminalPanel = newWorkspace.focusedTerminalPanel {
scheduleInitialWorkspaceGitMetadataRefresh(
workspaceId: newWorkspace.id,
panelId: terminalPanel.id,
directory: explicitWorkingDirectory
)
}
if eagerLoadTerminal {
requestBackgroundWorkspaceLoad(for: newWorkspace.id)
newWorkspace.requestBackgroundTerminalSurfaceStartIfNeeded()
}
if select {
selectedTabId = newWorkspace.id
NotificationCenter.default.post(
@ -797,9 +865,187 @@ class TabManager: ObservableObject {
return newWorkspace
}
private func scheduleInitialWorkspaceGitMetadataRefresh(
workspaceId: UUID,
panelId: UUID,
directory: String
) {
let normalizedDirectory = normalizeDirectory(directory)
let generation = UUID()
cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId)
initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] = generation
#if DEBUG
dlog(
"workspace.gitProbe.schedule workspace=\(workspaceId.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) dir=\(normalizedDirectory)"
)
#endif
let delays = Self.initialWorkspaceGitProbeDelays
var timers: [DispatchSourceTimer] = []
for (index, delay) in delays.enumerated() {
let isLastAttempt = index == delays.count - 1
let timer = DispatchSource.makeTimerSource(queue: initialWorkspaceGitProbeQueue)
timer.schedule(deadline: .now() + delay, repeating: .never)
timer.setEventHandler { [weak self] in
let snapshot = Self.initialWorkspaceGitMetadataSnapshot(for: normalizedDirectory)
Task { @MainActor [weak self] in
self?.applyInitialWorkspaceGitMetadataSnapshot(
snapshot,
generation: generation,
workspaceId: workspaceId,
panelId: panelId,
expectedDirectory: normalizedDirectory,
isLastAttempt: isLastAttempt
)
}
}
timers.append(timer)
timer.resume()
}
initialWorkspaceGitProbeTimersByWorkspace[workspaceId] = timers
}
private func cancelInitialWorkspaceGitProbeTimers(workspaceId: UUID) {
guard let timers = initialWorkspaceGitProbeTimersByWorkspace.removeValue(forKey: workspaceId) else {
return
}
for timer in timers {
timer.setEventHandler {}
timer.cancel()
}
}
private func clearInitialWorkspaceGitProbe(workspaceId: UUID) {
initialWorkspaceGitProbeGenerationByWorkspace.removeValue(forKey: workspaceId)
cancelInitialWorkspaceGitProbeTimers(workspaceId: workspaceId)
}
private func applyInitialWorkspaceGitMetadataSnapshot(
_ snapshot: InitialWorkspaceGitMetadataSnapshot,
generation: UUID,
workspaceId: UUID,
panelId: UUID,
expectedDirectory: String,
isLastAttempt: Bool
) {
defer {
if isLastAttempt,
initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation {
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
}
}
guard initialWorkspaceGitProbeGenerationByWorkspace[workspaceId] == generation else { return }
guard let workspace = tabs.first(where: { $0.id == workspaceId }) else {
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
return
}
guard workspace.panels[panelId] != nil else {
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
return
}
let currentDirectory = normalizedWorkingDirectory(
workspace.panelDirectories[panelId] ?? workspace.currentDirectory
)
if let currentDirectory, currentDirectory != expectedDirectory {
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
#if DEBUG
dlog(
"workspace.gitProbe.skip workspace=\(workspaceId.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) reason=directoryChanged " +
"expected=\(expectedDirectory) current=\(currentDirectory)"
)
#endif
return
}
workspace.updatePanelDirectory(panelId: panelId, directory: expectedDirectory)
let previousBranch = Self.normalizedBranchName(workspace.panelGitBranches[panelId]?.branch)
let nextBranch = snapshot.branch
if let nextBranch {
workspace.updatePanelGitBranch(panelId: panelId, branch: nextBranch, isDirty: snapshot.isDirty)
} else {
workspace.clearPanelGitBranch(panelId: panelId)
}
if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) {
workspace.clearPanelPullRequest(panelId: panelId)
}
#if DEBUG
let branchLabel = snapshot.branch ?? "none"
dlog(
"workspace.gitProbe.apply workspace=\(workspaceId.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0)"
)
#endif
}
private nonisolated static func initialWorkspaceGitMetadataSnapshot(
for directory: String
) -> InitialWorkspaceGitMetadataSnapshot {
let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"]))
guard let branch else {
return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false)
}
let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"])
let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty)
}
private nonisolated static func runGitCommand(directory: String, arguments: [String]) -> String? {
let process = Process()
let stdout = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["git", "-C", directory] + arguments
process.standardOutput = stdout
process.standardError = FileHandle.nullDevice
do {
try process.run()
} catch {
return nil
}
// Drain stdout while the subprocess is active so large repos cannot fill the pipe buffer.
let data = stdout.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
return nil
}
return String(data: data, encoding: .utf8)
}
private nonisolated static func normalizedBranchName(_ branch: String?) -> String? {
let trimmed = branch?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
func requestBackgroundWorkspaceLoad(for workspaceId: UUID) {
guard pendingBackgroundWorkspaceLoadIds.insert(workspaceId).inserted else { return }
}
func completeBackgroundWorkspaceLoad(for workspaceId: UUID) {
guard pendingBackgroundWorkspaceLoadIds.remove(workspaceId) != nil else { return }
}
func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) {
let pruned = pendingBackgroundWorkspaceLoadIds.intersection(existingIds)
guard pruned != pendingBackgroundWorkspaceLoadIds else { return }
pendingBackgroundWorkspaceLoadIds = pruned
}
// Keep addTab as convenience alias
@discardableResult
func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) }
func addTab(select: Bool = true, eagerLoadTerminal: Bool = false) -> Workspace {
addWorkspace(select: select, eagerLoadTerminal: eagerLoadTerminal)
}
func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? {
guard let workspace = selectedWorkspace else { return nil }
@ -925,6 +1171,16 @@ class TabManager: ObservableObject {
tabs.insert(tab, at: insertIndex)
}
func moveTabToTopForNotification(_ tabId: UUID) {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
let pinnedCount = tabs.filter { $0.isPinned }.count
guard index != pinnedCount else { return }
let tab = tabs[index]
guard !tab.isPinned else { return }
tabs.remove(at: index)
tabs.insert(tab, at: pinnedCount)
}
func moveTabsToTop(_ tabIds: Set<UUID>) {
guard !tabIds.isEmpty else { return }
let selectedTabs = tabs.filter { tabIds.contains($0.id) }
@ -1022,12 +1278,15 @@ class TabManager: ObservableObject {
func closeWorkspace(_ workspace: Workspace) {
guard tabs.count > 1 else { return }
guard let index = tabs.firstIndex(where: { $0.id == workspace.id }) else { return }
sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
clearInitialWorkspaceGitProbe(workspaceId: workspace.id)
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id)
workspace.teardownRemoteConnection()
unwireClosedBrowserTracking(for: workspace)
workspace.teardownAllPanels()
if let index = tabs.firstIndex(where: { $0.id == workspace.id }) {
tabs.remove(at: index)
if selectedTabId == workspace.id {
@ -1038,13 +1297,13 @@ class TabManager: ObservableObject {
selectedTabId = tabs[newIndex].id
}
}
}
/// Detach a workspace from this window without closing its panels.
/// Used by the socket API for cross-window moves.
@discardableResult
func detachWorkspace(tabId: UUID) -> Workspace? {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil }
clearInitialWorkspaceGitProbe(workspaceId: tabId)
let removed = tabs.remove(at: index)
unwireClosedBrowserTracking(for: removed)
@ -1106,9 +1365,13 @@ class TabManager: ObservableObject {
let count = plan.panelIds.count
let titleLines = plan.titles.map { "\($0)" }.joined(separator: "\n")
let message = "This is about to close \(count) tab\(count == 1 ? "" : "s") in this pane:\n\(titleLines)"
let message = if count == 1 {
String(localized: "dialog.closeOtherTabs.message.one", defaultValue: "This will close 1 tab in this pane:\n\(titleLines)")
} else {
String(localized: "dialog.closeOtherTabs.message.other", defaultValue: "This will close \(count) tabs in this pane:\n\(titleLines)")
}
guard confirmClose(
title: "Close other tabs?",
title: String(localized: "dialog.closeOtherTabs.title", defaultValue: "Close other tabs?"),
message: message,
acceptCmdD: false
) else { return }
@ -1148,8 +1411,8 @@ class TabManager: ObservableObject {
alert.messageText = title
alert.informativeText = message
alert.alertStyle = .warning
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: "Cancel")
alert.addButton(withTitle: String(localized: "common.close", defaultValue: "Close"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
// macOS convention: Cmd+D = confirm destructive close (e.g. "Don't Save").
// We only opt into this for the "close last workspace => close window" path to avoid
@ -1210,15 +1473,15 @@ class TabManager: ObservableObject {
if let collapsed, !collapsed.isEmpty {
return collapsed
}
return "Untitled Tab"
return String(localized: "tab.untitled", defaultValue: "Untitled Tab")
}
private func closeWorkspaceIfRunningProcess(_ workspace: Workspace) {
let willCloseWindow = tabs.count <= 1
if workspaceNeedsConfirmClose(workspace),
!confirmClose(
title: "Close workspace?",
message: "This will close the workspace and all of its panels.",
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."),
acceptCmdD: willCloseWindow
) {
return
@ -1259,8 +1522,8 @@ class TabManager: ObservableObject {
let needsConfirm = workspaceNeedsConfirmClose(tab)
if needsConfirm {
let message = willCloseWindow
? "This will close the last tab and close the window."
: "This will close the last tab and close its workspace."
? 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)) " +
@ -1268,7 +1531,7 @@ class TabManager: ObservableObject {
)
#endif
guard confirmClose(
title: "Close tab?",
title: String(localized: "dialog.closeTab.title", defaultValue: "Close tab?"),
message: message,
acceptCmdD: willCloseWindow
) else {
@ -1300,8 +1563,8 @@ class TabManager: ObservableObject {
)
#endif
guard confirmClose(
title: "Close tab?",
message: "This will close the current tab.",
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
@ -1339,8 +1602,8 @@ class TabManager: ObservableObject {
if let terminalPanel = tab.terminalPanel(for: surfaceId),
terminalPanel.needsConfirmClose() {
guard confirmClose(
title: "Close tab?",
message: "This will close the current tab.",
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 { return }
}
@ -1567,19 +1830,37 @@ class TabManager: ObservableObject {
guard !shouldSuppressFlash else { return }
guard AppFocusState.isAppActive() else { return }
guard let panelId = focusedPanelId(for: tabId) else { return }
markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId)
_ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true)
}
private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) {
guard selectedTabId == tabId else { return }
guard !suppressFocusFlash else { return }
guard AppFocusState.isAppActive() else { return }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: panelId) else { return }
if let tab = tabs.first(where: { $0.id == tabId }) {
_ = dismissNotificationIfActive(tabId: tabId, surfaceId: panelId, triggerFlash: true)
}
@discardableResult
func dismissNotificationOnDirectInteraction(tabId: UUID, surfaceId: UUID?) -> Bool {
dismissNotificationIfActive(tabId: tabId, surfaceId: surfaceId, triggerFlash: true)
}
@discardableResult
private func dismissNotificationIfActive(
tabId: UUID,
surfaceId: UUID?,
triggerFlash: Bool
) -> Bool {
guard selectedTabId == tabId else { return false }
guard AppFocusState.isAppActive() else { return false }
guard let notificationStore = AppDelegate.shared?.notificationStore else { return false }
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return false }
if triggerFlash,
let panelId = surfaceId,
let tab = tabs.first(where: { $0.id == tabId }) {
tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false)
}
notificationStore.markRead(forTabId: tabId, surfaceId: panelId)
notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId)
return true
}
private func enqueuePanelTitleUpdate(tabId: UUID, panelId: UUID, title: String) {
@ -1864,9 +2145,24 @@ class TabManager: ObservableObject {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }),
let focusedPanelId = tab.focusedPanelId else { return }
#if DEBUG
let directionLabel = direction.debugLabel
dlog(
"split.create.request kind=terminal dir=\(directionLabel) " +
"tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
tab.clearSplitZoom()
sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)])
_ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
let createdPanelId = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
#if DEBUG
dlog(
"split.create.result kind=terminal dir=\(directionLabel) " +
"created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
}
/// Create a new browser split from the currently focused panel.
@ -1875,14 +2171,30 @@ class TabManager: ObservableObject {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }),
let focusedPanelId = tab.focusedPanelId else { return nil }
#if DEBUG
let directionLabel = direction.debugLabel
dlog(
"split.create.request kind=browser dir=\(directionLabel) " +
"tab=\(selectedTabId.uuidString.prefix(5)) panel=\(focusedPanelId.uuidString.prefix(5)) " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
tab.clearSplitZoom()
return newBrowserSplit(
let createdPanelId = newBrowserSplit(
tabId: selectedTabId,
fromPanelId: focusedPanelId,
orientation: direction.orientation,
insertFirst: direction.insertFirst,
url: url
)
#if DEBUG
dlog(
"split.create.result kind=browser dir=\(directionLabel) " +
"created=\(createdPanelId?.uuidString.prefix(5) ?? "nil") " +
"panels=\(tab.panels.count) panes=\(tab.bonsplitController.allPaneIds.count)"
)
#endif
return createdPanelId
}
/// Refresh Bonsplit right-side action button tooltips for all workspaces.
@ -1983,11 +2295,20 @@ class TabManager: ObservableObject {
/// Returns the new panel's ID (which is also the surface ID for terminals)
func newSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection) -> UUID? {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
return tab.newTerminalSplit(
let createdPanel = tab.newTerminalSplit(
from: surfaceId,
orientation: direction.orientation,
insertFirst: direction.insertFirst
)?.id
#if DEBUG
let directionLabel = direction.debugLabel
dlog(
"split.newSurface result dir=\(directionLabel) " +
"tab=\(tabId.uuidString.prefix(5)) source=\(surfaceId.uuidString.prefix(5)) " +
"created=\(createdPanel?.uuidString.prefix(5) ?? "nil") focus=\(focus ? 1 : 0)"
)
#endif
return createdPanel
}
/// Move focus in the specified direction
@ -2584,7 +2905,7 @@ class TabManager: ObservableObject {
continue
}
terminal.hostedView.reconcileGeometryNow()
terminal.surface.forceRefresh()
terminal.surface.forceRefresh(reason: "tabManager.reconcileVisibleTerminalGeometry")
}
}
@ -3550,6 +3871,15 @@ enum SplitDirection {
var insertFirst: Bool {
self == .left || self == .up
}
var debugLabel: String {
switch self {
case .left: return "left"
case .right: return "right"
case .up: return "up"
case .down: return "down"
}
}
}
/// Resize direction for backwards compatibility
@ -3561,11 +3891,14 @@ extension Notification.Name {
static let commandPaletteToggleRequested = Notification.Name("cmux.commandPaletteToggleRequested")
static let commandPaletteRequested = Notification.Name("cmux.commandPaletteRequested")
static let commandPaletteSwitcherRequested = Notification.Name("cmux.commandPaletteSwitcherRequested")
static let commandPaletteSubmitRequested = Notification.Name("cmux.commandPaletteSubmitRequested")
static let commandPaletteDismissRequested = Notification.Name("cmux.commandPaletteDismissRequested")
static let commandPaletteRenameTabRequested = Notification.Name("cmux.commandPaletteRenameTabRequested")
static let commandPaletteRenameWorkspaceRequested = Notification.Name("cmux.commandPaletteRenameWorkspaceRequested")
static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection")
static let commandPaletteRenameInputInteractionRequested = Notification.Name("cmux.commandPaletteRenameInputInteractionRequested")
static let commandPaletteRenameInputDeleteBackwardRequested = Notification.Name("cmux.commandPaletteRenameInputDeleteBackwardRequested")
static let feedbackComposerRequested = Notification.Name("cmux.feedbackComposerRequested")
static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle")
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -54,6 +54,13 @@ final class WindowTerminalHostView: NSView {
private var lastDragRouteSignature: String?
#endif
deinit {
if let trackingArea {
removeTrackingArea(trackingArea)
}
clearActiveDividerCursor(restoreArrow: false)
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
@ -698,12 +705,13 @@ final class WindowTerminalPortal: NSObject {
synchronizeAllHostedViews(excluding: nil)
// During live resize, AppKit can deliver frame churn where host/container geometry
// settles a tick before the terminal's own scroll/surface hierarchy. Force a final
// in-place geometry + surface refresh for all visible entries in this window.
// settles a tick before the terminal's own scroll/surface hierarchy. Only force an
// in-place surface refresh when reconciliation actually changed terminal geometry.
for entry in entriesByHostedId.values {
guard let hostedView = entry.hostedView, !hostedView.isHidden else { continue }
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow()
if hostedView.reconcileGeometryNow() {
hostedView.refreshSurfaceNow(reason: "portal.externalGeometrySync")
}
}
}
@ -726,6 +734,7 @@ final class WindowTerminalPortal: NSObject {
guard let window else { return false }
guard let (container, reference) = installedTargetIfStillValid(for: window) ?? installationTarget(for: window)
else { return false }
let browserHost = preferredBrowserHost(in: container)
if hostView.superview !== container ||
installedContainerView !== container ||
@ -734,7 +743,11 @@ final class WindowTerminalPortal: NSObject {
installConstraints.removeAll()
hostView.removeFromSuperview()
if let browserHost {
container.addSubview(hostView, positioned: .below, relativeTo: browserHost)
} else {
container.addSubview(hostView, positioned: .above, relativeTo: reference)
}
installConstraints = [
hostView.leadingAnchor.constraint(equalTo: reference.leadingAnchor),
@ -745,6 +758,10 @@ final class WindowTerminalPortal: NSObject {
NSLayoutConstraint.activate(installConstraints)
installedContainerView = container
installedReferenceView = reference
} else if let browserHost {
if !Self.isView(browserHost, above: hostView, in: container) {
container.addSubview(hostView, positioned: .below, relativeTo: browserHost)
}
} else if !Self.isView(hostView, above: reference, in: container) {
container.addSubview(hostView, positioned: .above, relativeTo: reference)
}
@ -837,6 +854,10 @@ final class WindowTerminalPortal: NSObject {
return viewIndex > referenceIndex
}
private func preferredBrowserHost(in container: NSView) -> WindowBrowserHostView? {
container.subviews.last(where: { $0 is WindowBrowserHostView }) as? WindowBrowserHostView
}
#if DEBUG
private func nearestBonsplitContainer(from anchorView: NSView) -> NSView? {
var current: NSView? = anchorView
@ -1379,7 +1400,7 @@ final class WindowTerminalPortal: NSObject {
hostedView.frame = targetFrame
CATransaction.commit()
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow()
hostedView.refreshSurfaceNow(reason: "portal.frameChange")
}
if hasFiniteFrame {
@ -1418,7 +1439,7 @@ final class WindowTerminalPortal: NSObject {
// normal frame-change refresh path won't run. Nudge geometry + redraw so newly
// revealed terminals don't sit on a stale/blank IOSurface until later focus churn.
hostedView.reconcileGeometryNow()
hostedView.refreshSurfaceNow()
hostedView.refreshSurfaceNow(reason: "portal.reveal")
}
if transientRecoveryReason == nil {
@ -1488,6 +1509,55 @@ final class WindowTerminalPortal: NSObject {
}
#if DEBUG
struct DebugStats {
let windowNumber: Int
let entryCount: Int
let hostSubviewCount: Int
let terminalSubviewCount: Int
let mappedTerminalSubviewCount: Int
let orphanTerminalSubviewCount: Int
let visibleOrphanTerminalSubviewCount: Int
let staleEntryCount: Int
}
func debugStats() -> DebugStats {
let terminalSubviews = hostView.subviews.compactMap { $0 as? GhosttySurfaceScrollView }
var mappedTerminalSubviewCount = 0
var orphanTerminalSubviewCount = 0
var visibleOrphanTerminalSubviewCount = 0
for hostedView in terminalSubviews {
let hostedId = ObjectIdentifier(hostedView)
if entriesByHostedId[hostedId] != nil {
mappedTerminalSubviewCount += 1
} else {
orphanTerminalSubviewCount += 1
if hostedView.window != nil,
!hostedView.isHidden,
hostedView.frame.width > Self.tinyHideThreshold,
hostedView.frame.height > Self.tinyHideThreshold {
visibleOrphanTerminalSubviewCount += 1
}
}
}
let staleEntryCount = entriesByHostedId.values.reduce(0) { partialResult, entry in
guard let hostedView = entry.hostedView else { return partialResult + 1 }
return hostedView.superview === hostView ? partialResult : partialResult + 1
}
return DebugStats(
windowNumber: window?.windowNumber ?? -1,
entryCount: entriesByHostedId.count,
hostSubviewCount: hostView.subviews.count,
terminalSubviewCount: terminalSubviews.count,
mappedTerminalSubviewCount: mappedTerminalSubviewCount,
orphanTerminalSubviewCount: orphanTerminalSubviewCount,
visibleOrphanTerminalSubviewCount: visibleOrphanTerminalSubviewCount,
staleEntryCount: staleEntryCount
)
}
func debugEntryCount() -> Int {
entriesByHostedId.count
}
@ -1540,6 +1610,30 @@ final class WindowTerminalPortal: NSObject {
enum TerminalWindowPortalRegistry {
private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:]
private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:]
#if DEBUG
private static var blockedBindCount: Int = 0
private static var blockedBindReasons: [String: Int] = [:]
#endif
private static func bindBlockReason(
expectedSurfaceId: UUID?,
expectedGeneration: UInt64?,
actual: (surfaceId: UUID?, generation: UInt64?, state: String)
) -> String {
if actual.surfaceId == nil {
return "missingSurface"
}
if actual.state != "live" {
return "state_\(actual.state)"
}
if let expectedSurfaceId, actual.surfaceId != expectedSurfaceId {
return "surfaceMismatch"
}
if let expectedGeneration, actual.generation != expectedGeneration {
return "generationMismatch"
}
return "guardRejected"
}
private static func installWindowCloseObserverIfNeeded(for window: NSWindow) {
guard objc_getAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey) == nil else { return }
@ -1603,11 +1697,46 @@ enum TerminalWindowPortalRegistry {
return portal
}
static func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
static func bind(
hostedView: GhosttySurfaceScrollView,
to anchorView: NSView,
visibleInUI: Bool,
zPriority: Int = 0,
expectedSurfaceId: UUID? = nil,
expectedGeneration: UInt64? = nil
) {
guard let window = anchorView.window else { return }
let windowId = ObjectIdentifier(window)
let hostedId = ObjectIdentifier(hostedView)
let guardState = hostedView.portalBindingGuardState()
guard hostedView.canAcceptPortalBinding(
expectedSurfaceId: expectedSurfaceId,
expectedGeneration: expectedGeneration
) else {
if let oldWindowId = hostedToWindowId.removeValue(forKey: hostedId) {
portalsByWindowId[oldWindowId]?.detachHostedView(withId: hostedId)
}
#if DEBUG
let reason = bindBlockReason(
expectedSurfaceId: expectedSurfaceId,
expectedGeneration: expectedGeneration,
actual: guardState
)
blockedBindCount += 1
blockedBindReasons[reason, default: 0] += 1
dlog(
"portal.bind.blocked hosted=\(portalDebugToken(hostedView)) " +
"reason=\(reason) expectedSurface=\(expectedSurfaceId?.uuidString.prefix(5) ?? "nil") " +
"expectedGeneration=\(expectedGeneration.map { String($0) } ?? "nil") " +
"actualSurface=\(guardState.surfaceId?.uuidString.prefix(5) ?? "nil") " +
"actualGeneration=\(guardState.generation.map { String($0) } ?? "nil") " +
"actualState=\(guardState.state)"
)
#endif
return
}
let nextPortal = portal(for: window)
if let oldWindowId = hostedToWindowId[hostedId],
@ -1674,5 +1803,68 @@ enum TerminalWindowPortalRegistry {
static func debugPortalCount() -> Int {
portalsByWindowId.count
}
static func debugPortalStats() -> [String: Any] {
var portals: [[String: Any]] = []
var totals: [String: Int] = [
"entry_count": 0,
"host_subview_count": 0,
"terminal_subview_count": 0,
"mapped_terminal_subview_count": 0,
"orphan_terminal_subview_count": 0,
"visible_orphan_terminal_subview_count": 0,
"stale_entry_count": 0,
"mapped_hosted_count": 0,
]
for (windowId, portal) in portalsByWindowId {
let stats = portal.debugStats()
let mappedHostedCount = hostedToWindowId.values.reduce(0) { partialResult, mappedWindowId in
partialResult + (mappedWindowId == windowId ? 1 : 0)
}
let integrityOK =
stats.orphanTerminalSubviewCount == 0 &&
stats.visibleOrphanTerminalSubviewCount == 0 &&
stats.staleEntryCount == 0 &&
mappedHostedCount == stats.entryCount
portals.append([
"window_number": stats.windowNumber,
"entry_count": stats.entryCount,
"mapped_hosted_count": mappedHostedCount,
"host_subview_count": stats.hostSubviewCount,
"terminal_subview_count": stats.terminalSubviewCount,
"mapped_terminal_subview_count": stats.mappedTerminalSubviewCount,
"orphan_terminal_subview_count": stats.orphanTerminalSubviewCount,
"visible_orphan_terminal_subview_count": stats.visibleOrphanTerminalSubviewCount,
"stale_entry_count": stats.staleEntryCount,
"integrity_ok": integrityOK,
])
totals["entry_count", default: 0] += stats.entryCount
totals["host_subview_count", default: 0] += stats.hostSubviewCount
totals["terminal_subview_count", default: 0] += stats.terminalSubviewCount
totals["mapped_terminal_subview_count", default: 0] += stats.mappedTerminalSubviewCount
totals["orphan_terminal_subview_count", default: 0] += stats.orphanTerminalSubviewCount
totals["visible_orphan_terminal_subview_count", default: 0] += stats.visibleOrphanTerminalSubviewCount
totals["stale_entry_count", default: 0] += stats.staleEntryCount
totals["mapped_hosted_count", default: 0] += mappedHostedCount
}
portals.sort {
let lhs = ($0["window_number"] as? Int) ?? Int.min
let rhs = ($1["window_number"] as? Int) ?? Int.min
return lhs < rhs
}
return [
"portal_count": portals.count,
"hosted_mapping_count": hostedToWindowId.count,
"guarded_bind_blocked_count": blockedBindCount,
"guarded_bind_blocked_reasons": blockedBindReasons,
"portals": portals,
"totals": totals,
]
}
#endif
}

View file

@ -1,4 +1,5 @@
import AppKit
import Bonsplit
import Foundation
import SwiftUI
@ -54,7 +55,7 @@ struct UpdatePill: View {
.contentShape(Capsule())
}
.buttonStyle(.plain)
.help(model.text)
.safeHelp(model.text)
.accessibilityLabel(model.text)
.accessibilityIdentifier("UpdatePill")
}
@ -72,7 +73,7 @@ struct InstallUpdateMenuItem: View {
var body: some View {
if model.state.isInstallable {
Button("Install Update and Relaunch") {
Button(String(localized: "update.installAndRelaunch", defaultValue: "Install Update and Relaunch")) {
model.state.confirm()
}
}

View file

@ -49,17 +49,17 @@ fileprivate struct PermissionRequestView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Enable automatic updates?")
Text(String(localized: "update.popover.enableAutoUpdates", defaultValue: "Enable automatic updates?"))
.font(.system(size: 13, weight: .semibold))
Text("cmux can automatically check for updates in the background.")
Text(String(localized: "update.popover.autoUpdatesDescription", defaultValue: "cmux can automatically check for updates in the background."))
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 8) {
Button("Not Now") {
Button(String(localized: "common.notNow", defaultValue: "Not Now")) {
request.reply(SUUpdatePermissionResponse(
automaticUpdateChecks: false,
sendSystemProfile: false))
@ -69,7 +69,7 @@ fileprivate struct PermissionRequestView: View {
Spacer()
Button("Allow") {
Button(String(localized: "common.allow", defaultValue: "Allow")) {
request.reply(SUUpdatePermissionResponse(
automaticUpdateChecks: true,
sendSystemProfile: false))
@ -92,13 +92,13 @@ fileprivate struct CheckingView: View {
HStack(spacing: 10) {
ProgressView()
.controlSize(.small)
Text("Checking for updates…")
Text(String(localized: "update.popover.checking", defaultValue: "Checking for updates…"))
.font(.system(size: 13))
}
HStack {
Spacer()
Button("Cancel") {
Button(String(localized: "common.cancel", defaultValue: "Cancel")) {
checking.cancel()
dismiss()
}
@ -120,12 +120,12 @@ fileprivate struct UpdateAvailableView: View {
VStack(alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
Text("Update Available")
Text(String(localized: "update.popover.updateAvailable", defaultValue: "Update Available"))
.font(.system(size: 13, weight: .semibold))
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text("Version:")
Text(String(localized: "update.popover.version", defaultValue: "Version:"))
.foregroundColor(.secondary)
.frame(width: labelWidth, alignment: .trailing)
Text(update.appcastItem.displayVersionString)
@ -134,7 +134,7 @@ fileprivate struct UpdateAvailableView: View {
if update.appcastItem.contentLength > 0 {
HStack(spacing: 6) {
Text("Size:")
Text(String(localized: "update.popover.size", defaultValue: "Size:"))
.foregroundColor(.secondary)
.frame(width: labelWidth, alignment: .trailing)
Text(ByteCountFormatter.string(fromByteCount: Int64(update.appcastItem.contentLength), countStyle: .file))
@ -144,7 +144,7 @@ fileprivate struct UpdateAvailableView: View {
if let date = update.appcastItem.date {
HStack(spacing: 6) {
Text("Released:")
Text(String(localized: "update.popover.released", defaultValue: "Released:"))
.foregroundColor(.secondary)
.frame(width: labelWidth, alignment: .trailing)
Text(date.formatted(date: .abbreviated, time: .omitted))
@ -156,13 +156,13 @@ fileprivate struct UpdateAvailableView: View {
}
HStack(spacing: 8) {
Button("Skip") {
Button(String(localized: "common.skip", defaultValue: "Skip")) {
update.reply(.skip)
dismiss()
}
.controlSize(.small)
Button("Later") {
Button(String(localized: "common.later", defaultValue: "Later")) {
update.reply(.dismiss)
dismiss()
}
@ -171,7 +171,7 @@ fileprivate struct UpdateAvailableView: View {
Spacer()
Button("Install and Relaunch") {
Button(String(localized: "common.installAndRelaunch", defaultValue: "Install and Relaunch")) {
update.reply(.install)
dismiss()
}
@ -214,7 +214,7 @@ fileprivate struct DownloadingView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Downloading Update")
Text(String(localized: "update.popover.downloadingUpdate", defaultValue: "Downloading Update"))
.font(.system(size: 13, weight: .semibold))
if let expectedLength = download.expectedLength, expectedLength > 0 {
@ -233,7 +233,7 @@ fileprivate struct DownloadingView: View {
HStack {
Spacer()
Button("Cancel") {
Button(String(localized: "common.cancel", defaultValue: "Cancel")) {
download.cancel()
dismiss()
}
@ -250,7 +250,7 @@ fileprivate struct ExtractingView: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Preparing Update")
Text(String(localized: "update.popover.preparingUpdate", defaultValue: "Preparing Update"))
.font(.system(size: 13, weight: .semibold))
VStack(alignment: .leading, spacing: 6) {
@ -271,17 +271,17 @@ fileprivate struct InstallingView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Restart Required")
Text(String(localized: "update.popover.restartRequired", defaultValue: "Restart Required"))
.font(.system(size: 13, weight: .semibold))
Text("The update is ready. Please restart the application to complete the installation.")
Text(String(localized: "update.popover.restartRequired.message", defaultValue: "The update is ready. Please restart the application to complete the installation."))
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Button("Restart Later") {
Button(String(localized: "common.restartLater", defaultValue: "Restart Later")) {
installing.dismiss()
dismiss()
}
@ -290,7 +290,7 @@ fileprivate struct InstallingView: View {
Spacer()
Button("Restart Now") {
Button(String(localized: "common.restartNow", defaultValue: "Restart Now")) {
installing.retryTerminatingApplication()
dismiss()
}
@ -310,10 +310,10 @@ fileprivate struct NotFoundView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("No Updates Found")
Text(String(localized: "update.popover.noUpdatesFound", defaultValue: "No Updates Found"))
.font(.system(size: 13, weight: .semibold))
Text("You're already running the latest version.")
Text(String(localized: "update.popover.noUpdatesFound.message", defaultValue: "You're already running the latest version."))
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
@ -321,7 +321,7 @@ fileprivate struct NotFoundView: View {
HStack {
Spacer()
Button("OK") {
Button(String(localized: "common.ok", defaultValue: "OK")) {
notFound.acknowledgement()
dismiss()
}
@ -363,7 +363,7 @@ fileprivate struct UpdateErrorView: View {
}
VStack(alignment: .leading, spacing: 6) {
Text("Details")
Text(String(localized: "update.popover.details", defaultValue: "Details"))
.font(.system(size: 11, weight: .semibold))
Text(details)
.font(.system(size: 10, design: .monospaced))
@ -373,14 +373,14 @@ fileprivate struct UpdateErrorView: View {
}
HStack(spacing: 8) {
Button("Copy Details") {
Button(String(localized: "common.copyDetails", defaultValue: "Copy Details")) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(details, forType: .string)
}
.controlSize(.small)
Button("OK") {
Button(String(localized: "common.ok", defaultValue: "OK")) {
error.dismiss()
dismiss()
}
@ -389,7 +389,7 @@ fileprivate struct UpdateErrorView: View {
Spacer()
Button("Retry") {
Button(String(localized: "common.retry", defaultValue: "Retry")) {
error.retry()
dismiss()
}

View file

@ -237,7 +237,7 @@ struct TitlebarControlsView: View {
@AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@State private var shortcutRefreshTick = 0
@StateObject private var commandKeyMonitor = TitlebarCommandKeyMonitor()
@StateObject private var modifierKeyMonitor = TitlebarShortcutHintModifierMonitor()
private let titlebarHintRightSafetyShift: CGFloat = 10
private let titlebarHintBaseXShift: CGFloat = -10
private let titlebarHintBaseYShift: CGFloat = 1
@ -269,11 +269,11 @@ struct TitlebarControlsView: View {
}
private var shouldShowTitlebarShortcutHints: Bool {
alwaysShowShortcutHints || commandKeyMonitor.isCommandPressed
alwaysShowShortcutHints || modifierKeyMonitor.isModifierPressed
}
var body: some View {
// Force the `.help(...)` tooltips to re-evaluate when shortcuts are changed in settings.
// Force the `.safeHelp(...)` tooltips to re-evaluate when shortcuts are changed in settings.
// (The titlebar controls don't otherwise re-render on UserDefaults changes.)
let _ = shortcutRefreshTick
let style = TitlebarControlsStyle(rawValue: styleRawValue) ?? .classic
@ -283,7 +283,7 @@ struct TitlebarControlsView: View {
.padding(.trailing, titlebarHintTrailingInset)
.background(
WindowAccessor { window in
commandKeyMonitor.setHostWindow(window)
modifierKeyMonitor.setHostWindow(window)
}
.frame(width: 0, height: 0)
)
@ -291,10 +291,10 @@ struct TitlebarControlsView: View {
shortcutRefreshTick &+= 1
}
.onAppear {
commandKeyMonitor.start()
modifierKeyMonitor.start()
}
.onDisappear {
commandKeyMonitor.stop()
modifierKeyMonitor.stop()
}
}
@ -320,8 +320,8 @@ struct TitlebarControlsView: View {
iconLabel(systemName: "sidebar.left", config: config)
}
.accessibilityIdentifier("titlebarControl.toggleSidebar")
.accessibilityLabel("Toggle Sidebar")
.help(KeyboardShortcutSettings.Action.toggleSidebar.tooltip("Show or hide the sidebar"))
.accessibilityLabel(String(localized: "titlebar.sidebar.accessibilityLabel", defaultValue: "Toggle Sidebar"))
.safeHelp(KeyboardShortcutSettings.Action.toggleSidebar.tooltip(String(localized: "titlebar.sidebar.tooltip", defaultValue: "Show or hide the sidebar")))
TitlebarControlButton(config: config, action: {
#if DEBUG
@ -347,8 +347,8 @@ struct TitlebarControlsView: View {
}
.accessibilityIdentifier("titlebarControl.showNotifications")
.background(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 })
.accessibilityLabel("Notifications")
.help(KeyboardShortcutSettings.Action.showNotifications.tooltip("Show notifications"))
.accessibilityLabel(String(localized: "titlebar.notifications.accessibilityLabel", defaultValue: "Notifications"))
.safeHelp(KeyboardShortcutSettings.Action.showNotifications.tooltip(String(localized: "titlebar.notifications.tooltip", defaultValue: "Show notifications")))
TitlebarControlButton(config: config, action: {
#if DEBUG
@ -359,8 +359,8 @@ struct TitlebarControlsView: View {
iconLabel(systemName: "plus", config: config)
}
.accessibilityIdentifier("titlebarControl.newTab")
.accessibilityLabel("New Workspace")
.help(KeyboardShortcutSettings.Action.newTab.tooltip("New workspace"))
.accessibilityLabel(String(localized: "titlebar.newWorkspace.accessibilityLabel", defaultValue: "New Workspace"))
.safeHelp(KeyboardShortcutSettings.Action.newTab.tooltip(String(localized: "titlebar.newWorkspace.tooltip", defaultValue: "New workspace")))
}
let paddedContent = content.padding(config.groupPadding)
@ -503,8 +503,8 @@ struct TitlebarControlsView: View {
}
@MainActor
private final class TitlebarCommandKeyMonitor: ObservableObject {
@Published private(set) var isCommandPressed = false
private final class TitlebarShortcutHintModifierMonitor: ObservableObject {
@Published private(set) var isModifierPressed = false
private weak var hostWindow: NSWindow?
private var hostWindowDidBecomeKeyObserver: NSObjectProtocol?
@ -598,7 +598,7 @@ private final class TitlebarCommandKeyMonitor: ObservableObject {
}
private func isCurrentWindow(eventWindow: NSWindow?) -> Bool {
SidebarCommandHintPolicy.isCurrentWindow(
ShortcutHintModifierPolicy.isCurrentWindow(
hostWindowNumber: hostWindow?.windowNumber,
hostWindowIsKey: hostWindow?.isKeyWindow ?? false,
eventWindowNumber: eventWindow?.windowNumber,
@ -607,7 +607,7 @@ private final class TitlebarCommandKeyMonitor: ObservableObject {
}
private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) {
guard SidebarCommandHintPolicy.shouldShowHints(
guard ShortcutHintModifierPolicy.shouldShowHints(
for: modifierFlags,
hostWindowNumber: hostWindow?.windowNumber,
hostWindowIsKey: hostWindow?.isKeyWindow ?? false,
@ -622,31 +622,31 @@ private final class TitlebarCommandKeyMonitor: ObservableObject {
}
private func queueHintShow() {
guard !isCommandPressed else { return }
guard !isModifierPressed else { return }
guard pendingShowWorkItem == nil else { return }
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.pendingShowWorkItem = nil
guard SidebarCommandHintPolicy.shouldShowHints(
guard ShortcutHintModifierPolicy.shouldShowHints(
for: NSEvent.modifierFlags,
hostWindowNumber: self.hostWindow?.windowNumber,
hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false,
eventWindowNumber: nil,
keyWindowNumber: NSApp.keyWindow?.windowNumber
) else { return }
self.isCommandPressed = true
self.isModifierPressed = true
}
pendingShowWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + SidebarCommandHintPolicy.intentionalHoldDelay, execute: workItem)
DispatchQueue.main.asyncAfter(deadline: .now() + ShortcutHintModifierPolicy.intentionalHoldDelay, execute: workItem)
}
private func cancelPendingHintShow(resetVisible: Bool) {
pendingShowWorkItem?.cancel()
pendingShowWorkItem = nil
if resetVisible {
isCommandPressed = false
isModifierPressed = false
}
}
@ -729,6 +729,11 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
view = containerView
containerView.translatesAutoresizingMaskIntoConstraints = true
// Prevent the titlebar accessory from clipping button backgrounds
// at the bottom edge (the system constrains accessory height to the
// titlebar, which can be slightly shorter than the button frames).
containerView.wantsLayer = true
containerView.layer?.masksToBounds = false
hostingView.translatesAutoresizingMaskIntoConstraints = true
hostingView.autoresizingMask = [.width, .height]
containerView.addSubview(hostingView)
@ -901,11 +906,11 @@ private struct NotificationsPopoverView: View {
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Notifications")
Text(String(localized: "notifications.title", defaultValue: "Notifications"))
.font(.headline)
Spacer()
if !notificationStore.notifications.isEmpty {
Button("Clear All") {
Button(String(localized: "notifications.clearAll", defaultValue: "Clear All")) {
notificationStore.clearAll()
}
.buttonStyle(.bordered)
@ -921,9 +926,9 @@ private struct NotificationsPopoverView: View {
Image(systemName: "bell.slash")
.font(.system(size: 28))
.foregroundColor(.secondary)
Text("No notifications yet")
Text(String(localized: "notifications.empty.title", defaultValue: "No notifications yet"))
.font(.headline)
Text("Desktop notifications will appear here.")
Text(String(localized: "notifications.empty.subtitle", defaultValue: "Desktop notifications will appear here."))
.font(.subheadline)
.foregroundColor(.secondary)
}

View file

@ -22,27 +22,29 @@ class UpdateViewModel: ObservableObject {
case .idle:
return ""
case .permissionRequest:
return "Enable Automatic Updates?"
return String(localized: "update.permissionRequest.text", defaultValue: "Enable Automatic Updates?")
case .checking:
return "Checking for Updates…"
return String(localized: "update.checking", defaultValue: "Checking for Updates…")
case .updateAvailable(let update):
let version = update.appcastItem.displayVersionString
if !version.isEmpty {
return "Update Available: \(version)"
return String(localized: "update.available.withVersion", defaultValue: "Update Available: \(version)")
}
return "Update Available"
return String(localized: "update.available.short", defaultValue: "Update Available")
case .downloading(let download):
if let expectedLength = download.expectedLength, expectedLength > 0 {
let progress = Double(download.progress) / Double(expectedLength)
return String(format: "Downloading: %.0f%%", progress * 100)
let percent = String(format: "%.0f%%", progress * 100)
return String(localized: "update.downloading.progress", defaultValue: "Downloading: \(percent)")
}
return "Downloading…"
return String(localized: "update.downloading.status", defaultValue: "Downloading…")
case .extracting(let extracting):
return String(format: "Preparing: %.0f%%", extracting.progress * 100)
let percent = String(format: "%.0f%%", extracting.progress * 100)
return String(localized: "update.extracting.progress", defaultValue: "Preparing: \(percent)")
case .installing(let install):
return install.isAutoUpdate ? "Restart to Complete Update" : "Installing…"
return install.isAutoUpdate ? String(localized: "update.restartToComplete", defaultValue: "Restart to Complete Update") : String(localized: "update.installing.status", defaultValue: "Installing…")
case .notFound:
return "No Updates Available"
return String(localized: "update.noUpdates.title", defaultValue: "No Updates Available")
case .error(let err):
return Self.userFacingErrorTitle(for: err.error)
}
@ -87,19 +89,19 @@ class UpdateViewModel: ObservableObject {
case .idle:
return ""
case .permissionRequest:
return "Configure automatic update preferences"
return String(localized: "update.configureAutoUpdates", defaultValue: "Configure automatic update preferences")
case .checking:
return "Please wait while we check for available updates"
return String(localized: "update.pleaseWait", defaultValue: "Please wait while we check for available updates")
case .updateAvailable(let update):
return update.releaseNotes?.label ?? "Download and install the latest version"
return update.releaseNotes?.label ?? String(localized: "update.downloadAndInstall", defaultValue: "Download and install the latest version")
case .downloading:
return "Downloading the update package"
return String(localized: "update.downloadingPackage", defaultValue: "Downloading the update package")
case .extracting:
return "Extracting and preparing the update"
return String(localized: "update.preparingUpdate", defaultValue: "Extracting and preparing the update")
case let .installing(install):
return install.isAutoUpdate ? "Restart to Complete Update" : "Installing update and preparing to restart"
return install.isAutoUpdate ? String(localized: "update.restartToComplete", defaultValue: "Restart to Complete Update") : String(localized: "update.installingAndRestarting", defaultValue: "Installing update and preparing to restart")
case .notFound:
return "You are running the latest version"
return String(localized: "update.noUpdates.message", defaultValue: "You are running the latest version")
case .error(let err):
return Self.userFacingErrorMessage(for: err.error)
}
@ -177,21 +179,21 @@ class UpdateViewModel: ObservableObject {
if let networkError = networkError(from: nsError) {
switch networkError.code {
case NSURLErrorNotConnectedToInternet:
return "No Internet Connection"
return String(localized: "update.error.noInternet.title", defaultValue: "No Internet Connection")
case NSURLErrorTimedOut:
return "Update Timed Out"
return String(localized: "update.error.timedOut.title", defaultValue: "Update Timed Out")
case NSURLErrorCannotFindHost:
return "Server Not Found"
return String(localized: "update.error.serverNotFound.title", defaultValue: "Server Not Found")
case NSURLErrorCannotConnectToHost:
return "Server Unreachable"
return String(localized: "update.error.serverUnreachable.title", defaultValue: "Server Unreachable")
case NSURLErrorNetworkConnectionLost:
return "Connection Lost"
return String(localized: "update.error.connectionLost.title", defaultValue: "Connection Lost")
case NSURLErrorSecureConnectionFailed,
NSURLErrorServerCertificateUntrusted,
NSURLErrorServerCertificateHasBadDate,
NSURLErrorServerCertificateHasUnknownRoot,
NSURLErrorServerCertificateNotYetValid:
return "Secure Connection Failed"
return String(localized: "update.error.secureConnectionFailed.title", defaultValue: "Secure Connection Failed")
default:
break
}
@ -199,24 +201,24 @@ class UpdateViewModel: ObservableObject {
if nsError.domain == SUSparkleErrorDomain {
switch nsError.code {
case 4005:
return "Updater Permission Error"
return String(localized: "update.error.permissionError.title", defaultValue: "Updater Permission Error")
case 2001:
return "Couldn't Download Update"
return String(localized: "update.error.downloadFailed.title", defaultValue: "Couldn't Download Update")
case 1000, 1002:
return "Update Feed Error"
return String(localized: "update.error.feedError.title", defaultValue: "Update Feed Error")
case 4:
return "Invalid Update Feed"
return String(localized: "update.error.invalidFeed.title", defaultValue: "Invalid Update Feed")
case 3:
return "Insecure Update Feed"
return String(localized: "update.error.insecureFeed.title", defaultValue: "Insecure Update Feed")
case 1, 2, 3001, 3002:
return "Update Signature Error"
return String(localized: "update.error.signatureError.title", defaultValue: "Update Signature Error")
case 1003, 1005:
return "App Location Issue"
return String(localized: "update.error.appLocation.title", defaultValue: "App Location Issue")
default:
break
}
}
return "Update Failed"
return String(localized: "update.error.failed.title", defaultValue: "Update Failed")
}
static func userFacingErrorMessage(for error: Swift.Error) -> String {
@ -224,21 +226,21 @@ class UpdateViewModel: ObservableObject {
if let networkError = networkError(from: nsError) {
switch networkError.code {
case NSURLErrorNotConnectedToInternet:
return "cmux cant reach the update server. Check your internet connection and try again."
return String(localized: "update.error.noInternet.message", defaultValue: "cmux cant reach the update server. Check your internet connection and try again.")
case NSURLErrorTimedOut:
return "The update server took too long to respond. Try again in a moment."
return String(localized: "update.error.timedOut.message", defaultValue: "The update server took too long to respond. Try again in a moment.")
case NSURLErrorCannotFindHost:
return "The update server cant be found. Check your connection or try again later."
return String(localized: "update.error.serverNotFound.message", defaultValue: "The update server cant be found. Check your connection or try again later.")
case NSURLErrorCannotConnectToHost:
return "cmux couldnt connect to the update server. Check your connection or try again later."
return String(localized: "update.error.serverUnreachable.message", defaultValue: "cmux couldnt connect to the update server. Check your connection or try again later.")
case NSURLErrorNetworkConnectionLost:
return "The network connection was lost while checking for updates. Try again."
return String(localized: "update.error.connectionLost.message", defaultValue: "The network connection was lost while checking for updates. Try again.")
case NSURLErrorSecureConnectionFailed,
NSURLErrorServerCertificateUntrusted,
NSURLErrorServerCertificateHasBadDate,
NSURLErrorServerCertificateHasUnknownRoot,
NSURLErrorServerCertificateNotYetValid:
return "A secure connection to the update server couldnt be established. Try again later."
return String(localized: "update.error.secureConnectionFailed.message", defaultValue: "A secure connection to the update server couldnt be established. Try again later.")
default:
break
}
@ -246,17 +248,17 @@ class UpdateViewModel: ObservableObject {
if nsError.domain == SUSparkleErrorDomain {
switch nsError.code {
case 2001:
return "cmux couldn't download the update feed. Check your connection and try again."
return String(localized: "update.error.feedDownload.message", defaultValue: "cmux couldn't download the update feed. Check your connection and try again.")
case 1000, 1002:
return "The update feed could not be read. Please try again later."
return String(localized: "update.error.feedRead.message", defaultValue: "The update feed could not be read. Please try again later.")
case 4:
return "The update feed URL is invalid. Please contact support."
return String(localized: "update.error.invalidFeed.message", defaultValue: "The update feed URL is invalid. Please contact support.")
case 3:
return "The update feed is insecure. Please contact support."
return String(localized: "update.error.insecureFeed.message", defaultValue: "The update feed is insecure. Please contact support.")
case 1, 2, 3001, 3002:
return "The update's signature could not be verified. Please try again later."
return String(localized: "update.error.signatureError.message", defaultValue: "The update's signature could not be verified. Please try again later.")
case 1003, 1005, 4005:
return "Move cmux into Applications and relaunch to enable updates."
return String(localized: "update.error.permissionError.message", defaultValue: "Move cmux into Applications and relaunch to enable updates.")
default:
break
}
@ -487,8 +489,8 @@ enum UpdateState: Equatable {
var label: String {
switch self {
case .commit: return "View GitHub Commit"
case .tagged: return "View Release Notes"
case .commit: return String(localized: "update.viewGitHubCommit", defaultValue: "View GitHub Commit")
case .tagged: return String(localized: "update.viewReleaseNotes", defaultValue: "View Release Notes")
}
}
}

View file

@ -218,6 +218,12 @@ func windowDragHandleShouldTreatTopHitAsPassiveHost(_ view: NSView) -> Bool {
return false
}
/// Re-entrancy guard for the sibling hit-test walk. When `sibling.hitTest()`
/// triggers SwiftUI view-body evaluation, AppKit can call back into this
/// function before the outer invocation finishes, causing a Swift
/// exclusive-access violation (SIGABRT). Main-thread only, no lock needed.
private var _windowDragHandleIsResolvingSiblingHits = false
/// Returns whether the titlebar drag handle should capture a hit at `point`.
/// We only claim the hit when no sibling view already handles it, so interactive
/// controls layered in the titlebar (e.g. proxy folder icon) keep their gestures.
@ -295,6 +301,20 @@ func windowDragHandleShouldCaptureHit(
return true
}
// Bail out if we're already inside a sibling hit-test walk. This happens
// when sibling.hitTest() re-enters SwiftUI layout, which calls hitTest on
// this drag handle again. Proceeding would trigger an exclusive-access
// violation in the Swift runtime.
guard !_windowDragHandleIsResolvingSiblingHits else {
#if DEBUG
dlog("titlebar.dragHandle.hitTest capture=false reason=reentrant point=\(windowDragHandleFormatPoint(point))")
#endif
return false
}
_windowDragHandleIsResolvingSiblingHits = true
defer { _windowDragHandleIsResolvingSiblingHits = false }
let siblingSnapshot = Array(superview.subviews.reversed())
#if DEBUG
@ -359,6 +379,13 @@ struct WindowDragHandleView: NSViewRepresentable {
override func hitTest(_ point: NSPoint) -> NSView? {
let currentEvent = NSApp.currentEvent
// Fast bail-out: only claim hits for left-mouse-down events.
// For mouseMoved / mouseEntered / etc., return nil immediately
// to avoid re-entering SwiftUI view state during layout passes,
// which causes exclusive-access crashes.
guard currentEvent?.type == .leftMouseDown else {
return nil
}
let shouldCapture = windowDragHandleShouldCaptureHit(
point,
in: self,

View file

@ -1888,6 +1888,7 @@ final class Workspace: Identifiable, ObservableObject {
let terminalSnapshot: SessionTerminalPanelSnapshot?
let browserSnapshot: SessionBrowserPanelSnapshot?
let markdownSnapshot: SessionMarkdownPanelSnapshot?
switch panel.panelType {
case .terminal:
guard let _ = panel as? TerminalPanel else { return nil }
@ -1901,6 +1902,7 @@ final class Workspace: Identifiable, ObservableObject {
scrollback: resolvedScrollback
)
browserSnapshot = nil
markdownSnapshot = nil
case .browser:
guard let browserPanel = panel as? BrowserPanel else { return nil }
terminalSnapshot = nil
@ -1913,6 +1915,12 @@ final class Workspace: Identifiable, ObservableObject {
backHistoryURLStrings: historySnapshot.backHistoryURLStrings,
forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings
)
markdownSnapshot = nil
case .markdown:
guard let mdPanel = panel as? MarkdownPanel else { return nil }
terminalSnapshot = nil
browserSnapshot = nil
markdownSnapshot = SessionMarkdownPanelSnapshot(filePath: mdPanel.filePath)
}
return SessionPanelSnapshot(
@ -1927,7 +1935,8 @@ final class Workspace: Identifiable, ObservableObject {
listeningPorts: listeningPorts,
ttyName: ttyName,
terminal: terminalSnapshot,
browser: browserSnapshot
browser: browserSnapshot,
markdown: markdownSnapshot
)
}
@ -2077,6 +2086,19 @@ final class Workspace: Identifiable, ObservableObject {
}
applySessionPanelMetadata(snapshot, toPanelId: browserPanel.id)
return browserPanel.id
case .markdown:
guard let filePath = snapshot.markdown?.filePath else {
return nil
}
guard let markdownPanel = newMarkdownSurface(
inPane: paneId,
filePath: filePath,
focus: false
) else {
return nil
}
applySessionPanelMetadata(snapshot, toPanelId: markdownPanel.id)
return markdownPanel.id
}
}
@ -4815,21 +4837,34 @@ final class Workspace: Identifiable, ObservableObject {
private enum SurfaceKind {
static let terminal = "terminal"
static let browser = "browser"
static let markdown = "markdown"
}
// MARK: - Initialization
private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips {
BonsplitConfiguration.SplitButtonTooltips(
newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"),
newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"),
splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"),
splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down")
newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip(String(localized: "workspace.tooltip.newTerminal", defaultValue: "New Terminal")),
newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip(String(localized: "workspace.tooltip.newBrowser", defaultValue: "New Browser")),
splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip(String(localized: "workspace.tooltip.splitRight", defaultValue: "Split Right")),
splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip(String(localized: "workspace.tooltip.splitDown", defaultValue: "Split Down"))
)
}
private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance {
bonsplitAppearance(from: config.backgroundColor)
bonsplitAppearance(
from: config.backgroundColor,
backgroundOpacity: config.backgroundOpacity
)
}
static func bonsplitChromeHex(backgroundColor: NSColor, backgroundOpacity: Double) -> String {
let themedColor = GhosttyBackgroundTheme.color(
backgroundColor: backgroundColor,
opacity: backgroundOpacity
)
let includeAlpha = themedColor.alphaComponent < 0.999
return themedColor.hexString(includeAlpha: includeAlpha)
}
nonisolated static func resolvedChromeColors(
@ -4838,25 +4873,62 @@ final class Workspace: Identifiable, ObservableObject {
.init(backgroundHex: backgroundColor.hexString())
}
private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance {
let chromeColors = resolvedChromeColors(from: backgroundColor)
return BonsplitConfiguration.Appearance(
private static func bonsplitAppearance(
from backgroundColor: NSColor,
backgroundOpacity: Double
) -> BonsplitConfiguration.Appearance {
BonsplitConfiguration.Appearance(
splitButtonTooltips: Self.currentSplitButtonTooltips(),
enableAnimations: false,
chromeColors: chromeColors
chromeColors: .init(
backgroundHex: Self.bonsplitChromeHex(
backgroundColor: backgroundColor,
backgroundOpacity: backgroundOpacity
)
)
)
}
func applyGhosttyChrome(from config: GhosttyConfig) {
applyGhosttyChrome(backgroundColor: config.backgroundColor)
func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") {
applyGhosttyChrome(
backgroundColor: config.backgroundColor,
backgroundOpacity: config.backgroundOpacity,
reason: reason
)
}
func applyGhosttyChrome(backgroundColor: NSColor) {
let nextHex = backgroundColor.hexString()
if bonsplitController.configuration.appearance.chromeColors.backgroundHex == nextHex {
func applyGhosttyChrome(backgroundColor: NSColor, backgroundOpacity: Double, reason: String = "unspecified") {
let nextHex = Self.bonsplitChromeHex(
backgroundColor: backgroundColor,
backgroundOpacity: backgroundOpacity
)
let currentChromeColors = bonsplitController.configuration.appearance.chromeColors
let isNoOp = currentChromeColors.backgroundHex == nextHex
if GhosttyApp.shared.backgroundLogEnabled {
let currentBackgroundHex = currentChromeColors.backgroundHex ?? "nil"
GhosttyApp.shared.logBackground(
"theme apply workspace=\(id.uuidString) reason=\(reason) currentBg=\(currentBackgroundHex) nextBg=\(nextHex) noop=\(isNoOp)"
)
}
if isNoOp {
return
}
bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex
if GhosttyApp.shared.backgroundLogEnabled {
GhosttyApp.shared.logBackground(
"theme applied workspace=\(id.uuidString) reason=\(reason) resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil") resultingBorder=\(bonsplitController.configuration.appearance.chromeColors.borderHex ?? "nil")"
)
}
}
func applyGhosttyChrome(backgroundColor: NSColor, reason: String = "unspecified") {
applyGhosttyChrome(
backgroundColor: backgroundColor,
backgroundOpacity: backgroundColor.alphaComponent,
reason: reason
)
}
init(
@ -4882,7 +4954,12 @@ final class Workspace: Identifiable, ObservableObject {
// Configure bonsplit with keepAllAlive to preserve terminal state
// and keep split entry instantaneous.
let appearance = Self.bonsplitAppearance(from: GhosttyConfig.load())
// Avoid re-reading/parsing Ghostty config on every new workspace; this hot path
// runs for socket/CLI workspace creation and can cause visible typing lag.
let appearance = Self.bonsplitAppearance(
from: GhosttyApp.shared.defaultBackgroundColor,
backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity
)
let config = BonsplitConfiguration(
allowSplits: true,
allowCloseTabs: true,
@ -5079,6 +5156,30 @@ final class Workspace: Identifiable, ObservableObject {
}
}
private func installMarkdownPanelSubscription(_ markdownPanel: MarkdownPanel) {
let subscription = markdownPanel.$displayTitle
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self, weak markdownPanel] newTitle in
guard let self = self,
let markdownPanel = markdownPanel,
let tabId = self.surfaceIdFromPanelId(markdownPanel.id) else { return }
guard let existing = self.bonsplitController.tab(tabId) else { return }
if self.panelTitles[markdownPanel.id] != newTitle {
self.panelTitles[markdownPanel.id] = newTitle
}
let resolvedTitle = self.resolvedPanelTitle(panelId: markdownPanel.id, fallback: newTitle)
guard existing.title != resolvedTitle else { return }
self.bonsplitController.updateTab(
tabId,
title: resolvedTitle,
hasCustomTitle: self.panelCustomTitles[markdownPanel.id] != nil
)
}
panelSubscriptions[markdownPanel.id] = subscription
}
// MARK: - Panel Access
func panel(for surfaceId: TabID) -> (any Panel)? {
@ -5094,12 +5195,18 @@ final class Workspace: Identifiable, ObservableObject {
panels[panelId] as? BrowserPanel
}
func markdownPanel(for panelId: UUID) -> MarkdownPanel? {
panels[panelId] as? MarkdownPanel
}
private func surfaceKind(for panel: any Panel) -> String {
switch panel.panelType {
case .terminal:
return SurfaceKind.terminal
case .browser:
return SurfaceKind.browser
case .markdown:
return SurfaceKind.markdown
}
}
@ -5210,6 +5317,18 @@ final class Workspace: Identifiable, ObservableObject {
return surfaceKind(for: panel)
}
func requestBackgroundTerminalSurfaceStartIfNeeded() {
for terminalPanel in panels.values.compactMap({ $0 as? TerminalPanel }) {
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
}
}
func hasLoadedTerminalSurface() -> Bool {
let terminalPanels = panels.values.compactMap { $0 as? TerminalPanel }
guard !terminalPanels.isEmpty else { return true }
return terminalPanels.contains { $0.surface.surface != nil }
}
func panelTitle(panelId: UUID) -> String? {
guard let panel = panels[panelId] else { return nil }
let fallback = panelTitles[panelId] ?? panel.displayTitle
@ -5838,11 +5957,21 @@ final class Workspace: Identifiable, ObservableObject {
guard let paneId = sourcePaneId else { return nil }
// Inherit working directory: prefer the source panel's reported cwd,
// fall back to the workspace's current directory.
let splitWorkingDirectory: String? = panelDirectories[panelId]
?? (currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil : currentDirectory)
#if DEBUG
dlog("split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")")
#endif
// Create the new terminal panel.
let newPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: inheritedConfig,
workingDirectory: splitWorkingDirectory,
portOrdinal: portOrdinal
)
panels[newPanel.id] = newPanel
@ -6097,15 +6226,166 @@ final class Workspace: Identifiable, ObservableObject {
return browserPanel
}
// MARK: - Markdown Panel Creation
/// Create a new markdown panel split from an existing panel.
func newMarkdownSplit(
from panelId: UUID,
orientation: SplitOrientation,
insertFirst: Bool = false,
filePath: String,
focus: Bool = true
) -> MarkdownPanel? {
// Find the pane containing the source panel
guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil }
var sourcePaneId: PaneID?
for paneId in bonsplitController.allPaneIds {
let tabs = bonsplitController.tabs(inPane: paneId)
if tabs.contains(where: { $0.id == sourceTabId }) {
sourcePaneId = paneId
break
}
}
guard let paneId = sourcePaneId else { return nil }
// Create markdown panel
let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath)
panels[markdownPanel.id] = markdownPanel
panelTitles[markdownPanel.id] = markdownPanel.displayTitle
// Pre-generate the bonsplit tab ID so the mapping exists before the split lands.
let newTab = Bonsplit.Tab(
title: markdownPanel.displayTitle,
icon: markdownPanel.displayIcon,
kind: SurfaceKind.markdown,
isDirty: markdownPanel.isDirty,
isLoading: false,
isPinned: false
)
surfaceIdToPanelId[newTab.id] = markdownPanel.id
let previousFocusedPanelId = focusedPanelId
// Create the split with the markdown tab already present in the new pane.
// Mark this split as programmatic so didSplitPane doesn't auto-create a terminal.
isProgrammaticSplit = true
defer { isProgrammaticSplit = false }
guard bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) != nil else {
surfaceIdToPanelId.removeValue(forKey: newTab.id)
panels.removeValue(forKey: markdownPanel.id)
panelTitles.removeValue(forKey: markdownPanel.id)
return nil
}
// Suppress old view's becomeFirstResponder during reparenting.
let previousHostedView = focusedTerminalPanel?.hostedView
if focus {
previousHostedView?.suppressReparentFocus()
focusPanel(markdownPanel.id)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
previousHostedView?.clearSuppressReparentFocus()
}
} else {
preserveFocusAfterNonFocusSplit(
preferredPanelId: previousFocusedPanelId,
splitPanelId: markdownPanel.id,
previousHostedView: previousHostedView
)
}
installMarkdownPanelSubscription(markdownPanel)
return markdownPanel
}
/// Create a new markdown surface (tab) in the specified pane.
@discardableResult
func newMarkdownSurface(
inPane paneId: PaneID,
filePath: String,
focus: Bool? = nil
) -> MarkdownPanel? {
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
let markdownPanel = MarkdownPanel(workspaceId: id, filePath: filePath)
panels[markdownPanel.id] = markdownPanel
panelTitles[markdownPanel.id] = markdownPanel.displayTitle
guard let newTabId = bonsplitController.createTab(
title: markdownPanel.displayTitle,
icon: markdownPanel.displayIcon,
kind: SurfaceKind.markdown,
isDirty: markdownPanel.isDirty,
isLoading: false,
isPinned: false,
inPane: paneId
) else {
panels.removeValue(forKey: markdownPanel.id)
panelTitles.removeValue(forKey: markdownPanel.id)
return nil
}
surfaceIdToPanelId[newTabId] = markdownPanel.id
// Match terminal behavior: enforce deterministic selection + focus.
if shouldFocusNewTab {
bonsplitController.focusPane(paneId)
bonsplitController.selectTab(newTabId)
applyTabSelection(tabId: newTabId, inPane: paneId)
}
installMarkdownPanelSubscription(markdownPanel)
return markdownPanel
}
/// Tear down all panels in this workspace, freeing their Ghostty surfaces.
/// Called before the workspace is removed from TabManager to ensure child
/// processes receive SIGHUP even if ARC deallocation is delayed.
func teardownAllPanels() {
let panelEntries = Array(panels)
for (panelId, panel) in panelEntries {
panelSubscriptions.removeValue(forKey: panelId)
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
panel.close()
}
panels.removeAll(keepingCapacity: false)
surfaceIdToPanelId.removeAll(keepingCapacity: false)
panelSubscriptions.removeAll(keepingCapacity: false)
pruneSurfaceMetadata(validSurfaceIds: [])
restoredTerminalScrollbackByPanelId.removeAll(keepingCapacity: false)
terminalInheritanceFontPointsByPanelId.removeAll(keepingCapacity: false)
lastTerminalConfigInheritancePanelId = nil
lastTerminalConfigInheritanceFontPoints = nil
}
/// Close a panel.
/// Returns true when a bonsplit tab close request was issued.
func closePanel(_ panelId: UUID, force: Bool = false) -> Bool {
#if DEBUG
let mappedTabIdBeforeClose = surfaceIdFromPanelId(panelId)
dlog(
"surface.close.request panel=\(panelId.uuidString.prefix(5)) " +
"force=\(force ? 1 : 0) mappedTab=\(mappedTabIdBeforeClose.map { String(String(describing: $0).prefix(5)) } ?? "nil") " +
"focusedPanel=\(focusedPanelId?.uuidString.prefix(5) ?? "nil") " +
"focusedPane=\(bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil") " +
"\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))"
)
#endif
if let tabId = surfaceIdFromPanelId(panelId) {
if force {
forceCloseTabIds.insert(tabId)
}
// Close the tab in bonsplit (this triggers delegate callback)
return bonsplitController.closeTab(tabId)
let closed = bonsplitController.closeTab(tabId)
#if DEBUG
dlog(
"surface.close.request.done panel=\(panelId.uuidString.prefix(5)) " +
"tab=\(String(describing: tabId).prefix(5)) closed=\(closed ? 1 : 0) force=\(force ? 1 : 0)"
)
#endif
return closed
}
// Mapping can transiently drift during split-tree mutations. If the target panel is
@ -6137,12 +6417,38 @@ final class Workspace: Identifiable, ObservableObject {
dlog(
"surface.close.fallback panel=\(panelId.uuidString.prefix(5)) " +
"selectedTab=\(String(describing: selected.id).prefix(5)) " +
"closed=\(closed ? 1 : 0)"
"closed=\(closed ? 1 : 0) " +
"\(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))"
)
#endif
return closed
}
#if DEBUG
private func debugPanelLifecycleState(panelId: UUID, panel: (any Panel)?) -> String {
guard let panel else { return "panelState=missing" }
if let terminal = panel as? TerminalPanel {
let hosted = terminal.hostedView
let frame = String(format: "%.1fx%.1f", hosted.frame.width, hosted.frame.height)
let bounds = String(format: "%.1fx%.1f", hosted.bounds.width, hosted.bounds.height)
let hasRuntimeSurface = terminal.surface.surface != nil ? 1 : 0
return
"panelState=terminal panel=\(panelId.uuidString.prefix(5)) " +
"surface=\(terminal.id.uuidString.prefix(5)) runtimeSurface=\(hasRuntimeSurface) " +
"inWindow=\(hosted.window != nil ? 1 : 0) hasSuperview=\(hosted.superview != nil ? 1 : 0) " +
"hidden=\(hosted.isHidden ? 1 : 0) frame=\(frame) bounds=\(bounds)"
}
if let browser = panel as? BrowserPanel {
let webView = browser.webView
let frame = String(format: "%.1fx%.1f", webView.frame.width, webView.frame.height)
return
"panelState=browser panel=\(panelId.uuidString.prefix(5)) " +
"webInWindow=\(webView.window != nil ? 1 : 0) webHasSuperview=\(webView.superview != nil ? 1 : 0) frame=\(frame)"
}
return "panelState=\(String(describing: type(of: panel))) panel=\(panelId.uuidString.prefix(5))"
}
#endif
func paneId(forPanelId panelId: UUID) -> PaneID? {
guard let tabId = surfaceIdFromPanelId(panelId) else { return nil }
return bonsplitController.allPaneIds.first { paneId in
@ -6747,6 +7053,51 @@ final class Workspace: Identifiable, ObservableObject {
if let targetPaneId {
applyTabSelection(tabId: tabId, inPane: targetPaneId)
}
if let browserPanel = panels[panelId] as? BrowserPanel {
// Keep browser find focus behavior aligned with terminal find behavior.
// When switching back to a pane with an already-open find bar, reassert
// focus to that field instead of leaving first responder stale.
if browserPanel.searchState != nil {
browserPanel.startFind()
} else {
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger)
}
}
}
private func maybeAutoFocusBrowserAddressBarOnPanelFocus(
_ browserPanel: BrowserPanel,
trigger: FocusPanelTrigger
) {
guard trigger == .standard else { return }
guard !isCommandPaletteVisibleForWorkspaceWindow() else { return }
guard !browserPanel.shouldSuppressOmnibarAutofocus() else { return }
guard browserPanel.isShowingNewTabPage || browserPanel.preferredURLStringForOmnibar() == nil else { return }
_ = browserPanel.requestAddressBarFocus()
NotificationCenter.default.post(name: .browserFocusAddressBar, object: browserPanel.id)
}
private func isCommandPaletteVisibleForWorkspaceWindow() -> Bool {
guard let app = AppDelegate.shared else {
return false
}
if let manager = app.tabManagerFor(tabId: id),
let windowId = app.windowId(for: manager),
let window = app.mainWindow(for: windowId),
app.isCommandPaletteVisible(for: window) {
return true
}
if let keyWindow = NSApp.keyWindow, app.isCommandPaletteVisible(for: keyWindow) {
return true
}
if let mainWindow = NSApp.mainWindow, app.isCommandPaletteVisible(for: mainWindow) {
return true
}
return false
}
func moveFocus(direction: NavigationDirection) {
@ -6875,7 +7226,7 @@ final class Workspace: Identifiable, ObservableObject {
if requiresSplit && !isSplit {
return
}
terminalPanel.triggerFlash()
terminalPanel.triggerNotificationDismissFlash()
}
func triggerDebugFlash(panelId: UUID) {
@ -6895,6 +7246,19 @@ final class Workspace: Identifiable, ObservableObject {
}
}
/// Hide all browser portal views for this workspace.
/// Called before the workspace is unmounted so a portal-hosted WKWebView
/// cannot remain visible after this workspace stops being selected.
func hideAllBrowserPortalViews() {
for panel in panels.values {
guard let browser = panel as? BrowserPanel else { continue }
BrowserWindowPortalRegistry.hide(
webView: browser.webView,
source: "workspaceRetire"
)
}
}
// MARK: - Utility
/// Create a new terminal panel (used when replacing the last panel)
@ -7029,11 +7393,11 @@ final class Workspace: Identifiable, ObservableObject {
needsFollowUpPass = true
}
hostedView.reconcileGeometryNow()
let geometryChanged = hostedView.reconcileGeometryNow()
// Re-check surface after reconcileGeometryNow() which can trigger AppKit
// layout and view lifecycle changes that free surfaces (#432).
if terminalPanel.surface.surface != nil {
terminalPanel.surface.forceRefresh()
if geometryChanged, terminalPanel.surface.surface != nil {
terminalPanel.surface.forceRefresh(reason: "workspace.geometryReconcile")
}
if terminalPanel.surface.surface == nil, isAttached && hasUsableBounds {
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
@ -7088,9 +7452,9 @@ final class Workspace: Identifiable, ObservableObject {
let runRefreshPass: (TimeInterval) -> Void = { [weak self] delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
guard let self, let panel = self.terminalPanel(for: panelId) else { return }
panel.hostedView.reconcileGeometryNow()
if panel.surface.surface != nil {
panel.surface.forceRefresh()
let geometryChanged = panel.hostedView.reconcileGeometryNow()
if geometryChanged, panel.surface.surface != nil {
panel.surface.forceRefresh(reason: "workspace.movedTerminalRefresh")
}
if panel.surface.surface == nil {
panel.surface.requestBackgroundSurfaceStartIfNeeded()
@ -7157,15 +7521,15 @@ final class Workspace: Identifiable, ObservableObject {
let panel = panels[panelId] else { return }
let alert = NSAlert()
alert.messageText = "Rename Tab"
alert.informativeText = "Enter a custom name for this tab."
alert.messageText = String(localized: "dialog.renameTab.title", defaultValue: "Rename Tab")
alert.informativeText = String(localized: "dialog.renameTab.message", defaultValue: "Enter a custom name for this tab.")
let currentTitle = panelCustomTitles[panelId] ?? panelTitles[panelId] ?? panel.displayTitle
let input = NSTextField(string: currentTitle)
input.placeholderString = "Tab name"
input.placeholderString = String(localized: "dialog.renameTab.placeholder", defaultValue: "Tab name")
input.frame = NSRect(x: 0, y: 0, width: 240, height: 22)
alert.accessoryView = input
alert.addButton(withTitle: "Rename")
alert.addButton(withTitle: "Cancel")
alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
let alertWindow = alert.window
alertWindow.initialFirstResponder = input
DispatchQueue.main.async {
@ -7177,6 +7541,146 @@ final class Workspace: Identifiable, ObservableObject {
setPanelCustomTitle(panelId: panelId, title: input.stringValue)
}
private enum PanelMoveDestination {
case newWorkspaceInCurrentWindow
case selectedWorkspaceInNewWindow
case existingWorkspace(UUID)
}
private func promptMovePanel(tabId: TabID) {
guard let panelId = panelIdFromSurfaceId(tabId),
let app = AppDelegate.shared else { return }
let currentWindowId = app.tabManagerFor(tabId: id).flatMap { app.windowId(for: $0) }
let workspaceTargets = app.workspaceMoveTargets(
excludingWorkspaceId: id,
referenceWindowId: currentWindowId
)
var options: [(title: String, destination: PanelMoveDestination)] = [
(String(localized: "dialog.moveTab.newWorkspaceCurrentWindow", defaultValue: "New Workspace in Current Window"), .newWorkspaceInCurrentWindow),
(String(localized: "dialog.moveTab.selectedWorkspaceNewWindow", defaultValue: "Selected Workspace in New Window"), .selectedWorkspaceInNewWindow),
]
options.append(contentsOf: workspaceTargets.map { target in
(target.label, .existingWorkspace(target.workspaceId))
})
let alert = NSAlert()
alert.messageText = String(localized: "dialog.moveTab.title", defaultValue: "Move Tab")
alert.informativeText = String(localized: "dialog.moveTab.message", defaultValue: "Choose a destination for this tab.")
let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 320, height: 26), pullsDown: false)
for option in options {
popup.addItem(withTitle: option.title)
}
popup.selectItem(at: 0)
alert.accessoryView = popup
alert.addButton(withTitle: String(localized: "dialog.moveTab.move", defaultValue: "Move"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
guard alert.runModal() == .alertFirstButtonReturn else { return }
let selectedIndex = max(0, min(popup.indexOfSelectedItem, options.count - 1))
let destination = options[selectedIndex].destination
let moved: Bool
switch destination {
case .newWorkspaceInCurrentWindow:
guard let manager = app.tabManagerFor(tabId: id) else { return }
let workspace = manager.addWorkspace(select: true)
moved = app.moveSurface(
panelId: panelId,
toWorkspace: workspace.id,
focus: true,
focusWindow: false
)
case .selectedWorkspaceInNewWindow:
let newWindowId = app.createMainWindow()
guard let destinationManager = app.tabManagerFor(windowId: newWindowId),
let destinationWorkspaceId = destinationManager.selectedTabId else {
return
}
moved = app.moveSurface(
panelId: panelId,
toWorkspace: destinationWorkspaceId,
focus: true,
focusWindow: true
)
if !moved {
_ = app.closeMainWindow(windowId: newWindowId)
}
case .existingWorkspace(let workspaceId):
moved = app.moveSurface(
panelId: panelId,
toWorkspace: workspaceId,
focus: true,
focusWindow: true
)
}
if !moved {
let failure = NSAlert()
failure.alertStyle = .warning
failure.messageText = String(localized: "dialog.moveFailed.title", defaultValue: "Move Failed")
failure.informativeText = String(localized: "dialog.moveFailed.message", defaultValue: "cmux could not move this tab to the selected destination.")
failure.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK"))
_ = failure.runModal()
}
}
private func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool {
guard let app = AppDelegate.shared else { return false }
#if DEBUG
let dropStart = ProcessInfo.processInfo.systemUptime
#endif
let targetPane: PaneID
let targetIndex: Int?
let splitTarget: (orientation: SplitOrientation, insertFirst: Bool)?
#if DEBUG
let destinationLabel: String
#endif
switch request.destination {
case .insert(let paneId, let index):
targetPane = paneId
targetIndex = index
splitTarget = nil
#if DEBUG
destinationLabel = "insert pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil")"
#endif
case .split(let paneId, let orientation, let insertFirst):
targetPane = paneId
targetIndex = nil
splitTarget = (orientation, insertFirst)
#if DEBUG
destinationLabel = "split pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation.rawValue) insertFirst=\(insertFirst ? 1 : 0)"
#endif
}
#if DEBUG
dlog(
"split.externalDrop.begin ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " +
"sourcePane=\(request.sourcePaneId.id.uuidString.prefix(5)) destination=\(destinationLabel)"
)
#endif
let moved = app.moveBonsplitTab(
tabId: request.tabId.uuid,
toWorkspace: id,
targetPane: targetPane,
targetIndex: targetIndex,
splitTarget: splitTarget,
focus: true,
focusWindow: true
)
#if DEBUG
dlog(
"split.externalDrop.end ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " +
"moved=\(moved ? 1 : 0) elapsedMs=\(debugElapsedMs(since: dropStart))"
)
#endif
return moved
}
}
// MARK: - BonsplitDelegate
@ -7185,11 +7689,11 @@ extension Workspace: BonsplitDelegate {
@MainActor
private func confirmClosePanel(for tabId: TabID) async -> Bool {
let alert = NSAlert()
alert.messageText = "Close tab?"
alert.informativeText = "This will close the current tab."
alert.messageText = String(localized: "dialog.closeTab.title", defaultValue: "Close tab?")
alert.informativeText = String(localized: "dialog.closeTab.message", defaultValue: "This will close the current tab.")
alert.alertStyle = .warning
alert.addButton(withTitle: "Close")
alert.addButton(withTitle: "Cancel")
alert.addButton(withTitle: String(localized: "dialog.closeTab.close", defaultValue: "Close"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
// Prefer a sheet if we can find a window, otherwise fall back to modal.
if let window = NSApp.keyWindow ?? NSApp.mainWindow {
@ -7383,7 +7887,11 @@ extension Workspace: BonsplitDelegate {
// Clean up our panel
guard let panelId = panelIdFromSurfaceId(tabId) else {
#if DEBUG
NSLog("[Workspace] didCloseTab: no panelId for tabId")
dlog(
"surface.didCloseTab.skip tab=\(String(describing: tabId).prefix(5)) " +
"pane=\(pane.id.uuidString.prefix(5)) reason=missingPanelMapping " +
"panels=\(panels.count) panes=\(controller.allPaneIds.count)"
)
#endif
refreshFocusedGitBranchState()
scheduleTerminalGeometryReconcile()
@ -7391,12 +7899,15 @@ extension Workspace: BonsplitDelegate {
return
}
#if DEBUG
NSLog("[Workspace] didCloseTab panelId=\(panelId) remainingPanels=\(panels.count - 1) remainingPanes=\(controller.allPaneIds.count)")
#endif
let isDetaching = detachingTabIds.remove(tabId) != nil
let panel = panels[panelId]
#if DEBUG
dlog(
"surface.didCloseTab.begin tab=\(String(describing: tabId).prefix(5)) " +
"pane=\(pane.id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " +
"isDetaching=\(isDetaching ? 1 : 0) selectAfter=\(selectTabId.map { String(String(describing: $0).prefix(5)) } ?? "nil") " +
"\(debugPanelLifecycleState(panelId: panelId, panel: panel))"
)
#endif
if isDetaching, let panel {
let browserPanel = panel as? BrowserPanel
@ -7441,6 +7952,13 @@ extension Workspace: BonsplitDelegate {
if panels.isEmpty {
if isDetaching {
gitBranch = nil
#if DEBUG
dlog(
"surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) mode=detachingEmptyWorkspace"
)
#endif
scheduleTerminalGeometryReconcile()
return
}
let replacement = createReplacementTerminalPanel()
@ -7453,6 +7971,13 @@ extension Workspace: BonsplitDelegate {
refreshFocusedGitBranchState()
scheduleTerminalGeometryReconcile()
scheduleFocusReconcile()
#if DEBUG
dlog(
"surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) mode=replacementCreated " +
"replacement=\(replacement.id.uuidString.prefix(5)) panels=\(panels.count)"
)
#endif
return
}
@ -7470,6 +7995,16 @@ extension Workspace: BonsplitDelegate {
normalizePinnedTabs(in: pane)
}
refreshFocusedGitBranchState()
#if DEBUG
let focusedPaneAfter = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil"
dlog(
"surface.didCloseTab.end tab=\(String(describing: tabId).prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) panels=\(panels.count) panes=\(controller.allPaneIds.count) " +
"focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter)"
)
#endif
refreshFocusedGitBranchState()
scheduleTerminalGeometryReconcile()
scheduleFocusReconcile()
}
@ -7512,36 +8047,56 @@ extension Workspace: BonsplitDelegate {
}
func splitTabBar(_ controller: BonsplitController, didClosePane paneId: PaneID) {
_ = paneId
let liveTabIds: Set<TabID> = Set(
controller.allPaneIds.flatMap { controller.tabs(inPane: $0).map(\.id) }
let closedPanelIds = pendingPaneClosePanelIds.removeValue(forKey: paneId.id) ?? []
let shouldScheduleFocusReconcile = !isDetachingCloseTransaction
#if DEBUG
dlog(
"surface.didClosePane.begin pane=\(paneId.id.uuidString.prefix(5)) " +
"closedPanels=\(closedPanelIds.count) detaching=\(isDetachingCloseTransaction ? 1 : 0)"
)
let staleMappings = surfaceIdToPanelId.filter { !liveTabIds.contains($0.key) }
for (staleTabId, stalePanelId) in staleMappings {
panels[stalePanelId]?.close()
panels.removeValue(forKey: stalePanelId)
surfaceIdToPanelId.removeValue(forKey: staleTabId)
panelDirectories.removeValue(forKey: stalePanelId)
panelTitles.removeValue(forKey: stalePanelId)
panelCustomTitles.removeValue(forKey: stalePanelId)
pinnedPanelIds.remove(stalePanelId)
manualUnreadPanelIds.remove(stalePanelId)
panelGitBranches.removeValue(forKey: stalePanelId)
panelPullRequests.removeValue(forKey: stalePanelId)
panelSubscriptions.removeValue(forKey: stalePanelId)
surfaceTTYNames.removeValue(forKey: stalePanelId)
surfaceListeningPorts.removeValue(forKey: stalePanelId)
restoredTerminalScrollbackByPanelId.removeValue(forKey: stalePanelId)
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: stalePanelId)
#endif
if !closedPanelIds.isEmpty {
for panelId in closedPanelIds {
#if DEBUG
dlog(
"surface.didClosePane.panel pane=\(paneId.id.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) \(debugPanelLifecycleState(panelId: panelId, panel: panels[panelId]))"
)
#endif
panels[panelId]?.close()
panels.removeValue(forKey: panelId)
panelDirectories.removeValue(forKey: panelId)
panelGitBranches.removeValue(forKey: panelId)
panelPullRequests.removeValue(forKey: panelId)
panelTitles.removeValue(forKey: panelId)
panelCustomTitles.removeValue(forKey: panelId)
pinnedPanelIds.remove(panelId)
manualUnreadPanelIds.remove(panelId)
panelSubscriptions.removeValue(forKey: panelId)
surfaceTTYNames.removeValue(forKey: panelId)
surfaceListeningPorts.removeValue(forKey: panelId)
restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
}
if !staleMappings.isEmpty {
let closedSet = Set(closedPanelIds)
surfaceIdToPanelId = surfaceIdToPanelId.filter { !closedSet.contains($0.value) }
recomputeListeningPorts()
}
refreshFocusedGitBranchState()
scheduleTerminalGeometryReconcile()
if shouldScheduleFocusReconcile {
scheduleFocusReconcile()
}
#if DEBUG
dlog(
"surface.didClosePane.end pane=\(paneId.id.uuidString.prefix(5)) " +
"remainingPanels=\(panels.count) remainingPanes=\(bonsplitController.allPaneIds.count)"
)
#endif
}
func splitTabBar(_ controller: BonsplitController, shouldClosePane pane: PaneID) -> Bool {
// Check if any panel in this pane needs close confirmation

View file

@ -53,12 +53,52 @@ struct WorkspaceContentView: View {
}()
BonsplitView(controller: workspace.bonsplitController) { tab, paneId in
panelView(
tab: tab,
paneId: paneId,
isSplit: isSplit,
appearance: appearance
// Content for each tab in bonsplit
let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)
if let panel = workspace.panel(for: tab.id) {
let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
let isVisibleInUI = Self.panelVisibleInUI(
isWorkspaceVisible: isWorkspaceVisible,
isSelectedInPane: isSelectedInPane,
isFocused: isFocused
)
let hasUnreadNotification = Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id),
isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id)
)
PanelContentView(
panel: panel,
paneId: paneId,
isFocused: isFocused,
isSelectedInPane: isSelectedInPane,
isVisibleInUI: isVisibleInUI,
portalPriority: workspacePortalPriority,
isSplit: isSplit,
appearance: appearance,
hasUnreadNotification: hasUnreadNotification,
onFocus: {
// Keep bonsplit focus in sync with the AppKit first responder for the
// active workspace. This prevents divergence between the blue focused-tab
// indicator and where keyboard input/flash-focus actually lands.
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)
},
onRequestPanelFocus: {
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id)
},
onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) }
)
.onTapGesture {
workspace.bonsplitController.focusPane(paneId)
}
} else {
// Fallback for tabs without panels (shouldn't happen normally)
EmptyPanelView(workspace: workspace, paneId: paneId)
}
} emptyPane: { paneId in
// Empty pane content
EmptyPanelView(workspace: workspace, paneId: paneId)
@ -104,55 +144,6 @@ struct WorkspaceContentView: View {
}
}
@ViewBuilder
private func panelView(
tab: Bonsplit.Tab,
paneId: PaneID,
isSplit: Bool,
appearance: PanelAppearance
) -> some View {
let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)
if let panel = workspace.panel(for: tab.id) {
let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
let isVisibleInUI = Self.panelVisibleInUI(
isWorkspaceVisible: isWorkspaceVisible,
isSelectedInPane: isSelectedInPane,
isFocused: isFocused
)
let hasUnreadNotification = Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id),
isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id)
)
PanelContentView(
panel: panel,
isFocused: isFocused,
isSelectedInPane: isSelectedInPane,
isVisibleInUI: isVisibleInUI,
portalPriority: workspacePortalPriority,
isSplit: isSplit,
appearance: appearance,
hasUnreadNotification: hasUnreadNotification,
onFocus: {
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id)
},
onRequestPanelFocus: {
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id)
},
onTriggerFlash: { workspace.triggerDebugFlash(panelId: panel.id) }
)
.onTapGesture {
workspace.bonsplitController.focusPane(paneId)
}
} else {
EmptyPanelView(workspace: workspace, paneId: paneId)
}
}
private func syncBonsplitNotificationBadges() {
let unreadFromNotifications: Set<UUID> = Set(
notificationStore.notifications
@ -187,7 +178,8 @@ struct WorkspaceContentView: View {
reason: String = "unspecified",
backgroundOverride: NSColor? = nil,
loadConfig: () -> GhosttyConfig = { GhosttyConfig.load() },
defaultBackground: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor }
defaultBackground: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor },
defaultBackgroundOpacity: () -> Double = { GhosttyApp.shared.defaultBackgroundOpacity }
) -> GhosttyConfig {
var next = loadConfig()
let loadedBackgroundHex = next.backgroundColor.hexString()
@ -204,9 +196,12 @@ struct WorkspaceContentView: View {
}
next.backgroundColor = resolvedBackground
// Use the runtime opacity from the Ghostty engine, which may differ from the
// file-level value parsed by GhosttyConfig.load().
next.backgroundOpacity = defaultBackgroundOpacity()
if GhosttyApp.shared.backgroundLogEnabled {
GhosttyApp.shared.logBackground(
"theme resolve reason=\(reason) loadedBg=\(loadedBackgroundHex) overrideBg=\(backgroundOverride?.hexString() ?? "nil") defaultBg=\(defaultBackgroundHex) finalBg=\(next.backgroundColor.hexString()) theme=\(next.theme ?? "nil")"
"theme resolve reason=\(reason) loadedBg=\(loadedBackgroundHex) overrideBg=\(backgroundOverride?.hexString() ?? "nil") defaultBg=\(defaultBackgroundHex) finalBg=\(next.backgroundColor.hexString()) opacity=\(String(format: "%.3f", next.backgroundOpacity)) theme=\(next.theme ?? "nil")"
)
}
return next
@ -228,7 +223,8 @@ struct WorkspaceContentView: View {
let sourceLabel = backgroundSource ?? "nil"
let payloadLabel = notificationPayloadHex ?? "nil"
let backgroundChanged = previousBackgroundHex != next.backgroundColor.hexString()
let shouldRequestTitlebarRefresh = backgroundChanged || reason == "onAppear"
let opacityChanged = abs(config.backgroundOpacity - next.backgroundOpacity) > 0.0001
let shouldRequestTitlebarRefresh = backgroundChanged || opacityChanged || reason == "onAppear"
logTheme(
"theme refresh begin workspace=\(workspace.id.uuidString) reason=\(reason) event=\(eventLabel) source=\(sourceLabel) payload=\(payloadLabel) previousBg=\(previousBackgroundHex) nextBg=\(next.backgroundColor.hexString()) overrideBg=\(backgroundOverride?.hexString() ?? "nil")"
)
@ -253,8 +249,7 @@ struct WorkspaceContentView: View {
)
let chromeReason =
"refreshGhosttyAppearanceConfig:reason=\(reason):event=\(eventLabel):source=\(sourceLabel):payload=\(payloadLabel)"
_ = chromeReason
workspace.applyGhosttyChrome(from: next)
workspace.applyGhosttyChrome(from: next, reason: chromeReason)
if let terminalPanel = workspace.focusedTerminalPanel {
terminalPanel.applyWindowBackgroundIfActive()
logTheme(
@ -411,7 +406,7 @@ struct EmptyPanelView: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
.background(Color(nsColor: GhosttyBackgroundTheme.currentColor()))
#if DEBUG
.onAppear {
DebugUIEventCounters.emptyPanelAppearCount += 1

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

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