Merge branch 'main' into issue-151-ssh-remote-port-proxying
This commit is contained in:
commit
d67090994e
61 changed files with 11220 additions and 614 deletions
36
.github/workflows/nightly.yml
vendored
36
.github/workflows/nightly.yml
vendored
|
|
@ -13,7 +13,10 @@ on:
|
|||
|
||||
concurrency:
|
||||
group: nightly-build-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
# Queue main pushes instead of hard-canceling older runs. The decide job
|
||||
# already coalesces to the current main HEAD, and we re-check HEAD before
|
||||
# publishing so stale queued runs exit cleanly instead of showing up red.
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
|
@ -189,7 +192,29 @@ jobs:
|
|||
[[ "$APP_ARCHS" == *arm64* && "$APP_ARCHS" == *x86_64* ]]
|
||||
[[ "$CLI_ARCHS" == *arm64* && "$CLI_ARCHS" == *x86_64* ]]
|
||||
|
||||
- name: Check whether build commit is still current main HEAD
|
||||
if: needs.decide.outputs.should_publish == 'true'
|
||||
id: current_head
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CURRENT_MAIN_SHA="$(git ls-remote origin refs/heads/main | awk '{print $1}')"
|
||||
BUILD_SHA="${{ needs.decide.outputs.head_sha }}"
|
||||
if [ "$CURRENT_MAIN_SHA" = "$BUILD_SHA" ]; then
|
||||
STILL_CURRENT=true
|
||||
else
|
||||
STILL_CURRENT=false
|
||||
fi
|
||||
echo "still_current=${STILL_CURRENT}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "### Publish guard"
|
||||
echo
|
||||
echo "- build sha: \`$BUILD_SHA\`"
|
||||
echo "- current main sha: \`$CURRENT_MAIN_SHA\`"
|
||||
echo "- continue signing/publish: \`$STILL_CURRENT\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Inject nightly identities and metadata
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHORT_SHA="${{ needs.decide.outputs.short_sha }}"
|
||||
|
|
@ -253,6 +278,7 @@ jobs:
|
|||
echo "Commit SHA: ${SHORT_SHA}"
|
||||
|
||||
- name: Import signing cert
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true'
|
||||
env:
|
||||
APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
|
@ -276,6 +302,7 @@ jobs:
|
|||
security list-keychains -d user -s build.keychain
|
||||
|
||||
- name: Codesign apps
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true'
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
run: |
|
||||
|
|
@ -297,6 +324,7 @@ jobs:
|
|||
done
|
||||
|
||||
- name: Notarize apps and dmgs
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true'
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
|
|
@ -365,6 +393,7 @@ jobs:
|
|||
"$NIGHTLY_UNIVERSAL_DMG_IMMUTABLE"
|
||||
|
||||
- name: Upload dSYMs to Sentry
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true'
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: manaflow
|
||||
|
|
@ -380,6 +409,7 @@ jobs:
|
|||
build-universal/Build/Products/Release/
|
||||
|
||||
- name: Generate Sparkle appcasts (nightly)
|
||||
if: needs.decide.outputs.should_publish != 'true' || steps.current_head.outputs.still_current == 'true'
|
||||
env:
|
||||
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
|
||||
run: |
|
||||
|
|
@ -403,7 +433,7 @@ jobs:
|
|||
if-no-files-found: error
|
||||
|
||||
- name: Move nightly tag to built commit
|
||||
if: needs.decide.outputs.should_publish == 'true'
|
||||
if: needs.decide.outputs.should_publish == 'true' && steps.current_head.outputs.still_current == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
|
|
@ -412,7 +442,7 @@ jobs:
|
|||
git push origin refs/tags/nightly --force
|
||||
|
||||
- name: Publish nightly release assets
|
||||
if: needs.decide.outputs.should_publish == 'true'
|
||||
if: needs.decide.outputs.should_publish == 'true' && steps.current_head.outputs.still_current == 'true'
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
|
||||
with:
|
||||
tag_name: nightly
|
||||
|
|
|
|||
15
.github/workflows/update-homebrew.yml
vendored
15
.github/workflows/update-homebrew.yml
vendored
|
|
@ -37,11 +37,22 @@ jobs:
|
|||
echo "Could not determine version" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "Invalid version: ${VERSION}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Skipping homebrew cask update for non-release ref: ${VERSION}"
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Updating homebrew cask to version $VERSION"
|
||||
|
||||
- name: Download DMG and get SHA256
|
||||
id: sha
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
URL="https://github.com/manaflow-ai/cmux/releases/download/v${VERSION}/cmux-macos.dmg"
|
||||
|
|
@ -65,6 +76,7 @@ jobs:
|
|||
echo "DMG SHA256: $SHA256"
|
||||
|
||||
- name: Checkout homebrew-cmux
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
repository: manaflow-ai/homebrew-cmux
|
||||
|
|
@ -72,6 +84,7 @@ jobs:
|
|||
path: homebrew-cmux
|
||||
|
||||
- name: Update cask formula
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
SHA256: ${{ steps.sha.outputs.sha256 }}
|
||||
|
|
@ -107,6 +120,7 @@ jobs:
|
|||
sed -i 's/^ //' homebrew-cmux/Casks/cmux.rb
|
||||
|
||||
- name: Verify cask SHA matches DMG
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
run: |
|
||||
CASK_SHA=$(grep 'sha256' homebrew-cmux/Casks/cmux.rb | sed 's/.*"\(.*\)".*/\1/')
|
||||
ACTUAL_SHA=$(shasum -a 256 cmux.dmg | cut -d' ' -f1)
|
||||
|
|
@ -117,6 +131,7 @@ jobs:
|
|||
echo "SHA verification passed: $CASK_SHA"
|
||||
|
||||
- name: Commit and push
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
|
|
|
|||
1492
CLI/cmux.swift
1492
CLI/cmux.swift
File diff suppressed because it is too large
Load diff
|
|
@ -23,6 +23,7 @@
|
|||
A5001501 /* UITestRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001511 /* UITestRecorder.swift */; };
|
||||
A5001226 /* SocketControlSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlSettings.swift */; };
|
||||
A5001601 /* SentryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001600 /* SentryHelper.swift */; };
|
||||
A5001621 /* AppleScriptSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001620 /* AppleScriptSupport.swift */; };
|
||||
A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; };
|
||||
A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; };
|
||||
A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; };
|
||||
|
|
@ -94,6 +95,7 @@
|
|||
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 */; };
|
||||
A5001623 /* cmux.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* cmux.sdef */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
|
|
@ -165,6 +167,7 @@
|
|||
A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; };
|
||||
A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = "<group>"; };
|
||||
A5001620 /* AppleScriptSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptSupport.swift; sourceTree = "<group>"; };
|
||||
A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = "<group>"; };
|
||||
A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = "<group>"; };
|
||||
A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -235,6 +238,7 @@
|
|||
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>"; };
|
||||
A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
|
@ -284,6 +288,7 @@
|
|||
A5002000 /* THIRD_PARTY_LICENSES.md in Resources */,
|
||||
DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */,
|
||||
DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */,
|
||||
A5001623 /* cmux.sdef in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -364,6 +369,7 @@
|
|||
A5001541 /* PortScanner.swift */,
|
||||
A5001225 /* SocketControlSettings.swift */,
|
||||
A5001600 /* SentryHelper.swift */,
|
||||
A5001620 /* AppleScriptSupport.swift */,
|
||||
A5001090 /* AppDelegate.swift */,
|
||||
A5001091 /* NotificationsPage.swift */,
|
||||
A5001092 /* TerminalNotificationStore.swift */,
|
||||
|
|
@ -415,6 +421,7 @@
|
|||
C1ADE00001A1B2C3D4E5F719 /* claude */,
|
||||
DA7A10CA710E000000000001 /* Localizable.xcstrings */,
|
||||
DA7A10CA710E000000000002 /* InfoPlist.xcstrings */,
|
||||
A5001622 /* cmux.sdef */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -631,6 +638,7 @@
|
|||
A5001540 /* PortScanner.swift in Sources */,
|
||||
A5001226 /* SocketControlSettings.swift in Sources */,
|
||||
A5001601 /* SentryHelper.swift in Sources */,
|
||||
A5001621 /* AppleScriptSupport.swift in Sources */,
|
||||
A5001093 /* AppDelegate.swift in Sources */,
|
||||
A5001094 /* NotificationsPage.swift in Sources */,
|
||||
A5001095 /* TerminalNotificationStore.swift in Sources */,
|
||||
|
|
|
|||
148
README.ar.md
148
README.ar.md
|
|
@ -1,9 +1,5 @@
|
|||
> تمت هذه الترجمة بواسطة 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.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | العربية | <a href="README.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">تطبيق طرفية لنظام macOS مبني على Ghostty مع علامات تبويب عمودية وإشعارات لوكلاء البرمجة بالذكاء الاصطناعي</p>
|
||||
|
||||
|
|
@ -14,16 +10,63 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/screenshot.png" alt="لقطة شاشة cmux" width="900" />
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | العربية | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="لقطة شاشة cmux" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ فيديو توضيحي</a> · <a href="https://cmux.dev/blog/zen-of-cmux">فلسفة cmux</a>
|
||||
</p>
|
||||
|
||||
## الميزات
|
||||
|
||||
- **علامات تبويب عمودية** — يعرض الشريط الجانبي فرع git ومجلد العمل والمنافذ المستمعة وآخر نص إشعار
|
||||
- **حلقات الإشعارات** — تحصل الأجزاء على حلقة زرقاء وتضيء علامات التبويب عندما يحتاج وكلاء الذكاء الاصطناعي (Claude Code، OpenCode) انتباهك
|
||||
- **لوحة الإشعارات** — عرض جميع الإشعارات المعلقة في مكان واحد، والانتقال إلى أحدث إشعار غير مقروء
|
||||
- **أجزاء مقسمة** — تقسيم أفقي وعمودي
|
||||
- **متصفح مدمج** — قسّم متصفحاً بجانب الطرفية مع API قابل للبرمجة مأخوذ من [agent-browser](https://github.com/vercel-labs/agent-browser)
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>حلقات الإشعارات</h3>
|
||||
تحصل الأجزاء على حلقة زرقاء وتضيء علامات التبويب عندما يحتاج وكلاء البرمجة انتباهك
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/notification-rings.png" alt="حلقات الإشعارات" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>لوحة الإشعارات</h3>
|
||||
عرض جميع الإشعارات المعلقة في مكان واحد، والانتقال إلى أحدث إشعار غير مقروء
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/sidebar-notification-badge.png" alt="شارة إشعارات الشريط الجانبي" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>متصفح مدمج</h3>
|
||||
قسّم متصفحًا بجانب الطرفية مع API قابل للبرمجة مأخوذ من <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/built-in-browser.png" alt="المتصفح المدمج" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>علامات تبويب عمودية + أفقية</h3>
|
||||
يعرض الشريط الجانبي فرع git وحالة/رقم طلب السحب المرتبط ومجلد العمل والمنافذ المستمعة وآخر نص إشعار. تقسيم أفقي وعمودي.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="علامات تبويب عمودية وأجزاء مقسمة" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- **قابل للبرمجة** — CLI وsocket API لإنشاء مساحات العمل وتقسيم الأجزاء وإرسال ضغطات المفاتيح وأتمتة المتصفح
|
||||
- **تطبيق macOS أصلي** — مبني بـ Swift وAppKit، وليس Electron. بدء تشغيل سريع واستهلاك ذاكرة منخفض.
|
||||
- **متوافق مع Ghostty** — يقرأ إعداداتك الحالية من `~/.config/ghostty/config` للسمات والخطوط والألوان
|
||||
|
|
@ -37,7 +80,7 @@
|
|||
<img src="./docs/assets/macos-badge.png" alt="تحميل cmux لنظام macOS" width="180" />
|
||||
</a>
|
||||
|
||||
افتح ملف `.dmg` واسحب cmux إلى مجلد التطبيقات. يتم تحديث cmux تلقائياً عبر Sparkle، لذا تحتاج للتحميل مرة واحدة فقط.
|
||||
افتح ملف `.dmg` واسحب cmux إلى مجلد التطبيقات. يتم تحديث cmux تلقائيًا عبر Sparkle، لذا تحتاج للتحميل مرة واحدة فقط.
|
||||
|
||||
### Homebrew
|
||||
|
||||
|
|
@ -46,7 +89,7 @@ brew tap manaflow-ai/cmux
|
|||
brew install --cask cmux
|
||||
```
|
||||
|
||||
للتحديث لاحقاً:
|
||||
للتحديث لاحقًا:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cmux
|
||||
|
|
@ -56,16 +99,30 @@ brew upgrade --cask cmux
|
|||
|
||||
## لماذا 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 تحبسك في سير عملها. لذا بنيت cmux كتطبيق macOS أصلي بـ Swift/AppKit. يستخدم libghostty لعرض الطرفية ويقرأ إعدادات Ghostty الحالية للسمات والخطوط والألوان.
|
||||
|
||||
الإضافات الرئيسية هي الشريط الجانبي ونظام الإشعارات. يحتوي الشريط الجانبي على علامات تبويب عمودية تعرض فرع git ومجلد العمل والمنافذ المستمعة وآخر نص إشعار لكل مساحة عمل. يلتقط نظام الإشعارات تسلسلات الطرفية (OSC 9/99/777) ولديه CLI (`cmux notify`) يمكنك ربطه بخطافات الوكلاء لـ Claude Code وOpenCode وغيرها. عندما ينتظر وكيل ما، يحصل جزؤه على حلقة زرقاء وتضيء علامة التبويب في الشريط الجانبي، حتى أتمكن من معرفة أيها يحتاجني عبر الأقسام وعلامات التبويب. Cmd+Shift+U ينتقل إلى أحدث إشعار غير مقروء.
|
||||
الإضافات الرئيسية هي الشريط الجانبي ونظام الإشعارات. يحتوي الشريط الجانبي على علامات تبويب عمودية تعرض فرع git وحالة/رقم طلب السحب المرتبط ومجلد العمل والمنافذ المستمعة وآخر نص إشعار لكل مساحة عمل. يلتقط نظام الإشعارات تسلسلات الطرفية (OSC 9/99/777) ولديه CLI (`cmux notify`) يمكنك ربطه بخطافات الوكلاء لـ Claude Code وOpenCode وغيرها. عندما ينتظر وكيل ما، يحصل جزؤه على حلقة زرقاء وتضيء علامة التبويب في الشريط الجانبي، حتى أتمكن من معرفة أيها يحتاجني عبر الأقسام وعلامات التبويب. Cmd+Shift+U ينتقل إلى أحدث إشعار غير مقروء.
|
||||
|
||||
المتصفح المدمج لديه API قابل للبرمجة مأخوذ من [agent-browser](https://github.com/vercel-labs/agent-browser). يمكن للوكلاء التقاط شجرة إمكانية الوصول والحصول على مراجع العناصر والنقر وملء النماذج وتنفيذ JS. يمكنك تقسيم جزء متصفح بجانب الطرفية وجعل Claude Code يتفاعل مع خادم التطوير مباشرة.
|
||||
|
||||
كل شيء قابل للبرمجة عبر CLI وsocket API — إنشاء مساحات العمل/علامات التبويب، تقسيم الأجزاء، إرسال ضغطات المفاتيح، فتح عناوين URL في المتصفح.
|
||||
|
||||
## فلسفة cmux
|
||||
|
||||
cmux لا يفرض على المطورين طريقة استخدام أدواتهم. إنه طرفية ومتصفح مع واجهة سطر أوامر، والباقي متروك لك.
|
||||
|
||||
cmux هو لبنة أساسية وليس حلًا جاهزًا. يمنحك طرفية ومتصفحًا وإشعارات ومساحات عمل وأقسامًا وعلامات تبويب وواجهة سطر أوامر للتحكم في كل ذلك. cmux لا يجبرك على طريقة محددة لاستخدام وكلاء البرمجة. ما تبنيه باستخدام هذه اللبنات الأساسية هو ملكك.
|
||||
|
||||
أفضل المطورين دائمًا ما بنوا أدواتهم الخاصة. لم يكتشف أحد بعد أفضل طريقة للعمل مع الوكلاء، والفرق التي تبني منتجات مغلقة لم تكتشفها أيضًا بالتأكيد. المطورون الأقرب لقواعد بياناتهم الخاصة سيكتشفونها أولًا.
|
||||
|
||||
أعطِ مليون مطور لبنات أساسية قابلة للتركيب وسيجدون بشكل جماعي أكثر سير العمل كفاءة أسرع مما يمكن لأي فريق منتج تصميمه من الأعلى إلى الأسفل.
|
||||
|
||||
## التوثيق
|
||||
|
||||
لمزيد من المعلومات حول كيفية إعداد cmux، [توجه إلى وثائقنا](https://cmux.dev/docs/getting-started?utm_source=readme).
|
||||
|
||||
## اختصارات لوحة المفاتيح
|
||||
|
||||
### مساحات العمل
|
||||
|
|
@ -78,6 +135,7 @@ brew upgrade --cask cmux
|
|||
| ⌃ ⌘ ] | مساحة العمل التالية |
|
||||
| ⌃ ⌘ [ | مساحة العمل السابقة |
|
||||
| ⌘ ⇧ W | إغلاق مساحة العمل |
|
||||
| ⌘ ⇧ R | إعادة تسمية مساحة العمل |
|
||||
| ⌘ B | تبديل الشريط الجانبي |
|
||||
|
||||
### الأسطح
|
||||
|
|
@ -104,6 +162,8 @@ brew upgrade --cask cmux
|
|||
|
||||
### المتصفح
|
||||
|
||||
اختصارات أدوات المطور في المتصفح تتبع إعدادات Safari الافتراضية ويمكن تخصيصها في `الإعدادات ← اختصارات لوحة المفاتيح`.
|
||||
|
||||
| الاختصار | الإجراء |
|
||||
|----------|--------|
|
||||
| ⌘ ⇧ L | فتح المتصفح في قسم |
|
||||
|
|
@ -111,7 +171,8 @@ brew upgrade --cask cmux
|
|||
| ⌘ [ | للخلف |
|
||||
| ⌘ ] | للأمام |
|
||||
| ⌘ R | إعادة تحميل الصفحة |
|
||||
| ⌥ ⌘ I | فتح أدوات المطور |
|
||||
| ⌥ ⌘ I | تبديل أدوات المطور (إعداد Safari الافتراضي) |
|
||||
| ⌥ ⌘ C | عرض وحدة تحكم JavaScript (إعداد Safari الافتراضي) |
|
||||
|
||||
### الإشعارات
|
||||
|
||||
|
|
@ -148,6 +209,63 @@ brew upgrade --cask cmux
|
|||
| ⌘ ⇧ , | إعادة تحميل الإعدادات |
|
||||
| ⌘ Q | إنهاء |
|
||||
|
||||
## الإصدارات الليلية
|
||||
|
||||
[تحميل cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
|
||||
cmux NIGHTLY هو تطبيق منفصل بمعرّف حزمة خاص به، لذا يعمل بجانب الإصدار المستقر. يُبنى تلقائيًا من أحدث commit على فرع `main` ويتم تحديثه تلقائيًا عبر Sparkle الخاص به.
|
||||
|
||||
## استعادة الجلسة (السلوك الحالي)
|
||||
|
||||
عند إعادة التشغيل، يستعيد cmux حاليًا تخطيط التطبيق والبيانات الوصفية فقط:
|
||||
- تخطيط النوافذ/مساحات العمل/الأجزاء
|
||||
- مجلدات العمل
|
||||
- سجل تمرير الطرفية (أفضل جهد)
|
||||
- عنوان URL للمتصفح وسجل التنقل
|
||||
|
||||
cmux **لا** يستعيد حالة العمليات الحية داخل تطبيقات الطرفية. على سبيل المثال، جلسات Claude Code/tmux/vim النشطة لا يتم استئنافها بعد إعادة التشغيل بعد.
|
||||
|
||||
## تاريخ النجوم
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## المساهمة
|
||||
|
||||
طرق للمشاركة:
|
||||
|
||||
- تابعنا على X للتحديثات [@manaflowai](https://x.com/manaflowai)، [@lawrencecchen](https://x.com/lawrencecchen)، و[@austinywang](https://x.com/austinywang)
|
||||
- انضم إلى المحادثة على [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- أنشئ وشارك في [قضايا GitHub](https://github.com/manaflow-ai/cmux/issues) و[المناقشات](https://github.com/manaflow-ai/cmux/discussions)
|
||||
- أخبرنا بما تبنيه باستخدام cmux
|
||||
|
||||
## المجتمع
|
||||
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
- [Reddit](https://www.reddit.com/r/cmux/)
|
||||
|
||||
## إصدار المؤسسين
|
||||
|
||||
cmux مجاني ومفتوح المصدر وسيظل كذلك دائمًا. إذا كنت ترغب في دعم التطوير والحصول على وصول مبكر لما هو قادم:
|
||||
|
||||
**[احصل على إصدار المؤسسين](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
|
||||
|
||||
- **أولوية لطلبات الميزات/إصلاح الأخطاء**
|
||||
- **وصول مبكر: ذكاء اصطناعي لـ cmux يمنحك سياقًا عن كل مساحة عمل وعلامة تبويب ولوحة**
|
||||
- **وصول مبكر: تطبيق iOS مع مزامنة الطرفيات بين سطح المكتب والهاتف**
|
||||
- **وصول مبكر: أجهزة افتراضية سحابية**
|
||||
- **وصول مبكر: وضع الصوت**
|
||||
- **iMessage/WhatsApp الشخصي الخاص بي**
|
||||
|
||||
## الرخصة
|
||||
|
||||
هذا المشروع مرخص بموجب رخصة GNU Affero العامة الإصدار 3.0 أو أحدث (`AGPL-3.0-or-later`).
|
||||
|
|
|
|||
142
README.bs.md
142
README.bs.md
|
|
@ -1,9 +1,5 @@
|
|||
> Ovaj prijevod je generisan od strane Claude. Ako imate prijedloge za poboljšanje, otvorite 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.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | Bosanski | <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">macOS terminal baziran na Ghostty sa vertikalnim tabovima i obavještenjima za AI agente za programiranje</p>
|
||||
|
||||
|
|
@ -14,16 +10,63 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/screenshot.png" alt="cmux snimak ekrana" width="900" />
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | Bosanski | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="cmux snimak ekrana" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Demo video</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a>
|
||||
</p>
|
||||
|
||||
## Funkcije
|
||||
|
||||
- **Vertikalni tabovi** — Bočna traka prikazuje git granu, radni direktorij, portove koji slušaju i tekst posljednjeg obavještenja
|
||||
- **Prstenovi obavještenja** — Paneli dobijaju plavi prsten, a tabovi se osvjetljavaju kada AI agenti (Claude Code, OpenCode) trebaju vašu pažnju
|
||||
- **Panel obavještenja** — Pregledajte sva obavještenja na čekanju na jednom mjestu, skočite na najnovije nepročitano
|
||||
- **Podijeljeni paneli** — Horizontalna i vertikalna podjela
|
||||
- **Ugrađeni preglednik** — Podijelite preglednik pored terminala sa skriptabilnim API portiranim iz [agent-browser](https://github.com/vercel-labs/agent-browser)
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Prstenovi obavještenja</h3>
|
||||
Paneli dobijaju plavi prsten, a tabovi se osvjetljavaju kada agenti za programiranje trebaju vašu pažnju
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/notification-rings.png" alt="Prstenovi obavještenja" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Panel obavještenja</h3>
|
||||
Pregledajte sva obavještenja na čekanju na jednom mjestu, skočite na najnovije nepročitano
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/sidebar-notification-badge.png" alt="Značka obavještenja u bočnoj traci" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Ugrađeni preglednik</h3>
|
||||
Podijelite preglednik pored terminala sa skriptabilnim API portiranim iz <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/built-in-browser.png" alt="Ugrađeni preglednik" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Vertikalni + horizontalni tabovi</h3>
|
||||
Bočna traka prikazuje git granu, status/broj povezanog PR-a, radni direktorij, portove koji slušaju i tekst posljednjeg obavještenja. Horizontalna i vertikalna podjela.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Vertikalni tabovi i podijeljeni paneli" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- **Skriptabilan** — CLI i socket API za kreiranje radnih prostora, dijeljenje panela, slanje pritisaka tipki i automatizaciju preglednika
|
||||
- **Nativna macOS aplikacija** — Izgrađena sa Swift i AppKit, ne Electron. Brzo pokretanje, niska potrošnja memorije.
|
||||
- **Kompatibilan sa Ghostty** — Čita vašu postojeću konfiguraciju `~/.config/ghostty/config` za teme, fontove i boje
|
||||
|
|
@ -60,12 +103,26 @@ Pokrećem mnogo Claude Code i Codex sesija paralelno. Koristio sam Ghostty sa go
|
|||
|
||||
Isprobao sam nekoliko orkestratora za kodiranje, ali većina ih je bila Electron/Tauri aplikacije i performanse su me nervirale. Također jednostavno preferiram terminal jer GUI orkestratori vas zaključavaju u svoj radni tok. Zato sam izgradio cmux kao nativnu macOS aplikaciju u Swift/AppKit. Koristi libghostty za renderiranje terminala i čita vašu postojeću Ghostty konfiguraciju za teme, fontove i boje.
|
||||
|
||||
Glavni dodaci su bočna traka i sistem obavještenja. Bočna traka ima vertikalne tabove koji prikazuju git granu, radni direktorij, portove koji slušaju i tekst posljednjeg obavještenja za svaki radni prostor. Sistem obavještenja hvata terminalne sekvence (OSC 9/99/777) i ima CLI (`cmux notify`) koji možete povezati sa hookovima agenata za Claude Code, OpenCode itd. Kada agent čeka, njegov panel dobija plavi prsten, a tab se osvjetljava u bočnoj traci, tako da mogu vidjeti koji me treba kroz podjele i tabove. Cmd+Shift+U skače na najnovije nepročitano.
|
||||
Glavni dodaci su bočna traka i sistem obavještenja. Bočna traka ima vertikalne tabove koji prikazuju git granu, status/broj povezanog PR-a, radni direktorij, portove koji slušaju i tekst posljednjeg obavještenja za svaki radni prostor. Sistem obavještenja hvata terminalne sekvence (OSC 9/99/777) i ima CLI (`cmux notify`) koji možete povezati sa hookovima agenata za Claude Code, OpenCode itd. Kada agent čeka, njegov panel dobija plavi prsten, a tab se osvjetljava u bočnoj traci, tako da mogu vidjeti koji me treba kroz podjele i tabove. Cmd+Shift+U skače na najnovije nepročitano.
|
||||
|
||||
Ugrađeni preglednik ima skriptabilni API portiran iz [agent-browser](https://github.com/vercel-labs/agent-browser). Agenti mogu snimiti stablo pristupačnosti, dobiti reference elemenata, kliknuti, popuniti formulare i evaluirati JS. Možete podijeliti panel preglednika pored terminala i omogućiti Claude Code da direktno komunicira sa vašim razvojnim serverom.
|
||||
|
||||
Sve je skriptabilno kroz CLI i socket API — kreiranje radnih prostora/tabova, dijeljenje panela, slanje pritisaka tipki, otvaranje URL-ova u pregledniku.
|
||||
|
||||
## The Zen of cmux
|
||||
|
||||
cmux ne propisuje programerima kako da koriste svoje alate. To je terminal i preglednik sa CLI-jem, a ostatak je na vama.
|
||||
|
||||
cmux je primitiv, ne rješenje. Daje vam terminal, preglednik, obavještenja, radne prostore, podjele, tabove i CLI za kontrolu svega toga. cmux vas ne prisiljava na određeni način korištenja agenata za kodiranje. Ono što izgradite sa tim primitivima je vaše.
|
||||
|
||||
Najbolji programeri su oduvijek gradili vlastite alate. Niko još nije otkrio najbolji način rada sa agentima, a timovi koji grade zatvorene proizvode to također nisu uradili. Programeri koji su najbliži svojim bazama koda će to otkriti prvi.
|
||||
|
||||
Dajte milion programera kompozabilne primitive i oni će kolektivno pronaći najefikasnije tokove rada brže nego što bi bilo koji produktni tim mogao dizajnirati odozgo prema dolje.
|
||||
|
||||
## Dokumentacija
|
||||
|
||||
Za više informacija o konfiguraciji cmux, posjetite [našu dokumentaciju](https://cmux.dev/docs/getting-started?utm_source=readme).
|
||||
|
||||
## Prečice na Tastaturi
|
||||
|
||||
### Radni prostori
|
||||
|
|
@ -78,6 +135,7 @@ Sve je skriptabilno kroz CLI i socket API — kreiranje radnih prostora/tabova,
|
|||
| ⌃ ⌘ ] | Sljedeći radni prostor |
|
||||
| ⌃ ⌘ [ | Prethodni radni prostor |
|
||||
| ⌘ ⇧ W | Zatvori radni prostor |
|
||||
| ⌘ ⇧ R | Preimenuj radni prostor |
|
||||
| ⌘ B | Prikaži/sakrij bočnu traku |
|
||||
|
||||
### Površine
|
||||
|
|
@ -104,6 +162,8 @@ Sve je skriptabilno kroz CLI i socket API — kreiranje radnih prostora/tabova,
|
|||
|
||||
### Preglednik
|
||||
|
||||
Prečice razvojnih alata preglednika prate Safari zadane postavke i mogu se prilagoditi u `Postavke → Prečice na tastaturi`.
|
||||
|
||||
| Prečica | Akcija |
|
||||
|----------|--------|
|
||||
| ⌘ ⇧ L | Otvori preglednik u podjeli |
|
||||
|
|
@ -111,7 +171,8 @@ Sve je skriptabilno kroz CLI i socket API — kreiranje radnih prostora/tabova,
|
|||
| ⌘ [ | Nazad |
|
||||
| ⌘ ] | Naprijed |
|
||||
| ⌘ R | Ponovo učitaj stranicu |
|
||||
| ⌥ ⌘ I | Otvori Alate za Programere |
|
||||
| ⌥ ⌘ I | Prikaži/sakrij Alate za Programere (Safari zadano) |
|
||||
| ⌥ ⌘ C | Prikaži JavaScript Konzolu (Safari zadano) |
|
||||
|
||||
### Obavještenja
|
||||
|
||||
|
|
@ -148,6 +209,63 @@ Sve je skriptabilno kroz CLI i socket API — kreiranje radnih prostora/tabova,
|
|||
| ⌘ ⇧ , | Ponovo učitaj konfiguraciju |
|
||||
| ⌘ Q | Zatvori |
|
||||
|
||||
## Noćne verzije
|
||||
|
||||
[Preuzmi cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
|
||||
cmux NIGHTLY je zasebna aplikacija sa vlastitim bundle ID-om, tako da radi uporedo sa stabilnom verzijom. Automatski se gradi iz najnovijeg `main` commita i ažurira se putem vlastitog Sparkle feeda.
|
||||
|
||||
## Vraćanje sesije (trenutno ponašanje)
|
||||
|
||||
Prilikom ponovnog pokretanja, cmux trenutno vraća samo raspored aplikacije i metapodatke:
|
||||
- Raspored prozora/radnih prostora/panela
|
||||
- Radne direktorije
|
||||
- Scrollback terminala (po mogućnosti)
|
||||
- URL preglednika i historija navigacije
|
||||
|
||||
cmux **ne** vraća stanje živih procesa unutar terminalnih aplikacija. Na primjer, aktivne sesije Claude Code/tmux/vim se još ne nastavljaju nakon restarta.
|
||||
|
||||
## Historija zvjezdica
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Doprinos
|
||||
|
||||
Načini da se uključite:
|
||||
|
||||
- Pratite nas na X za ažuriranja [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen) i [@austinywang](https://x.com/austinywang)
|
||||
- Pridružite se razgovoru na [Discordu](https://discord.gg/xsgFEVrWCZ)
|
||||
- Kreirajte i učestvujte u [GitHub issues](https://github.com/manaflow-ai/cmux/issues) i [diskusijama](https://github.com/manaflow-ai/cmux/discussions)
|
||||
- Javite nam šta gradite sa cmux
|
||||
|
||||
## Zajednica
|
||||
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
- [Reddit](https://www.reddit.com/r/cmux/)
|
||||
|
||||
## Osnivačko izdanje
|
||||
|
||||
cmux je besplatan, otvorenog koda i uvijek će biti. Ako želite podržati razvoj i dobiti rani pristup onome što dolazi:
|
||||
|
||||
**[Nabavite Osnivačko izdanje](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
|
||||
|
||||
- **Prioritetni zahtjevi za funkcije/ispravke grešaka**
|
||||
- **Rani pristup: cmux AI koji vam daje kontekst o svakom radnom prostoru, tabu i panelu**
|
||||
- **Rani pristup: iOS aplikacija sa terminalima sinhroniziranim između desktopa i telefona**
|
||||
- **Rani pristup: Cloud VM-ovi**
|
||||
- **Rani pristup: Glasovni režim**
|
||||
- **Moj lični iMessage/WhatsApp**
|
||||
|
||||
## Licenca
|
||||
|
||||
Ovaj projekat je licenciran pod GNU Affero General Public License v3.0 ili novijom (`AGPL-3.0-or-later`).
|
||||
|
|
|
|||
142
README.da.md
142
README.da.md
|
|
@ -1,9 +1,5 @@
|
|||
> Denne oversættelse er genereret af Claude. Har du forslag til forbedringer, er du velkommen til at oprette en 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.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | Dansk | <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">En Ghostty-baseret macOS-terminal med lodrette faner og notifikationer til AI-kodningsagenter</p>
|
||||
|
||||
|
|
@ -14,16 +10,63 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/screenshot.png" alt="cmux skærmbillede" width="900" />
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | Dansk | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="cmux skærmbillede" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Demovideo</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a>
|
||||
</p>
|
||||
|
||||
## Funktioner
|
||||
|
||||
- **Lodrette faner** — Sidebjælken viser git-branch, arbejdsmappe, lyttende porte og seneste notifikationstekst
|
||||
- **Notifikationsringe** — Paneler får en blå ring, og faner lyser op, når AI-agenter (Claude Code, OpenCode) har brug for din opmærksomhed
|
||||
- **Notifikationspanel** — Se alle ventende notifikationer ét sted, hop til den seneste ulæste
|
||||
- **Delte paneler** — Vandrette og lodrette opdelinger
|
||||
- **Indbygget browser** — Del en browser ved siden af din terminal med en scriptbar API porteret fra [agent-browser](https://github.com/vercel-labs/agent-browser)
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Notifikationsringe</h3>
|
||||
Paneler får en blå ring, og faner lyser op, når kodningsagenter har brug for din opmærksomhed
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/notification-rings.png" alt="Notifikationsringe" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Notifikationspanel</h3>
|
||||
Se alle ventende notifikationer ét sted, hop til den seneste ulæste
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/sidebar-notification-badge.png" alt="Notifikationsbadge i sidebjælken" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Indbygget browser</h3>
|
||||
Del en browser ved siden af din terminal med en scriptbar API porteret fra <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/built-in-browser.png" alt="Indbygget browser" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Lodrette + vandrette faner</h3>
|
||||
Sidebjælken viser git-branch, tilknyttet PR-status/nummer, arbejdsmappe, lyttende porte og seneste notifikationstekst. Del vandret og lodret.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Lodrette faner og delte paneler" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- **Scriptbar** — CLI og socket API til at oprette workspaces, dele paneler, sende tastetryk og automatisere browseren
|
||||
- **Nativ macOS-app** — Bygget med Swift og AppKit, ikke Electron. Hurtig opstart, lavt hukommelsesforbrug.
|
||||
- **Ghostty-kompatibel** — Læser din eksisterende `~/.config/ghostty/config` til temaer, skrifttyper og farver
|
||||
|
|
@ -60,12 +103,26 @@ Jeg kører mange Claude Code- og Codex-sessioner parallelt. Jeg brugte Ghostty m
|
|||
|
||||
Jeg prøvede et par kodningsorkestratore, men de fleste var Electron/Tauri-apps, og ydelsen irriterede mig. Jeg foretrækker også bare terminalen, da GUI-orkestratore låser dig ind i deres arbejdsgang. Så jeg byggede cmux som en nativ macOS-app i Swift/AppKit. Den bruger libghostty til terminal-rendering og læser din eksisterende Ghostty-konfiguration til temaer, skrifttyper og farver.
|
||||
|
||||
De vigtigste tilføjelser er sidebjælken og notifikationssystemet. Sidebjælken har lodrette faner, der viser git-branch, arbejdsmappe, lyttende porte og den seneste notifikationstekst for hvert workspace. Notifikationssystemet opfanger terminalsekvenser (OSC 9/99/777) og har en CLI (`cmux notify`), du kan koble til agent-hooks for Claude Code, OpenCode osv. Når en agent venter, får dens panel en blå ring, og fanen lyser op i sidebjælken, så jeg kan se, hvilken der har brug for mig på tværs af opdelinger og faner. Cmd+Shift+U hopper til den seneste ulæste.
|
||||
De vigtigste tilføjelser er sidebjælken og notifikationssystemet. Sidebjælken har lodrette faner, der viser git-branch, tilknyttet PR-status/nummer, arbejdsmappe, lyttende porte og den seneste notifikationstekst for hvert workspace. Notifikationssystemet opfanger terminalsekvenser (OSC 9/99/777) og har en CLI (`cmux notify`), du kan koble til agent-hooks for Claude Code, OpenCode osv. Når en agent venter, får dens panel en blå ring, og fanen lyser op i sidebjælken, så jeg kan se, hvilken der har brug for mig på tværs af opdelinger og faner. Cmd+Shift+U hopper til den seneste ulæste.
|
||||
|
||||
Den indbyggede browser har en scriptbar API porteret fra [agent-browser](https://github.com/vercel-labs/agent-browser). Agenter kan tage et snapshot af tilgængelighedstræet, få elementreferencer, klikke, udfylde formularer og evaluere JS. Du kan dele et browserpanel ved siden af din terminal og lade Claude Code interagere direkte med din udviklingsserver.
|
||||
|
||||
Alt er scriptbart gennem CLI og socket API — opret workspaces/faner, del paneler, send tastetryk, åbn URL'er i browseren.
|
||||
|
||||
## The Zen of cmux
|
||||
|
||||
cmux foreskriver ikke, hvordan udviklere bruger deres værktøjer. Det er en terminal og browser med en CLI, resten er op til dig.
|
||||
|
||||
cmux er en primitiv, ikke en løsning. Det giver dig en terminal, en browser, notifikationer, workspaces, opdelinger, faner og en CLI til at styre det hele. cmux tvinger dig ikke ind i en forudbestemt måde at bruge kodningsagenter på. Hvad du bygger med primitiverne, er dit eget.
|
||||
|
||||
De bedste udviklere har altid bygget deres egne værktøjer. Ingen har endnu fundet den bedste måde at arbejde med agenter på, og holdene bag lukkede produkter har heller ikke. De udviklere, der er tættest på deres egne kodebaser, vil finde ud af det først.
|
||||
|
||||
Giv en million udviklere komponerbare primitiver, og de vil kollektivt finde de mest effektive arbejdsgange hurtigere, end noget produkthold kunne designe oppefra.
|
||||
|
||||
## Dokumentation
|
||||
|
||||
For mere information om konfiguration af cmux, [se vores dokumentation](https://cmux.dev/docs/getting-started?utm_source=readme).
|
||||
|
||||
## Tastaturgenveje
|
||||
|
||||
### Workspaces
|
||||
|
|
@ -78,6 +135,7 @@ Alt er scriptbart gennem CLI og socket API — opret workspaces/faner, del panel
|
|||
| ⌃ ⌘ ] | Næste workspace |
|
||||
| ⌃ ⌘ [ | Forrige workspace |
|
||||
| ⌘ ⇧ W | Luk workspace |
|
||||
| ⌘ ⇧ R | Omdøb workspace |
|
||||
| ⌘ B | Skjul/vis sidebjælke |
|
||||
|
||||
### Overflader
|
||||
|
|
@ -104,6 +162,8 @@ Alt er scriptbart gennem CLI og socket API — opret workspaces/faner, del panel
|
|||
|
||||
### Browser
|
||||
|
||||
Browserens udviklerværktøjsgenveje følger Safaris standarder og kan tilpasses i `Indstillinger → Tastaturgenveje`.
|
||||
|
||||
| Genvej | Handling |
|
||||
|----------|--------|
|
||||
| ⌘ ⇧ L | Åbn browser i opdeling |
|
||||
|
|
@ -111,7 +171,8 @@ Alt er scriptbart gennem CLI og socket API — opret workspaces/faner, del panel
|
|||
| ⌘ [ | Tilbage |
|
||||
| ⌘ ] | Frem |
|
||||
| ⌘ R | Genindlæs side |
|
||||
| ⌥ ⌘ I | Åbn Udviklerværktøjer |
|
||||
| ⌥ ⌘ I | Slå Udviklerværktøjer til/fra (Safari-standard) |
|
||||
| ⌥ ⌘ C | Vis JavaScript-konsol (Safari-standard) |
|
||||
|
||||
### Notifikationer
|
||||
|
||||
|
|
@ -148,6 +209,63 @@ Alt er scriptbart gennem CLI og socket API — opret workspaces/faner, del panel
|
|||
| ⌘ ⇧ , | Genindlæs konfiguration |
|
||||
| ⌘ Q | Afslut |
|
||||
|
||||
## Nightly Builds
|
||||
|
||||
[Download cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
|
||||
cmux NIGHTLY er en separat app med sit eget bundle-ID, så den kører side om side med den stabile version. Bygges automatisk fra det seneste `main`-commit og opdaterer sig selv automatisk via sit eget Sparkle-feed.
|
||||
|
||||
## Sessionsgenoprettelse (nuværende adfærd)
|
||||
|
||||
Ved genstart genopretter cmux i øjeblikket kun app-layout og metadata:
|
||||
- Vindue/workspace/panel-layout
|
||||
- Arbejdsmapper
|
||||
- Terminal-scrollback (best effort)
|
||||
- Browser-URL og navigationshistorik
|
||||
|
||||
cmux genopretter **ikke** aktive procestilstande i terminalapps. For eksempel genoptages aktive Claude Code/tmux/vim-sessioner endnu ikke efter genstart.
|
||||
|
||||
## Stjernehistorik
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Bidrag
|
||||
|
||||
Måder at deltage:
|
||||
|
||||
- Følg os på X for opdateringer [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen) og [@austinywang](https://x.com/austinywang)
|
||||
- Deltag i samtalen på [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- Opret og deltag i [GitHub issues](https://github.com/manaflow-ai/cmux/issues) og [diskussioner](https://github.com/manaflow-ai/cmux/discussions)
|
||||
- Fortæl os, hvad du bygger med cmux
|
||||
|
||||
## Fællesskab
|
||||
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
- [Reddit](https://www.reddit.com/r/cmux/)
|
||||
|
||||
## Founder's Edition
|
||||
|
||||
cmux er gratis, open source og vil altid være det. Hvis du gerne vil støtte udviklingen og få tidlig adgang til det, der kommer:
|
||||
|
||||
**[Få Founder's Edition](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
|
||||
|
||||
- **Prioriterede funktionsønsker og fejlrettelser**
|
||||
- **Tidlig adgang: cmux AI der giver dig kontekst om hvert workspace, fane og panel**
|
||||
- **Tidlig adgang: iOS-app med terminaler synkroniseret mellem desktop og telefon**
|
||||
- **Tidlig adgang: Cloud VM'er**
|
||||
- **Tidlig adgang: Stemmetilstand**
|
||||
- **Min personlige iMessage/WhatsApp**
|
||||
|
||||
## Licens
|
||||
|
||||
Dette projekt er licenseret under GNU Affero General Public License v3.0 eller senere (`AGPL-3.0-or-later`).
|
||||
|
|
|
|||
140
README.de.md
140
README.de.md
|
|
@ -1,7 +1,5 @@
|
|||
> Diese Übersetzung wurde von Claude erstellt. Verbesserungsvorschläge sind als PR willkommen.
|
||||
|
||||
<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.ko.md">한국어</a> | Deutsch | <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">Ein Ghostty-basiertes macOS-Terminal mit vertikalen Tabs und Benachrichtigungen für AI-Coding-Agenten</p>
|
||||
|
||||
|
|
@ -12,16 +10,63 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/screenshot.png" alt="cmux Screenshot" width="900" />
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | Deutsch | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="cmux Screenshot" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Demo-Video</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a>
|
||||
</p>
|
||||
|
||||
## Funktionen
|
||||
|
||||
- **Vertikale Tabs** — Die Seitenleiste zeigt Git-Branch, Arbeitsverzeichnis, lauschende Ports und den neuesten Benachrichtigungstext
|
||||
- **Benachrichtigungsringe** — Bereiche erhalten einen blauen Ring und Tabs leuchten auf, wenn AI-Agenten (Claude Code, OpenCode) Ihre Aufmerksamkeit benötigen
|
||||
- **Benachrichtigungspanel** — Alle ausstehenden Benachrichtigungen auf einen Blick sehen und zur neuesten ungelesenen springen
|
||||
- **Geteilte Bereiche** — Horizontale und vertikale Teilung
|
||||
- **Integrierter Browser** — Teilen Sie einen Browser neben Ihrem Terminal mit einer skriptfähigen API, portiert von [agent-browser](https://github.com/vercel-labs/agent-browser)
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Benachrichtigungsringe</h3>
|
||||
Bereiche erhalten einen blauen Ring und Tabs leuchten auf, wenn Coding-Agenten Ihre Aufmerksamkeit benötigen
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/notification-rings.png" alt="Benachrichtigungsringe" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Benachrichtigungspanel</h3>
|
||||
Alle ausstehenden Benachrichtigungen auf einen Blick sehen und zur neuesten ungelesenen springen
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/sidebar-notification-badge.png" alt="Seitenleisten-Benachrichtigungsabzeichen" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Integrierter Browser</h3>
|
||||
Teilen Sie einen Browser neben Ihrem Terminal mit einer skriptfähigen API, portiert von <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/built-in-browser.png" alt="Integrierter Browser" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Vertikale + horizontale Tabs</h3>
|
||||
Die Seitenleiste zeigt Git-Branch, verknüpften PR-Status/Nummer, Arbeitsverzeichnis, lauschende Ports und den neuesten Benachrichtigungstext. Horizontal und vertikal teilen.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Vertikale Tabs und geteilte Bereiche" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- **Skriptfähig** — CLI und Socket-API zum Erstellen von Arbeitsbereichen, Teilen von Bereichen, Senden von Tastenanschlägen und Automatisieren des Browsers
|
||||
- **Native macOS-App** — Entwickelt mit Swift und AppKit, nicht Electron. Schneller Start, geringer Speicherverbrauch.
|
||||
- **Ghostty-kompatibel** — Liest Ihre vorhandene `~/.config/ghostty/config` für Themes, Schriftarten und Farben
|
||||
|
|
@ -58,12 +103,26 @@ Ich führe viele Claude Code- und Codex-Sitzungen parallel aus. Ich habe Ghostty
|
|||
|
||||
Ich habe einige Coding-Orchestratoren ausprobiert, aber die meisten waren Electron/Tauri-Apps und die Performance hat mich gestört. Ich bevorzuge außerdem das Terminal, da GUI-Orchestratoren einen in ihren Workflow einschließen. Also habe ich cmux als native macOS-App in Swift/AppKit gebaut. Es verwendet libghostty für das Terminal-Rendering und liest Ihre vorhandene Ghostty-Konfiguration für Themes, Schriftarten und Farben.
|
||||
|
||||
Die wesentlichen Ergänzungen sind die Seitenleiste und das Benachrichtigungssystem. Die Seitenleiste hat vertikale Tabs, die Git-Branch, Arbeitsverzeichnis, lauschende Ports und den neuesten Benachrichtigungstext für jeden Arbeitsbereich anzeigen. Das Benachrichtigungssystem erkennt Terminal-Sequenzen (OSC 9/99/777) und bietet eine CLI (`cmux notify`), die Sie in Agent-Hooks für Claude Code, OpenCode usw. einbinden können. Wenn ein Agent wartet, bekommt sein Bereich einen blauen Ring und der Tab leuchtet in der Seitenleiste auf, sodass ich über Teilungen und Tabs hinweg erkennen kann, welcher mich braucht. ⌘⇧U springt zur neuesten ungelesenen Benachrichtigung.
|
||||
Die wesentlichen Ergänzungen sind die Seitenleiste und das Benachrichtigungssystem. Die Seitenleiste hat vertikale Tabs, die Git-Branch, verknüpften PR-Status/Nummer, Arbeitsverzeichnis, lauschende Ports und den neuesten Benachrichtigungstext für jeden Arbeitsbereich anzeigen. Das Benachrichtigungssystem erkennt Terminal-Sequenzen (OSC 9/99/777) und bietet eine CLI (`cmux notify`), die Sie in Agent-Hooks für Claude Code, OpenCode usw. einbinden können. Wenn ein Agent wartet, bekommt sein Bereich einen blauen Ring und der Tab leuchtet in der Seitenleiste auf, sodass ich über Teilungen und Tabs hinweg erkennen kann, welcher mich braucht. ⌘⇧U springt zur neuesten ungelesenen Benachrichtigung.
|
||||
|
||||
Der integrierte Browser hat eine skriptfähige API, portiert von [agent-browser](https://github.com/vercel-labs/agent-browser). Agenten können den Barrierefreiheitsbaum erfassen, Elementreferenzen erhalten, klicken, Formulare ausfüllen und JS ausführen. Sie können einen Browser-Bereich neben Ihrem Terminal teilen und Claude Code direkt mit Ihrem Entwicklungsserver interagieren lassen.
|
||||
|
||||
Alles ist über CLI und Socket-API skriptfähig — Arbeitsbereiche/Tabs erstellen, Bereiche teilen, Tastenanschläge senden, URLs im Browser öffnen.
|
||||
|
||||
## The Zen of cmux
|
||||
|
||||
cmux schreibt Entwicklern nicht vor, wie sie ihre Werkzeuge nutzen sollen. Es ist ein Terminal und Browser mit einer CLI, und der Rest liegt bei Ihnen.
|
||||
|
||||
cmux ist ein Grundbaustein, keine fertige Lösung. Es bietet Ihnen ein Terminal, einen Browser, Benachrichtigungen, Arbeitsbereiche, Teilungen, Tabs und eine CLI, um alles zu steuern. cmux zwingt Sie nicht in eine bestimmte Art, Coding-Agenten zu nutzen. Was Sie mit den Grundbausteinen bauen, ist Ihre Sache.
|
||||
|
||||
Die besten Entwickler haben schon immer ihre eigenen Werkzeuge gebaut. Niemand hat bisher die beste Art gefunden, mit Agenten zu arbeiten, und die Teams, die geschlossene Produkte bauen, auch nicht. Die Entwickler, die ihren eigenen Codebasen am nächsten sind, werden es zuerst herausfinden.
|
||||
|
||||
Geben Sie einer Million Entwickler komponierbare Grundbausteine, und sie werden gemeinsam die effizientesten Workflows schneller finden, als jedes Produktteam es von oben herab entwerfen könnte.
|
||||
|
||||
## Dokumentation
|
||||
|
||||
Weitere Informationen zur Konfiguration von cmux finden Sie in [unserer Dokumentation](https://cmux.dev/docs/getting-started?utm_source=readme).
|
||||
|
||||
## Tastenkürzel
|
||||
|
||||
### Arbeitsbereiche
|
||||
|
|
@ -76,6 +135,7 @@ Alles ist über CLI und Socket-API skriptfähig — Arbeitsbereiche/Tabs erstell
|
|||
| ⌃ ⌘ ] | Nächster Arbeitsbereich |
|
||||
| ⌃ ⌘ [ | Vorheriger Arbeitsbereich |
|
||||
| ⌘ ⇧ W | Arbeitsbereich schließen |
|
||||
| ⌘ ⇧ R | Arbeitsbereich umbenennen |
|
||||
| ⌘ B | Seitenleiste umschalten |
|
||||
|
||||
### Oberflächen
|
||||
|
|
@ -102,6 +162,8 @@ Alles ist über CLI und Socket-API skriptfähig — Arbeitsbereiche/Tabs erstell
|
|||
|
||||
### Browser
|
||||
|
||||
Tastenkürzel für Browser-Entwicklertools folgen den Safari-Standardeinstellungen und sind in `Einstellungen → Tastenkürzel` anpassbar.
|
||||
|
||||
| Tastenkürzel | Aktion |
|
||||
|----------|--------|
|
||||
| ⌘ ⇧ L | Browser in Teilung öffnen |
|
||||
|
|
@ -109,7 +171,8 @@ Alles ist über CLI und Socket-API skriptfähig — Arbeitsbereiche/Tabs erstell
|
|||
| ⌘ [ | Zurück |
|
||||
| ⌘ ] | Vorwärts |
|
||||
| ⌘ R | Seite neu laden |
|
||||
| ⌥ ⌘ I | Entwicklertools öffnen |
|
||||
| ⌥ ⌘ I | Entwicklertools umschalten (Safari-Standard) |
|
||||
| ⌥ ⌘ C | JavaScript-Konsole anzeigen (Safari-Standard) |
|
||||
|
||||
### Benachrichtigungen
|
||||
|
||||
|
|
@ -146,6 +209,63 @@ Alles ist über CLI und Socket-API skriptfähig — Arbeitsbereiche/Tabs erstell
|
|||
| ⌘ ⇧ , | Konfiguration neu laden |
|
||||
| ⌘ Q | Beenden |
|
||||
|
||||
## Nightly Builds
|
||||
|
||||
[cmux NIGHTLY herunterladen](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
|
||||
cmux NIGHTLY ist eine separate App mit eigener Bundle-ID, die neben der stabilen Version läuft. Wird automatisch vom neuesten `main`-Commit gebaut und aktualisiert sich über einen eigenen Sparkle-Feed.
|
||||
|
||||
## Sitzungswiederherstellung (aktuelles Verhalten)
|
||||
|
||||
Beim Neustart stellt cmux derzeit nur App-Layout und Metadaten wieder her:
|
||||
- Fenster-/Arbeitsbereich-/Bereichs-Layout
|
||||
- Arbeitsverzeichnisse
|
||||
- Terminal-Scrollback (bestmöglich)
|
||||
- Browser-URL und Navigationsverlauf
|
||||
|
||||
cmux stellt **keine** laufenden Prozesse in Terminal-Apps wieder her. Zum Beispiel werden aktive Claude Code-/tmux-/vim-Sitzungen nach einem Neustart noch nicht fortgesetzt.
|
||||
|
||||
## Star-Verlauf
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Mitwirken
|
||||
|
||||
Möglichkeiten, sich einzubringen:
|
||||
|
||||
- Folgen Sie uns auf X für Updates [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen) und [@austinywang](https://x.com/austinywang)
|
||||
- Nehmen Sie an der Diskussion auf [Discord](https://discord.gg/xsgFEVrWCZ) teil
|
||||
- Erstellen Sie [GitHub Issues](https://github.com/manaflow-ai/cmux/issues) und beteiligen Sie sich an [Diskussionen](https://github.com/manaflow-ai/cmux/discussions)
|
||||
- Lassen Sie uns wissen, was Sie mit cmux bauen
|
||||
|
||||
## Community
|
||||
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
- [Reddit](https://www.reddit.com/r/cmux/)
|
||||
|
||||
## Founder's Edition
|
||||
|
||||
cmux ist kostenlos, Open Source und wird es immer sein. Wenn Sie die Entwicklung unterstützen und frühen Zugang zu kommenden Funktionen erhalten möchten:
|
||||
|
||||
**[Founder's Edition erhalten](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
|
||||
|
||||
- **Priorisierte Feature-Requests/Bugfixes**
|
||||
- **Früher Zugang: cmux AI, das Ihnen Kontext zu jedem Arbeitsbereich, Tab und Panel gibt**
|
||||
- **Früher Zugang: iOS-App mit zwischen Desktop und Telefon synchronisierten Terminals**
|
||||
- **Früher Zugang: Cloud-VMs**
|
||||
- **Früher Zugang: Sprachmodus**
|
||||
- **Meine persönliche iMessage/WhatsApp**
|
||||
|
||||
## Lizenz
|
||||
|
||||
Dieses Projekt ist unter der GNU Affero General Public License v3.0 oder neuer (`AGPL-3.0-or-later`) lizenziert.
|
||||
|
|
|
|||
140
README.es.md
140
README.es.md
|
|
@ -1,7 +1,5 @@
|
|||
> Esta traducción fue generada por Claude. Si tienes sugerencias de mejora, abre un 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.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | Español | <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">Un terminal macOS basado en Ghostty con pestañas verticales y notificaciones para agentes de programación con IA</p>
|
||||
|
||||
|
|
@ -12,16 +10,63 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/screenshot.png" alt="Captura de pantalla de cmux" width="900" />
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | Español | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="Captura de pantalla de cmux" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Video de demostración</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a>
|
||||
</p>
|
||||
|
||||
## Características
|
||||
|
||||
- **Pestañas verticales** — La barra lateral muestra la rama de git, el directorio de trabajo, los puertos en escucha y el texto de la última notificación
|
||||
- **Anillos de notificación** — Los paneles obtienen un anillo azul y las pestañas se iluminan cuando los agentes de IA (Claude Code, OpenCode) necesitan tu atención
|
||||
- **Panel de notificaciones** — Ve todas las notificaciones pendientes en un solo lugar, salta a la más reciente no leída
|
||||
- **Paneles divididos** — Divisiones horizontales y verticales
|
||||
- **Navegador integrado** — Divide un navegador junto a tu terminal con una API programable portada de [agent-browser](https://github.com/vercel-labs/agent-browser)
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Anillos de notificación</h3>
|
||||
Los paneles obtienen un anillo azul y las pestañas se iluminan cuando los agentes de programación necesitan tu atención
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/notification-rings.png" alt="Anillos de notificación" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Panel de notificaciones</h3>
|
||||
Ve todas las notificaciones pendientes en un solo lugar, salta a la más reciente no leída
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/sidebar-notification-badge.png" alt="Insignia de notificación en la barra lateral" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Navegador integrado</h3>
|
||||
Divide un navegador junto a tu terminal con una API programable portada de <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/built-in-browser.png" alt="Navegador integrado" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Pestañas verticales + horizontales</h3>
|
||||
La barra lateral muestra la rama de git, el estado/número del PR vinculado, el directorio de trabajo, los puertos en escucha y el texto de la última notificación. Divide horizontal y verticalmente.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Pestañas verticales y paneles divididos" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- **Programable** — CLI y API de socket para crear espacios de trabajo, dividir paneles, enviar pulsaciones de teclas y automatizar el navegador
|
||||
- **App nativa de macOS** — Construida con Swift y AppKit, no con Electron. Inicio rápido, bajo consumo de memoria.
|
||||
- **Compatible con Ghostty** — Lee tu configuración existente en `~/.config/ghostty/config` para temas, fuentes y colores
|
||||
|
|
@ -58,12 +103,26 @@ Ejecuto muchas sesiones de Claude Code y Codex en paralelo. Estaba usando Ghostt
|
|||
|
||||
Probé algunos orquestadores de programación, pero la mayoría eran aplicaciones Electron/Tauri y el rendimiento me molestaba. Además, simplemente prefiero la terminal ya que los orquestadores con GUI te encierran en su flujo de trabajo. Así que construí cmux como una app nativa de macOS en Swift/AppKit. Usa libghostty para el renderizado del terminal y lee tu configuración existente de Ghostty para temas, fuentes y colores.
|
||||
|
||||
Las principales adiciones son la barra lateral y el sistema de notificaciones. La barra lateral tiene pestañas verticales que muestran la rama de git, el directorio de trabajo, los puertos en escucha y el texto de la última notificación para cada espacio de trabajo. El sistema de notificaciones detecta secuencias de terminal (OSC 9/99/777) y tiene un CLI (`cmux notify`) que puedes conectar a los hooks de agentes para Claude Code, OpenCode, etc. Cuando un agente está esperando, su panel obtiene un anillo azul y la pestaña se ilumina en la barra lateral, para que pueda saber cuál me necesita entre divisiones y pestañas. ⌘⇧U salta a la notificación no leída más reciente.
|
||||
Las principales adiciones son la barra lateral y el sistema de notificaciones. La barra lateral tiene pestañas verticales que muestran la rama de git, el estado/número del PR vinculado, el directorio de trabajo, los puertos en escucha y el texto de la última notificación para cada espacio de trabajo. El sistema de notificaciones detecta secuencias de terminal (OSC 9/99/777) y tiene un CLI (`cmux notify`) que puedes conectar a los hooks de agentes para Claude Code, OpenCode, etc. Cuando un agente está esperando, su panel obtiene un anillo azul y la pestaña se ilumina en la barra lateral, para que pueda saber cuál me necesita entre divisiones y pestañas. ⌘⇧U salta a la notificación no leída más reciente.
|
||||
|
||||
El navegador integrado tiene una API programable portada de [agent-browser](https://github.com/vercel-labs/agent-browser). Los agentes pueden capturar el árbol de accesibilidad, obtener referencias de elementos, hacer clic, rellenar formularios y ejecutar JS. Puedes dividir un panel de navegador junto a tu terminal y hacer que Claude Code interactúe directamente con tu servidor de desarrollo.
|
||||
|
||||
Todo es programable a través del CLI y la API de socket — crear espacios de trabajo/pestañas, dividir paneles, enviar pulsaciones de teclas, abrir URLs en el navegador.
|
||||
|
||||
## The Zen of cmux
|
||||
|
||||
cmux no prescribe cómo los desarrolladores deben usar sus herramientas. Es un terminal y navegador con un CLI, y el resto depende de ti.
|
||||
|
||||
cmux es un primitivo, no una solución. Te da un terminal, un navegador, notificaciones, espacios de trabajo, divisiones, pestañas y un CLI para controlarlo todo. cmux no te obliga a usar los agentes de programación de una manera específica. Lo que construyas con los primitivos es tuyo.
|
||||
|
||||
Los mejores desarrolladores siempre han construido sus propias herramientas. Nadie ha descubierto la mejor manera de trabajar con agentes todavía, y los equipos que construyen productos cerrados tampoco. Los desarrolladores más cercanos a sus propias bases de código lo descubrirán primero.
|
||||
|
||||
Dale a un millón de desarrolladores primitivos componibles y encontrarán colectivamente los flujos de trabajo más eficientes más rápido de lo que cualquier equipo de producto podría diseñar de arriba hacia abajo.
|
||||
|
||||
## Documentación
|
||||
|
||||
Para más información sobre cómo configurar cmux, [visita nuestra documentación](https://cmux.dev/docs/getting-started?utm_source=readme).
|
||||
|
||||
## Atajos de teclado
|
||||
|
||||
### Espacios de trabajo
|
||||
|
|
@ -76,6 +135,7 @@ Todo es programable a través del CLI y la API de socket — crear espacios de t
|
|||
| ⌃ ⌘ ] | Siguiente espacio de trabajo |
|
||||
| ⌃ ⌘ [ | Espacio de trabajo anterior |
|
||||
| ⌘ ⇧ W | Cerrar espacio de trabajo |
|
||||
| ⌘ ⇧ R | Renombrar espacio de trabajo |
|
||||
| ⌘ B | Alternar barra lateral |
|
||||
|
||||
### Superficies
|
||||
|
|
@ -102,6 +162,8 @@ Todo es programable a través del CLI y la API de socket — crear espacios de t
|
|||
|
||||
### Navegador
|
||||
|
||||
Los atajos de herramientas de desarrollo del navegador siguen los valores predeterminados de Safari y son personalizables en `Ajustes → Atajos de teclado`.
|
||||
|
||||
| Atajo | Acción |
|
||||
|----------|--------|
|
||||
| ⌘ ⇧ L | Abrir navegador en división |
|
||||
|
|
@ -109,7 +171,8 @@ Todo es programable a través del CLI y la API de socket — crear espacios de t
|
|||
| ⌘ [ | Atrás |
|
||||
| ⌘ ] | Adelante |
|
||||
| ⌘ R | Recargar página |
|
||||
| ⌥ ⌘ I | Abrir herramientas de desarrollo |
|
||||
| ⌥ ⌘ I | Alternar herramientas de desarrollo (predeterminado de Safari) |
|
||||
| ⌥ ⌘ C | Mostrar consola de JavaScript (predeterminado de Safari) |
|
||||
|
||||
### Notificaciones
|
||||
|
||||
|
|
@ -146,6 +209,63 @@ Todo es programable a través del CLI y la API de socket — crear espacios de t
|
|||
| ⌘ ⇧ , | Recargar configuración |
|
||||
| ⌘ Q | Salir |
|
||||
|
||||
## Compilaciones nocturnas
|
||||
|
||||
[Descargar cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
|
||||
cmux NIGHTLY es una app separada con su propio bundle ID, por lo que se ejecuta junto a la versión estable. Se compila automáticamente desde el último commit de `main` y se actualiza automáticamente a través de su propio feed de Sparkle.
|
||||
|
||||
## Restauración de sesión (comportamiento actual)
|
||||
|
||||
Al relanzar, cmux actualmente restaura solo el diseño y los metadatos de la aplicación:
|
||||
- Diseño de ventanas/espacios de trabajo/paneles
|
||||
- Directorios de trabajo
|
||||
- Historial de desplazamiento del terminal (mejor esfuerzo)
|
||||
- URL del navegador e historial de navegación
|
||||
|
||||
cmux **no** restaura el estado de los procesos activos dentro de las aplicaciones de terminal. Por ejemplo, las sesiones activas de Claude Code/tmux/vim no se reanudan después de reiniciar todavía.
|
||||
|
||||
## Historial de estrellas
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Contribuir
|
||||
|
||||
Formas de participar:
|
||||
|
||||
- Síguenos en X para actualizaciones [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen) y [@austinywang](https://x.com/austinywang)
|
||||
- Únete a la conversación en [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- Crea y participa en [GitHub issues](https://github.com/manaflow-ai/cmux/issues) y [discusiones](https://github.com/manaflow-ai/cmux/discussions)
|
||||
- Cuéntanos qué estás construyendo con cmux
|
||||
|
||||
## Comunidad
|
||||
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
- [Reddit](https://www.reddit.com/r/cmux/)
|
||||
|
||||
## Founder's Edition
|
||||
|
||||
cmux es gratuito, de código abierto, y siempre lo será. Si deseas apoyar el desarrollo y obtener acceso anticipado a lo que viene:
|
||||
|
||||
**[Obtener Founder's Edition](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
|
||||
|
||||
- **Solicitudes de funciones/corrección de errores priorizadas**
|
||||
- **Acceso anticipado: cmux AI que te da contexto sobre cada espacio de trabajo, pestaña y panel**
|
||||
- **Acceso anticipado: app de iOS con terminales sincronizadas entre escritorio y teléfono**
|
||||
- **Acceso anticipado: VMs en la nube**
|
||||
- **Acceso anticipado: Modo de voz**
|
||||
- **Mi iMessage/WhatsApp personal**
|
||||
|
||||
## Licencia
|
||||
|
||||
Este proyecto está licenciado bajo la Licencia Pública General Affero de GNU v3.0 o posterior (`AGPL-3.0-or-later`).
|
||||
|
|
|
|||
140
README.fr.md
140
README.fr.md
|
|
@ -1,7 +1,5 @@
|
|||
> Cette traduction a été générée par Claude. Si vous avez des suggestions d'amélioration, ouvrez une 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.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | Français | <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">Un terminal macOS basé sur Ghostty avec des onglets verticaux et des notifications pour les agents de programmation IA</p>
|
||||
|
||||
|
|
@ -12,16 +10,63 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/screenshot.png" alt="Capture d'écran de cmux" width="900" />
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | Français | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="Capture d'écran de cmux" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Vidéo de démonstration</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a>
|
||||
</p>
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **Onglets verticaux** — La barre latérale affiche la branche git, le répertoire de travail, les ports en écoute et le texte de la dernière notification
|
||||
- **Anneaux de notification** — Les panneaux reçoivent un anneau bleu et les onglets s'illuminent lorsque les agents IA (Claude Code, OpenCode) ont besoin de votre attention
|
||||
- **Panneau de notifications** — Consultez toutes les notifications en attente au même endroit, accédez directement à la plus récente non lue
|
||||
- **Panneaux divisés** — Divisions horizontales et verticales
|
||||
- **Navigateur intégré** — Divisez un navigateur à côté de votre terminal avec une API scriptable portée depuis [agent-browser](https://github.com/vercel-labs/agent-browser)
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Anneaux de notification</h3>
|
||||
Les panneaux reçoivent un anneau bleu et les onglets s'illuminent lorsque les agents de programmation ont besoin de votre attention
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/notification-rings.png" alt="Anneaux de notification" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Panneau de notifications</h3>
|
||||
Consultez toutes les notifications en attente au même endroit, accédez directement à la plus récente non lue
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/sidebar-notification-badge.png" alt="Badge de notification dans la barre latérale" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Navigateur intégré</h3>
|
||||
Divisez un navigateur à côté de votre terminal avec une API scriptable portée depuis <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/built-in-browser.png" alt="Navigateur intégré" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Onglets verticaux + horizontaux</h3>
|
||||
La barre latérale affiche la branche git, le statut/numéro de PR lié, le répertoire de travail, les ports en écoute et le texte de la dernière notification. Divisez horizontalement et verticalement.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Onglets verticaux et panneaux divisés" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- **Scriptable** — CLI et API socket pour créer des espaces de travail, diviser des panneaux, envoyer des frappes clavier et automatiser le navigateur
|
||||
- **Application macOS native** — Construite avec Swift et AppKit, pas Electron. Démarrage rapide, faible consommation mémoire.
|
||||
- **Compatible Ghostty** — Lit votre fichier `~/.config/ghostty/config` existant pour les thèmes, polices et couleurs
|
||||
|
|
@ -58,12 +103,26 @@ J'exécute beaucoup de sessions Claude Code et Codex en parallèle. J'utilisais
|
|||
|
||||
J'ai essayé quelques orchestrateurs de programmation, mais la plupart étaient des applications Electron/Tauri et les performances me dérangeaient. Je préfère aussi simplement le terminal, car les orchestrateurs à interface graphique vous enferment dans leur flux de travail. J'ai donc construit cmux comme une application macOS native en Swift/AppKit. Elle utilise libghostty pour le rendu du terminal et lit votre configuration Ghostty existante pour les thèmes, polices et couleurs.
|
||||
|
||||
Les principaux ajouts sont la barre latérale et le système de notifications. La barre latérale comporte des onglets verticaux qui affichent la branche git, le répertoire de travail, les ports en écoute et le texte de la dernière notification pour chaque espace de travail. Le système de notifications capte les séquences de terminal (OSC 9/99/777) et dispose d'un CLI (`cmux notify`) que vous pouvez brancher aux hooks d'agents pour Claude Code, OpenCode, etc. Quand un agent est en attente, son panneau reçoit un anneau bleu et l'onglet s'illumine dans la barre latérale, pour que je puisse identifier lequel a besoin de moi parmi les divisions et les onglets. ⌘⇧U permet de sauter à la notification non lue la plus récente.
|
||||
Les principaux ajouts sont la barre latérale et le système de notifications. La barre latérale comporte des onglets verticaux qui affichent la branche git, le statut/numéro de PR lié, le répertoire de travail, les ports en écoute et le texte de la dernière notification pour chaque espace de travail. Le système de notifications capte les séquences de terminal (OSC 9/99/777) et dispose d'un CLI (`cmux notify`) que vous pouvez brancher aux hooks d'agents pour Claude Code, OpenCode, etc. Quand un agent est en attente, son panneau reçoit un anneau bleu et l'onglet s'illumine dans la barre latérale, pour que je puisse identifier lequel a besoin de moi parmi les divisions et les onglets. ⌘⇧U permet de sauter à la notification non lue la plus récente.
|
||||
|
||||
Le navigateur intégré dispose d'une API scriptable portée depuis [agent-browser](https://github.com/vercel-labs/agent-browser). Les agents peuvent capturer l'arbre d'accessibilité, obtenir des références d'éléments, cliquer, remplir des formulaires et exécuter du JS. Vous pouvez diviser un panneau navigateur à côté de votre terminal et laisser Claude Code interagir directement avec votre serveur de développement.
|
||||
|
||||
Tout est scriptable via le CLI et l'API socket — créer des espaces de travail/onglets, diviser des panneaux, envoyer des frappes clavier, ouvrir des URL dans le navigateur.
|
||||
|
||||
## The Zen of cmux
|
||||
|
||||
cmux ne prescrit pas comment les développeurs utilisent leurs outils. C'est un terminal et un navigateur avec un CLI, le reste vous appartient.
|
||||
|
||||
cmux est une primitive, pas une solution. Il vous donne un terminal, un navigateur, des notifications, des espaces de travail, des divisions, des onglets et un CLI pour tout contrôler. cmux ne vous impose pas une façon préconçue d'utiliser les agents de programmation. Ce que vous construisez avec ces primitives vous appartient.
|
||||
|
||||
Les meilleurs développeurs ont toujours construit leurs propres outils. Personne n'a encore trouvé la meilleure façon de travailler avec les agents, et les équipes qui construisent des produits fermés ne l'ont pas trouvée non plus. Les développeurs les plus proches de leurs propres bases de code trouveront la solution en premier.
|
||||
|
||||
Donnez à un million de développeurs des primitives composables et ils trouveront collectivement les flux de travail les plus efficaces plus rapidement que n'importe quelle équipe produit ne pourrait les concevoir de manière descendante.
|
||||
|
||||
## Documentation
|
||||
|
||||
Pour plus d'informations sur la configuration de cmux, [consultez notre documentation](https://cmux.dev/docs/getting-started?utm_source=readme).
|
||||
|
||||
## Raccourcis clavier
|
||||
|
||||
### Espaces de travail
|
||||
|
|
@ -76,6 +135,7 @@ Tout est scriptable via le CLI et l'API socket — créer des espaces de travail
|
|||
| ⌃ ⌘ ] | Espace de travail suivant |
|
||||
| ⌃ ⌘ [ | Espace de travail précédent |
|
||||
| ⌘ ⇧ W | Fermer l'espace de travail |
|
||||
| ⌘ ⇧ R | Renommer l'espace de travail |
|
||||
| ⌘ B | Basculer la barre latérale |
|
||||
|
||||
### Surfaces
|
||||
|
|
@ -102,6 +162,8 @@ Tout est scriptable via le CLI et l'API socket — créer des espaces de travail
|
|||
|
||||
### Navigateur
|
||||
|
||||
Les raccourcis des outils de développement du navigateur suivent les valeurs par défaut de Safari et sont personnalisables dans `Paramètres → Raccourcis clavier`.
|
||||
|
||||
| Raccourci | Action |
|
||||
|----------|--------|
|
||||
| ⌘ ⇧ L | Ouvrir le navigateur en division |
|
||||
|
|
@ -109,7 +171,8 @@ Tout est scriptable via le CLI et l'API socket — créer des espaces de travail
|
|||
| ⌘ [ | Reculer |
|
||||
| ⌘ ] | Avancer |
|
||||
| ⌘ R | Recharger la page |
|
||||
| ⌥ ⌘ I | Ouvrir les outils de développement |
|
||||
| ⌥ ⌘ I | Basculer les outils de développement (par défaut Safari) |
|
||||
| ⌥ ⌘ C | Afficher la console JavaScript (par défaut Safari) |
|
||||
|
||||
### Notifications
|
||||
|
||||
|
|
@ -146,6 +209,63 @@ Tout est scriptable via le CLI et l'API socket — créer des espaces de travail
|
|||
| ⌘ ⇧ , | Recharger la configuration |
|
||||
| ⌘ Q | Quitter |
|
||||
|
||||
## Builds Nightly
|
||||
|
||||
[Télécharger cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
|
||||
cmux NIGHTLY est une application séparée avec son propre identifiant de bundle, elle fonctionne donc en parallèle de la version stable. Construite automatiquement à partir du dernier commit `main` et mise à jour automatiquement via son propre flux Sparkle.
|
||||
|
||||
## Restauration de session (comportement actuel)
|
||||
|
||||
Au relancement, cmux restaure actuellement uniquement la disposition et les métadonnées de l'application :
|
||||
- Disposition des fenêtres/espaces de travail/panneaux
|
||||
- Répertoires de travail
|
||||
- Historique de défilement du terminal (au mieux)
|
||||
- URL du navigateur et historique de navigation
|
||||
|
||||
cmux ne restaure **pas** l'état des processus actifs dans les applications du terminal. Par exemple, les sessions actives de Claude Code/tmux/vim ne sont pas encore reprises après un redémarrage.
|
||||
|
||||
## Historique des étoiles
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Contribuer
|
||||
|
||||
Façons de s'impliquer :
|
||||
|
||||
- Suivez-nous sur X pour les mises à jour [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen), et [@austinywang](https://x.com/austinywang)
|
||||
- Rejoignez la conversation sur [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- Créez et participez aux [issues GitHub](https://github.com/manaflow-ai/cmux/issues) et aux [discussions](https://github.com/manaflow-ai/cmux/discussions)
|
||||
- Dites-nous ce que vous construisez avec cmux
|
||||
|
||||
## Communauté
|
||||
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
- [Reddit](https://www.reddit.com/r/cmux/)
|
||||
|
||||
## Édition Fondateur
|
||||
|
||||
cmux est gratuit, open source, et le restera toujours. Si vous souhaitez soutenir le développement et obtenir un accès anticipé à ce qui arrive :
|
||||
|
||||
**[Obtenir l'Édition Fondateur](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
|
||||
|
||||
- **Demandes de fonctionnalités et corrections de bugs prioritaires**
|
||||
- **Accès anticipé : cmux AI qui vous donne du contexte sur chaque espace de travail, onglet et panneau**
|
||||
- **Accès anticipé : application iOS avec des terminaux synchronisés entre ordinateur et téléphone**
|
||||
- **Accès anticipé : VMs cloud**
|
||||
- **Accès anticipé : Mode vocal**
|
||||
- **Mon iMessage/WhatsApp personnel**
|
||||
|
||||
## Licence
|
||||
|
||||
Ce projet est sous licence GNU Affero General Public License v3.0 ou ultérieure (`AGPL-3.0-or-later`).
|
||||
|
|
|
|||
142
README.it.md
142
README.it.md
|
|
@ -1,9 +1,5 @@
|
|||
> Questa traduzione è stata generata da Claude. Se hai suggerimenti per migliorarla, apri una 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.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | Italiano | <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">Un terminale macOS basato su Ghostty con schede verticali e notifiche per agenti di programmazione AI</p>
|
||||
|
||||
|
|
@ -14,16 +10,63 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/screenshot.png" alt="Screenshot di cmux" width="900" />
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | Italiano | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="Screenshot di cmux" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Video demo</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a>
|
||||
</p>
|
||||
|
||||
## Funzionalità
|
||||
|
||||
- **Schede verticali** — La barra laterale mostra il branch git, la directory di lavoro, le porte in ascolto e il testo dell'ultima notifica
|
||||
- **Anelli di notifica** — I pannelli ricevono un anello blu e le schede si illuminano quando gli agenti AI (Claude Code, OpenCode) richiedono la tua attenzione
|
||||
- **Pannello notifiche** — Visualizza tutte le notifiche in sospeso in un unico posto, salta alla più recente non letta
|
||||
- **Pannelli divisi** — Divisioni orizzontali e verticali
|
||||
- **Browser integrato** — Dividi un browser accanto al tuo terminale con un'API scriptabile derivata da [agent-browser](https://github.com/vercel-labs/agent-browser)
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Anelli di notifica</h3>
|
||||
I pannelli ricevono un anello blu e le schede si illuminano quando gli agenti di programmazione richiedono la tua attenzione
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/notification-rings.png" alt="Anelli di notifica" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Pannello notifiche</h3>
|
||||
Visualizza tutte le notifiche in sospeso in un unico posto, salta alla più recente non letta
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/sidebar-notification-badge.png" alt="Badge notifica nella barra laterale" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Browser integrato</h3>
|
||||
Dividi un browser accanto al tuo terminale con un'API scriptabile derivata da <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/built-in-browser.png" alt="Browser integrato" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Schede verticali + orizzontali</h3>
|
||||
La barra laterale mostra il branch git, lo stato/numero della PR collegata, la directory di lavoro, le porte in ascolto e il testo dell'ultima notifica. Dividi orizzontalmente e verticalmente.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Schede verticali e pannelli divisi" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- **Scriptabile** — CLI e socket API per creare workspace, dividere pannelli, inviare sequenze di tasti e automatizzare il browser
|
||||
- **App macOS nativa** — Costruita con Swift e AppKit, non Electron. Avvio rapido, basso consumo di memoria.
|
||||
- **Compatibile con Ghostty** — Legge la tua configurazione esistente `~/.config/ghostty/config` per temi, font e colori
|
||||
|
|
@ -60,12 +103,26 @@ Eseguo molte sessioni di Claude Code e Codex in parallelo. Usavo Ghostty con un
|
|||
|
||||
Ho provato alcuni orchestratori di codifica, ma la maggior parte erano app Electron/Tauri e le prestazioni mi infastidivano. Inoltre preferisco semplicemente il terminale dato che gli orchestratori con interfaccia grafica ti vincolano al loro flusso di lavoro. Così ho costruito cmux come app macOS nativa in Swift/AppKit. Usa libghostty per il rendering del terminale e legge la tua configurazione Ghostty esistente per temi, font e colori.
|
||||
|
||||
Le aggiunte principali sono la barra laterale e il sistema di notifiche. La barra laterale ha schede verticali che mostrano il branch git, la directory di lavoro, le porte in ascolto e il testo dell'ultima notifica per ogni workspace. Il sistema di notifiche rileva le sequenze terminale (OSC 9/99/777) e ha un CLI (`cmux notify`) che puoi collegare agli hook degli agenti per Claude Code, OpenCode, ecc. Quando un agente è in attesa, il suo pannello riceve un anello blu e la scheda si illumina nella barra laterale, così posso capire quale ha bisogno di me tra divisioni e schede. Cmd+Shift+U salta alla più recente non letta.
|
||||
Le aggiunte principali sono la barra laterale e il sistema di notifiche. La barra laterale ha schede verticali che mostrano il branch git, lo stato/numero della PR collegata, la directory di lavoro, le porte in ascolto e il testo dell'ultima notifica per ogni workspace. Il sistema di notifiche rileva le sequenze terminale (OSC 9/99/777) e ha un CLI (`cmux notify`) che puoi collegare agli hook degli agenti per Claude Code, OpenCode, ecc. Quando un agente è in attesa, il suo pannello riceve un anello blu e la scheda si illumina nella barra laterale, così posso capire quale ha bisogno di me tra divisioni e schede. Cmd+Shift+U salta alla più recente non letta.
|
||||
|
||||
Il browser integrato ha un'API scriptabile derivata da [agent-browser](https://github.com/vercel-labs/agent-browser). Gli agenti possono acquisire l'albero di accessibilità, ottenere riferimenti agli elementi, fare clic, compilare moduli e valutare JS. Puoi dividere un pannello browser accanto al tuo terminale e far interagire Claude Code direttamente con il tuo server di sviluppo.
|
||||
|
||||
Tutto è scriptabile attraverso il CLI e la socket API — creare workspace/schede, dividere pannelli, inviare sequenze di tasti, aprire URL nel browser.
|
||||
|
||||
## The Zen of cmux
|
||||
|
||||
cmux non prescrive come gli sviluppatori usano i propri strumenti. È un terminale e un browser con un CLI, il resto dipende da te.
|
||||
|
||||
cmux è una primitiva, non una soluzione. Ti dà un terminale, un browser, notifiche, workspace, divisioni, schede e un CLI per controllare tutto. cmux non ti obbliga a usare gli agenti di programmazione in un modo predefinito. Quello che costruisci con le primitive è tuo.
|
||||
|
||||
I migliori sviluppatori hanno sempre costruito i propri strumenti. Nessuno ha ancora trovato il modo migliore di lavorare con gli agenti, e i team che costruiscono prodotti chiusi non l'hanno trovato nemmeno loro. Gli sviluppatori più vicini alle proprie basi di codice lo troveranno per primi.
|
||||
|
||||
Date a un milione di sviluppatori primitive componibili e troveranno collettivamente i flussi di lavoro più efficienti più velocemente di quanto qualsiasi team di prodotto potrebbe progettare dall'alto.
|
||||
|
||||
## Documentazione
|
||||
|
||||
Per maggiori informazioni su come configurare cmux, [consulta la nostra documentazione](https://cmux.dev/docs/getting-started?utm_source=readme).
|
||||
|
||||
## Scorciatoie da Tastiera
|
||||
|
||||
### Workspace
|
||||
|
|
@ -78,6 +135,7 @@ Tutto è scriptabile attraverso il CLI e la socket API — creare workspace/sche
|
|||
| ⌃ ⌘ ] | Workspace successivo |
|
||||
| ⌃ ⌘ [ | Workspace precedente |
|
||||
| ⌘ ⇧ W | Chiudi workspace |
|
||||
| ⌘ ⇧ R | Rinomina workspace |
|
||||
| ⌘ B | Mostra/nascondi barra laterale |
|
||||
|
||||
### Superfici
|
||||
|
|
@ -104,6 +162,8 @@ Tutto è scriptabile attraverso il CLI e la socket API — creare workspace/sche
|
|||
|
||||
### Browser
|
||||
|
||||
Le scorciatoie degli strumenti di sviluppo del browser seguono i valori predefiniti di Safari e sono personalizzabili in `Impostazioni → Scorciatoie da tastiera`.
|
||||
|
||||
| Scorciatoia | Azione |
|
||||
|----------|--------|
|
||||
| ⌘ ⇧ L | Apri browser in divisione |
|
||||
|
|
@ -111,7 +171,8 @@ Tutto è scriptabile attraverso il CLI e la socket API — creare workspace/sche
|
|||
| ⌘ [ | Indietro |
|
||||
| ⌘ ] | Avanti |
|
||||
| ⌘ R | Ricarica pagina |
|
||||
| ⌥ ⌘ I | Apri Strumenti di Sviluppo |
|
||||
| ⌥ ⌘ I | Mostra/Nascondi Strumenti di Sviluppo (predefinito Safari) |
|
||||
| ⌥ ⌘ C | Mostra Console JavaScript (predefinito Safari) |
|
||||
|
||||
### Notifiche
|
||||
|
||||
|
|
@ -148,6 +209,63 @@ Tutto è scriptabile attraverso il CLI e la socket API — creare workspace/sche
|
|||
| ⌘ ⇧ , | Ricarica configurazione |
|
||||
| ⌘ Q | Esci |
|
||||
|
||||
## Build Nightly
|
||||
|
||||
[Scarica cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
|
||||
cmux NIGHTLY è un'app separata con il proprio bundle ID, quindi funziona in parallelo alla versione stabile. Compilata automaticamente dall'ultimo commit `main` e aggiornata automaticamente tramite il proprio feed Sparkle.
|
||||
|
||||
## Ripristino sessione (comportamento attuale)
|
||||
|
||||
Al riavvio, cmux attualmente ripristina solo il layout e i metadati dell'applicazione:
|
||||
- Layout di finestre/workspace/pannelli
|
||||
- Directory di lavoro
|
||||
- Scrollback del terminale (best effort)
|
||||
- URL del browser e cronologia di navigazione
|
||||
|
||||
cmux **non** ripristina lo stato dei processi attivi nelle applicazioni del terminale. Per esempio, le sessioni attive di Claude Code/tmux/vim non vengono ancora riprese dopo un riavvio.
|
||||
|
||||
## Cronologia Stelle
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Contribuire
|
||||
|
||||
Modi per partecipare:
|
||||
|
||||
- Seguici su X per aggiornamenti [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen), e [@austinywang](https://x.com/austinywang)
|
||||
- Unisciti alla conversazione su [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- Crea e partecipa alle [issue su GitHub](https://github.com/manaflow-ai/cmux/issues) e alle [discussioni](https://github.com/manaflow-ai/cmux/discussions)
|
||||
- Facci sapere cosa stai costruendo con cmux
|
||||
|
||||
## Comunità
|
||||
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
- [Reddit](https://www.reddit.com/r/cmux/)
|
||||
|
||||
## Edizione Fondatore
|
||||
|
||||
cmux è gratuito, open source, e lo sarà sempre. Se vuoi supportare lo sviluppo e ottenere accesso anticipato a ciò che arriverà:
|
||||
|
||||
**[Ottieni l'Edizione Fondatore](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
|
||||
|
||||
- **Richieste di funzionalità e correzioni di bug prioritarie**
|
||||
- **Accesso anticipato: cmux AI che ti dà contesto su ogni workspace, scheda e pannello**
|
||||
- **Accesso anticipato: app iOS con terminali sincronizzati tra desktop e telefono**
|
||||
- **Accesso anticipato: VM cloud**
|
||||
- **Accesso anticipato: Modalità vocale**
|
||||
- **Il mio iMessage/WhatsApp personale**
|
||||
|
||||
## Licenza
|
||||
|
||||
Questo progetto è distribuito sotto la GNU Affero General Public License v3.0 o successiva (`AGPL-3.0-or-later`).
|
||||
|
|
|
|||
142
README.ja.md
142
README.ja.md
|
|
@ -1,9 +1,5 @@
|
|||
> この翻訳は 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.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | 日本語 | <a href="README.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>
|
||||
|
||||
|
|
@ -14,16 +10,63 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/screenshot.png" alt="cmuxスクリーンショット" width="900" />
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | 日本語 | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="cmuxスクリーンショット" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ デモ動画</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a>
|
||||
</p>
|
||||
|
||||
## 機能
|
||||
|
||||
- **縦タブ** — サイドバーにgitブランチ、作業ディレクトリ、リッスン中のポート、最新の通知テキストを表示
|
||||
- **通知リング** — AIエージェント(Claude Code、OpenCode)があなたの注意を必要とするとき、ペインに青いリングが表示され、タブが点灯
|
||||
- **通知パネル** — 保留中のすべての通知を一か所で確認、最新の未読にジャンプ
|
||||
- **分割ペイン** — 水平・垂直分割
|
||||
- **アプリ内ブラウザ** — [agent-browser](https://github.com/vercel-labs/agent-browser)から移植されたスクリプタブルなAPIで、ターミナルの横にブラウザを分割表示
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>通知リング</h3>
|
||||
コーディングエージェントがあなたの注意を必要とするとき、ペインに青いリングが表示され、タブが点灯します
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/notification-rings.png" alt="通知リング" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>通知パネル</h3>
|
||||
保留中のすべての通知を一か所で確認、最新の未読にジャンプ
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/sidebar-notification-badge.png" alt="サイドバー通知バッジ" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>アプリ内ブラウザ</h3>
|
||||
<a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>から移植されたスクリプタブルなAPIで、ターミナルの横にブラウザを分割表示
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/built-in-browser.png" alt="内蔵ブラウザ" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>縦タブ + 横タブ</h3>
|
||||
サイドバーにgitブランチ、リンクされたPRのステータス/番号、作業ディレクトリ、リッスン中のポート、最新の通知テキストを表示。水平・垂直に分割可能。
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="縦タブと分割ペイン" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- **スクリプタブル** — CLIとsocket APIでワークスペースの作成、ペインの分割、キーストロークの送信、ブラウザの自動化が可能
|
||||
- **ネイティブmacOSアプリ** — SwiftとAppKitで構築、Electronではありません。高速起動、低メモリ消費。
|
||||
- **Ghostty互換** — 既存の`~/.config/ghostty/config`からテーマ、フォント、カラーを読み込み
|
||||
|
|
@ -60,12 +103,26 @@ brew upgrade --cask cmux
|
|||
|
||||
いくつかのコーディングオーケストレーターを試しましたが、そのほとんどがElectron/Tauriアプリで、パフォーマンスが気になりました。また、GUIオーケストレーターはそのワークフローに縛られるため、単純にターミナルのほうが好みです。そこで、cmuxをSwift/AppKitのネイティブmacOSアプリとして構築しました。ターミナルレンダリングにはlibghosttyを使用し、テーマ、フォント、カラーは既存のGhostty設定を読み込みます。
|
||||
|
||||
主な追加機能はサイドバーと通知システムです。サイドバーには、各ワークスペースのgitブランチ、作業ディレクトリ、リッスン中のポート、最新の通知テキストを表示する縦タブがあります。通知システムはターミナルシーケンス(OSC 9/99/777)を検出し、Claude Code、OpenCodeなどのエージェントフックに接続できるCLI(`cmux notify`)を備えています。エージェントが待機中のとき、そのペインに青いリングが表示され、サイドバーのタブが点灯するので、分割やタブをまたいでどれが私を必要としているかがわかります。Cmd+Shift+Uで最新の未読にジャンプします。
|
||||
主な追加機能はサイドバーと通知システムです。サイドバーには、各ワークスペースのgitブランチ、リンクされたPRのステータス/番号、作業ディレクトリ、リッスン中のポート、最新の通知テキストを表示する縦タブがあります。通知システムはターミナルシーケンス(OSC 9/99/777)を検出し、Claude Code、OpenCodeなどのエージェントフックに接続できるCLI(`cmux notify`)を備えています。エージェントが待機中のとき、そのペインに青いリングが表示され、サイドバーのタブが点灯するので、分割やタブをまたいでどれが私を必要としているかがわかります。Cmd+Shift+Uで最新の未読にジャンプします。
|
||||
|
||||
アプリ内ブラウザには[agent-browser](https://github.com/vercel-labs/agent-browser)から移植されたスクリプタブルなAPIがあります。エージェントはアクセシビリティツリーのスナップショットを取得し、要素参照を取得し、クリック、フォーム入力、JSの評価が可能です。ターミナルの横にブラウザペインを分割し、Claude Codeに開発サーバーと直接やり取りさせることができます。
|
||||
|
||||
すべてがCLIとsocket APIを通じてスクリプタブルです — ワークスペース/タブの作成、ペインの分割、キーストロークの送信、ブラウザでのURL表示。
|
||||
|
||||
## The Zen of cmux
|
||||
|
||||
cmuxは開発者のツールの使い方を規定しません。ターミナルとブラウザにCLIがあり、あとはあなた次第です。
|
||||
|
||||
cmuxはソリューションではなくプリミティブです。ターミナル、ブラウザ、通知、ワークスペース、分割、タブ、そしてそのすべてを制御するCLIを提供します。cmuxはコーディングエージェントの使い方を強制しません。プリミティブで何を構築するかはあなた次第です。
|
||||
|
||||
優れた開発者は常に自分のツールを構築してきました。エージェントとの最適な作業方法はまだ誰も見つけていませんし、クローズドな製品を作っているチームも見つけていません。自分のコードベースに最も近い開発者が最初に見つけるでしょう。
|
||||
|
||||
100万人の開発者にコンポーザブルなプリミティブを与えれば、どんなプロダクトチームがトップダウンで設計するよりも速く、最も効率的なワークフローを集合的に見つけ出すでしょう。
|
||||
|
||||
## ドキュメント
|
||||
|
||||
cmuxの設定方法の詳細は、[ドキュメントをご覧ください](https://cmux.dev/docs/getting-started?utm_source=readme)。
|
||||
|
||||
## キーボードショートカット
|
||||
|
||||
### ワークスペース
|
||||
|
|
@ -78,6 +135,7 @@ brew upgrade --cask cmux
|
|||
| ⌃ ⌘ ] | 次のワークスペース |
|
||||
| ⌃ ⌘ [ | 前のワークスペース |
|
||||
| ⌘ ⇧ W | ワークスペースを閉じる |
|
||||
| ⌘ ⇧ R | ワークスペースの名前を変更 |
|
||||
| ⌘ B | サイドバーの表示切替 |
|
||||
|
||||
### サーフェス
|
||||
|
|
@ -104,6 +162,8 @@ brew upgrade --cask cmux
|
|||
|
||||
### ブラウザ
|
||||
|
||||
ブラウザの開発者ツールのショートカットはSafariのデフォルトに従い、`設定 → キーボードショートカット`でカスタマイズできます。
|
||||
|
||||
| ショートカット | アクション |
|
||||
|----------|--------|
|
||||
| ⌘ ⇧ L | 分割でブラウザを開く |
|
||||
|
|
@ -111,7 +171,8 @@ brew upgrade --cask cmux
|
|||
| ⌘ [ | 戻る |
|
||||
| ⌘ ] | 進む |
|
||||
| ⌘ R | ページを再読み込み |
|
||||
| ⌥ ⌘ I | 開発者ツールを開く |
|
||||
| ⌥ ⌘ I | 開発者ツールの表示切替(Safariデフォルト) |
|
||||
| ⌥ ⌘ C | JavaScriptコンソールを表示(Safariデフォルト) |
|
||||
|
||||
### 通知
|
||||
|
||||
|
|
@ -148,6 +209,63 @@ brew upgrade --cask cmux
|
|||
| ⌘ ⇧ , | 設定を再読み込み |
|
||||
| ⌘ Q | 終了 |
|
||||
|
||||
## ナイトリービルド
|
||||
|
||||
[cmux NIGHTLYをダウンロード](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
|
||||
cmux NIGHTLYは独自のバンドルIDを持つ別のアプリなので、安定版と並行して実行できます。最新の`main`コミットから自動的にビルドされ、独自のSparkleフィード経由で自動更新されます。
|
||||
|
||||
## セッション復元(現在の動作)
|
||||
|
||||
再起動時、cmuxは現在アプリのレイアウトとメタデータのみを復元します:
|
||||
- ウィンドウ/ワークスペース/ペインのレイアウト
|
||||
- 作業ディレクトリ
|
||||
- ターミナルのスクロールバック(ベストエフォート)
|
||||
- ブラウザのURLとナビゲーション履歴
|
||||
|
||||
cmuxはターミナルアプリ内のライブプロセスの状態を復元**しません**。例えば、アクティブなClaude Code/tmux/vimセッションは再起動後にまだ再開されません。
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## コントリビューション
|
||||
|
||||
参加方法:
|
||||
|
||||
- Xでフォロー:[@manaflowai](https://x.com/manaflowai)、[@lawrencecchen](https://x.com/lawrencecchen)、[@austinywang](https://x.com/austinywang)
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)で会話に参加
|
||||
- [GitHubのIssues](https://github.com/manaflow-ai/cmux/issues)や[ディスカッション](https://github.com/manaflow-ai/cmux/discussions)に参加
|
||||
- cmuxで何を構築しているか教えてください
|
||||
|
||||
## コミュニティ
|
||||
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
- [Reddit](https://www.reddit.com/r/cmux/)
|
||||
|
||||
## Founder's Edition
|
||||
|
||||
cmuxは無料でオープンソースであり、今後もそうあり続けます。開発をサポートし、次に来る機能への早期アクセスを得たい方へ:
|
||||
|
||||
**[Founder's Editionを入手](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
|
||||
|
||||
- **機能リクエスト/バグ修正の優先対応**
|
||||
- **早期アクセス:すべてのワークスペース、タブ、パネルのコンテキストを提供するcmux AI**
|
||||
- **早期アクセス:デスクトップと携帯電話間でターミナルを同期するiOSアプリ**
|
||||
- **早期アクセス:クラウドVM**
|
||||
- **早期アクセス:ボイスモード**
|
||||
- **私の個人的なiMessage/WhatsApp**
|
||||
|
||||
## ライセンス
|
||||
|
||||
このプロジェクトはGNU Affero General Public License v3.0以降(`AGPL-3.0-or-later`)の下でライセンスされています。
|
||||
|
|
|
|||
268
README.km.md
Normal file
268
README.km.md
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
<h1 align="center">cmux</h1>
|
||||
<p align="center">Terminal សម្រាប់ macOS ផ្អែកលើ Ghostty ដែលមាន tab បញ្ឈរ និងការជូនដំណឹងសម្រាប់ AI coding agents</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/manaflow-ai/cmux/releases/latest/download/cmux-macos.dmg">
|
||||
<img src="./docs/assets/macos-badge.png" alt="Download cmux for macOS" width="180" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<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.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | ភាសាខ្មែរ
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="cmux screenshot" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ វីដេអូបង្ហាញពីដំណើរការ (Demo)</a> · <a href="https://cmux.dev/blog/zen-of-cmux">ទស្សនវិជ្ជារបស់ cmux (The Zen of cmux)</a>
|
||||
</p>
|
||||
|
||||
## លក្ខណៈពិសេសនានា (Features)
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Notification rings</h3>
|
||||
Panes get a blue ring and tabs light up when coding agents need your attention
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/notification-rings.png" alt="Notification rings" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Notification panel</h3>
|
||||
See all pending notifications in one place, jump to the most recent unread
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/sidebar-notification-badge.png" alt="Sidebar notification badge" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>In-app browser</h3>
|
||||
Split a browser alongside your terminal with a scriptable API ported from <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/built-in-browser.png" alt="Built-in browser" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Vertical + horizontal tabs</h3>
|
||||
Sidebar shows git branch, linked PR status/number, working directory, listening ports, and latest notification text. Split horizontally and vertically.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Vertical tabs and split panes" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
* **អាចសរសេរ Script បាន (Scriptable)** — CLI និង socket API ដើម្បីបង្កើត workspaces, បំបែក panes, បញ្ជូន keystrokes, និងធ្វើស្វ័យប្រវត្តិកម្មកម្មវិធីរុករក (browser)
|
||||
* **កម្មវិធីដើមរបស់ macOS (Native macOS app)** — បង្កើតឡើងដោយប្រើ Swift និង AppKit មិនមែន Electron ទេ។ ចាប់ផ្តើមលឿន, ស៊ីមេម៉ូរី (memory) តិច។
|
||||
* **ត្រូវគ្នាជាមួយ Ghostty (Ghostty compatible)** — អានការកំណត់ `~/.config/ghostty/config` ដែលអ្នកមានស្រាប់សម្រាប់ theme, font, និងពណ៌
|
||||
* **បង្កើនល្បឿនដោយ GPU (GPU-accelerated)** — ដំណើរការដោយ libghostty ដើម្បីការបង្ហាញរូបភាពរលូនល្អ (smooth rendering)
|
||||
|
||||
## ការដំឡើង (Install)
|
||||
|
||||
### DMG (ត្រូវបានណែនាំ)
|
||||
|
||||
បើកឯកសារ `.dmg` ហើយអូស cmux បញ្ចូលទៅក្នុងថត Applications របស់អ្នក។ cmux ធ្វើបច្ចុប្បន្នភាពដោយស្វ័យប្រវត្តិតាមរយៈ Sparkle ដូច្នេះអ្នកគ្រាន់តែទាញយកវាតែម្តងគត់។
|
||||
|
||||
### Homebrew
|
||||
|
||||
```bash
|
||||
brew tap manaflow-ai/cmux
|
||||
brew install --cask cmux
|
||||
```
|
||||
|
||||
ដើម្បីធ្វើបច្ចុប្បន្នភាពនៅពេលក្រោយ៖
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cmux
|
||||
```
|
||||
|
||||
នៅពេលបើកដំណើរការជាលើកដំបូង macOS អាចនឹងសុំឱ្យអ្នកបញ្ជាក់ការបើកកម្មវិធីពីអ្នកអភិវឌ្ឍន៍ដែលបានកំណត់អត្តសញ្ញាណ។ ចុច **Open** ដើម្បីបន្ត។
|
||||
|
||||
## ហេតុអ្វីត្រូវជ្រើសរើស cmux?
|
||||
|
||||
ខ្ញុំបើកដំណើរការ Claude Code និង Codex ច្រើនក្នុងពេលតែមួយ។ ខ្ញុំធ្លាប់ប្រើ Ghostty ជាមួយ split panes ជាច្រើន ហើយពឹងផ្អែកលើការជូនដំណឹងដើមរបស់ macOS ដើម្បីដឹងថានៅពេលណាដែល agent ត្រូវការខ្ញុំ។ ប៉ុន្តែខ្លឹមសារជូនដំណឹងរបស់ Claude Code តែងតែសរសេរត្រឹម "Claude វាកំពុងរង់ចាំការបញ្ចូលព័ត៌មានពីអ្នក" ដោយគ្មានបរិបទ (context) ហើយនៅពេលដែលបើក tab ច្រើនពេក ខ្ញុំសឹងតែមិនអាចអានចំណងជើងបានទៀតផង។
|
||||
|
||||
ខ្ញុំបានសាកល្បងប្រើ coding orchestrators មួយចំនួន ប៉ុន្តែភាគច្រើននៃពួកវាគឺជាកម្មវិធី Electron/Tauri ហើយដំណើរការ (performance) របស់វារំខានដល់ខ្ញុំ។ ម្យ៉ាងទៀត ខ្ញុំចូលចិត្តប្រើ terminal ជាង ពីព្រោះ GUI orchestrators តែងតែកំណត់លំហូរការងារ (workflow) របស់អ្នក។ ដូច្នេះ ខ្ញុំបានបង្កើត cmux ជាកម្មវិធីដើមសម្រាប់ macOS នៅក្នុង Swift/AppKit។ វាប្រើប្រាស់ libghostty សម្រាប់ការបង្ហាញ terminal និងអាន config របស់ Ghostty ដែលអ្នកមានស្រាប់សម្រាប់ themes, fonts និងពណ៌។
|
||||
|
||||
ការបន្ថែមដ៏សំខាន់គឺរបារចំហៀង (sidebar) និងប្រព័ន្ធជូនដំណឹង។ របារចំហៀងមាន tab បញ្ឈរដែលបង្ហាញពី git branch, ស្ថានភាព/លេខ PR, ថតការងារ, port ដែលកំពុងស្តាប់ និងអត្ថបទជូនដំណឹងចុងក្រោយសម្រាប់ workspace នីមួយៗ។ ប្រព័ន្ធជូនដំណឹងចាប់យក terminal sequences (OSC 9/99/777) និងមាន CLI (`cmux notify`) ដែលអ្នកអាចភ្ជាប់ទៅកាន់ agent hooks សម្រាប់ Claude Code, OpenCode ជាដើម។ នៅពេល agent កំពុងរង់ចាំ ផ្ទាំង (pane) របស់វានឹងមានរង្វង់ពណ៌ខៀវ ហើយ tab នឹងភ្លឺឡើងនៅលើរបារចំហៀង ដូច្នេះខ្ញុំអាចដឹងថាមួយណាដែលត្រូវការខ្ញុំនៅទូទាំង splits និង tabs ទាំងអស់។ ចុច Cmd+Shift+U ដើម្បីលោតទៅកាន់សារមិនទាន់អានថ្មីបំផុត។
|
||||
|
||||
កម្មវិធីរុករកក្នុងកម្មវិធី (in-app browser) មាន scriptable API ដែលបានយកចេញពី [agent-browser](https://github.com/vercel-labs/agent-browser)។ Agents អាចថតចម្លង (snapshot) ដើមឈើភាពងាយស្រួល (accessibility tree), យក element refs, ចុច (click), បំពេញទម្រង់បែបបទ (fill forms) និងវាយតម្លៃ (evaluate) JS។ អ្នកអាចបំបែកផ្ទាំងកម្មវិធីរុករកនៅក្បែរ terminal របស់អ្នក ហើយឱ្យ Claude Code ប្រាស្រ័យទាក់ទងដោយផ្ទាល់ជាមួយ dev server របស់អ្នក។
|
||||
|
||||
អ្វីៗទាំងអស់អាចសរសេរ script បានតាមរយៈ CLI និង socket API — បង្កើត workspaces/tabs, បំបែក panes, បញ្ជូន keystrokes, បើក URLs នៅក្នុងកម្មវិធីរុករក។
|
||||
|
||||
## ទស្សនវិជ្ជារបស់ cmux (The Zen of cmux)
|
||||
|
||||
cmux មិនបង្ខំអំពីរបៀបដែលអ្នកអភិវឌ្ឍន៍ប្រើប្រាស់ឧបករណ៍របស់ពួកគេទេ។ វាគឺជា terminal និងកម្មវិធីរុករកដែលមាន CLI ហើយអ្វីៗផ្សេងទៀតគឺអាស្រ័យលើអ្នក។
|
||||
|
||||
cmux គឺជាមូលដ្ឋានគ្រឹះ (primitive) មិនមែនជាដំណោះស្រាយពេញលេញទេ។ វាផ្តល់ឱ្យអ្នកនូវ terminal, កម្មវិធីរុករក, ការជូនដំណឹង, workspaces, splits, tabs និង CLI ដើម្បីគ្រប់គ្រងអ្វីៗទាំងអស់នេះ។ cmux មិនបង្ខំអ្នកឱ្យប្រើវិធីសាស្ត្រណាមួយដែលវាបានកំណត់ទុកមុនក្នុងការប្រើប្រាស់ coding agents នោះទេ។ អ្វីដែលអ្នកបង្កើតជាមួយមូលដ្ឋានគ្រឹះទាំងនេះ គឺជារបស់អ្នក។
|
||||
|
||||
អ្នកអភិវឌ្ឍន៍ដ៏ល្អបំផុតតែងតែបង្កើតឧបករណ៍ដោយខ្លួនឯង។ មិនទាន់មាននរណាម្នាក់រកឃើញវិធីល្អបំផុតក្នុងការធ្វើការជាមួយ agents នៅឡើយទេ ហើយក្រុមដែលបង្កើតផលិតផលបិទជិត (closed products) ក៏ច្បាស់ជាមិនទាន់រកឃើញដូចគ្នា។ អ្នកអភិវឌ្ឍន៍ដែលយល់ច្បាស់ពី codebases របស់ពួកគេ នឹងរកឃើញវាមុនគេ។
|
||||
|
||||
ផ្តល់ឱ្យអ្នកអភិវឌ្ឍន៍មួយលាននាក់នូវមូលដ្ឋានគ្រឹះដែលអាចផ្សំបញ្ចូលគ្នាបាន នោះពួកគេរួមគ្នានឹងស្វែងរកលំហូរការងារដែលមានប្រសិទ្ធភាពបំផុត លឿនជាងក្រុមការងារផលិតផលណាមួយអាចរចនាពីលើចុះក្រោម (top-down) ទៅទៀត។
|
||||
|
||||
## ឯកសារ (Documentation)
|
||||
|
||||
សម្រាប់ព័ត៌មានបន្ថែមអំពីរបៀបកំណត់រចនាសម្ព័ន្ធ cmux, [សូមចូលទៅកាន់ឯកសាររបស់យើង](https://cmux.dev/docs/getting-started?utm_source=readme)។
|
||||
|
||||
## គ្រាប់ចុចផ្លូវកាត់ (Keyboard Shortcuts)
|
||||
|
||||
### តំបន់ការងារ (Workspaces)
|
||||
|
||||
| ផ្លូវកាត់ (Shortcut) | សកម្មភាព (Action) |
|
||||
|---|---|
|
||||
| ⌘ N | បង្កើត workspace ថ្មី |
|
||||
| ⌘ 1–8 | លោតទៅ workspace ទី 1–8 |
|
||||
| ⌘ 9 | លោតទៅ workspace ចុងក្រោយ |
|
||||
| ⌃ ⌘ ] | workspace បន្ទាប់ |
|
||||
| ⌃ ⌘ [ | workspace មុន |
|
||||
| ⌘ ⇧ W | បិទ workspace |
|
||||
| ⌘ ⇧ R | ប្តូរឈ្មោះ workspace |
|
||||
| ⌘ B | បិទ/បើក របារចំហៀង |
|
||||
|
||||
### ផ្ទៃ (Surfaces)
|
||||
|
||||
| ផ្លូវកាត់ (Shortcut) | សកម្មភាព (Action) |
|
||||
|---|---|
|
||||
| ⌘ T | បង្កើត surface ថ្មី |
|
||||
| ⌘ ⇧ ] | surface បន្ទាប់ |
|
||||
| ⌘ ⇧ [ | surface មុន |
|
||||
| ⌃ Tab | surface បន្ទាប់ |
|
||||
| ⌃ ⇧ Tab | surface មុន |
|
||||
| ⌃ 1–8 | លោតទៅ surface ទី 1–8 |
|
||||
| ⌃ 9 | លោតទៅ surface ចុងក្រោយ |
|
||||
| ⌘ W | បិទ surface |
|
||||
|
||||
### បំបែកផ្ទាំង (Split Panes)
|
||||
|
||||
| ផ្លូវកាត់ (Shortcut) | សកម្មភាព (Action) |
|
||||
|---|---|
|
||||
| ⌘ D | បំបែកទៅស្តាំ |
|
||||
| ⌘ ⇧ D | បំបែកចុះក្រោម |
|
||||
| ⌥ ⌘ ← → ↑ ↓ | ផ្ដោតលើ pane តាមទិសដៅ |
|
||||
| ⌘ ⇧ H | បញ្ចេញពន្លឺលើ panel ដែលកំពុងផ្ដោត |
|
||||
|
||||
### កម្មវិធីរុករក (Browser)
|
||||
|
||||
Browser developer-tool shortcuts follow Safari defaults and are customizable in `Settings → Keyboard Shortcuts`.
|
||||
|
||||
| ផ្លូវកាត់ (Shortcut) | សកម្មភាព (Action) |
|
||||
|---|---|
|
||||
| ⌘ ⇧ L | បើកកម្មវិធីរុករកក្នុងលក្ខណៈបំបែក (split) |
|
||||
| ⌘ L | ផ្ដោតលើរបារអាសយដ្ឋាន |
|
||||
| ⌘ [ | ថយក្រោយ |
|
||||
| ⌘ ] | ទៅមុខ |
|
||||
| ⌘ R | ផ្ទុកទំព័រឡើងវិញ |
|
||||
| ⌥ ⌘ I | បិទ/បើក ឧបករណ៍អ្នកអភិវឌ្ឍន៍ (លំនាំដើម Safari) |
|
||||
| ⌥ ⌘ C | បង្ហាញ JavaScript Console (លំនាំដើម Safari) |
|
||||
|
||||
### ការជូនដំណឹង (Notifications)
|
||||
|
||||
| ផ្លូវកាត់ (Shortcut) | សកម្មភាព (Action) |
|
||||
|---|---|
|
||||
| ⌘ I | បង្ហាញផ្ទាំងជូនដំណឹង |
|
||||
| ⌘ ⇧ U | លោតទៅសារមិនទាន់អានថ្មីបំផុត |
|
||||
|
||||
### ស្វែងរក (Find)
|
||||
|
||||
| ផ្លូវកាត់ (Shortcut) | សកម្មភាព (Action) |
|
||||
|---|---|
|
||||
| ⌘ F | ស្វែងរក |
|
||||
| ⌘ G / ⌘ ⇧ G | ស្វែងរកបន្ទាប់ / មុន |
|
||||
| ⌘ ⇧ F | លាក់របារស្វែងរក |
|
||||
| ⌘ E | ប្រើអត្ថបទដែលបានជ្រើសរើសដើម្បីស្វែងរក |
|
||||
|
||||
### Terminal
|
||||
|
||||
| ផ្លូវកាត់ (Shortcut) | សកម្មភាព (Action) |
|
||||
|---|---|
|
||||
| ⌘ K | សម្អាត scrollback |
|
||||
| ⌘ C | ចម្លង (ជាមួយនឹងការជ្រើសរើស) |
|
||||
| ⌘ V | ដាក់បញ្ចូល (Paste) |
|
||||
| ⌘ + / ⌘ - | បង្កើន / បន្ថយ ទំហំអក្សរ |
|
||||
| ⌘ 0 | កំណត់ទំហំអក្សរឡើងវិញ |
|
||||
|
||||
### ផ្ទាំងវីនដូ (Window)
|
||||
|
||||
| ផ្លូវកាត់ (Shortcut) | សកម្មភាព (Action) |
|
||||
|---|---|
|
||||
| ⌘ ⇧ N | បង្កើតវីនដូថ្មី |
|
||||
| ⌘ , | ការកំណត់ (Settings) |
|
||||
| ⌘ ⇧ , | ផ្ទុកការកំណត់ឡើងវិញ (Reload configuration) |
|
||||
| ⌘ Q | ចាកចេញ |
|
||||
|
||||
## កំណែ Nightly Builds
|
||||
|
||||
[ទាញយក cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
|
||||
cmux NIGHTLY គឺជាកម្មវិធីដាច់ដោយឡែកមួយដែលមាន bundle ID ផ្ទាល់ខ្លួន ដូច្នេះវាអាចដំណើរការទន្ទឹមគ្នាជាមួយនឹងកំណែធម្មតា (stable version)។ វាត្រូវបានបង្កើតឡើងដោយស្វ័យប្រវត្តិពី commit `main` ចុងក្រោយបង្អស់ និងធ្វើបច្ចុប្បន្នភាពដោយស្វ័យប្រវត្តិតាមរយៈ Sparkle feed របស់វាផ្ទាល់។
|
||||
|
||||
## ការស្ដារ Session ឡើងវិញ (អាកប្បកិរិយាបច្ចុប្បន្ន)
|
||||
|
||||
នៅពេលបើកឡើងវិញ បច្ចុប្បន្ន cmux នឹងស្ដារតែប្លង់កម្មវិធី និងទិន្នន័យមេតា (metadata) ប៉ុណ្ណោះ៖
|
||||
|
||||
* ប្លង់ Window/workspace/pane
|
||||
* ថតការងារ (Working directories)
|
||||
* Terminal scrollback (ប្រឹងប្រែងឱ្យអស់លទ្ធភាព)
|
||||
* ប្រវត្តិរុករក និង URL របស់កម្មវិធីរុករក
|
||||
|
||||
cmux **មិន** ស្ដារស្ថានភាពដំណើរការផ្ទាល់ (live process state) នៅក្នុងកម្មវិធី terminal ឡើយ។ ឧទាហរណ៍ session របស់ Claude Code/tmux/vim ដែលកំពុងដំណើរការ មិនទាន់អាចបន្តឡើងវិញបានទេបន្ទាប់ពីចាប់ផ្ដើមឡើងវិញ។
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## ការចូលរួមចំណែក (Contributing)
|
||||
|
||||
វិធីក្នុងការចូលរួម៖
|
||||
|
||||
* តាមដានពួកយើងនៅលើ X សម្រាប់ការធ្វើបច្ចុប្បន្នភាពនានា [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen), និង [@austinywang](https://x.com/austinywang)
|
||||
* ចូលរួមការសន្ទនានៅលើ [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
* បង្កើត និងចូលរួមក្នុង [GitHub issues](https://github.com/manaflow-ai/cmux/issues) និង [discussions](https://github.com/manaflow-ai/cmux/discussions)
|
||||
* ប្រាប់ពួកយើងអំពីអ្វីដែលអ្នកកំពុងបង្កើតជាមួយ cmux
|
||||
|
||||
## សហគមន៍ (Community)
|
||||
|
||||
* [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
* [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
* [X / Twitter](https://twitter.com/manaflowai)
|
||||
* [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
* [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
* [Reddit](https://www.reddit.com/r/cmux/)
|
||||
|
||||
## កំណែអ្នកស្ថាបនិក (Founder's Edition)
|
||||
|
||||
cmux គឺឥតគិតថ្លៃ ជាកូដបើកចំហ (open source) និងតែងតែបែបនេះជារៀងរហូត។ ប្រសិនបើអ្នកចង់គាំទ្រដល់ការអភិវឌ្ឍន៍ និងទទួលបានសិទ្ធិប្រើប្រាស់មុខងារថ្មីៗមុនគេ (early access)៖
|
||||
|
||||
[**ទទួលបានកំណែអ្នកស្ថាបនិក (Get Founder's Edition)**](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)
|
||||
|
||||
* **ការស្នើសុំមុខងារ/ការជួសជុលកំហុសត្រូវបានផ្តល់អាទិភាព**
|
||||
* **សិទ្ធិប្រើប្រាស់មុនគេ៖ cmux AI ដែលផ្តល់ឱ្យអ្នកនូវបរិបទ (context) លើរាល់ workspace, tab និង panel**
|
||||
* **សិទ្ធិប្រើប្រាស់មុនគេ៖ កម្មវិធី iOS ដែលមាន terminal ធ្វើសមកាលកម្ម (synced) រវាងកុំព្យូទ័រ និងទូរស័ព្ទ**
|
||||
* **សិទ្ធិប្រើប្រាស់មុនគេ៖ Cloud VMs**
|
||||
* **សិទ្ធិប្រើប្រាស់មុនគេ៖ មុខងារសំឡេង (Voice mode)**
|
||||
* **iMessage/WhatsApp ផ្ទាល់ខ្លួនរបស់ខ្ញុំ**
|
||||
|
||||
## អាជ្ញាប័ណ្ណ (License)
|
||||
|
||||
គម្រោងនេះត្រូវបានផ្តល់អាជ្ញាប័ណ្ណក្រោម GNU Affero General Public License v3.0 ឬក្រោយនេះ (`AGPL-3.0-or-later`)។
|
||||
|
||||
សូមមើលឯកសារ `LICENSE` សម្រាប់អត្ថបទពេញលេញ។
|
||||
146
README.ko.md
146
README.ko.md
|
|
@ -1,7 +1,5 @@
|
|||
> 이 문서는 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>
|
||||
|
||||
|
|
@ -12,20 +10,67 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/screenshot.png" alt="cmux 스크린샷" width="900" />
|
||||
<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>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="cmux 스크린샷" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ 데모 영상</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a>
|
||||
</p>
|
||||
|
||||
## 기능
|
||||
|
||||
- **세로 탭** — 사이드바에서 git 브랜치, 작업 디렉토리, 수신 포트, 최근 알림 텍스트를 한눈에 볼 수 있어요.
|
||||
- **알림 링** — AI 에이전트(Claude Code, OpenCode)가 입력을 기다리면 패널에 파란색 링이 뜨고 탭이 강조돼요.
|
||||
- **알림 패널** — 대기 중인 알림을 한곳에서 확인하고, 가장 최근 읽지 않은 알림으로 바로 이동할 수 있어요.
|
||||
- **분할 패널** — 수평·수직 분할을 지원해요.
|
||||
- **내장 브라우저** — [agent-browser](https://github.com/vercel-labs/agent-browser)에서 포팅된 스크립팅 API를 갖춘 브라우저를 터미널 옆에 띄울 수 있어요.
|
||||
- **스크립팅** — CLI와 socket API로 워크스페이스 생성, 패널 분할, 키 입력 전송, 브라우저 자동화가 가능해요.
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>알림 링</h3>
|
||||
코딩 에이전트가 입력을 기다리면 패널에 파란색 링이 뜨고 탭이 강조돼요
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/notification-rings.png" alt="알림 링" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>알림 패널</h3>
|
||||
대기 중인 알림을 한곳에서 확인하고, 가장 최근 읽지 않은 알림으로 바로 이동할 수 있어요
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/sidebar-notification-badge.png" alt="사이드바 알림 배지" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>내장 브라우저</h3>
|
||||
<a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>에서 포팅된 스크립팅 API를 갖춘 브라우저를 터미널 옆에 띄울 수 있어요
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/built-in-browser.png" alt="내장 브라우저" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>세로 + 가로 탭</h3>
|
||||
사이드바에서 git 브랜치, 연결된 PR 상태/번호, 작업 디렉토리, 수신 포트, 최근 알림 텍스트를 한눈에 볼 수 있어요. 수평·수직 분할을 지원해요.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="세로 탭과 분할 패널" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- **스크립팅** — CLI와 socket API로 워크스페이스 생성, 패널 분할, 키 입력 전송, 브라우저 자동화가 가능해요
|
||||
- **네이티브 macOS 앱** — Electron이 아닌 Swift와 AppKit으로 만들었어요. 빠르게 실행되고 메모리도 적게 써요.
|
||||
- **Ghostty 호환** — 기존 `~/.config/ghostty/config`에서 테마, 글꼴, 색상 설정을 그대로 읽어와요.
|
||||
- **GPU 가속** — libghostty 기반이라 렌더링이 부드러워요.
|
||||
- **Ghostty 호환** — 기존 `~/.config/ghostty/config`에서 테마, 글꼴, 색상 설정을 그대로 읽어와요
|
||||
- **GPU 가속** — libghostty 기반이라 렌더링이 부드러워요
|
||||
|
||||
## 설치하기
|
||||
|
||||
|
|
@ -58,12 +103,26 @@ brew upgrade --cask cmux
|
|||
|
||||
여러 코딩 오케스트레이터를 써봤는데, 대부분 Electron/Tauri 앱이라 성능이 별로였어요. GUI 오케스트레이터는 특정 워크플로우에 갇히게 돼서 터미널이 더 낫다고 생각했고요. 그래서 Swift/AppKit으로 네이티브 macOS 앱인 cmux를 직접 만들었어요. 터미널 렌더링에는 libghostty를 쓰고, 기존 Ghostty 설정에서 테마, 글꼴, 색상을 그대로 가져와요.
|
||||
|
||||
핵심은 사이드바와 알림 시스템이에요. 사이드바에는 각 워크스페이스의 git 브랜치, 작업 디렉토리, 수신 포트, 최근 알림 텍스트를 보여주는 세로 탭이 있어요. 알림 시스템은 터미널 시퀀스(OSC 9/99/777)를 감지하고, Claude Code나 OpenCode 같은 에이전트 훅에 연결할 수 있는 CLI(`cmux notify`)를 제공해요. 에이전트가 대기 중이면 해당 패널에 파란색 링이 뜨고 사이드바 탭이 강조되니까, 여러 패널과 탭 중에서 어디서 입력을 기다리는지 바로 알 수 있어요. ⌘⇧U를 누르면 가장 최근 읽지 않은 알림으로 이동해요.
|
||||
핵심은 사이드바와 알림 시스템이에요. 사이드바에는 각 워크스페이스의 git 브랜치, 연결된 PR 상태/번호, 작업 디렉토리, 수신 포트, 최근 알림 텍스트를 보여주는 세로 탭이 있어요. 알림 시스템은 터미널 시퀀스(OSC 9/99/777)를 감지하고, Claude Code나 OpenCode 같은 에이전트 훅에 연결할 수 있는 CLI(`cmux notify`)를 제공해요. 에이전트가 대기 중이면 해당 패널에 파란색 링이 뜨고 사이드바 탭이 강조되니까, 여러 패널과 탭 중에서 어디서 입력을 기다리는지 바로 알 수 있어요. ⌘⇧U를 누르면 가장 최근 읽지 않은 알림으로 이동해요.
|
||||
|
||||
내장 브라우저는 [agent-browser](https://github.com/vercel-labs/agent-browser)에서 포팅한 스크립팅 API를 제공해요. 에이전트가 접근성 트리 스냅샷을 가져오고, 요소를 참조·클릭하고, 양식을 채우고, JS를 실행할 수 있어요. 터미널 옆에 브라우저 패널을 띄워서 Claude Code가 개발 서버와 직접 상호작용하게 할 수 있어요.
|
||||
|
||||
CLI와 socket API로 모든 걸 자동화할 수 있어요 — 워크스페이스/탭 생성, 패널 분할, 키 입력 전송, 브라우저에서 URL 열기까지요.
|
||||
|
||||
## The Zen of cmux
|
||||
|
||||
cmux는 개발자가 도구를 어떻게 사용해야 하는지 규정하지 않아요. 터미널과 브라우저에 CLI가 있고, 나머지는 여러분의 몫이에요.
|
||||
|
||||
cmux는 솔루션이 아니라 프리미티브예요. 터미널, 브라우저, 알림, 워크스페이스, 분할, 탭, 그리고 이 모든 것을 제어하는 CLI를 제공해요. cmux는 코딩 에이전트를 특정 방식으로 사용하도록 강요하지 않아요. 프리미티브로 무엇을 만들지는 여러분에게 달려 있어요.
|
||||
|
||||
최고의 개발자들은 항상 자신만의 도구를 만들어왔어요. 에이전트와 함께 일하는 최적의 방법은 아직 아무도 찾지 못했고, 폐쇄적인 제품을 만드는 팀들도 마찬가지예요. 자신의 코드베이스에 가장 가까운 개발자가 먼저 답을 찾을 거예요.
|
||||
|
||||
100만 명의 개발자에게 조합 가능한 프리미티브를 주면, 어떤 프로덕트 팀이 위에서 설계하는 것보다 빠르게 가장 효율적인 워크플로우를 함께 찾아낼 거예요.
|
||||
|
||||
## 문서
|
||||
|
||||
cmux 설정 방법에 대한 자세한 내용은 [문서를 확인해주세요](https://cmux.dev/docs/getting-started?utm_source=readme).
|
||||
|
||||
## 키보드 단축키
|
||||
|
||||
### 워크스페이스
|
||||
|
|
@ -76,6 +135,7 @@ CLI와 socket API로 모든 걸 자동화할 수 있어요 — 워크스페이
|
|||
| ⌃ ⌘ ] | 다음 워크스페이스 |
|
||||
| ⌃ ⌘ [ | 이전 워크스페이스 |
|
||||
| ⌘ ⇧ W | 워크스페이스 닫기 |
|
||||
| ⌘ ⇧ R | 워크스페이스 이름 변경 |
|
||||
| ⌘ B | 사이드바 토글 |
|
||||
|
||||
### 서피스
|
||||
|
|
@ -102,6 +162,8 @@ CLI와 socket API로 모든 걸 자동화할 수 있어요 — 워크스페이
|
|||
|
||||
### 브라우저
|
||||
|
||||
브라우저 개발자 도구 단축키는 Safari 기본값을 따르며, `설정 → 키보드 단축키`에서 변경할 수 있어요.
|
||||
|
||||
| 단축키 | 동작 |
|
||||
|----------|--------|
|
||||
| ⌘ ⇧ L | 분할 패널로 브라우저 열기 |
|
||||
|
|
@ -109,7 +171,8 @@ CLI와 socket API로 모든 걸 자동화할 수 있어요 — 워크스페이
|
|||
| ⌘ [ | 뒤로 |
|
||||
| ⌘ ] | 앞으로 |
|
||||
| ⌘ R | 페이지 새로고침 |
|
||||
| ⌥ ⌘ I | 개발자 도구 열기 |
|
||||
| ⌥ ⌘ I | 개발자 도구 열기 (Safari 기본값) |
|
||||
| ⌥ ⌘ C | JavaScript 콘솔 표시 (Safari 기본값) |
|
||||
|
||||
### 알림
|
||||
|
||||
|
|
@ -146,6 +209,63 @@ CLI와 socket API로 모든 걸 자동화할 수 있어요 — 워크스페이
|
|||
| ⌘ ⇧ , | 설정 다시 불러오기 |
|
||||
| ⌘ Q | 종료 |
|
||||
|
||||
## 나이틀리 빌드
|
||||
|
||||
[cmux NIGHTLY 다운로드](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
|
||||
cmux NIGHTLY는 자체 번들 ID를 가진 별도의 앱이라 안정 버전과 함께 실행할 수 있어요. 최신 `main` 커밋에서 자동으로 빌드되고, 자체 Sparkle 피드를 통해 자동 업데이트돼요.
|
||||
|
||||
## 세션 복원 (현재 동작)
|
||||
|
||||
재실행 시 cmux는 현재 앱 레이아웃과 메타데이터만 복원해요:
|
||||
- 창/워크스페이스/패널 레이아웃
|
||||
- 작업 디렉토리
|
||||
- 터미널 스크롤백 (최선 노력)
|
||||
- 브라우저 URL 및 탐색 기록
|
||||
|
||||
cmux는 터미널 앱 내부의 라이브 프로세스 상태를 복원하지 **않아요**. 예를 들어 활성 Claude Code/tmux/vim 세션은 재시작 후 아직 복원되지 않아요.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 기여하기
|
||||
|
||||
참여 방법:
|
||||
|
||||
- X에서 팔로우해주세요: [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen), [@austinywang](https://x.com/austinywang)
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)에서 대화에 참여해주세요
|
||||
- [GitHub Issues](https://github.com/manaflow-ai/cmux/issues)와 [토론](https://github.com/manaflow-ai/cmux/discussions)에 참여해주세요
|
||||
- cmux로 무엇을 만들고 있는지 알려주세요
|
||||
|
||||
## 커뮤니티
|
||||
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
- [Reddit](https://www.reddit.com/r/cmux/)
|
||||
|
||||
## Founder's Edition
|
||||
|
||||
cmux는 무료이고 오픈 소스이며, 앞으로도 그럴 거예요. 개발을 지원하고 다음에 나올 기능에 먼저 접근하고 싶다면:
|
||||
|
||||
**[Founder's Edition 구매하기](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
|
||||
|
||||
- **기능 요청/버그 수정 우선 처리**
|
||||
- **얼리 액세스: 모든 워크스페이스, 탭, 패널의 컨텍스트를 제공하는 cmux AI**
|
||||
- **얼리 액세스: 데스크톱과 휴대폰 간 터미널을 동기화하는 iOS 앱**
|
||||
- **얼리 액세스: 클라우드 VM**
|
||||
- **얼리 액세스: 음성 모드**
|
||||
- **저의 개인 iMessage/WhatsApp**
|
||||
|
||||
## 라이선스
|
||||
|
||||
이 프로젝트는 GNU Affero General Public License v3.0 이상(`AGPL-3.0-or-later`)으로 배포돼요.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
English | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a>
|
||||
English | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a> | <a href="README.km.md">ភាសាខ្មែរ</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
|
|
|||
190
README.no.md
190
README.no.md
|
|
@ -1,9 +1,5 @@
|
|||
> Denne oversettelsen ble generert av Claude. Hvis du har forslag til forbedringer, send gjerne en 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.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | Norsk | <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">En Ghostty-basert macOS-terminal med vertikale faner og varsler for AI-kodeagenter</p>
|
||||
|
||||
|
|
@ -14,17 +10,64 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/screenshot.png" alt="cmux skjermbilde" width="900" />
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | Norsk | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="cmux skjermbilde" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Demovideo</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a>
|
||||
</p>
|
||||
|
||||
## Funksjoner
|
||||
|
||||
- **Vertikale faner** — Sidefeltet viser git-gren, arbeidsmappe, lyttende porter og siste varselstekst
|
||||
- **Varselringer** — Paneler far en bla ring og faner lyser opp nar AI-agenter (Claude Code, OpenCode) trenger oppmerksomheten din
|
||||
- **Varselpanel** — Se alle ventende varsler pa ett sted, hopp til det nyeste uleste
|
||||
- **Delte paneler** — Horisontale og vertikale delinger
|
||||
- **Innebygd nettleser** — Del en nettleser ved siden av terminalen med et skriptbart API portet fra [agent-browser](https://github.com/vercel-labs/agent-browser)
|
||||
- **Skriptbar** — CLI og socket API for a opprette arbeidsomrader, dele paneler, sende tastetrykk og automatisere nettleseren
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Varselringer</h3>
|
||||
Paneler får en blå ring og faner lyser opp når kodeagenter trenger oppmerksomheten din
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/notification-rings.png" alt="Varselringer" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Varselpanel</h3>
|
||||
Se alle ventende varsler på ett sted, hopp til det nyeste uleste
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/sidebar-notification-badge.png" alt="Varselmerke i sidefeltet" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Innebygd nettleser</h3>
|
||||
Del en nettleser ved siden av terminalen med et skriptbart API portet fra <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/built-in-browser.png" alt="Innebygd nettleser" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Vertikale + horisontale faner</h3>
|
||||
Sidefeltet viser git-gren, tilknyttet PR-status/nummer, arbeidsmappe, lyttende porter og siste varselstekst. Del horisontalt og vertikalt.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Vertikale faner og delte paneler" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- **Skriptbar** — CLI og socket API for å opprette arbeidsområder, dele paneler, sende tastetrykk og automatisere nettleseren
|
||||
- **Nativ macOS-app** — Bygget med Swift og AppKit, ikke Electron. Rask oppstart, lavt minneforbruk.
|
||||
- **Ghostty-kompatibel** — Leser din eksisterende `~/.config/ghostty/config` for temaer, skrifttyper og farger
|
||||
- **GPU-akselerert** — Drevet av libghostty for jevn gjengivelse
|
||||
|
|
@ -37,7 +80,7 @@
|
|||
<img src="./docs/assets/macos-badge.png" alt="Last ned cmux for macOS" width="180" />
|
||||
</a>
|
||||
|
||||
Apne `.dmg`-filen og dra cmux til Programmer-mappen. cmux oppdaterer seg selv automatisk via Sparkle, sa du trenger bare a laste ned en gang.
|
||||
Åpne `.dmg`-filen og dra cmux til Programmer-mappen. cmux oppdaterer seg selv automatisk via Sparkle, så du trenger bare å laste ned én gang.
|
||||
|
||||
### Homebrew
|
||||
|
||||
|
|
@ -46,38 +89,53 @@ brew tap manaflow-ai/cmux
|
|||
brew install --cask cmux
|
||||
```
|
||||
|
||||
For a oppdatere senere:
|
||||
For å oppdatere senere:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cmux
|
||||
```
|
||||
|
||||
Ved forste oppstart kan macOS be deg bekrefte apning av en app fra en identifisert utvikler. Klikk **Apne** for a fortsette.
|
||||
Ved første oppstart kan macOS be deg bekrefte åpning av en app fra en identifisert utvikler. Klikk **Åpne** for å fortsette.
|
||||
|
||||
## Hvorfor cmux?
|
||||
|
||||
Jeg kjorer mange Claude Code- og Codex-sesjoner parallelt. Jeg brukte Ghostty med en haug delte paneler, og stolte pa native macOS-varsler for a vite nar en agent trengte meg. Men Claude Codes varselstekst er alltid bare "Claude is waiting for your input" uten kontekst, og med nok faner apne kunne jeg ikke engang lese titlene lenger.
|
||||
Jeg kjører mange Claude Code- og Codex-sesjoner parallelt. Jeg brukte Ghostty med en haug delte paneler, og stolte på native macOS-varsler for å vite når en agent trengte meg. Men Claude Codes varselstekst er alltid bare "Claude is waiting for your input" uten kontekst, og med nok faner åpne kunne jeg ikke engang lese titlene lenger.
|
||||
|
||||
Jeg provde noen kodeorkestratorer, men de fleste var Electron/Tauri-apper og ytelsen irriterte meg. Jeg foretrekker ogsa terminalen siden GUI-orkestratorer laser deg inn i arbeidsflyten deres. Sa jeg bygde cmux som en nativ macOS-app i Swift/AppKit. Den bruker libghostty for terminalgjengivelse og leser din eksisterende Ghostty-konfigurasjon for temaer, skrifttyper og farger.
|
||||
Jeg prøvde noen kodeorkestratorer, men de fleste var Electron/Tauri-apper og ytelsen irriterte meg. Jeg foretrekker også terminalen siden GUI-orkestratorer låser deg inn i arbeidsflyten deres. Så jeg bygde cmux som en nativ macOS-app i Swift/AppKit. Den bruker libghostty for terminalgjengivelse og leser din eksisterende Ghostty-konfigurasjon for temaer, skrifttyper og farger.
|
||||
|
||||
Hovedtilleggene er sidefeltet og varselsystemet. Sidefeltet har vertikale faner som viser git-gren, arbeidsmappe, lyttende porter og siste varselstekst for hvert arbeidsomrade. Varselsystemet fanger opp terminalsekvenser (OSC 9/99/777) og har en CLI (`cmux notify`) du kan koble til agentkroker for Claude Code, OpenCode osv. Nar en agent venter, far panelet en bla ring og fanen lyser opp i sidefeltet, sa jeg kan se hvilken som trenger meg pa tvers av delinger og faner. Cmd+Shift+U hopper til det nyeste uleste.
|
||||
Hovedtilleggene er sidefeltet og varselsystemet. Sidefeltet har vertikale faner som viser git-gren, tilknyttet PR-status/nummer, arbeidsmappe, lyttende porter og siste varselstekst for hvert arbeidsområde. Varselsystemet fanger opp terminalsekvenser (OSC 9/99/777) og har en CLI (`cmux notify`) du kan koble til agentkroker for Claude Code, OpenCode osv. Når en agent venter, får panelet en blå ring og fanen lyser opp i sidefeltet, så jeg kan se hvilken som trenger meg på tvers av delinger og faner. Cmd+Shift+U hopper til det nyeste uleste.
|
||||
|
||||
Den innebygde nettleseren har et skriptbart API portet fra [agent-browser](https://github.com/vercel-labs/agent-browser). Agenter kan ta overblikk over tilgjengelighetstreet, hente elementreferanser, klikke, fylle ut skjemaer og kjore JS. Du kan dele et nettleserpanel ved siden av terminalen og la Claude Code samhandle med utviklingsserveren din direkte.
|
||||
Den innebygde nettleseren har et skriptbart API portet fra [agent-browser](https://github.com/vercel-labs/agent-browser). Agenter kan ta overblikk over tilgjengelighetstreet, hente elementreferanser, klikke, fylle ut skjemaer og kjøre JS. Du kan dele et nettleserpanel ved siden av terminalen og la Claude Code samhandle med utviklingsserveren din direkte.
|
||||
|
||||
Alt er skriptbart gjennom CLI og socket API — opprett arbeidsomrader/faner, del paneler, send tastetrykk, apne URLer i nettleseren.
|
||||
Alt er skriptbart gjennom CLI og socket API — opprett arbeidsområder/faner, del paneler, send tastetrykk, åpne URLer i nettleseren.
|
||||
|
||||
## The Zen of cmux
|
||||
|
||||
cmux er ikke foreskrivende om hvordan utviklere bruker verktøyene sine. Det er en terminal og nettleser med en CLI, og resten er opp til deg.
|
||||
|
||||
cmux er en primitiv, ikke en løsning. Det gir deg en terminal, en nettleser, varsler, arbeidsområder, delinger, faner og en CLI for å kontrollere alt sammen. cmux tvinger deg ikke inn i en bestemt måte å bruke kodeagenter på. Hva du bygger med primitivene er ditt.
|
||||
|
||||
De beste utviklerne har alltid bygget sine egne verktøy. Ingen har funnet ut den beste måten å jobbe med agenter på ennå, og teamene som bygger lukkede produkter har definitivt ikke gjort det heller. Utviklerne som er nærmest sine egne kodebaser vil finne det ut først.
|
||||
|
||||
Gi en million utviklere komponerbare primitiver og de vil kollektivt finne de mest effektive arbeidsflytene raskere enn noe produktteam kunne designet ovenfra og ned.
|
||||
|
||||
## Dokumentasjon
|
||||
|
||||
For mer informasjon om hvordan du konfigurerer cmux, [gå til dokumentasjonen vår](https://cmux.dev/docs/getting-started?utm_source=readme).
|
||||
|
||||
## Tastatursnarveier
|
||||
|
||||
### Arbeidsomrader
|
||||
### Arbeidsområder
|
||||
|
||||
| Snarvei | Handling |
|
||||
|----------|--------|
|
||||
| ⌘ N | Nytt arbeidsomrade |
|
||||
| ⌘ 1–8 | Hopp til arbeidsomrade 1–8 |
|
||||
| ⌘ 9 | Hopp til siste arbeidsomrade |
|
||||
| ⌃ ⌘ ] | Neste arbeidsomrade |
|
||||
| ⌃ ⌘ [ | Forrige arbeidsomrade |
|
||||
| ⌘ ⇧ W | Lukk arbeidsomrade |
|
||||
| ⌘ N | Nytt arbeidsområde |
|
||||
| ⌘ 1–8 | Hopp til arbeidsområde 1–8 |
|
||||
| ⌘ 9 | Hopp til siste arbeidsområde |
|
||||
| ⌃ ⌘ ] | Neste arbeidsområde |
|
||||
| ⌃ ⌘ [ | Forrige arbeidsområde |
|
||||
| ⌘ ⇧ W | Lukk arbeidsområde |
|
||||
| ⌘ ⇧ R | Gi nytt navn til arbeidsområde |
|
||||
| ⌘ B | Vis/skjul sidefelt |
|
||||
|
||||
### Overflater
|
||||
|
|
@ -97,21 +155,24 @@ Alt er skriptbart gjennom CLI og socket API — opprett arbeidsomrader/faner, de
|
|||
|
||||
| Snarvei | Handling |
|
||||
|----------|--------|
|
||||
| ⌘ D | Del til hoyre |
|
||||
| ⌘ D | Del til høyre |
|
||||
| ⌘ ⇧ D | Del nedover |
|
||||
| ⌥ ⌘ ← → ↑ ↓ | Fokuser panel i retning |
|
||||
| ⌘ ⇧ H | Blink fokusert panel |
|
||||
|
||||
### Nettleser
|
||||
|
||||
Nettleserens utviklerverktøysnarveier følger Safari-standarder og kan tilpasses i `Innstillinger → Tastatursnarveier`.
|
||||
|
||||
| Snarvei | Handling |
|
||||
|----------|--------|
|
||||
| ⌘ ⇧ L | Apne nettleser i deling |
|
||||
| ⌘ ⇧ L | Åpne nettleser i deling |
|
||||
| ⌘ L | Fokuser adressefeltet |
|
||||
| ⌘ [ | Tilbake |
|
||||
| ⌘ ] | Fremover |
|
||||
| ⌘ R | Last inn siden pa nytt |
|
||||
| ⌥ ⌘ I | Apne utviklerverktoy |
|
||||
| ⌘ R | Last inn siden på nytt |
|
||||
| ⌥ ⌘ I | Vis/skjul utviklerverktøy (Safari-standard) |
|
||||
| ⌥ ⌘ C | Vis JavaScript-konsoll (Safari-standard) |
|
||||
|
||||
### Varsler
|
||||
|
||||
|
|
@ -120,14 +181,14 @@ Alt er skriptbart gjennom CLI og socket API — opprett arbeidsomrader/faner, de
|
|||
| ⌘ I | Vis varselpanel |
|
||||
| ⌘ ⇧ U | Hopp til nyeste uleste |
|
||||
|
||||
### Sok
|
||||
### Søk
|
||||
|
||||
| Snarvei | Handling |
|
||||
|----------|--------|
|
||||
| ⌘ F | Sok |
|
||||
| ⌘ G / ⌘ ⇧ G | Sok neste / forrige |
|
||||
| ⌘ ⇧ F | Skjul sokelinje |
|
||||
| ⌘ E | Bruk utvalg til sok |
|
||||
| ⌘ F | Søk |
|
||||
| ⌘ G / ⌘ ⇧ G | Søk neste / forrige |
|
||||
| ⌘ ⇧ F | Skjul søkelinje |
|
||||
| ⌘ E | Bruk utvalg til søk |
|
||||
|
||||
### Terminal
|
||||
|
||||
|
|
@ -145,9 +206,66 @@ Alt er skriptbart gjennom CLI og socket API — opprett arbeidsomrader/faner, de
|
|||
|----------|--------|
|
||||
| ⌘ ⇧ N | Nytt vindu |
|
||||
| ⌘ , | Innstillinger |
|
||||
| ⌘ ⇧ , | Last inn konfigurasjon pa nytt |
|
||||
| ⌘ ⇧ , | Last inn konfigurasjon på nytt |
|
||||
| ⌘ Q | Avslutt |
|
||||
|
||||
## Nattlige bygg
|
||||
|
||||
[Last ned cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
|
||||
cmux NIGHTLY er en separat app med sin egen bundle-ID, så den kjører ved siden av den stabile versjonen. Bygges automatisk fra den siste `main`-commiten og oppdateres automatisk via sin egen Sparkle-feed.
|
||||
|
||||
## Sesjonssgjenoppretting (nåværende oppførsel)
|
||||
|
||||
Ved omstart gjenoppretter cmux for øyeblikket kun applayouten og metadata:
|
||||
- Vindu-/arbeidsområde-/panellayout
|
||||
- Arbeidsmapper
|
||||
- Terminal-rullingshistorikk (best effort)
|
||||
- Nettleser-URL og navigasjonshistorikk
|
||||
|
||||
cmux gjenoppretter **ikke** aktive prosesstilstander inne i terminalapper. For eksempel blir aktive Claude Code/tmux/vim-sesjoner ikke gjenopptatt etter omstart ennå.
|
||||
|
||||
## Stjernehistorikk
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Bidra
|
||||
|
||||
Måter å engasjere seg:
|
||||
|
||||
- Følg oss på X for oppdateringer [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen), og [@austinywang](https://x.com/austinywang)
|
||||
- Bli med i samtalen på [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- Opprett og delta i [GitHub-issues](https://github.com/manaflow-ai/cmux/issues) og [diskusjoner](https://github.com/manaflow-ai/cmux/discussions)
|
||||
- Fortell oss hva du bygger med cmux
|
||||
|
||||
## Fellesskap
|
||||
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
- [Reddit](https://www.reddit.com/r/cmux/)
|
||||
|
||||
## Grunnleggerutgaven
|
||||
|
||||
cmux er gratis, åpen kildekode, og vil alltid være det. Hvis du vil støtte utviklingen og få tidlig tilgang til det som kommer:
|
||||
|
||||
**[Få Grunnleggerutgaven](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
|
||||
|
||||
- **Prioriterte funksjonsforespørsler/feilrettinger**
|
||||
- **Tidlig tilgang: cmux AI som gir deg kontekst om hvert arbeidsområde, fane og panel**
|
||||
- **Tidlig tilgang: iOS-app med terminaler synkronisert mellom desktop og telefon**
|
||||
- **Tidlig tilgang: Sky-VMer**
|
||||
- **Tidlig tilgang: Stemmemodus**
|
||||
- **Min personlige iMessage/WhatsApp**
|
||||
|
||||
## Lisens
|
||||
|
||||
Dette prosjektet er lisensiert under GNU Affero General Public License v3.0 eller nyere (`AGPL-3.0-or-later`).
|
||||
|
|
|
|||
142
README.pl.md
142
README.pl.md
|
|
@ -1,9 +1,5 @@
|
|||
> To tłumaczenie zostało wygenerowane przez Claude. Jeśli masz sugestie dotyczące poprawek, otwórz 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.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | Polski | <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">Terminal macOS oparty na Ghostty z pionowymi kartami i powiadomieniami dla agentów kodowania AI</p>
|
||||
|
||||
|
|
@ -14,16 +10,63 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/screenshot.png" alt="Zrzut ekranu cmux" width="900" />
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | Polski | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="Zrzut ekranu cmux" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Film demonstracyjny</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a>
|
||||
</p>
|
||||
|
||||
## Funkcje
|
||||
|
||||
- **Pionowe karty** — Pasek boczny pokazuje gałąź git, katalog roboczy, nasłuchujące porty i tekst ostatniego powiadomienia
|
||||
- **Pierścienie powiadomień** — Panele otrzymują niebieski pierścień, a karty podświetlają się, gdy agenci AI (Claude Code, OpenCode) potrzebują Twojej uwagi
|
||||
- **Panel powiadomień** — Zobacz wszystkie oczekujące powiadomienia w jednym miejscu, przeskocz do najnowszego nieprzeczytanego
|
||||
- **Podzielone panele** — Podziały poziome i pionowe
|
||||
- **Wbudowana przeglądarka** — Podziel przeglądarkę obok terminala ze skryptowalnym API przeniesionym z [agent-browser](https://github.com/vercel-labs/agent-browser)
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Pierścienie powiadomień</h3>
|
||||
Panele otrzymują niebieski pierścień, a karty podświetlają się, gdy agenci kodowania potrzebują Twojej uwagi
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/notification-rings.png" alt="Pierścienie powiadomień" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Panel powiadomień</h3>
|
||||
Zobacz wszystkie oczekujące powiadomienia w jednym miejscu, przeskocz do najnowszego nieprzeczytanego
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/sidebar-notification-badge.png" alt="Znacznik powiadomień w pasku bocznym" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Wbudowana przeglądarka</h3>
|
||||
Podziel przeglądarkę obok terminala ze skryptowalnym API przeniesionym z <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/built-in-browser.png" alt="Wbudowana przeglądarka" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Pionowe + poziome karty</h3>
|
||||
Pasek boczny pokazuje gałąź git, status/numer powiązanego PR, katalog roboczy, nasłuchujące porty i tekst ostatniego powiadomienia. Podziały poziome i pionowe.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Pionowe karty i podzielone panele" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- **Skryptowalny** — CLI i socket API do tworzenia przestrzeni roboczych, dzielenia paneli, wysyłania naciśnięć klawiszy i automatyzacji przeglądarki
|
||||
- **Natywna aplikacja macOS** — Zbudowana w Swift i AppKit, nie Electron. Szybki start, niskie zużycie pamięci.
|
||||
- **Kompatybilny z Ghostty** — Odczytuje istniejącą konfigurację `~/.config/ghostty/config` dla motywów, czcionek i kolorów
|
||||
|
|
@ -60,12 +103,26 @@ Uruchamiam wiele sesji Claude Code i Codex równolegle. Używałem Ghostty z mas
|
|||
|
||||
Wypróbowałem kilka orkiestratorów kodowania, ale większość z nich to aplikacje Electron/Tauri, a ich wydajność mi przeszkadzała. Po prostu wolę też terminal, ponieważ orkiestratory GUI zamykają cię w swoim przepływie pracy. Dlatego zbudowałem cmux jako natywną aplikację macOS w Swift/AppKit. Używa libghostty do renderowania terminala i odczytuje istniejącą konfigurację Ghostty dla motywów, czcionek i kolorów.
|
||||
|
||||
Główne dodatki to pasek boczny i system powiadomień. Pasek boczny ma pionowe karty pokazujące gałąź git, katalog roboczy, nasłuchujące porty i tekst ostatniego powiadomienia dla każdej przestrzeni roboczej. System powiadomień przechwytuje sekwencje terminala (OSC 9/99/777) i ma CLI (`cmux notify`), który można podpiąć do hooków agentów dla Claude Code, OpenCode itp. Gdy agent czeka, jego panel otrzymuje niebieski pierścień, a karta podświetla się w pasku bocznym, więc mogę powiedzieć, który mnie potrzebuje, niezależnie od podziałów i kart. Cmd+Shift+U przeskakuje do najnowszego nieprzeczytanego.
|
||||
Główne dodatki to pasek boczny i system powiadomień. Pasek boczny ma pionowe karty pokazujące gałąź git, status/numer powiązanego PR, katalog roboczy, nasłuchujące porty i tekst ostatniego powiadomienia dla każdej przestrzeni roboczej. System powiadomień przechwytuje sekwencje terminala (OSC 9/99/777) i ma CLI (`cmux notify`), który można podpiąć do hooków agentów dla Claude Code, OpenCode itp. Gdy agent czeka, jego panel otrzymuje niebieski pierścień, a karta podświetla się w pasku bocznym, więc mogę powiedzieć, który mnie potrzebuje, niezależnie od podziałów i kart. Cmd+Shift+U przeskakuje do najnowszego nieprzeczytanego.
|
||||
|
||||
Wbudowana przeglądarka ma skryptowalny API przeniesiony z [agent-browser](https://github.com/vercel-labs/agent-browser). Agenci mogą wykonać migawkę drzewa dostępności, uzyskać referencje elementów, klikać, wypełniać formularze i ewaluować JS. Możesz podzielić panel przeglądarki obok terminala i pozwolić Claude Code bezpośrednio komunikować się z Twoim serwerem deweloperskim.
|
||||
|
||||
Wszystko jest skryptowalne przez CLI i socket API — tworzenie przestrzeni roboczych/kart, dzielenie paneli, wysyłanie naciśnięć klawiszy, otwieranie URL-ów w przeglądarce.
|
||||
|
||||
## The Zen of cmux
|
||||
|
||||
cmux nie narzuca programistom sposobu korzystania z narzędzi. To terminal i przeglądarka z CLI, a reszta zależy od Ciebie.
|
||||
|
||||
cmux jest prymitywem, nie rozwiązaniem. Daje Ci terminal, przeglądarkę, powiadomienia, przestrzenie robocze, podziały, karty i CLI do kontrolowania tego wszystkiego. cmux nie zmusza Cię do określonego sposobu korzystania z agentów kodowania. To, co zbudujesz z tych prymitywów, jest Twoje.
|
||||
|
||||
Najlepsi programiści zawsze budowali własne narzędzia. Nikt jeszcze nie wymyślił najlepszego sposobu pracy z agentami, a zespoły budujące zamknięte produkty też tego nie odkryły. Programiści najbliżej swoich własnych baz kodu wymyślą to pierwsi.
|
||||
|
||||
Daj milionowi programistów kompozycyjne prymitywy, a wspólnie znajdą najefektywniejsze przepływy pracy szybciej, niż jakikolwiek zespół produktowy mógłby zaprojektować odgórnie.
|
||||
|
||||
## Dokumentacja
|
||||
|
||||
Więcej informacji o konfiguracji cmux znajdziesz w [naszej dokumentacji](https://cmux.dev/docs/getting-started?utm_source=readme).
|
||||
|
||||
## Skróty Klawiszowe
|
||||
|
||||
### Przestrzenie robocze
|
||||
|
|
@ -78,6 +135,7 @@ Wszystko jest skryptowalne przez CLI i socket API — tworzenie przestrzeni robo
|
|||
| ⌃ ⌘ ] | Następna przestrzeń robocza |
|
||||
| ⌃ ⌘ [ | Poprzednia przestrzeń robocza |
|
||||
| ⌘ ⇧ W | Zamknij przestrzeń roboczą |
|
||||
| ⌘ ⇧ R | Zmień nazwę przestrzeni roboczej |
|
||||
| ⌘ B | Przełącz pasek boczny |
|
||||
|
||||
### Powierzchnie
|
||||
|
|
@ -104,6 +162,8 @@ Wszystko jest skryptowalne przez CLI i socket API — tworzenie przestrzeni robo
|
|||
|
||||
### Przeglądarka
|
||||
|
||||
Skróty narzędzi deweloperskich przeglądarki odpowiadają domyślnym ustawieniom Safari i można je dostosować w `Ustawienia → Skróty klawiszowe`.
|
||||
|
||||
| Skrót | Akcja |
|
||||
|----------|--------|
|
||||
| ⌘ ⇧ L | Otwórz przeglądarkę w podziale |
|
||||
|
|
@ -111,7 +171,8 @@ Wszystko jest skryptowalne przez CLI i socket API — tworzenie przestrzeni robo
|
|||
| ⌘ [ | Wstecz |
|
||||
| ⌘ ] | Do przodu |
|
||||
| ⌘ R | Przeładuj stronę |
|
||||
| ⌥ ⌘ I | Otwórz Narzędzia Deweloperskie |
|
||||
| ⌥ ⌘ I | Przełącz Narzędzia Deweloperskie (domyślne Safari) |
|
||||
| ⌥ ⌘ C | Pokaż Konsolę JavaScript (domyślne Safari) |
|
||||
|
||||
### Powiadomienia
|
||||
|
||||
|
|
@ -148,6 +209,63 @@ Wszystko jest skryptowalne przez CLI i socket API — tworzenie przestrzeni robo
|
|||
| ⌘ ⇧ , | Przeładuj konfigurację |
|
||||
| ⌘ Q | Zakończ |
|
||||
|
||||
## Wersje Nightly
|
||||
|
||||
[Pobierz cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
|
||||
cmux NIGHTLY to osobna aplikacja z własnym identyfikatorem pakietu, więc działa obok wersji stabilnej. Budowana automatycznie z najnowszego commitu `main` i aktualizuje się automatycznie przez własny kanał Sparkle.
|
||||
|
||||
## Przywracanie sesji (obecne zachowanie)
|
||||
|
||||
Przy ponownym uruchomieniu cmux obecnie przywraca tylko układ aplikacji i metadane:
|
||||
- Układ okien/przestrzeni roboczych/paneli
|
||||
- Katalogi robocze
|
||||
- Scrollback terminala (najlepsza próba)
|
||||
- URL przeglądarki i historia nawigacji
|
||||
|
||||
cmux **nie** przywraca stanu żywych procesów wewnątrz aplikacji terminalowych. Na przykład aktywne sesje Claude Code/tmux/vim nie są jeszcze wznawiane po restarcie.
|
||||
|
||||
## Historia Gwiazdek
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Współtworzenie
|
||||
|
||||
Sposoby zaangażowania się:
|
||||
|
||||
- Obserwuj nas na X po aktualizacje [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen) i [@austinywang](https://x.com/austinywang)
|
||||
- Dołącz do rozmowy na [Discordzie](https://discord.gg/xsgFEVrWCZ)
|
||||
- Twórz i uczestniczaj w [zgłoszeniach GitHub](https://github.com/manaflow-ai/cmux/issues) i [dyskusjach](https://github.com/manaflow-ai/cmux/discussions)
|
||||
- Daj nam znać, co budujesz z cmux
|
||||
|
||||
## Społeczność
|
||||
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
- [Reddit](https://www.reddit.com/r/cmux/)
|
||||
|
||||
## Edycja Założycielska
|
||||
|
||||
cmux jest darmowy, open source i zawsze taki będzie. Jeśli chcesz wesprzeć rozwój i uzyskać wczesny dostęp do nadchodzących funkcji:
|
||||
|
||||
**[Zdobądź Edycję Założycielską](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
|
||||
|
||||
- **Priorytetowe prośby o funkcje/poprawki błędów**
|
||||
- **Wczesny dostęp: cmux AI, które daje Ci kontekst każdej przestrzeni roboczej, karty i panelu**
|
||||
- **Wczesny dostęp: aplikacja iOS z terminalami synchronizowanymi między komputerem a telefonem**
|
||||
- **Wczesny dostęp: maszyny wirtualne w chmurze**
|
||||
- **Wczesny dostęp: tryb głosowy**
|
||||
- **Mój osobisty iMessage/WhatsApp**
|
||||
|
||||
## Licencja
|
||||
|
||||
Ten projekt jest licencjonowany na warunkach GNU Affero General Public License v3.0 lub nowszej (`AGPL-3.0-or-later`).
|
||||
|
|
|
|||
146
README.pt-BR.md
146
README.pt-BR.md
|
|
@ -1,9 +1,5 @@
|
|||
> Esta tradução foi gerada pelo Claude. Se você tiver sugestões de melhoria, abra um 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.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | Português (Brasil) | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">cmux</h1>
|
||||
<p align="center">Um terminal macOS baseado em Ghostty com abas verticais e notificações para agentes de programação com IA</p>
|
||||
|
||||
|
|
@ -14,16 +10,63 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/screenshot.png" alt="Captura de tela do cmux" width="900" />
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | Português (Brasil) | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="Captura de tela do cmux" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Vídeo de demonstração</a> · <a href="https://cmux.dev/blog/zen-of-cmux">O Zen do cmux</a>
|
||||
</p>
|
||||
|
||||
## Recursos
|
||||
|
||||
- **Abas verticais** — A barra lateral mostra o branch do git, diretório de trabalho, portas em escuta e o texto da última notificação
|
||||
- **Anéis de notificação** — Os painéis recebem um anel azul e as abas acendem quando agentes de IA (Claude Code, OpenCode) precisam da sua atenção
|
||||
- **Painel de notificações** — Veja todas as notificações pendentes em um só lugar, vá direto para a mais recente não lida
|
||||
- **Painéis divididos** — Divisões horizontais e verticais
|
||||
- **Navegador integrado** — Divida um navegador ao lado do seu terminal com uma API programável portada do [agent-browser](https://github.com/vercel-labs/agent-browser)
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Anéis de notificação</h3>
|
||||
Os painéis recebem um anel azul e as abas acendem quando agentes de programação precisam da sua atenção
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/notification-rings.png" alt="Anéis de notificação" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Painel de notificações</h3>
|
||||
Veja todas as notificações pendentes em um só lugar, vá direto para a mais recente não lida
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/sidebar-notification-badge.png" alt="Badge de notificação na barra lateral" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Navegador integrado</h3>
|
||||
Divida um navegador ao lado do seu terminal com uma API programável portada do <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/built-in-browser.png" alt="Navegador integrado" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Abas verticais + horizontais</h3>
|
||||
A barra lateral mostra o branch do git, status/número do PR vinculado, diretório de trabalho, portas em escuta e texto da última notificação. Divida horizontal e verticalmente.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Abas verticais e painéis divididos" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- **Programável** — CLI e socket API para criar workspaces, dividir painéis, enviar teclas e automatizar o navegador
|
||||
- **App nativo macOS** — Construído com Swift e AppKit, não Electron. Inicialização rápida, baixo consumo de memória.
|
||||
- **Compatível com Ghostty** — Lê sua configuração existente em `~/.config/ghostty/config` para temas, fontes e cores
|
||||
|
|
@ -60,15 +103,29 @@ Eu executo muitas sessões de Claude Code e Codex em paralelo. Eu estava usando
|
|||
|
||||
Eu tentei alguns orquestradores de código, mas a maioria era apps Electron/Tauri e o desempenho me incomodava. Eu também prefiro o terminal, já que orquestradores GUI te prendem no fluxo de trabalho deles. Então eu construí o cmux como um app nativo macOS em Swift/AppKit. Ele usa o libghostty para renderização do terminal e lê sua configuração existente do Ghostty para temas, fontes e cores.
|
||||
|
||||
As principais adições são a barra lateral e o sistema de notificações. A barra lateral tem abas verticais que mostram o branch do git, diretório de trabalho, portas em escuta e o texto da última notificação para cada workspace. O sistema de notificações captura sequências do terminal (OSC 9/99/777) e tem uma CLI (`cmux notify`) que você pode conectar aos hooks de agentes para Claude Code, OpenCode, etc. Quando um agente está esperando, seu painel recebe um anel azul e a aba acende na barra lateral, para que eu possa ver qual precisa de mim entre divisões e abas. Cmd+Shift+U pula para o mais recente não lido.
|
||||
As principais adições são a barra lateral e o sistema de notificações. A barra lateral tem abas verticais que mostram o branch do git, status/número do PR vinculado, diretório de trabalho, portas em escuta e o texto da última notificação para cada workspace. O sistema de notificações captura sequências do terminal (OSC 9/99/777) e tem uma CLI (`cmux notify`) que você pode conectar aos hooks de agentes para Claude Code, OpenCode, etc. Quando um agente está esperando, seu painel recebe um anel azul e a aba acende na barra lateral, para que eu possa ver qual precisa de mim entre divisões e abas. Cmd+Shift+U pula para o mais recente não lido.
|
||||
|
||||
O navegador integrado tem uma API programável portada do [agent-browser](https://github.com/vercel-labs/agent-browser). Agentes podem capturar a árvore de acessibilidade, obter referências de elementos, clicar, preencher formulários e executar JS. Você pode dividir um painel de navegador ao lado do seu terminal e fazer o Claude Code interagir diretamente com seu servidor de desenvolvimento.
|
||||
|
||||
Tudo é programável através da CLI e socket API — criar workspaces/abas, dividir painéis, enviar teclas, abrir URLs no navegador.
|
||||
|
||||
## O Zen do cmux
|
||||
|
||||
O cmux não é prescritivo sobre como os desenvolvedores usam suas ferramentas. É um terminal e navegador com uma CLI, e o resto é com você.
|
||||
|
||||
O cmux é uma primitiva, não uma solução. Ele te dá um terminal, um navegador, notificações, workspaces, divisões, abas e uma CLI para controlar tudo isso. O cmux não te força a usar agentes de programação de uma forma específica. O que você constrói com as primitivas é seu.
|
||||
|
||||
Os melhores desenvolvedores sempre construíram suas próprias ferramentas. Ninguém descobriu ainda a melhor forma de trabalhar com agentes, e as equipes construindo produtos fechados definitivamente também não. Os desenvolvedores mais próximos de suas próprias bases de código vão descobrir primeiro.
|
||||
|
||||
Dê a um milhão de desenvolvedores primitivas combináveis e eles coletivamente encontrarão os fluxos de trabalho mais eficientes mais rápido do que qualquer equipe de produto poderia projetar de cima para baixo.
|
||||
|
||||
## Documentação
|
||||
|
||||
Para mais informações sobre como configurar o cmux, [acesse nossa documentação](https://cmux.dev/docs/getting-started?utm_source=readme).
|
||||
|
||||
## Atalhos de Teclado
|
||||
|
||||
### Workspaces
|
||||
### Áreas de Trabalho
|
||||
|
||||
| Atalho | Ação |
|
||||
|----------|--------|
|
||||
|
|
@ -78,9 +135,10 @@ Tudo é programável através da CLI e socket API — criar workspaces/abas, div
|
|||
| ⌃ ⌘ ] | Próximo workspace |
|
||||
| ⌃ ⌘ [ | Workspace anterior |
|
||||
| ⌘ ⇧ W | Fechar workspace |
|
||||
| ⌘ ⇧ R | Renomear workspace |
|
||||
| ⌘ B | Alternar barra lateral |
|
||||
|
||||
### Surfaces
|
||||
### Superfícies
|
||||
|
||||
| Atalho | Ação |
|
||||
|----------|--------|
|
||||
|
|
@ -104,6 +162,8 @@ Tudo é programável através da CLI e socket API — criar workspaces/abas, div
|
|||
|
||||
### Navegador
|
||||
|
||||
Os atalhos de ferramentas do desenvolvedor do navegador seguem os padrões do Safari e podem ser personalizados em `Configurações → Atalhos de Teclado`.
|
||||
|
||||
| Atalho | Ação |
|
||||
|----------|--------|
|
||||
| ⌘ ⇧ L | Abrir navegador em divisão |
|
||||
|
|
@ -111,7 +171,8 @@ Tudo é programável através da CLI e socket API — criar workspaces/abas, div
|
|||
| ⌘ [ | Voltar |
|
||||
| ⌘ ] | Avançar |
|
||||
| ⌘ R | Recarregar página |
|
||||
| ⌥ ⌘ I | Abrir Ferramentas do Desenvolvedor |
|
||||
| ⌥ ⌘ I | Alternar Ferramentas do Desenvolvedor (padrão Safari) |
|
||||
| ⌥ ⌘ C | Mostrar Console JavaScript (padrão Safari) |
|
||||
|
||||
### Notificações
|
||||
|
||||
|
|
@ -148,6 +209,63 @@ Tudo é programável através da CLI e socket API — criar workspaces/abas, div
|
|||
| ⌘ ⇧ , | Recarregar configuração |
|
||||
| ⌘ Q | Sair |
|
||||
|
||||
## Builds Noturnos
|
||||
|
||||
[Baixar cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
|
||||
O cmux NIGHTLY é um app separado com seu próprio bundle ID, então roda ao lado da versão estável. Construído automaticamente a partir do último commit em `main` e se atualiza automaticamente via seu próprio feed Sparkle.
|
||||
|
||||
## Restauração de sessão (comportamento atual)
|
||||
|
||||
Ao reiniciar, o cmux atualmente restaura apenas o layout do app e metadados:
|
||||
- Layout de janelas/workspaces/painéis
|
||||
- Diretórios de trabalho
|
||||
- Histórico de rolagem do terminal (melhor esforço)
|
||||
- URL do navegador e histórico de navegação
|
||||
|
||||
O cmux **não** restaura o estado de processos ativos dentro de apps de terminal. Por exemplo, sessões ativas de Claude Code/tmux/vim não são retomadas após reiniciar ainda.
|
||||
|
||||
## Histórico de Estrelas
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Contribuindo
|
||||
|
||||
Formas de participar:
|
||||
|
||||
- Siga-nos no X para atualizações [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen), e [@austinywang](https://x.com/austinywang)
|
||||
- Participe da conversa no [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- Crie e participe de [issues no GitHub](https://github.com/manaflow-ai/cmux/issues) e [discussões](https://github.com/manaflow-ai/cmux/discussions)
|
||||
- Nos conte o que você está construindo com o cmux
|
||||
|
||||
## Comunidade
|
||||
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
- [Reddit](https://www.reddit.com/r/cmux/)
|
||||
|
||||
## Edição do Fundador
|
||||
|
||||
O cmux é gratuito, open source, e sempre será. Se você gostaria de apoiar o desenvolvimento e ter acesso antecipado ao que está por vir:
|
||||
|
||||
**[Obter Edição do Fundador](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
|
||||
|
||||
- **Solicitações de recursos/correções de bugs priorizadas**
|
||||
- **Acesso antecipado: cmux AI que te dá contexto sobre cada workspace, aba e painel**
|
||||
- **Acesso antecipado: app iOS com terminais sincronizados entre desktop e celular**
|
||||
- **Acesso antecipado: VMs na nuvem**
|
||||
- **Acesso antecipado: Modo de voz**
|
||||
- **Meu iMessage/WhatsApp pessoal**
|
||||
|
||||
## Licença
|
||||
|
||||
Este projeto é licenciado sob a GNU Affero General Public License v3.0 ou posterior (`AGPL-3.0-or-later`).
|
||||
|
|
|
|||
142
README.ru.md
142
README.ru.md
|
|
@ -1,9 +1,5 @@
|
|||
> Этот перевод создан 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.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | Русский | <a href="README.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">Терминал macOS на базе Ghostty с вертикальными вкладками и уведомлениями для AI-агентов программирования</p>
|
||||
|
||||
|
|
@ -14,16 +10,63 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/screenshot.png" alt="Скриншот cmux" width="900" />
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | Русский | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="Скриншот cmux" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Демо-видео</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a>
|
||||
</p>
|
||||
|
||||
## Возможности
|
||||
|
||||
- **Вертикальные вкладки** — Боковая панель показывает ветку git, рабочий каталог, прослушиваемые порты и текст последнего уведомления
|
||||
- **Кольца уведомлений** — Панели получают синее кольцо, а вкладки подсвечиваются, когда AI-агенты (Claude Code, OpenCode) нуждаются в вашем внимании
|
||||
- **Панель уведомлений** — Просматривайте все ожидающие уведомления в одном месте, переходите к последнему непрочитанному
|
||||
- **Разделённые панели** — Горизонтальное и вертикальное разделение
|
||||
- **Встроенный браузер** — Разделите браузер рядом с терминалом со скриптуемым API, портированным из [agent-browser](https://github.com/vercel-labs/agent-browser)
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Кольца уведомлений</h3>
|
||||
Панели получают синее кольцо, а вкладки подсвечиваются, когда агенты программирования нуждаются в вашем внимании
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/notification-rings.png" alt="Кольца уведомлений" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Панель уведомлений</h3>
|
||||
Просматривайте все ожидающие уведомления в одном месте, переходите к последнему непрочитанному
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/sidebar-notification-badge.png" alt="Значок уведомлений в боковой панели" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Встроенный браузер</h3>
|
||||
Разделите браузер рядом с терминалом со скриптуемым API, портированным из <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/built-in-browser.png" alt="Встроенный браузер" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Вертикальные + горизонтальные вкладки</h3>
|
||||
Боковая панель показывает ветку git, статус/номер связанного PR, рабочий каталог, прослушиваемые порты и текст последнего уведомления. Горизонтальное и вертикальное разделение.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Вертикальные вкладки и разделённые панели" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- **Скриптуемость** — CLI и socket API для создания рабочих пространств, разделения панелей, отправки нажатий клавиш и автоматизации браузера
|
||||
- **Нативное приложение macOS** — Создано на Swift и AppKit, не Electron. Быстрый запуск, низкое потребление памяти.
|
||||
- **Совместимость с Ghostty** — Читает вашу существующую конфигурацию `~/.config/ghostty/config` для тем, шрифтов и цветов
|
||||
|
|
@ -60,12 +103,26 @@ brew upgrade --cask cmux
|
|||
|
||||
Я попробовал несколько оркестраторов для кодирования, но большинство из них были приложениями Electron/Tauri, и их производительность меня раздражала. К тому же я просто предпочитаю терминал, поскольку GUI-оркестраторы привязывают вас к своему рабочему процессу. Поэтому я создал cmux как нативное приложение macOS на Swift/AppKit. Оно использует libghostty для рендеринга терминала и читает вашу существующую конфигурацию Ghostty для тем, шрифтов и цветов.
|
||||
|
||||
Основные дополнения — это боковая панель и система уведомлений. Боковая панель имеет вертикальные вкладки, которые показывают ветку git, рабочий каталог, прослушиваемые порты и текст последнего уведомления для каждого рабочего пространства. Система уведомлений перехватывает терминальные последовательности (OSC 9/99/777) и имеет CLI (`cmux notify`), который можно подключить к хукам агентов для Claude Code, OpenCode и т.д. Когда агент ожидает, его панель получает синее кольцо, а вкладка подсвечивается в боковой панели, так что я могу определить, какой из них нуждается во мне, среди разделений и вкладок. Cmd+Shift+U переходит к последнему непрочитанному.
|
||||
Основные дополнения — это боковая панель и система уведомлений. Боковая панель имеет вертикальные вкладки, которые показывают ветку git, статус/номер связанного PR, рабочий каталог, прослушиваемые порты и текст последнего уведомления для каждого рабочего пространства. Система уведомлений перехватывает терминальные последовательности (OSC 9/99/777) и имеет CLI (`cmux notify`), который можно подключить к хукам агентов для Claude Code, OpenCode и т.д. Когда агент ожидает, его панель получает синее кольцо, а вкладка подсвечивается в боковой панели, так что я могу определить, какой из них нуждается во мне, среди разделений и вкладок. Cmd+Shift+U переходит к последнему непрочитанному.
|
||||
|
||||
Встроенный браузер имеет скриптуемый API, портированный из [agent-browser](https://github.com/vercel-labs/agent-browser). Агенты могут делать снимок дерева доступности, получать ссылки на элементы, кликать, заполнять формы и выполнять JS. Вы можете разделить панель браузера рядом с терминалом и позволить Claude Code взаимодействовать с вашим сервером разработки напрямую.
|
||||
|
||||
Всё скриптуемо через CLI и socket API — создание рабочих пространств/вкладок, разделение панелей, отправка нажатий клавиш, открытие URL в браузере.
|
||||
|
||||
## The Zen of cmux
|
||||
|
||||
cmux не навязывает разработчикам, как использовать свои инструменты. Это терминал и браузер с CLI, а остальное зависит от вас.
|
||||
|
||||
cmux — это примитив, а не решение. Он даёт вам терминал, браузер, уведомления, рабочие пространства, разделения, вкладки и CLI для управления всем этим. cmux не заставляет вас использовать агентов для кодирования определённым образом. То, что вы построите из этих примитивов, принадлежит вам.
|
||||
|
||||
Лучшие разработчики всегда создавали собственные инструменты. Никто ещё не нашёл лучший способ работы с агентами, и команды, создающие закрытые продукты, тоже этого не сделали. Разработчики, ближе всех к своим кодовым базам, найдут это первыми.
|
||||
|
||||
Дайте миллиону разработчиков композируемые примитивы, и они коллективно найдут наиболее эффективные рабочие процессы быстрее, чем любая продуктовая команда могла бы спроектировать сверху вниз.
|
||||
|
||||
## Документация
|
||||
|
||||
Подробнее о настройке cmux читайте в [нашей документации](https://cmux.dev/docs/getting-started?utm_source=readme).
|
||||
|
||||
## Сочетания Клавиш
|
||||
|
||||
### Рабочие пространства
|
||||
|
|
@ -78,6 +135,7 @@ brew upgrade --cask cmux
|
|||
| ⌃ ⌘ ] | Следующее рабочее пространство |
|
||||
| ⌃ ⌘ [ | Предыдущее рабочее пространство |
|
||||
| ⌘ ⇧ W | Закрыть рабочее пространство |
|
||||
| ⌘ ⇧ R | Переименовать рабочее пространство |
|
||||
| ⌘ B | Переключить боковую панель |
|
||||
|
||||
### Поверхности
|
||||
|
|
@ -104,6 +162,8 @@ brew upgrade --cask cmux
|
|||
|
||||
### Браузер
|
||||
|
||||
Сочетания клавиш инструментов разработчика браузера соответствуют настройкам Safari по умолчанию и настраиваются в `Настройки → Сочетания клавиш`.
|
||||
|
||||
| Сочетание | Действие |
|
||||
|----------|--------|
|
||||
| ⌘ ⇧ L | Открыть браузер в разделении |
|
||||
|
|
@ -111,7 +171,8 @@ brew upgrade --cask cmux
|
|||
| ⌘ [ | Назад |
|
||||
| ⌘ ] | Вперёд |
|
||||
| ⌘ R | Перезагрузить страницу |
|
||||
| ⌥ ⌘ I | Открыть Инструменты Разработчика |
|
||||
| ⌥ ⌘ I | Переключить Инструменты Разработчика (по умолчанию Safari) |
|
||||
| ⌥ ⌘ C | Показать Консоль JavaScript (по умолчанию Safari) |
|
||||
|
||||
### Уведомления
|
||||
|
||||
|
|
@ -148,6 +209,63 @@ brew upgrade --cask cmux
|
|||
| ⌘ ⇧ , | Перезагрузить конфигурацию |
|
||||
| ⌘ Q | Выход |
|
||||
|
||||
## Ночные сборки
|
||||
|
||||
[Скачать cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
|
||||
cmux NIGHTLY — это отдельное приложение с собственным идентификатором пакета, поэтому оно работает параллельно со стабильной версией. Собирается автоматически из последнего коммита `main` и обновляется через собственный канал Sparkle.
|
||||
|
||||
## Восстановление сессии (текущее поведение)
|
||||
|
||||
При перезапуске cmux в настоящее время восстанавливает только макет приложения и метаданные:
|
||||
- Макет окон/рабочих пространств/панелей
|
||||
- Рабочие каталоги
|
||||
- Scrollback терминала (по возможности)
|
||||
- URL браузера и история навигации
|
||||
|
||||
cmux **не** восстанавливает состояние живых процессов внутри терминальных приложений. Например, активные сессии Claude Code/tmux/vim пока не возобновляются после перезапуска.
|
||||
|
||||
## История звёзд
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Участие
|
||||
|
||||
Способы принять участие:
|
||||
|
||||
- Подписывайтесь на нас в X для получения обновлений [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen) и [@austinywang](https://x.com/austinywang)
|
||||
- Присоединяйтесь к обсуждению в [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- Создавайте и участвуйте в [GitHub issues](https://github.com/manaflow-ai/cmux/issues) и [обсуждениях](https://github.com/manaflow-ai/cmux/discussions)
|
||||
- Расскажите нам, что вы создаёте с помощью cmux
|
||||
|
||||
## Сообщество
|
||||
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
- [Reddit](https://www.reddit.com/r/cmux/)
|
||||
|
||||
## Издание основателя
|
||||
|
||||
cmux бесплатен, с открытым исходным кодом и всегда будет таким. Если вы хотите поддержать разработку и получить ранний доступ к будущим возможностям:
|
||||
|
||||
**[Получить Издание основателя](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
|
||||
|
||||
- **Приоритетные запросы на функции/исправления ошибок**
|
||||
- **Ранний доступ: cmux AI, который даёт контекст по каждому рабочему пространству, вкладке и панели**
|
||||
- **Ранний доступ: приложение для iOS с терминалами, синхронизированными между компьютером и телефоном**
|
||||
- **Ранний доступ: облачные виртуальные машины**
|
||||
- **Ранний доступ: голосовой режим**
|
||||
- **Мой личный iMessage/WhatsApp**
|
||||
|
||||
## Лицензия
|
||||
|
||||
Этот проект лицензирован под GNU Affero General Public License v3.0 или более поздней версии (`AGPL-3.0-or-later`).
|
||||
|
|
|
|||
160
README.th.md
160
README.th.md
|
|
@ -1,9 +1,5 @@
|
|||
> การแปลนี้สร้างโดย 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.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | ไทย | <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">cmux</h1>
|
||||
<p align="center">เทอร์มินัล macOS ที่ใช้ Ghostty พร้อมแท็บแนวตั้งและการแจ้งเตือนสำหรับเอเจนต์เขียนโค้ด AI</p>
|
||||
|
||||
|
|
@ -14,16 +10,63 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/screenshot.png" alt="ภาพหน้าจอ cmux" width="900" />
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | ไทย | <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="ภาพหน้าจอ cmux" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ วิดีโอสาธิต</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a>
|
||||
</p>
|
||||
|
||||
## คุณสมบัติ
|
||||
|
||||
- **แท็บแนวตั้ง** — แถบด้านข้างแสดง git branch, ไดเรกทอรีทำงาน, พอร์ตที่กำลังฟัง และข้อความแจ้งเตือนล่าสุด
|
||||
- **วงแหวนแจ้งเตือน** — แผงจะมีวงแหวนสีน้ำเงินและแท็บจะสว่างขึ้นเมื่อเอเจนต์ AI (Claude Code, OpenCode) ต้องการความสนใจของคุณ
|
||||
- **แผงแจ้งเตือน** — ดูการแจ้งเตือนที่รอดำเนินการทั้งหมดในที่เดียว ข้ามไปยังรายการที่ยังไม่ได้อ่านล่าสุด
|
||||
- **แผงแบ่ง** — แบ่งแนวนอนและแนวตั้ง
|
||||
- **เบราว์เซอร์ในแอป** — แบ่งเบราว์เซอร์ข้างเทอร์มินัลพร้อม API ที่เขียนสคริปต์ได้ ย้ายมาจาก [agent-browser](https://github.com/vercel-labs/agent-browser)
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>วงแหวนแจ้งเตือน</h3>
|
||||
แผงจะมีวงแหวนสีน้ำเงินและแท็บจะสว่างขึ้นเมื่อเอเจนต์เขียนโค้ดต้องการความสนใจของคุณ
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/notification-rings.png" alt="วงแหวนแจ้งเตือน" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>แผงแจ้งเตือน</h3>
|
||||
ดูการแจ้งเตือนที่รอดำเนินการทั้งหมดในที่เดียว ข้ามไปยังรายการที่ยังไม่ได้อ่านล่าสุด
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/sidebar-notification-badge.png" alt="ป้ายแจ้งเตือนแถบด้านข้าง" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>เบราว์เซอร์ในแอป</h3>
|
||||
แบ่งเบราว์เซอร์ข้างเทอร์มินัลพร้อม API ที่เขียนสคริปต์ได้ ย้ายมาจาก <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/built-in-browser.png" alt="เบราว์เซอร์ในตัว" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>แท็บแนวตั้ง + แนวนอน</h3>
|
||||
แถบด้านข้างแสดง git branch, สถานะ/หมายเลข PR ที่เชื่อมโยง, ไดเรกทอรีทำงาน, พอร์ตที่กำลังฟัง และข้อความแจ้งเตือนล่าสุด แบ่งแนวนอนและแนวตั้ง
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="แท็บแนวตั้งและแผงแบ่ง" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- **เขียนสคริปต์ได้** — CLI และ socket API สำหรับสร้างเวิร์กสเปซ แบ่งแผง ส่งการกดแป้นพิมพ์ และควบคุมเบราว์เซอร์อัตโนมัติ
|
||||
- **แอป macOS ดั้งเดิม** — สร้างด้วย Swift และ AppKit ไม่ใช่ Electron เริ่มต้นเร็ว ใช้หน่วยความจำน้อย
|
||||
- **เข้ากันได้กับ Ghostty** — อ่านการตั้งค่าที่มีอยู่ของคุณจาก `~/.config/ghostty/config` สำหรับธีม ฟอนต์ และสี
|
||||
|
|
@ -60,12 +103,26 @@ brew upgrade --cask cmux
|
|||
|
||||
ผมลองใช้ออร์เคสเตรเตอร์สำหรับเขียนโค้ดบางตัว แต่ส่วนใหญ่เป็นแอป Electron/Tauri และประสิทธิภาพทำให้ผมรำคาญ ผมยังชอบเทอร์มินัลมากกว่าเพราะออร์เคสเตรเตอร์ GUI บังคับให้คุณใช้เวิร์กโฟลว์ของมัน ผมจึงสร้าง cmux เป็นแอป macOS ดั้งเดิมด้วย Swift/AppKit มันใช้ libghostty สำหรับการแสดงผลเทอร์มินัลและอ่านการตั้งค่า Ghostty ที่มีอยู่ของคุณสำหรับธีม ฟอนต์ และสี
|
||||
|
||||
สิ่งที่เพิ่มเติมหลักคือแถบด้านข้างและระบบแจ้งเตือน แถบด้านข้างมีแท็บแนวตั้งที่แสดง git branch, ไดเรกทอรีทำงาน, พอร์ตที่กำลังฟัง และข้อความแจ้งเตือนล่าสุดสำหรับแต่ละเวิร์กสเปซ ระบบแจ้งเตือนจับลำดับเทอร์มินัล (OSC 9/99/777) และมี CLI (`cmux notify`) ที่คุณสามารถเชื่อมต่อกับ hook ของเอเจนต์สำหรับ Claude Code, OpenCode เป็นต้น เมื่อเอเจนต์กำลังรอ แผงของมันจะมีวงแหวนสีน้ำเงินและแท็บจะสว่างขึ้นในแถบด้านข้าง เพื่อให้ผมบอกได้ว่าอันไหนต้องการผมข้ามแผงแบ่งและแท็บต่าง ๆ Cmd+Shift+U ข้ามไปยังรายการที่ยังไม่ได้อ่านล่าสุด
|
||||
สิ่งที่เพิ่มเติมหลักคือแถบด้านข้างและระบบแจ้งเตือน แถบด้านข้างมีแท็บแนวตั้งที่แสดง git branch, สถานะ/หมายเลข PR ที่เชื่อมโยง, ไดเรกทอรีทำงาน, พอร์ตที่กำลังฟัง และข้อความแจ้งเตือนล่าสุดสำหรับแต่ละเวิร์กสเปซ ระบบแจ้งเตือนจับลำดับเทอร์มินัล (OSC 9/99/777) และมี CLI (`cmux notify`) ที่คุณสามารถเชื่อมต่อกับ hook ของเอเจนต์สำหรับ Claude Code, OpenCode เป็นต้น เมื่อเอเจนต์กำลังรอ แผงของมันจะมีวงแหวนสีน้ำเงินและแท็บจะสว่างขึ้นในแถบด้านข้าง เพื่อให้ผมบอกได้ว่าอันไหนต้องการผมข้ามแผงแบ่งและแท็บต่าง ๆ Cmd+Shift+U ข้ามไปยังรายการที่ยังไม่ได้อ่านล่าสุด
|
||||
|
||||
เบราว์เซอร์ในแอปมี API ที่เขียนสคริปต์ได้ ย้ายมาจาก [agent-browser](https://github.com/vercel-labs/agent-browser) เอเจนต์สามารถจับภาพ accessibility tree, รับ element refs, คลิก, กรอกฟอร์ม และรัน JS ได้ คุณสามารถแบ่งแผงเบราว์เซอร์ข้างเทอร์มินัลและให้ Claude Code โต้ตอบกับเซิร์ฟเวอร์สำหรับพัฒนาของคุณโดยตรง
|
||||
|
||||
ทุกอย่างเขียนสคริปต์ได้ผ่าน CLI และ socket API — สร้างเวิร์กสเปซ/แท็บ แบ่งแผง ส่งการกดแป้นพิมพ์ เปิด URL ในเบราว์เซอร์
|
||||
|
||||
## The Zen of cmux
|
||||
|
||||
cmux ไม่ได้กำหนดว่านักพัฒนาต้องใช้เครื่องมืออย่างไร มันเป็นเทอร์มินัลและเบราว์เซอร์พร้อม CLI ส่วนที่เหลือขึ้นอยู่กับคุณ
|
||||
|
||||
cmux เป็นส่วนประกอบพื้นฐาน ไม่ใช่โซลูชันสำเร็จรูป มันให้เทอร์มินัล เบราว์เซอร์ การแจ้งเตือน เวิร์กสเปซ แผงแบ่ง แท็บ และ CLI เพื่อควบคุมทั้งหมด cmux ไม่บังคับให้คุณใช้เอเจนต์เขียนโค้ดในแบบที่มีความคิดเห็นตายตัว สิ่งที่คุณสร้างด้วยส่วนประกอบพื้นฐานเหล่านี้เป็นของคุณ
|
||||
|
||||
นักพัฒนาที่ดีที่สุดสร้างเครื่องมือของตัวเองมาตลอด ยังไม่มีใครหาวิธีทำงานกับเอเจนต์ที่ดีที่สุด และทีมที่สร้างผลิตภัณฑ์แบบปิดก็ยังไม่ได้หาเช่นกัน นักพัฒนาที่อยู่ใกล้โค้ดเบสของตัวเองมากที่สุดจะเป็นคนหาคำตอบก่อน
|
||||
|
||||
ให้ส่วนประกอบพื้นฐานที่ประกอบกันได้แก่นักพัฒนาล้านคน แล้วพวกเขาจะร่วมกันค้นพบเวิร์กโฟลว์ที่มีประสิทธิภาพที่สุดได้เร็วกว่าทีมผลิตภัณฑ์ใดจะออกแบบจากบนลงล่าง
|
||||
|
||||
## เอกสารประกอบ
|
||||
|
||||
สำหรับข้อมูลเพิ่มเติมเกี่ยวกับการตั้งค่า cmux, [ไปที่เอกสารของเรา](https://cmux.dev/docs/getting-started?utm_source=readme)
|
||||
|
||||
## ปุ่มลัด
|
||||
|
||||
### เวิร์กสเปซ
|
||||
|
|
@ -78,20 +135,21 @@ brew upgrade --cask cmux
|
|||
| ⌃ ⌘ ] | เวิร์กสเปซถัดไป |
|
||||
| ⌃ ⌘ [ | เวิร์กสเปซก่อนหน้า |
|
||||
| ⌘ ⇧ W | ปิดเวิร์กสเปซ |
|
||||
| ⌘ ⇧ R | เปลี่ยนชื่อเวิร์กสเปซ |
|
||||
| ⌘ B | สลับแถบด้านข้าง |
|
||||
|
||||
### Surfaces
|
||||
### เซอร์เฟซ
|
||||
|
||||
| ปุ่มลัด | การทำงาน |
|
||||
|----------|--------|
|
||||
| ⌘ T | Surface ใหม่ |
|
||||
| ⌘ ⇧ ] | Surface ถัดไป |
|
||||
| ⌘ ⇧ [ | Surface ก่อนหน้า |
|
||||
| ⌃ Tab | Surface ถัดไป |
|
||||
| ⌃ ⇧ Tab | Surface ก่อนหน้า |
|
||||
| ⌃ 1–8 | ข้ามไป surface 1–8 |
|
||||
| ⌃ 9 | ข้ามไป surface สุดท้าย |
|
||||
| ⌘ W | ปิด surface |
|
||||
| ⌘ T | เซอร์เฟซใหม่ |
|
||||
| ⌘ ⇧ ] | เซอร์เฟซถัดไป |
|
||||
| ⌘ ⇧ [ | เซอร์เฟซก่อนหน้า |
|
||||
| ⌃ Tab | เซอร์เฟซถัดไป |
|
||||
| ⌃ ⇧ Tab | เซอร์เฟซก่อนหน้า |
|
||||
| ⌃ 1–8 | ข้ามไปเซอร์เฟซ 1–8 |
|
||||
| ⌃ 9 | ข้ามไปเซอร์เฟซสุดท้าย |
|
||||
| ⌘ W | ปิดเซอร์เฟซ |
|
||||
|
||||
### แผงแบ่ง
|
||||
|
||||
|
|
@ -104,6 +162,8 @@ brew upgrade --cask cmux
|
|||
|
||||
### เบราว์เซอร์
|
||||
|
||||
ปุ่มลัดเครื่องมือสำหรับนักพัฒนาของเบราว์เซอร์ใช้ค่าเริ่มต้นของ Safari และสามารถปรับแต่งได้ใน `Settings → Keyboard Shortcuts`
|
||||
|
||||
| ปุ่มลัด | การทำงาน |
|
||||
|----------|--------|
|
||||
| ⌘ ⇧ L | เปิดเบราว์เซอร์ในแผงแบ่ง |
|
||||
|
|
@ -111,7 +171,8 @@ brew upgrade --cask cmux
|
|||
| ⌘ [ | ย้อนกลับ |
|
||||
| ⌘ ] | ไปข้างหน้า |
|
||||
| ⌘ R | โหลดหน้าใหม่ |
|
||||
| ⌥ ⌘ I | เปิดเครื่องมือสำหรับนักพัฒนา |
|
||||
| ⌥ ⌘ I | เปิด/ปิดเครื่องมือสำหรับนักพัฒนา (ค่าเริ่มต้น Safari) |
|
||||
| ⌥ ⌘ C | แสดง JavaScript Console (ค่าเริ่มต้น Safari) |
|
||||
|
||||
### การแจ้งเตือน
|
||||
|
||||
|
|
@ -148,6 +209,63 @@ brew upgrade --cask cmux
|
|||
| ⌘ ⇧ , | โหลดการตั้งค่าใหม่ |
|
||||
| ⌘ Q | ออก |
|
||||
|
||||
## บิลด์ Nightly
|
||||
|
||||
[ดาวน์โหลด cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
|
||||
cmux NIGHTLY เป็นแอปแยกต่างหากที่มี bundle ID เป็นของตัวเอง จึงสามารถรันควบคู่กับเวอร์ชันเสถียรได้ สร้างอัตโนมัติจากคอมมิต `main` ล่าสุดและอัปเดตอัตโนมัติผ่านฟีด Sparkle ของตัวเอง
|
||||
|
||||
## การกู้คืนเซสชัน (พฤติกรรมปัจจุบัน)
|
||||
|
||||
เมื่อเปิดใหม่ cmux จะกู้คืนเลย์เอาต์และข้อมูลเมตาของแอปเท่านั้น:
|
||||
- เลย์เอาต์หน้าต่าง/เวิร์กสเปซ/แผง
|
||||
- ไดเรกทอรีทำงาน
|
||||
- ประวัติการเลื่อนของเทอร์มินัล (พยายามอย่างดีที่สุด)
|
||||
- URL ของเบราว์เซอร์และประวัติการนำทาง
|
||||
|
||||
cmux **ไม่**กู้คืนสถานะกระบวนการที่กำลังทำงานภายในแอปเทอร์มินัล ตัวอย่างเช่น เซสชัน Claude Code/tmux/vim ที่กำลังทำงานอยู่จะยังไม่ถูกกู้คืนหลังจากรีสตาร์ท
|
||||
|
||||
## ประวัติดาว
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## การมีส่วนร่วม
|
||||
|
||||
วิธีเข้าร่วม:
|
||||
|
||||
- ติดตามเราบน X สำหรับข่าวสาร [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen) และ [@austinywang](https://x.com/austinywang)
|
||||
- เข้าร่วมสนทนาบน [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- สร้างและมีส่วนร่วมใน [GitHub issues](https://github.com/manaflow-ai/cmux/issues) และ [discussions](https://github.com/manaflow-ai/cmux/discussions)
|
||||
- แจ้งให้เรารู้ว่าคุณกำลังสร้างอะไรด้วย cmux
|
||||
|
||||
## ชุมชน
|
||||
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
- [Reddit](https://www.reddit.com/r/cmux/)
|
||||
|
||||
## Founder's Edition
|
||||
|
||||
cmux เป็นซอฟต์แวร์ฟรี โอเพนซอร์ส และจะเป็นเช่นนั้นตลอดไป หากคุณต้องการสนับสนุนการพัฒนาและเข้าถึงสิ่งที่กำลังจะมาถึงก่อนใคร:
|
||||
|
||||
**[รับ Founder's Edition](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
|
||||
|
||||
- **คำขอฟีเจอร์/แก้ไขบั๊กที่ได้รับความสำคัญ**
|
||||
- **เข้าถึงก่อน: cmux AI ที่ให้บริบทเกี่ยวกับทุกเวิร์กสเปซ แท็บ และแผง**
|
||||
- **เข้าถึงก่อน: แอป iOS ที่ซิงค์เทอร์มินัลระหว่างเดสก์ท็อปและโทรศัพท์**
|
||||
- **เข้าถึงก่อน: Cloud VMs**
|
||||
- **เข้าถึงก่อน: โหมดเสียง**
|
||||
- **iMessage/WhatsApp ส่วนตัวของผม**
|
||||
|
||||
## สัญญาอนุญาต
|
||||
|
||||
โปรเจกต์นี้อยู่ภายใต้สัญญาอนุญาต GNU Affero General Public License v3.0 หรือใหม่กว่า (`AGPL-3.0-or-later`)
|
||||
|
|
|
|||
142
README.tr.md
142
README.tr.md
|
|
@ -1,9 +1,5 @@
|
|||
> Bu çeviri Claude tarafından oluşturulmuştur. İyileştirme önerileriniz varsa lütfen bir PR açın.
|
||||
|
||||
<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.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | Türkçe
|
||||
</p>
|
||||
|
||||
<h1 align="center">cmux</h1>
|
||||
<p align="center">AI kodlama ajanları için dikey sekmeler ve bildirimler içeren Ghostty tabanlı macOS terminali</p>
|
||||
|
||||
|
|
@ -14,16 +10,63 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/screenshot.png" alt="cmux ekran görüntüsü" width="900" />
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | Türkçe
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="cmux ekran görüntüsü" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ Demo videosu</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a>
|
||||
</p>
|
||||
|
||||
## Özellikler
|
||||
|
||||
- **Dikey sekmeler** — Kenar çubuğu git dalını, çalışma dizinini, dinlenen portları ve en son bildirim metnini gösterir
|
||||
- **Bildirim halkaları** — AI ajanları (Claude Code, OpenCode) dikkatinizi istediğinde paneller mavi bir halka alır ve sekmeler yanar
|
||||
- **Bildirim paneli** — Bekleyen tüm bildirimleri tek bir yerden görün, en son okunmamışa atlayın
|
||||
- **Bölünmüş paneller** — Yatay ve dikey bölmeler
|
||||
- **Uygulama içi tarayıcı** — [agent-browser](https://github.com/vercel-labs/agent-browser)'dan aktarılmış betiklenebilir bir API ile terminalinizin yanında bir tarayıcı bölün
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Bildirim halkaları</h3>
|
||||
Kodlama ajanları dikkatinizi istediğinde paneller mavi bir halka alır ve sekmeler yanar
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/notification-rings.png" alt="Bildirim halkaları" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Bildirim paneli</h3>
|
||||
Bekleyen tüm bildirimleri tek bir yerden görün, en son okunmamışa atlayın
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/sidebar-notification-badge.png" alt="Kenar çubuğu bildirim rozeti" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Uygulama içi tarayıcı</h3>
|
||||
<a href="https://github.com/vercel-labs/agent-browser">agent-browser</a>'dan aktarılmış betiklenebilir bir API ile terminalinizin yanında bir tarayıcı bölün
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/built-in-browser.png" alt="Yerleşik tarayıcı" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>Dikey + yatay sekmeler</h3>
|
||||
Kenar çubuğu git dalını, bağlantılı PR durumunu/numarasını, çalışma dizinini, dinlenen portları ve en son bildirim metnini gösterir. Yatay ve dikey bölmeler.
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="Dikey sekmeler ve bölünmüş paneller" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- **Betiklenebilir** — Çalışma alanları oluşturmak, panelleri bölmek, tuş vuruşları göndermek ve tarayıcıyı otomatikleştirmek için CLI ve socket API
|
||||
- **Yerel macOS uygulaması** — Swift ve AppKit ile yapılmıştır, Electron değil. Hızlı başlangıç, düşük bellek kullanımı.
|
||||
- **Ghostty uyumlu** — Temalar, yazı tipleri ve renkler için mevcut `~/.config/ghostty/config` dosyanızı okur
|
||||
|
|
@ -60,12 +103,26 @@ Birçok Claude Code ve Codex oturumunu paralel olarak çalıştırıyorum. Ghost
|
|||
|
||||
Birkaç kodlama orkestratörü denedim ama çoğu Electron/Tauri uygulamasıydı ve performansları beni rahatsız ediyordu. Ayrıca terminali tercih ediyorum çünkü GUI orkestratörleri sizi kendi iş akışlarına kilitliyor. Bu yüzden cmux'u Swift/AppKit'te yerel bir macOS uygulaması olarak geliştirdim. Terminal görüntüleme için libghostty kullanıyor ve temalar, yazı tipleri ve renkler için mevcut Ghostty yapılandırmanızı okuyor.
|
||||
|
||||
Ana eklemeler kenar çubuğu ve bildirim sistemi. Kenar çubuğunda her çalışma alanı için git dalını, çalışma dizinini, dinlenen portları ve en son bildirim metnini gösteren dikey sekmeler var. Bildirim sistemi terminal dizilerini (OSC 9/99/777) yakalıyor ve Claude Code, OpenCode vb. için ajan kancalarına bağlayabileceğiniz bir CLI'ye (`cmux notify`) sahip. Bir ajan beklerken paneli mavi bir halka alıyor ve sekme kenar çubuğunda yanıyor, böylece bölmeler ve sekmeler arasında hangisinin bana ihtiyacı olduğunu görebiliyorum. Cmd+Shift+U en son okunmamışa atlıyor.
|
||||
Ana eklemeler kenar çubuğu ve bildirim sistemi. Kenar çubuğunda her çalışma alanı için git dalını, bağlantılı PR durumunu/numarasını, çalışma dizinini, dinlenen portları ve en son bildirim metnini gösteren dikey sekmeler var. Bildirim sistemi terminal dizilerini (OSC 9/99/777) yakalıyor ve Claude Code, OpenCode vb. için ajan kancalarına bağlayabileceğiniz bir CLI'ye (`cmux notify`) sahip. Bir ajan beklerken paneli mavi bir halka alıyor ve sekme kenar çubuğunda yanıyor, böylece bölmeler ve sekmeler arasında hangisinin bana ihtiyacı olduğunu görebiliyorum. Cmd+Shift+U en son okunmamışa atlıyor.
|
||||
|
||||
Uygulama içi tarayıcının [agent-browser](https://github.com/vercel-labs/agent-browser)'dan aktarılmış betiklenebilir bir API'si var. Ajanlar erişilebilirlik ağacının anlık görüntüsünü alabilir, öğe referansları elde edebilir, tıklayabilir, formları doldurabilir ve JS çalıştırabilir. Terminalinizin yanında bir tarayıcı paneli bölebilir ve Claude Code'un geliştirme sunucunuzla doğrudan etkileşime girmesini sağlayabilirsiniz.
|
||||
|
||||
Her şey CLI ve socket API aracılığıyla betiklenebilir — çalışma alanları/sekmeler oluşturun, panelleri bölün, tuş vuruşları gönderin, tarayıcıda URL'ler açın.
|
||||
|
||||
## The Zen of cmux
|
||||
|
||||
cmux, geliştiricilerin araçlarını nasıl kullandığını dikte etmez. Bir terminal ve tarayıcı ile CLI'dir, geri kalanı size kalmış.
|
||||
|
||||
cmux bir ilkel yapıdır, hazır bir çözüm değil. Size bir terminal, bir tarayıcı, bildirimler, çalışma alanları, bölmeler, sekmeler ve hepsini kontrol etmek için bir CLI verir. cmux sizi kodlama ajanlarını belirli bir şekilde kullanmaya zorlamaz. İlkel yapılarla ne inşa edeceğiniz tamamen size aittir.
|
||||
|
||||
En iyi geliştiriciler her zaman kendi araçlarını yapmıştır. Ajanlarla çalışmanın en iyi yolunu henüz kimse bulamadı ve kapalı ürünler geliştiren ekipler de kesinlikle bulamadı. Kendi kod tabanlarına en yakın olan geliştiriciler bunu ilk keşfedenler olacak.
|
||||
|
||||
Bir milyon geliştiriciye birleştirilebilir ilkel yapılar verin, en verimli iş akışlarını herhangi bir ürün ekibinin yukarıdan aşağıya tasarlayabileceğinden daha hızlı bulacaklardır.
|
||||
|
||||
## Dokümantasyon
|
||||
|
||||
cmux'u nasıl yapılandıracağınız hakkında daha fazla bilgi için, [dokümantasyonumuza gidin](https://cmux.dev/docs/getting-started?utm_source=readme).
|
||||
|
||||
## Klavye Kısayolları
|
||||
|
||||
### Çalışma Alanları
|
||||
|
|
@ -78,6 +135,7 @@ Her şey CLI ve socket API aracılığıyla betiklenebilir — çalışma alanla
|
|||
| ⌃ ⌘ ] | Sonraki çalışma alanı |
|
||||
| ⌃ ⌘ [ | Önceki çalışma alanı |
|
||||
| ⌘ ⇧ W | Çalışma alanını kapat |
|
||||
| ⌘ ⇧ R | Çalışma alanını yeniden adlandır |
|
||||
| ⌘ B | Kenar çubuğunu aç/kapat |
|
||||
|
||||
### Surfaces
|
||||
|
|
@ -104,6 +162,8 @@ Her şey CLI ve socket API aracılığıyla betiklenebilir — çalışma alanla
|
|||
|
||||
### Tarayıcı
|
||||
|
||||
Tarayıcı geliştirici araçları kısayolları Safari varsayılanlarını takip eder ve `Settings → Keyboard Shortcuts` bölümünden özelleştirilebilir.
|
||||
|
||||
| Kısayol | Eylem |
|
||||
|----------|--------|
|
||||
| ⌘ ⇧ L | Bölmede tarayıcı aç |
|
||||
|
|
@ -111,7 +171,8 @@ Her şey CLI ve socket API aracılığıyla betiklenebilir — çalışma alanla
|
|||
| ⌘ [ | Geri |
|
||||
| ⌘ ] | İleri |
|
||||
| ⌘ R | Sayfayı yeniden yükle |
|
||||
| ⌥ ⌘ I | Geliştirici Araçlarını aç |
|
||||
| ⌥ ⌘ I | Geliştirici Araçlarını aç/kapat (Safari varsayılanı) |
|
||||
| ⌥ ⌘ C | JavaScript Konsolunu göster (Safari varsayılanı) |
|
||||
|
||||
### Bildirimler
|
||||
|
||||
|
|
@ -148,6 +209,63 @@ Her şey CLI ve socket API aracılığıyla betiklenebilir — çalışma alanla
|
|||
| ⌘ ⇧ , | Yapılandırmayı yeniden yükle |
|
||||
| ⌘ Q | Çıkış |
|
||||
|
||||
## Nightly Sürümler
|
||||
|
||||
[cmux NIGHTLY'i indir](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
|
||||
cmux NIGHTLY, kendi bundle ID'sine sahip ayrı bir uygulamadır, bu yüzden kararlı sürümle yan yana çalışır. En son `main` commit'inden otomatik olarak derlenir ve kendi Sparkle akışı aracılığıyla otomatik güncellenir.
|
||||
|
||||
## Oturum geri yükleme (mevcut davranış)
|
||||
|
||||
Yeniden başlatıldığında, cmux şu anda yalnızca uygulama düzenini ve meta verileri geri yükler:
|
||||
- Pencere/çalışma alanı/panel düzeni
|
||||
- Çalışma dizinleri
|
||||
- Terminal kaydırma geçmişi (en iyi çaba)
|
||||
- Tarayıcı URL'si ve gezinme geçmişi
|
||||
|
||||
cmux, terminal uygulamaları içindeki canlı işlem durumunu geri **yüklemez**. Örneğin, aktif Claude Code/tmux/vim oturumları yeniden başlatma sonrasında henüz devam ettirilmez.
|
||||
|
||||
## Yıldız Geçmişi
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Katkıda Bulunma
|
||||
|
||||
Katılım yolları:
|
||||
|
||||
- Güncellemeler için bizi X'te takip edin [@manaflowai](https://x.com/manaflowai), [@lawrencecchen](https://x.com/lawrencecchen) ve [@austinywang](https://x.com/austinywang)
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)'da sohbete katılın
|
||||
- [GitHub issues](https://github.com/manaflow-ai/cmux/issues) ve [discussions](https://github.com/manaflow-ai/cmux/discussions) oluşturun ve katılın
|
||||
- cmux ile ne inşa ettiğinizi bize bildirin
|
||||
|
||||
## Topluluk
|
||||
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
- [Reddit](https://www.reddit.com/r/cmux/)
|
||||
|
||||
## Founder's Edition
|
||||
|
||||
cmux ücretsiz, açık kaynak ve her zaman öyle olacak. Geliştirmeyi desteklemek ve sırada ne olduğuna erken erişim almak isterseniz:
|
||||
|
||||
**[Founder's Edition'ı Edinin](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
|
||||
|
||||
- **Öncelikli özellik istekleri/hata düzeltmeleri**
|
||||
- **Erken erişim: Her çalışma alanı, sekme ve panel hakkında bağlam sağlayan cmux AI**
|
||||
- **Erken erişim: Masaüstü ve telefon arasında senkronize terminallere sahip iOS uygulaması**
|
||||
- **Erken erişim: Bulut VM'ler**
|
||||
- **Erken erişim: Sesli mod**
|
||||
- **Kişisel iMessage/WhatsApp'ım**
|
||||
|
||||
## Lisans
|
||||
|
||||
Bu proje GNU Affero Genel Kamu Lisansı v3.0 veya sonrası (`AGPL-3.0-or-later`) ile lisanslanmıştır.
|
||||
|
|
|
|||
140
README.zh-CN.md
140
README.zh-CN.md
|
|
@ -1,7 +1,5 @@
|
|||
> 此翻译由 Claude 生成。如有改进建议,欢迎提交 PR。
|
||||
|
||||
<p align="center"><a href="README.md">English</a> | 简体中文 | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a></p>
|
||||
|
||||
<h1 align="center">cmux</h1>
|
||||
<p align="center">基于 Ghostty 的 macOS 终端,带有垂直标签页和为 AI 编程代理设计的通知系统</p>
|
||||
|
||||
|
|
@ -12,16 +10,63 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/screenshot.png" alt="cmux 截图" width="900" />
|
||||
<a href="README.md">English</a> | 简体中文 | <a href="README.zh-TW.md">繁體中文</a> | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="cmux 截图" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ 演示视频</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a>
|
||||
</p>
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **垂直标签页** — 侧边栏显示 git 分支、工作目录、监听端口和最新通知文本
|
||||
- **通知提示环** — 当 AI 代理(Claude Code、OpenCode)需要您注意时,窗格会显示蓝色光环,标签页会高亮
|
||||
- **通知面板** — 在一处查看所有待处理通知,快速跳转到最新未读通知
|
||||
- **分割窗格** — 支持水平和垂直分割
|
||||
- **内置浏览器** — 在终端旁边分割出浏览器窗格,提供从 [agent-browser](https://github.com/vercel-labs/agent-browser) 移植的可脚本化 API
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>通知提示环</h3>
|
||||
当编程代理需要您注意时,窗格会显示蓝色光环,标签页会高亮
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/notification-rings.png" alt="通知提示环" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>通知面板</h3>
|
||||
在一处查看所有待处理通知,快速跳转到最新未读通知
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/sidebar-notification-badge.png" alt="侧边栏通知徽章" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>内置浏览器</h3>
|
||||
在终端旁边分割出浏览器窗格,提供从 <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> 移植的可脚本化 API
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/built-in-browser.png" alt="内置浏览器" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>垂直 + 水平标签页</h3>
|
||||
侧边栏显示 git 分支、关联 PR 状态/编号、工作目录、监听端口和最新通知文本。支持水平和垂直分割。
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="垂直标签页和分割窗格" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- **可脚本化** — 通过 CLI 和 socket API 创建工作区、分割窗格、发送按键和自动化浏览器操作
|
||||
- **原生 macOS 应用** — 使用 Swift 和 AppKit 构建,非 Electron。启动快速,内存占用低。
|
||||
- **兼容 Ghostty** — 读取您现有的 `~/.config/ghostty/config` 配置文件中的主题、字体和颜色设置
|
||||
|
|
@ -58,12 +103,26 @@ brew upgrade --cask cmux
|
|||
|
||||
我试过几个编程协调工具,但大多数都是 Electron/Tauri 应用,性能让我不满意。我也更喜欢终端,因为 GUI 协调工具会把你锁定在它们的工作流里。所以我用 Swift/AppKit 构建了 cmux,作为一个原生 macOS 应用。它使用 libghostty 进行终端渲染,并读取您现有的 Ghostty 配置中的主题、字体和颜色设置。
|
||||
|
||||
主要新增的是侧边栏和通知系统。侧边栏有垂直标签页,显示每个工作区的 git 分支、工作目录、监听端口和最新通知文本。通知系统能捕获终端序列(OSC 9/99/777),并提供 CLI(`cmux notify`),您可以将其接入 Claude Code、OpenCode 等代理的钩子。当代理等待时,其窗格会显示蓝色光环,标签页会在侧边栏高亮,这样我就能在多个分割窗格和标签页之间一眼看出哪个需要我。⌘⇧U 可以跳转到最新的未读通知。
|
||||
主要新增的是侧边栏和通知系统。侧边栏有垂直标签页,显示每个工作区的 git 分支、关联 PR 状态/编号、工作目录、监听端口和最新通知文本。通知系统能捕获终端序列(OSC 9/99/777),并提供 CLI(`cmux notify`),您可以将其接入 Claude Code、OpenCode 等代理的钩子。当代理等待时,其窗格会显示蓝色光环,标签页会在侧边栏高亮,这样我就能在多个分割窗格和标签页之间一眼看出哪个需要我。⌘⇧U 可以跳转到最新的未读通知。
|
||||
|
||||
内置浏览器拥有从 [agent-browser](https://github.com/vercel-labs/agent-browser) 移植的可脚本化 API。代理可以抓取无障碍树快照、获取元素引用、执行点击、填写表单和执行 JS。您可以在终端旁边分割出浏览器窗格,让 Claude Code 直接与您的开发服务器交互。
|
||||
|
||||
所有操作都可以通过 CLI 和 socket API 进行脚本化 — 创建工作区/标签页、分割窗格、发送按键、在浏览器中打开 URL。
|
||||
|
||||
## The Zen of cmux
|
||||
|
||||
cmux 不规定开发者应该如何使用工具。它是一个带有 CLI 的终端和浏览器,其余的由你决定。
|
||||
|
||||
cmux 是原语,而非解决方案。它提供终端、浏览器、通知、工作区、分割、标签页,以及控制这一切的 CLI。cmux 不强迫你以特定方式使用编程代理。你用这些原语构建什么,完全取决于你自己。
|
||||
|
||||
最优秀的开发者一直在构建自己的工具。还没有人找到与代理协作的最佳方式,那些构建封闭产品的团队也没有找到。最接近自己代码库的开发者会最先找到答案。
|
||||
|
||||
给一百万个开发者可组合的原语,他们会比任何自上而下设计的产品团队更快地找到最高效的工作流。
|
||||
|
||||
## 文档
|
||||
|
||||
有关 cmux 配置的更多信息,请[查看我们的文档](https://cmux.dev/docs/getting-started?utm_source=readme)。
|
||||
|
||||
## 键盘快捷键
|
||||
|
||||
### 工作区
|
||||
|
|
@ -76,6 +135,7 @@ brew upgrade --cask cmux
|
|||
| ⌃ ⌘ ] | 下一个工作区 |
|
||||
| ⌃ ⌘ [ | 上一个工作区 |
|
||||
| ⌘ ⇧ W | 关闭工作区 |
|
||||
| ⌘ ⇧ R | 重命名工作区 |
|
||||
| ⌘ B | 切换侧边栏 |
|
||||
|
||||
### 界面
|
||||
|
|
@ -102,6 +162,8 @@ brew upgrade --cask cmux
|
|||
|
||||
### 浏览器
|
||||
|
||||
浏览器开发者工具快捷键遵循 Safari 默认设置,可在`设置 → 键盘快捷键`中自定义。
|
||||
|
||||
| 快捷键 | 操作 |
|
||||
|----------|--------|
|
||||
| ⌘ ⇧ L | 在分割中打开浏览器 |
|
||||
|
|
@ -109,7 +171,8 @@ brew upgrade --cask cmux
|
|||
| ⌘ [ | 后退 |
|
||||
| ⌘ ] | 前进 |
|
||||
| ⌘ R | 刷新页面 |
|
||||
| ⌥ ⌘ I | 打开开发者工具 |
|
||||
| ⌥ ⌘ I | 切换开发者工具(Safari 默认) |
|
||||
| ⌥ ⌘ C | 显示 JavaScript 控制台(Safari 默认) |
|
||||
|
||||
### 通知
|
||||
|
||||
|
|
@ -146,6 +209,63 @@ brew upgrade --cask cmux
|
|||
| ⌘ ⇧ , | 重新加载配置 |
|
||||
| ⌘ Q | 退出 |
|
||||
|
||||
## 每夜构建
|
||||
|
||||
[下载 cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
|
||||
cmux NIGHTLY 是一个拥有独立 Bundle ID 的单独应用,因此可以与稳定版并行运行。它从最新的 `main` 提交自动构建,并通过独立的 Sparkle 更新源自动更新。
|
||||
|
||||
## 会话恢复(当前行为)
|
||||
|
||||
重新启动时,cmux 目前仅恢复应用布局和元数据:
|
||||
- 窗口/工作区/窗格布局
|
||||
- 工作目录
|
||||
- 终端回滚缓冲区(尽力恢复)
|
||||
- 浏览器 URL 和导航历史
|
||||
|
||||
cmux **不会**恢复终端应用内部的实时进程状态。例如,活动的 Claude Code/tmux/vim 会话在重启后尚无法恢复。
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 参与贡献
|
||||
|
||||
参与方式:
|
||||
|
||||
- 在 X 上关注我们:[@manaflowai](https://x.com/manaflowai)、[@lawrencecchen](https://x.com/lawrencecchen)、[@austinywang](https://x.com/austinywang)
|
||||
- 加入 [Discord](https://discord.gg/xsgFEVrWCZ) 讨论
|
||||
- 创建和参与 [GitHub Issues](https://github.com/manaflow-ai/cmux/issues) 和[讨论](https://github.com/manaflow-ai/cmux/discussions)
|
||||
- 告诉我们您在用 cmux 构建什么
|
||||
|
||||
## 社区
|
||||
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
- [Reddit](https://www.reddit.com/r/cmux/)
|
||||
|
||||
## Founder's Edition
|
||||
|
||||
cmux 免费、开源,并将一直如此。如果您想支持开发并提前体验即将推出的功能:
|
||||
|
||||
**[获取 Founder's Edition](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
|
||||
|
||||
- **功能请求/Bug 修复优先处理**
|
||||
- **抢先体验:为每个工作区、标签页和面板提供上下文的 cmux AI**
|
||||
- **抢先体验:桌面与手机间终端同步的 iOS 应用**
|
||||
- **抢先体验:云端虚拟机**
|
||||
- **抢先体验:语音模式**
|
||||
- **我的个人 iMessage/WhatsApp**
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 GNU Affero 通用公共许可证 v3.0 或更高版本(`AGPL-3.0-or-later`)授权。
|
||||
|
|
|
|||
140
README.zh-TW.md
140
README.zh-TW.md
|
|
@ -1,7 +1,5 @@
|
|||
> 此翻譯由 Claude 生成。如有改進建議,歡迎提交 PR。
|
||||
|
||||
<p align="center"><a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | 繁體中文 | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a></p>
|
||||
|
||||
<h1 align="center">cmux</h1>
|
||||
<p align="center">基於 Ghostty 的 macOS 終端機,具備垂直分頁和為 AI 程式設計代理設計的通知系統</p>
|
||||
|
||||
|
|
@ -12,16 +10,63 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/screenshot.png" alt="cmux 螢幕截圖" width="900" />
|
||||
<a href="README.md">English</a> | <a href="README.zh-CN.md">简体中文</a> | 繁體中文 | <a href="README.ko.md">한국어</a> | <a href="README.de.md">Deutsch</a> | <a href="README.es.md">Español</a> | <a href="README.fr.md">Français</a> | <a href="README.it.md">Italiano</a> | <a href="README.da.md">Dansk</a> | <a href="README.ja.md">日本語</a> | <a href="README.pl.md">Polski</a> | <a href="README.ru.md">Русский</a> | <a href="README.bs.md">Bosanski</a> | <a href="README.ar.md">العربية</a> | <a href="README.no.md">Norsk</a> | <a href="README.pt-BR.md">Português (Brasil)</a> | <a href="README.th.md">ไทย</a> | <a href="README.tr.md">Türkçe</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/manaflowai"><img src="https://img.shields.io/badge/@manaflow-555?logo=x" alt="X / Twitter" /></a>
|
||||
<a href="https://discord.gg/xsgFEVrWCZ"><img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/assets/main-first-image.png" alt="cmux 螢幕截圖" width="900" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=i-WxO5YUTOs">▶ 示範影片</a> · <a href="https://cmux.dev/blog/zen-of-cmux">The Zen of cmux</a>
|
||||
</p>
|
||||
|
||||
## 功能特色
|
||||
|
||||
- **垂直分頁** — 側邊欄顯示 git 分支、工作目錄、監聽連接埠和最新通知文字
|
||||
- **通知提示環** — 當 AI 代理(Claude Code、OpenCode)需要您注意時,窗格會顯示藍色光環,分頁會亮起
|
||||
- **通知面板** — 在同一處檢視所有待處理通知,快速跳轉到最新未讀通知
|
||||
- **分割窗格** — 支援水平和垂直分割
|
||||
- **內建瀏覽器** — 在終端機旁分割出瀏覽器窗格,提供從 [agent-browser](https://github.com/vercel-labs/agent-browser) 移植的可腳本化 API
|
||||
<table>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>通知提示環</h3>
|
||||
當 AI 代理需要您注意時,窗格會顯示藍色光環,分頁會亮起
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/notification-rings.png" alt="通知提示環" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>通知面板</h3>
|
||||
在同一處檢視所有待處理通知,快速跳轉到最新未讀通知
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/sidebar-notification-badge.png" alt="側邊欄通知徽章" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>內建瀏覽器</h3>
|
||||
在終端機旁分割出瀏覽器窗格,提供從 <a href="https://github.com/vercel-labs/agent-browser">agent-browser</a> 移植的可腳本化 API
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/built-in-browser.png" alt="內建瀏覽器" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="40%" valign="middle">
|
||||
<h3>垂直 + 水平分頁</h3>
|
||||
側邊欄顯示 git 分支、關聯的 PR 狀態/編號、工作目錄、監聽連接埠和最新通知文字。支援水平和垂直分割。
|
||||
</td>
|
||||
<td width="60%">
|
||||
<img src="./docs/assets/vertical-horizontal-tabs-and-splits.png" alt="垂直分頁和分割窗格" width="100%" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
- **可腳本化** — 透過 CLI 和 socket API 建立工作區、分割窗格、傳送按鍵和自動化瀏覽器操作
|
||||
- **原生 macOS 應用程式** — 使用 Swift 和 AppKit 建構,非 Electron。啟動快速,記憶體佔用低。
|
||||
- **相容 Ghostty** — 讀取您現有的 `~/.config/ghostty/config` 設定檔中的主題、字型和色彩設定
|
||||
|
|
@ -58,12 +103,26 @@ brew upgrade --cask cmux
|
|||
|
||||
我試過幾個程式設計協調工具,但大多數都是 Electron/Tauri 應用程式,效能讓我不滿意。我也更偏好終端機,因為 GUI 協調工具會把你鎖定在它們的工作流程裡。所以我用 Swift/AppKit 建構了 cmux,作為一個原生 macOS 應用程式。它使用 libghostty 進行終端機渲染,並讀取您現有的 Ghostty 設定中的主題、字型和色彩設定。
|
||||
|
||||
主要新增的是側邊欄和通知系統。側邊欄有垂直分頁,顯示每個工作區的 git 分支、工作目錄、監聽連接埠和最新通知文字。通知系統能擷取終端機序列(OSC 9/99/777),並提供 CLI(`cmux notify`),您可以將其接入 Claude Code、OpenCode 等代理的鉤子。當代理等待時,其窗格會顯示藍色光環,分頁會在側邊欄亮起,這樣我就能在多個分割窗格和分頁之間一眼看出哪個需要我。⌘⇧U 可以跳轉到最新的未讀通知。
|
||||
主要新增的是側邊欄和通知系統。側邊欄有垂直分頁,顯示每個工作區的 git 分支、關聯的 PR 狀態/編號、工作目錄、監聽連接埠和最新通知文字。通知系統能擷取終端機序列(OSC 9/99/777),並提供 CLI(`cmux notify`),您可以將其接入 Claude Code、OpenCode 等代理的鉤子。當代理等待時,其窗格會顯示藍色光環,分頁會在側邊欄亮起,這樣我就能在多個分割窗格和分頁之間一眼看出哪個需要我。⌘⇧U 可以跳轉到最新的未讀通知。
|
||||
|
||||
內建瀏覽器擁有從 [agent-browser](https://github.com/vercel-labs/agent-browser) 移植的可腳本化 API。代理可以擷取無障礙樹快照、取得元素參考、執行點擊、填寫表單和執行 JS。您可以在終端機旁分割出瀏覽器窗格,讓 Claude Code 直接與您的開發伺服器互動。
|
||||
|
||||
所有操作都可以透過 CLI 和 socket API 進行腳本化 — 建立工作區/分頁、分割窗格、傳送按鍵、在瀏覽器中開啟 URL。
|
||||
|
||||
## The Zen of cmux
|
||||
|
||||
cmux 不會規定開發者如何使用工具。它是一個帶有 CLI 的終端機和瀏覽器,其餘由您決定。
|
||||
|
||||
cmux 是一個基礎元件,而非完整方案。它提供終端機、瀏覽器、通知、工作區、分割、分頁,以及控制一切的 CLI。cmux 不會強迫您採用特定的方式使用程式設計代理。您用這些基礎元件打造什麼,由您決定。
|
||||
|
||||
最好的開發者一直在打造自己的工具。沒有人知道與代理協作的最佳方式,那些打造封閉產品的團隊也一樣。最了解自己程式碼庫的開發者會最先找到答案。
|
||||
|
||||
給一百萬個開發者可組合的基礎元件,他們會比任何自上而下設計的產品團隊更快地集體找到最高效的工作流程。
|
||||
|
||||
## 文件
|
||||
|
||||
如需更多 cmux 設定資訊,[請前往我們的文件](https://cmux.dev/docs/getting-started?utm_source=readme)。
|
||||
|
||||
## 鍵盤快捷鍵
|
||||
|
||||
### 工作區
|
||||
|
|
@ -76,6 +135,7 @@ brew upgrade --cask cmux
|
|||
| ⌃ ⌘ ] | 下一個工作區 |
|
||||
| ⌃ ⌘ [ | 上一個工作區 |
|
||||
| ⌘ ⇧ W | 關閉工作區 |
|
||||
| ⌘ ⇧ R | 重新命名工作區 |
|
||||
| ⌘ B | 切換側邊欄 |
|
||||
|
||||
### 介面
|
||||
|
|
@ -102,6 +162,8 @@ brew upgrade --cask cmux
|
|||
|
||||
### 瀏覽器
|
||||
|
||||
瀏覽器開發者工具快捷鍵遵循 Safari 預設設定,可在 `設定 → 鍵盤快捷鍵` 中自訂。
|
||||
|
||||
| 快捷鍵 | 動作 |
|
||||
|----------|--------|
|
||||
| ⌘ ⇧ L | 在分割中開啟瀏覽器 |
|
||||
|
|
@ -109,7 +171,8 @@ brew upgrade --cask cmux
|
|||
| ⌘ [ | 後退 |
|
||||
| ⌘ ] | 前進 |
|
||||
| ⌘ R | 重新整理頁面 |
|
||||
| ⌥ ⌘ I | 開啟開發者工具 |
|
||||
| ⌥ ⌘ I | 切換開發者工具(Safari 預設) |
|
||||
| ⌥ ⌘ C | 顯示 JavaScript 主控台(Safari 預設) |
|
||||
|
||||
### 通知
|
||||
|
||||
|
|
@ -146,6 +209,63 @@ brew upgrade --cask cmux
|
|||
| ⌘ ⇧ , | 重新載入設定 |
|
||||
| ⌘ Q | 結束 |
|
||||
|
||||
## 每夜建構
|
||||
|
||||
[下載 cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
|
||||
|
||||
cmux NIGHTLY 是一個獨立的應用程式,擁有自己的 bundle ID,因此可以與穩定版並行執行。每次從最新的 `main` 提交自動建構,並透過自己的 Sparkle 來源自動更新。
|
||||
|
||||
## 工作階段還原(目前行為)
|
||||
|
||||
重新啟動時,cmux 目前僅還原應用程式佈局和中繼資料:
|
||||
- 視窗/工作區/窗格佈局
|
||||
- 工作目錄
|
||||
- 終端機捲動緩衝區(盡力而為)
|
||||
- 瀏覽器 URL 和瀏覽歷程
|
||||
|
||||
cmux **不會**還原終端機應用程式內的即時程序狀態。例如,活躍的 Claude Code/tmux/vim 工作階段在重新啟動後尚無法恢復。
|
||||
|
||||
## Star 歷史
|
||||
|
||||
<a href="https://star-history.com/#manaflow-ai/cmux&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=manaflow-ai/cmux&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 參與貢獻
|
||||
|
||||
參與方式:
|
||||
|
||||
- 在 X 上追蹤我們獲取最新動態 [@manaflowai](https://x.com/manaflowai)、[@lawrencecchen](https://x.com/lawrencecchen) 和 [@austinywang](https://x.com/austinywang)
|
||||
- 加入 [Discord](https://discord.gg/xsgFEVrWCZ) 上的討論
|
||||
- 建立和參與 [GitHub issues](https://github.com/manaflow-ai/cmux/issues) 和 [discussions](https://github.com/manaflow-ai/cmux/discussions)
|
||||
- 讓我們知道您正在用 cmux 打造什麼
|
||||
|
||||
## 社群
|
||||
|
||||
- [Discord](https://discord.gg/xsgFEVrWCZ)
|
||||
- [GitHub](https://github.com/manaflow-ai/cmux)
|
||||
- [X / Twitter](https://twitter.com/manaflowai)
|
||||
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
|
||||
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
|
||||
- [Reddit](https://www.reddit.com/r/cmux/)
|
||||
|
||||
## 創始版
|
||||
|
||||
cmux 免費、開源,且將永遠如此。如果您想支持開發並提前體驗即將推出的功能:
|
||||
|
||||
**[取得創始版](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
|
||||
|
||||
- **優先處理的功能請求/錯誤修復**
|
||||
- **搶先體驗:cmux AI 為您提供每個工作區、分頁和面板的上下文資訊**
|
||||
- **搶先體驗:iOS 應用程式,終端機在桌面和手機之間同步**
|
||||
- **搶先體驗:雲端虛擬機**
|
||||
- **搶先體驗:語音模式**
|
||||
- **我的個人 iMessage/WhatsApp**
|
||||
|
||||
## 授權條款
|
||||
|
||||
本專案採用 GNU Affero 通用公共授權條款 v3.0 或更新版本(`AGPL-3.0-or-later`)授權。
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@
|
|||
</array>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>NSAppleScriptEnabled</key>
|
||||
<true/>
|
||||
<key>OSAScriptingDefinition</key>
|
||||
<string>cmux.sdef</string>
|
||||
<key>NSServices</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
|
@ -89,27 +93,15 @@
|
|||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<key>UTImportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.splittabbar.tabtransfer</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Bonsplit Tab Transfer</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.cmux.sidebar-tab-reorder</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>cmux Sidebar Tab Reorder</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
|
|
|||
|
|
@ -115,6 +115,210 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"cli.claude-teams.usage": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Usage: cmux claude-teams [claude-args...]\n\nLaunch Claude Code with agent teams enabled.\n\nThis command:\n - defaults Claude teammate mode to auto\n - sets a tmux-like environment so Claude auto mode uses cmux splits\n - sets CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1\n - prepends a private tmux shim to PATH\n - forwards all remaining arguments to claude\n\nThe tmux shim translates supported tmux window/pane commands into cmux\nworkspace and split operations in the current cmux session.\n\nExamples:\n cmux claude-teams\n cmux claude-teams --continue\n cmux claude-teams --model sonnet"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "使い方: cmux claude-teams [claude-args...]\n\nエージェントチームを有効にした状態で Claude Code を起動します。\n\nこのコマンドは次を行います:\n - Claude の teammate mode を auto に設定\n - Claude の auto mode が cmux の split を使うよう tmux 風の環境を設定\n - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 を設定\n - 専用の tmux shim を PATH の先頭に追加\n - 残りの引数をそのまま claude に渡す\n\ntmux shim は、対応している tmux の window/pane コマンドを、現在の cmux セッション内の workspace と split 操作に変換します。\n\n例:\n cmux claude-teams\n cmux claude-teams --continue\n cmux claude-teams --model sonnet"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"applescript.error.disabled": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "AppleScript is disabled by the macos-applescript configuration."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "macos-applescript の設定で AppleScript は無効になっています。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"applescript.error.failedToCreateSplit": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Failed to create split."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "分割の作成に失敗しました。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"applescript.error.failedToCreateWindow": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Failed to create window."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ウインドウの作成に失敗しました。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"applescript.error.failedToCreateWorkspace": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Failed to create workspace."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ワークスペースの作成に失敗しました。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"applescript.error.missingAction": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Missing action string."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "アクション文字列がありません。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"applescript.error.missingInputText": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Missing input text."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "入力するテキストがありません。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"applescript.error.missingSplitDirection": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Missing or unknown split direction."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "分割方向がないか、不明です。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"applescript.error.missingTerminalTarget": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Missing terminal target."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "対象のターミナルがありません。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"applescript.error.terminalUnavailable": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Terminal is no longer available."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ターミナルはもう利用できません。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"applescript.error.windowUnavailable": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Window is no longer available."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ウインドウはもう利用できません。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"applescript.error.workspaceUnavailable": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Workspace is no longer available."
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ワークスペースはもう利用できません。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"about.build": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
@ -618,6 +822,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.welcome": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Welcome"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "ようこそ"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sidebar.help.changelog": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
@ -38774,6 +38995,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"notifications.jumpToLatest": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Jump to Latest"
|
||||
}
|
||||
},
|
||||
"ja": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "最新へジャンプ"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications.jumpToLatestUnread": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
|
|
|
|||
192
Resources/cmux.sdef
Normal file
192
Resources/cmux.sdef
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE dictionary SYSTEM "file://localhost/System/Library/DTDs/sdef.dtd">
|
||||
|
||||
<dictionary title="cmux Scripting Dictionary">
|
||||
<suite name="cmux Suite" code="Cmux" description="cmux scripting support.">
|
||||
<class name="application" code="capp" description="The cmux application.">
|
||||
<cocoa class="NSApplication"/>
|
||||
<property name="name" code="pnam" type="text" access="r" description="The name of the application."/>
|
||||
<property name="frontmost" code="pisf" type="boolean" access="r" description="Is this the active application?">
|
||||
<cocoa key="isActive"/>
|
||||
</property>
|
||||
<property name="front window" code="CMFW" type="window" access="r" description="The frontmost cmux window.">
|
||||
<cocoa key="frontWindow"/>
|
||||
</property>
|
||||
<property name="version" code="vers" type="text" access="r" description="The version number of the application."/>
|
||||
<responds-to command="perform action">
|
||||
<cocoa method="handlePerformActionScriptCommand:"/>
|
||||
</responds-to>
|
||||
<responds-to command="new window">
|
||||
<cocoa method="handleNewWindowScriptCommand:"/>
|
||||
</responds-to>
|
||||
<responds-to command="new tab">
|
||||
<cocoa method="handleNewTabScriptCommand:"/>
|
||||
</responds-to>
|
||||
<responds-to command="quit">
|
||||
<cocoa method="handleQuitScriptCommand:"/>
|
||||
</responds-to>
|
||||
|
||||
<element type="window" access="r">
|
||||
<cocoa key="scriptWindows"/>
|
||||
</element>
|
||||
|
||||
<element type="terminal" access="r">
|
||||
<cocoa key="terminals"/>
|
||||
</element>
|
||||
</class>
|
||||
|
||||
<class name="window" code="CMwn" plural="windows" description="A cmux window containing one or more workspaces.">
|
||||
<cocoa class="CmuxScriptWindow"/>
|
||||
<property name="id" code="ID " type="text" access="r" description="Stable ID for this window."/>
|
||||
<property name="name" code="pnam" type="text" access="r" description="The title of the window.">
|
||||
<cocoa key="title"/>
|
||||
</property>
|
||||
<property name="selected tab" code="CMsT" type="tab" access="r" description="The selected workspace in this window.">
|
||||
<cocoa key="selectedTab"/>
|
||||
</property>
|
||||
<responds-to command="activate window">
|
||||
<cocoa method="handleActivateWindowCommand:"/>
|
||||
</responds-to>
|
||||
<responds-to command="close window">
|
||||
<cocoa method="handleCloseWindowCommand:"/>
|
||||
</responds-to>
|
||||
<element type="tab" access="r">
|
||||
<cocoa key="tabs"/>
|
||||
</element>
|
||||
<element type="terminal" access="r">
|
||||
<cocoa key="terminals"/>
|
||||
</element>
|
||||
</class>
|
||||
|
||||
<class name="tab" code="CMtb" plural="tabs" description="A cmux workspace.">
|
||||
<cocoa class="CmuxScriptTab"/>
|
||||
<property name="id" code="ID " type="text" access="r" description="Stable ID for this workspace."/>
|
||||
<property name="name" code="pnam" type="text" access="r" description="The title of the workspace.">
|
||||
<cocoa key="title"/>
|
||||
</property>
|
||||
<property name="index" code="pidx" type="integer" access="r" description="1-based index of this workspace in its window."/>
|
||||
<property name="selected" code="CMsl" type="boolean" access="r" description="Whether this workspace is selected in its window."/>
|
||||
<property name="focused terminal" code="CMfT" type="terminal" access="r" description="The currently focused terminal panel in this workspace.">
|
||||
<cocoa key="focusedTerminal"/>
|
||||
</property>
|
||||
<responds-to command="select tab">
|
||||
<cocoa method="handleSelectTabCommand:"/>
|
||||
</responds-to>
|
||||
<responds-to command="close tab">
|
||||
<cocoa method="handleCloseTabCommand:"/>
|
||||
</responds-to>
|
||||
<element type="terminal" access="r">
|
||||
<cocoa key="terminals"/>
|
||||
</element>
|
||||
</class>
|
||||
|
||||
<class name="terminal" code="CMtr" plural="terminals" description="An individual terminal panel.">
|
||||
<cocoa class="CmuxScriptTerminal"/>
|
||||
<property name="id" code="ID " type="text" access="r" description="Stable ID for this terminal panel."/>
|
||||
<property name="name" code="pnam" type="text" access="r" description="Current terminal title.">
|
||||
<cocoa key="title"/>
|
||||
</property>
|
||||
<property name="working directory" code="CMwd" type="text" access="r" description="Current working directory for the terminal process.">
|
||||
<cocoa key="workingDirectory"/>
|
||||
</property>
|
||||
<responds-to command="split">
|
||||
<cocoa method="handleSplitCommand:"/>
|
||||
</responds-to>
|
||||
<responds-to command="focus">
|
||||
<cocoa method="handleFocusCommand:"/>
|
||||
</responds-to>
|
||||
<responds-to command="close">
|
||||
<cocoa method="handleCloseCommand:"/>
|
||||
</responds-to>
|
||||
</class>
|
||||
|
||||
<enumeration name="split direction" code="CMSD" description="Direction for a new split.">
|
||||
<enumerator name="right" code="GSrt" description="Split to the right."/>
|
||||
<enumerator name="left" code="GSlf" description="Split to the left."/>
|
||||
<enumerator name="down" code="GSdn" description="Split downward."/>
|
||||
<enumerator name="up" code="GSup" description="Split upward."/>
|
||||
</enumeration>
|
||||
|
||||
<command name="perform action" code="CmuxPfAc" description="Perform a Ghostty action string on a terminal.">
|
||||
<direct-parameter type="text" description="The Ghostty action string."/>
|
||||
<parameter name="on" code="CMoT" type="terminal" description="Target terminal.">
|
||||
<cocoa key="on"/>
|
||||
</parameter>
|
||||
<result type="boolean" description="True when the action was performed."/>
|
||||
</command>
|
||||
|
||||
<command name="new window" code="CmuxNWin" description="Create a new cmux window.">
|
||||
<result type="window" description="The newly created window."/>
|
||||
</command>
|
||||
|
||||
<command name="new tab" code="CmuxNTab" description="Create a new workspace.">
|
||||
<parameter name="in" code="CMtW" type="window" optional="yes" description="Target window for the new workspace.">
|
||||
<cocoa key="window"/>
|
||||
</parameter>
|
||||
<result type="tab" description="The newly created workspace."/>
|
||||
</command>
|
||||
|
||||
<command name="split" code="CmuxSplt" description="Split a terminal in the given direction.">
|
||||
<direct-parameter type="specifier" description="The terminal to split."/>
|
||||
<parameter name="direction" code="CMpd" type="split direction" description="The direction to split.">
|
||||
<cocoa key="direction"/>
|
||||
</parameter>
|
||||
<result type="terminal" description="The newly created terminal."/>
|
||||
</command>
|
||||
|
||||
<command name="focus" code="CmuxFcus" description="Focus a terminal, bringing its window to the front.">
|
||||
<direct-parameter type="specifier" description="The terminal to focus."/>
|
||||
</command>
|
||||
|
||||
<command name="close" code="CmuxClos" description="Close a terminal.">
|
||||
<direct-parameter type="specifier" description="The terminal to close."/>
|
||||
</command>
|
||||
|
||||
<command name="activate window" code="CmuxAcWn" description="Activate a cmux window, bringing it to the front.">
|
||||
<direct-parameter type="specifier" description="The window to activate."/>
|
||||
</command>
|
||||
|
||||
<command name="select tab" code="CmuxSlTb" description="Select a workspace in its window.">
|
||||
<direct-parameter type="specifier" description="The workspace to select."/>
|
||||
</command>
|
||||
|
||||
<command name="close tab" code="CmuxClTb" description="Close a workspace.">
|
||||
<direct-parameter type="specifier" description="The workspace to close."/>
|
||||
</command>
|
||||
|
||||
<command name="close window" code="CmuxClWn" description="Close a window.">
|
||||
<direct-parameter type="specifier" description="The window to close."/>
|
||||
</command>
|
||||
|
||||
<command name="input text" code="CmuxInTx" description="Input text to a terminal as if it was pasted.">
|
||||
<cocoa class="CmuxScriptInputTextCommand"/>
|
||||
<direct-parameter type="text" description="The text to input."/>
|
||||
<parameter name="to" code="CMiT" type="terminal" description="The terminal to input text to.">
|
||||
<cocoa key="terminal"/>
|
||||
</parameter>
|
||||
</command>
|
||||
</suite>
|
||||
|
||||
<suite name="Standard Suite" code="????" description="Common classes and commands for all applications.">
|
||||
<command name="count" code="corecnte" description="Return the number of elements of a particular class within an object.">
|
||||
<cocoa class="NSCountCommand"/>
|
||||
<access-group identifier="*"/>
|
||||
<direct-parameter type="specifier" requires-access="r" description="The objects to be counted."/>
|
||||
<parameter name="each" code="kocl" type="type" optional="yes" description="The class of objects to be counted." hidden="yes">
|
||||
<cocoa key="ObjectClass"/>
|
||||
</parameter>
|
||||
<result type="integer" description="The count."/>
|
||||
</command>
|
||||
|
||||
<command name="exists" code="coredoex" description="Verify that an object exists.">
|
||||
<cocoa class="NSExistsCommand"/>
|
||||
<access-group identifier="*"/>
|
||||
<direct-parameter type="any" requires-access="r" description="The object(s) to check."/>
|
||||
<result type="boolean" description="Did the object(s) exist?"/>
|
||||
</command>
|
||||
|
||||
<command name="quit" code="aevtquit" description="Quit the application.">
|
||||
<cocoa class="NSQuitCommand"/>
|
||||
</command>
|
||||
</suite>
|
||||
</dictionary>
|
||||
|
|
@ -13,9 +13,7 @@
|
|||
# - 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
|
||||
|
|
@ -33,9 +31,10 @@ fi
|
|||
if [[ -o interactive ]]; then
|
||||
# We overwrote GhosttyKit's injected ZDOTDIR, so manually load Ghostty's
|
||||
# zsh integration if available.
|
||||
# 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
|
||||
#
|
||||
# We can't rely on GHOSTTY_ZSH_ZDOTDIR here because Ghostty's own zsh
|
||||
# bootstrap unsets it before chaining into this cmux wrapper.
|
||||
if [[ "${CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION:-0}" == "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
|
||||
|
|
@ -47,5 +46,5 @@ fi
|
|||
fi
|
||||
fi
|
||||
|
||||
builtin unset _cmux_file _cmux_ghostty _cmux_integ _cmux_had_ghostty_zdotdir
|
||||
builtin unset _cmux_file _cmux_ghostty _cmux_integ
|
||||
}
|
||||
|
|
|
|||
|
|
@ -307,6 +307,9 @@ final class VSCodeServeWebController {
|
|||
private var isLaunching = false
|
||||
private var activeLaunchGeneration: UInt64?
|
||||
private var lifecycleGeneration: UInt64 = 0
|
||||
#if DEBUG
|
||||
private var testingTrackedProcesses: [Process] = []
|
||||
#endif
|
||||
|
||||
private init(launchProcessOverride: ((URL, UInt64) -> (process: Process, url: URL)?)? = nil) {
|
||||
self.launchProcessOverride = launchProcessOverride
|
||||
|
|
@ -318,6 +321,26 @@ final class VSCodeServeWebController {
|
|||
) -> VSCodeServeWebController {
|
||||
VSCodeServeWebController(launchProcessOverride: launchProcessOverride)
|
||||
}
|
||||
|
||||
func trackConnectionTokenFileForTesting(
|
||||
_ connectionTokenFileURL: URL,
|
||||
setAsLaunchingProcess: Bool = false,
|
||||
setAsServeWebProcess: Bool = false
|
||||
) {
|
||||
let process = Process()
|
||||
queue.sync {
|
||||
if setAsLaunchingProcess {
|
||||
self.launchingProcess = process
|
||||
}
|
||||
if setAsServeWebProcess {
|
||||
self.serveWebProcess = process
|
||||
}
|
||||
if !setAsLaunchingProcess && !setAsServeWebProcess {
|
||||
self.testingTrackedProcesses.append(process)
|
||||
}
|
||||
self.connectionTokenFilesByProcessID[ObjectIdentifier(process)] = connectionTokenFileURL
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func ensureServeWebURL(vscodeApplicationURL: URL, completion: @escaping (URL?) -> Void) {
|
||||
|
|
@ -420,6 +443,9 @@ final class VSCodeServeWebController {
|
|||
}
|
||||
self.serveWebProcess = nil
|
||||
self.launchingProcess = nil
|
||||
#if DEBUG
|
||||
self.testingTrackedProcesses.removeAll()
|
||||
#endif
|
||||
var tokenFileURLs = processes.compactMap {
|
||||
self.connectionTokenFilesByProcessID.removeValue(forKey: ObjectIdentifier($0))
|
||||
}
|
||||
|
|
@ -1490,6 +1516,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
}
|
||||
|
||||
struct ScriptableMainWindowState {
|
||||
let windowId: UUID
|
||||
let tabManager: TabManager
|
||||
let window: NSWindow?
|
||||
}
|
||||
|
||||
struct SessionDisplayGeometry {
|
||||
let displayID: UInt32?
|
||||
let frame: CGRect
|
||||
|
|
@ -3414,6 +3446,95 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
windowForMainWindowId(windowId)
|
||||
}
|
||||
|
||||
func mainWindowContainingWorkspace(_ workspaceId: UUID) -> NSWindow? {
|
||||
for context in mainWindowContexts.values where context.tabManager.tabs.contains(where: { $0.id == workspaceId }) {
|
||||
if let window = context.window ?? windowForMainWindowId(context.windowId) {
|
||||
return window
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scriptableMainWindows() -> [ScriptableMainWindowState] {
|
||||
var results: [ScriptableMainWindowState] = []
|
||||
var seen: Set<UUID> = []
|
||||
|
||||
for window in NSApp.orderedWindows {
|
||||
guard let context = contextForMainTerminalWindow(window, reindex: false) else { continue }
|
||||
guard seen.insert(context.windowId).inserted else { continue }
|
||||
results.append(
|
||||
ScriptableMainWindowState(
|
||||
windowId: context.windowId,
|
||||
tabManager: context.tabManager,
|
||||
window: context.window ?? windowForMainWindowId(context.windowId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let remaining = mainWindowContexts.values
|
||||
.sorted { $0.windowId.uuidString < $1.windowId.uuidString }
|
||||
.filter { seen.insert($0.windowId).inserted }
|
||||
|
||||
for context in remaining {
|
||||
results.append(
|
||||
ScriptableMainWindowState(
|
||||
windowId: context.windowId,
|
||||
tabManager: context.tabManager,
|
||||
window: context.window ?? windowForMainWindowId(context.windowId)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func scriptableMainWindow(windowId: UUID) -> ScriptableMainWindowState? {
|
||||
guard let context = mainWindowContexts.values.first(where: { $0.windowId == windowId }) else {
|
||||
return nil
|
||||
}
|
||||
return ScriptableMainWindowState(
|
||||
windowId: context.windowId,
|
||||
tabManager: context.tabManager,
|
||||
window: context.window ?? windowForMainWindowId(context.windowId)
|
||||
)
|
||||
}
|
||||
|
||||
func scriptableMainWindowForTab(_ tabId: UUID) -> ScriptableMainWindowState? {
|
||||
guard let context = contextContainingTabId(tabId) else { return nil }
|
||||
return ScriptableMainWindowState(
|
||||
windowId: context.windowId,
|
||||
tabManager: context.tabManager,
|
||||
window: context.window ?? windowForMainWindowId(context.windowId)
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func focusScriptableMainWindow(windowId: UUID, bringToFront shouldBringToFront: Bool) -> Bool {
|
||||
guard let state = scriptableMainWindow(windowId: windowId),
|
||||
let window = state.window else {
|
||||
return false
|
||||
}
|
||||
setActiveMainWindow(window)
|
||||
if shouldBringToFront {
|
||||
bringToFront(window)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addWorkspace(windowId: UUID, workingDirectory: String? = nil, bringToFront shouldBringToFront: Bool = false) -> UUID? {
|
||||
guard let state = scriptableMainWindow(windowId: windowId) else { return nil }
|
||||
if shouldBringToFront, let window = state.window {
|
||||
setActiveMainWindow(window)
|
||||
bringToFront(window)
|
||||
}
|
||||
let workspace = state.tabManager.addWorkspace(
|
||||
workingDirectory: workingDirectory,
|
||||
select: shouldBringToFront
|
||||
)
|
||||
return workspace.id
|
||||
}
|
||||
|
||||
private func markCommandPaletteOpenRequested(for window: NSWindow?) {
|
||||
guard let window,
|
||||
let windowId = mainWindowId(for: window) else { return }
|
||||
|
|
@ -4785,6 +4906,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
updateController.checkForUpdates()
|
||||
}
|
||||
|
||||
func openWelcomeWorkspace() {
|
||||
guard let context = preferredMainWindowContextForWorkspaceCreation(event: nil, debugSource: "welcome") else {
|
||||
return
|
||||
}
|
||||
if let window = context.window ?? windowForMainWindowId(context.windowId) {
|
||||
setActiveMainWindow(window)
|
||||
bringToFront(window)
|
||||
}
|
||||
let workspace = context.tabManager.addWorkspace(select: true, autoWelcomeIfNeeded: false)
|
||||
sendWelcomeCommandWhenReady(to: workspace)
|
||||
}
|
||||
|
||||
func sendWelcomeCommandWhenReady(to workspace: Workspace, markShownOnSend: Bool = false) {
|
||||
sendTextWhenReady("cmux welcome\n", to: workspace) {
|
||||
if markShownOnSend {
|
||||
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func applyUpdateIfAvailable(_ sender: Any?) {
|
||||
updateViewModel.overrideState = nil
|
||||
updateController.installUpdate()
|
||||
|
|
@ -4914,7 +5055,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
SettingsWindowController.shared.show(navigationTarget: target)
|
||||
},
|
||||
activateApplication: @MainActor () -> Void = {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
|
||||
}
|
||||
) {
|
||||
#if DEBUG
|
||||
|
|
@ -4922,6 +5063,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
#endif
|
||||
showFallbackSettingsWindow(navigationTarget)
|
||||
activateApplication()
|
||||
if let window = SettingsWindowController.shared.window {
|
||||
window.orderFrontRegardless()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
DispatchQueue.main.async {
|
||||
window.orderFrontRegardless()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
}
|
||||
#if DEBUG
|
||||
dlog("settings.open.present activate=1")
|
||||
#endif
|
||||
|
|
@ -5019,6 +5168,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
pasteboard.setString(payload, forType: .string)
|
||||
}
|
||||
|
||||
private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0, beforeSend: (() -> Void)? = nil) {
|
||||
let maxAttempts = 60
|
||||
if let terminalPanel = tab.focusedTerminalPanel, terminalPanel.surface.surface != nil {
|
||||
beforeSend?()
|
||||
terminalPanel.sendText(text)
|
||||
return
|
||||
}
|
||||
guard attempt < maxAttempts else {
|
||||
NSLog("Command send: surface not ready after \(maxAttempts) attempts")
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
||||
self?.sendTextWhenReady(text, to: tab, attempt: attempt + 1, beforeSend: beforeSend)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private let debugColorWorkspaceTitlePrefix = "Debug Color - "
|
||||
private let debugPerfWorkspaceTitlePrefix = "Debug Perf - "
|
||||
|
|
@ -5352,21 +5517,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
)
|
||||
}
|
||||
|
||||
private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0) {
|
||||
let maxAttempts = 60
|
||||
if let terminalPanel = tab.focusedTerminalPanel, terminalPanel.surface.surface != nil {
|
||||
terminalPanel.sendText(text)
|
||||
return
|
||||
}
|
||||
guard attempt < maxAttempts else {
|
||||
NSLog("Debug scrollback: surface not ready after \(maxAttempts) attempts")
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
||||
self?.sendTextWhenReady(text, to: tab, attempt: attempt + 1)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func triggerSentryTestCrash(_ sender: Any?) {
|
||||
SentrySDK.crash()
|
||||
}
|
||||
|
|
@ -6129,6 +6279,80 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
writeGotoSplitTestData(updates)
|
||||
}
|
||||
|
||||
private func recordGotoSplitZoomIfNeeded() {
|
||||
guard isGotoSplitUITestRecordingEnabled() else { return }
|
||||
recordGotoSplitZoomRetry(attempt: 0)
|
||||
}
|
||||
|
||||
private func recordGotoSplitZoomRetry(attempt: Int) {
|
||||
let delays: [Double] = [0.05, 0.1, 0.2, 0.35, 0.5]
|
||||
let delay = attempt < delays.count ? delays[attempt] : delays.last!
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
guard let self,
|
||||
let workspace = self.tabManager?.selectedWorkspace else { return }
|
||||
|
||||
let browserPanel = workspace.panels.values.compactMap { $0 as? BrowserPanel }.first
|
||||
let otherTerminal = workspace.panels.values.compactMap { $0 as? TerminalPanel }.first
|
||||
let browserSnapshot = browserPanel.flatMap {
|
||||
BrowserWindowPortalRegistry.debugSnapshot(for: $0.webView)
|
||||
}
|
||||
|
||||
var updates = self.gotoSplitFindStateSnapshot(for: workspace)
|
||||
updates["splitZoomedAfterToggle"] = workspace.bonsplitController.isSplitZoomed ? "true" : "false"
|
||||
updates["zoomedPaneIdAfterToggle"] = workspace.bonsplitController.zoomedPaneId?.description ?? ""
|
||||
updates["browserPanelIdAfterToggle"] = browserPanel?.id.uuidString ?? ""
|
||||
updates["browserContainerHiddenAfterToggle"] = browserSnapshot.map { $0.containerHidden ? "true" : "false" } ?? ""
|
||||
updates["browserVisibleFlagAfterToggle"] = browserSnapshot.map { $0.visibleInUI ? "true" : "false" } ?? ""
|
||||
updates["browserFrameAfterToggle"] = browserSnapshot.map {
|
||||
String(
|
||||
format: "%.1f,%.1f %.1fx%.1f",
|
||||
$0.frameInWindow.origin.x,
|
||||
$0.frameInWindow.origin.y,
|
||||
$0.frameInWindow.size.width,
|
||||
$0.frameInWindow.size.height
|
||||
)
|
||||
} ?? ""
|
||||
updates["otherTerminalPanelIdAfterToggle"] = otherTerminal?.id.uuidString ?? ""
|
||||
updates["otherTerminalHostHiddenAfterToggle"] = otherTerminal.map { $0.hostedView.isHidden ? "true" : "false" } ?? ""
|
||||
updates["otherTerminalVisibleFlagAfterToggle"] = otherTerminal.map { $0.hostedView.debugPortalVisibleInUI ? "true" : "false" } ?? ""
|
||||
updates["otherTerminalFrameAfterToggle"] = otherTerminal.map {
|
||||
let frame = $0.hostedView.debugPortalFrameInWindow
|
||||
return String(
|
||||
format: "%.1f,%.1f %.1fx%.1f",
|
||||
frame.origin.x,
|
||||
frame.origin.y,
|
||||
frame.size.width,
|
||||
frame.size.height
|
||||
)
|
||||
} ?? ""
|
||||
|
||||
let settled: Bool = {
|
||||
if workspace.bonsplitController.isSplitZoomed {
|
||||
if let focusedPanelId = workspace.focusedPanelId,
|
||||
workspace.terminalPanel(for: focusedPanelId) != nil {
|
||||
guard let browserSnapshot else { return false }
|
||||
return browserSnapshot.containerHidden && !browserSnapshot.visibleInUI
|
||||
}
|
||||
guard let otherTerminal else { return true }
|
||||
return otherTerminal.hostedView.isHidden && !otherTerminal.hostedView.debugPortalVisibleInUI
|
||||
}
|
||||
let browserRestored = browserSnapshot.map { !$0.containerHidden && $0.visibleInUI } ?? true
|
||||
let terminalRestored = otherTerminal.map {
|
||||
!$0.hostedView.isHidden && $0.hostedView.debugPortalVisibleInUI
|
||||
} ?? true
|
||||
return browserRestored && terminalRestored
|
||||
}()
|
||||
|
||||
if !settled && attempt < delays.count - 1 {
|
||||
self.recordGotoSplitZoomRetry(attempt: attempt + 1)
|
||||
return
|
||||
}
|
||||
|
||||
self.writeGotoSplitTestData(updates)
|
||||
}
|
||||
}
|
||||
|
||||
private func writeGotoSplitTestData(_ updates: [String: String]) {
|
||||
guard let path = gotoSplitUITestDataPath() else { return }
|
||||
var payload = loadGotoSplitTestData(at: path)
|
||||
|
|
@ -7432,6 +7656,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSplitZoom)) {
|
||||
_ = tabManager?.toggleFocusedSplitZoom()
|
||||
#if DEBUG
|
||||
recordGotoSplitZoomIfNeeded()
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -10138,7 +10365,14 @@ private extension NSWindow {
|
|||
}
|
||||
if String(describing: type(of: candidate)).contains("WindowBrowserSlotView"),
|
||||
let portalWebView = cmuxUniqueBrowserWebView(in: candidate) {
|
||||
return portalWebView
|
||||
// Portal-hosted browser chrome (for example the Cmd+F overlay) is a
|
||||
// sibling of the hosted WKWebView inside WindowBrowserSlotView, not a
|
||||
// descendant of it. Treating every view in that slot as "web-owned"
|
||||
// blocks legitimate first-responder changes to overlay text fields.
|
||||
if view === portalWebView || view.isDescendant(of: portalWebView) {
|
||||
return portalWebView
|
||||
}
|
||||
return nil
|
||||
}
|
||||
current = candidate.superview
|
||||
}
|
||||
|
|
|
|||
705
Sources/AppleScriptSupport.swift
Normal file
705
Sources/AppleScriptSupport.swift
Normal file
|
|
@ -0,0 +1,705 @@
|
|||
import AppKit
|
||||
|
||||
private enum AppleScriptStrings {
|
||||
static let disabled = String(
|
||||
localized: "applescript.error.disabled",
|
||||
defaultValue: "AppleScript is disabled by the macos-applescript configuration."
|
||||
)
|
||||
static let missingAction = String(
|
||||
localized: "applescript.error.missingAction",
|
||||
defaultValue: "Missing action string."
|
||||
)
|
||||
static let missingInputText = String(
|
||||
localized: "applescript.error.missingInputText",
|
||||
defaultValue: "Missing input text."
|
||||
)
|
||||
static let missingTerminalTarget = String(
|
||||
localized: "applescript.error.missingTerminalTarget",
|
||||
defaultValue: "Missing terminal target."
|
||||
)
|
||||
static let missingSplitDirection = String(
|
||||
localized: "applescript.error.missingSplitDirection",
|
||||
defaultValue: "Missing or unknown split direction."
|
||||
)
|
||||
static let windowUnavailable = String(
|
||||
localized: "applescript.error.windowUnavailable",
|
||||
defaultValue: "Window is no longer available."
|
||||
)
|
||||
static let workspaceUnavailable = String(
|
||||
localized: "applescript.error.workspaceUnavailable",
|
||||
defaultValue: "Workspace is no longer available."
|
||||
)
|
||||
static let terminalUnavailable = String(
|
||||
localized: "applescript.error.terminalUnavailable",
|
||||
defaultValue: "Terminal is no longer available."
|
||||
)
|
||||
static let failedToCreateWindow = String(
|
||||
localized: "applescript.error.failedToCreateWindow",
|
||||
defaultValue: "Failed to create window."
|
||||
)
|
||||
static let failedToCreateWorkspace = String(
|
||||
localized: "applescript.error.failedToCreateWorkspace",
|
||||
defaultValue: "Failed to create workspace."
|
||||
)
|
||||
static let failedToCreateSplit = String(
|
||||
localized: "applescript.error.failedToCreateSplit",
|
||||
defaultValue: "Failed to create split."
|
||||
)
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var fourCharCode: UInt32 {
|
||||
utf8.reduce(0) { ($0 << 8) + UInt32($1) }
|
||||
}
|
||||
}
|
||||
|
||||
private extension Workspace {
|
||||
func scriptingTerminalPanels() -> [TerminalPanel] {
|
||||
var results: [TerminalPanel] = []
|
||||
var seen: Set<UUID> = []
|
||||
|
||||
for panelId in sidebarOrderedPanelIds() {
|
||||
guard seen.insert(panelId).inserted,
|
||||
let terminal = terminalPanel(for: panelId) else {
|
||||
continue
|
||||
}
|
||||
results.append(terminal)
|
||||
}
|
||||
|
||||
let remaining = panels.values
|
||||
.compactMap { $0 as? TerminalPanel }
|
||||
.sorted { $0.id.uuidString < $1.id.uuidString }
|
||||
|
||||
for terminal in remaining where seen.insert(terminal.id).inserted {
|
||||
results.append(terminal)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension NSApplication {
|
||||
var isAppleScriptEnabled: Bool {
|
||||
GhosttyApp.shared.appleScriptAutomationEnabled()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func validateScript(command: NSScriptCommand) -> Bool {
|
||||
guard isAppleScriptEnabled else {
|
||||
command.scriptErrorNumber = errAEEventNotPermitted
|
||||
command.scriptErrorString = AppleScriptStrings.disabled
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@objc(scriptWindows)
|
||||
var scriptWindows: [ScriptWindow] {
|
||||
guard isAppleScriptEnabled,
|
||||
let appDelegate = AppDelegate.shared else {
|
||||
return []
|
||||
}
|
||||
return appDelegate.scriptableMainWindows().map { ScriptWindow(windowId: $0.windowId) }
|
||||
}
|
||||
|
||||
@objc(frontWindow)
|
||||
var frontWindow: ScriptWindow? {
|
||||
scriptWindows.first
|
||||
}
|
||||
|
||||
@objc(valueInScriptWindowsWithUniqueID:)
|
||||
func valueInScriptWindows(uniqueID: String) -> ScriptWindow? {
|
||||
guard isAppleScriptEnabled,
|
||||
let windowId = UUID(uuidString: uniqueID),
|
||||
let appDelegate = AppDelegate.shared,
|
||||
appDelegate.scriptableMainWindow(windowId: windowId) != nil else {
|
||||
return nil
|
||||
}
|
||||
return ScriptWindow(windowId: windowId)
|
||||
}
|
||||
|
||||
@objc(terminals)
|
||||
var terminals: [ScriptTerminal] {
|
||||
guard isAppleScriptEnabled,
|
||||
let appDelegate = AppDelegate.shared else {
|
||||
return []
|
||||
}
|
||||
|
||||
return appDelegate.scriptableMainWindows()
|
||||
.flatMap { state in
|
||||
state.tabManager.tabs.flatMap { workspace in
|
||||
workspace.scriptingTerminalPanels().map {
|
||||
ScriptTerminal(workspaceId: workspace.id, terminalId: $0.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc(valueInTerminalsWithUniqueID:)
|
||||
func valueInTerminals(uniqueID: String) -> ScriptTerminal? {
|
||||
guard isAppleScriptEnabled,
|
||||
let terminalId = UUID(uuidString: uniqueID),
|
||||
let appDelegate = AppDelegate.shared else {
|
||||
return nil
|
||||
}
|
||||
|
||||
for state in appDelegate.scriptableMainWindows() {
|
||||
for workspace in state.tabManager.tabs where workspace.terminalPanel(for: terminalId) != nil {
|
||||
return ScriptTerminal(workspaceId: workspace.id, terminalId: terminalId)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@objc(handlePerformActionScriptCommand:)
|
||||
func handlePerformActionScriptCommand(_ command: NSScriptCommand) -> NSNumber? {
|
||||
guard validateScript(command: command) else { return nil }
|
||||
|
||||
guard let action = command.directParameter as? String else {
|
||||
command.scriptErrorNumber = errAEParamMissed
|
||||
command.scriptErrorString = AppleScriptStrings.missingAction
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let terminal = command.evaluatedArguments?["on"] as? ScriptTerminal else {
|
||||
command.scriptErrorNumber = errAEParamMissed
|
||||
command.scriptErrorString = AppleScriptStrings.missingTerminalTarget
|
||||
return nil
|
||||
}
|
||||
|
||||
return NSNumber(value: terminal.perform(action: action))
|
||||
}
|
||||
|
||||
@objc(handleNewWindowScriptCommand:)
|
||||
func handleNewWindowScriptCommand(_ command: NSScriptCommand) -> ScriptWindow? {
|
||||
guard validateScript(command: command) else { return nil }
|
||||
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.failedToCreateWindow
|
||||
return nil
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
return ScriptWindow(windowId: windowId)
|
||||
}
|
||||
|
||||
@objc(handleNewTabScriptCommand:)
|
||||
func handleNewTabScriptCommand(_ command: NSScriptCommand) -> ScriptTab? {
|
||||
guard validateScript(command: command) else { return nil }
|
||||
|
||||
guard let appDelegate = AppDelegate.shared else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.failedToCreateWorkspace
|
||||
return nil
|
||||
}
|
||||
|
||||
if let targetWindow = command.evaluatedArguments?["window"] as? ScriptWindow {
|
||||
guard let workspaceId = appDelegate.addWorkspace(windowId: targetWindow.windowId, bringToFront: false) else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.failedToCreateWorkspace
|
||||
return nil
|
||||
}
|
||||
return ScriptTab(windowId: targetWindow.windowId, tabId: workspaceId)
|
||||
}
|
||||
|
||||
if let frontWindow = scriptWindows.first,
|
||||
let workspaceId = appDelegate.addWorkspace(windowId: frontWindow.windowId, bringToFront: false) {
|
||||
return ScriptTab(windowId: frontWindow.windowId, tabId: workspaceId)
|
||||
}
|
||||
|
||||
let windowId = appDelegate.createMainWindow()
|
||||
return ScriptWindow(windowId: windowId).selectedTab
|
||||
}
|
||||
|
||||
@objc(handleQuitScriptCommand:)
|
||||
func handleQuitScriptCommand(_ command: NSScriptCommand) {
|
||||
guard validateScript(command: command) else { return }
|
||||
terminate(nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc(CmuxScriptWindow)
|
||||
final class ScriptWindow: NSObject {
|
||||
let windowId: UUID
|
||||
|
||||
init(windowId: UUID) {
|
||||
self.windowId = windowId
|
||||
}
|
||||
|
||||
private var state: AppDelegate.ScriptableMainWindowState? {
|
||||
AppDelegate.shared?.scriptableMainWindow(windowId: windowId)
|
||||
}
|
||||
|
||||
@objc(id)
|
||||
var idValue: String {
|
||||
guard NSApp.isAppleScriptEnabled else { return "" }
|
||||
return windowId.uuidString
|
||||
}
|
||||
|
||||
@objc(title)
|
||||
var title: String {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let state else {
|
||||
return ""
|
||||
}
|
||||
|
||||
let windowTitle = state.window?.title.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !windowTitle.isEmpty {
|
||||
return windowTitle
|
||||
}
|
||||
|
||||
return state.tabManager.selectedWorkspace?.title ?? ""
|
||||
}
|
||||
|
||||
@objc(tabs)
|
||||
var tabs: [ScriptTab] {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let state else {
|
||||
return []
|
||||
}
|
||||
return state.tabManager.tabs.map { ScriptTab(windowId: windowId, tabId: $0.id) }
|
||||
}
|
||||
|
||||
@objc(selectedTab)
|
||||
var selectedTab: ScriptTab? {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let selectedId = state?.tabManager.selectedTabId else {
|
||||
return nil
|
||||
}
|
||||
return ScriptTab(windowId: windowId, tabId: selectedId)
|
||||
}
|
||||
|
||||
@objc(terminals)
|
||||
var terminals: [ScriptTerminal] {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let state else {
|
||||
return []
|
||||
}
|
||||
return state.tabManager.tabs.flatMap { workspace in
|
||||
workspace.scriptingTerminalPanels().map {
|
||||
ScriptTerminal(workspaceId: workspace.id, terminalId: $0.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc(valueInTabsWithUniqueID:)
|
||||
func valueInTabs(uniqueID: String) -> ScriptTab? {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let tabId = UUID(uuidString: uniqueID),
|
||||
let state,
|
||||
state.tabManager.tabs.contains(where: { $0.id == tabId }) else {
|
||||
return nil
|
||||
}
|
||||
return ScriptTab(windowId: windowId, tabId: tabId)
|
||||
}
|
||||
|
||||
@objc(valueInTerminalsWithUniqueID:)
|
||||
func valueInTerminals(uniqueID: String) -> ScriptTerminal? {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let terminalId = UUID(uuidString: uniqueID),
|
||||
let state else {
|
||||
return nil
|
||||
}
|
||||
|
||||
for workspace in state.tabManager.tabs where workspace.terminalPanel(for: terminalId) != nil {
|
||||
return ScriptTerminal(workspaceId: workspace.id, terminalId: terminalId)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@objc(handleActivateWindowCommand:)
|
||||
func handleActivateWindow(_ command: NSScriptCommand) -> Any? {
|
||||
guard NSApp.validateScript(command: command) else { return nil }
|
||||
|
||||
guard AppDelegate.shared?.focusScriptableMainWindow(windowId: windowId, bringToFront: true) == true else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.windowUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@objc(handleCloseWindowCommand:)
|
||||
func handleCloseWindow(_ command: NSScriptCommand) -> Any? {
|
||||
guard NSApp.validateScript(command: command) else { return nil }
|
||||
|
||||
guard let window = state?.window else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.windowUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
window.performClose(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
override var objectSpecifier: NSScriptObjectSpecifier? {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return NSUniqueIDSpecifier(
|
||||
containerClassDescription: appClassDescription,
|
||||
containerSpecifier: nil,
|
||||
key: "scriptWindows",
|
||||
uniqueID: windowId.uuidString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc(CmuxScriptTab)
|
||||
final class ScriptTab: NSObject {
|
||||
let windowId: UUID
|
||||
let tabId: UUID
|
||||
|
||||
init(windowId: UUID, tabId: UUID) {
|
||||
self.windowId = windowId
|
||||
self.tabId = tabId
|
||||
}
|
||||
|
||||
private var state: AppDelegate.ScriptableMainWindowState? {
|
||||
AppDelegate.shared?.scriptableMainWindow(windowId: windowId)
|
||||
}
|
||||
|
||||
private var workspace: Workspace? {
|
||||
state?.tabManager.tabs.first(where: { $0.id == tabId })
|
||||
}
|
||||
|
||||
private var window: ScriptWindow {
|
||||
ScriptWindow(windowId: windowId)
|
||||
}
|
||||
|
||||
@objc(id)
|
||||
var idValue: String {
|
||||
guard NSApp.isAppleScriptEnabled else { return "" }
|
||||
return tabId.uuidString
|
||||
}
|
||||
|
||||
@objc(title)
|
||||
var title: String {
|
||||
guard NSApp.isAppleScriptEnabled else { return "" }
|
||||
return workspace?.title ?? ""
|
||||
}
|
||||
|
||||
@objc(index)
|
||||
var index: Int {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let state,
|
||||
let idx = state.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else {
|
||||
return 0
|
||||
}
|
||||
return idx + 1
|
||||
}
|
||||
|
||||
@objc(selected)
|
||||
var selected: Bool {
|
||||
guard NSApp.isAppleScriptEnabled else { return false }
|
||||
return state?.tabManager.selectedTabId == tabId
|
||||
}
|
||||
|
||||
@objc(focusedTerminal)
|
||||
var focusedTerminal: ScriptTerminal? {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let terminalId = workspace?.focusedTerminalPanel?.id else {
|
||||
return nil
|
||||
}
|
||||
return ScriptTerminal(workspaceId: tabId, terminalId: terminalId)
|
||||
}
|
||||
|
||||
@objc(terminals)
|
||||
var terminals: [ScriptTerminal] {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let workspace else {
|
||||
return []
|
||||
}
|
||||
return workspace.scriptingTerminalPanels().map {
|
||||
ScriptTerminal(workspaceId: tabId, terminalId: $0.id)
|
||||
}
|
||||
}
|
||||
|
||||
@objc(valueInTerminalsWithUniqueID:)
|
||||
func valueInTerminals(uniqueID: String) -> ScriptTerminal? {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let workspace,
|
||||
let terminalId = UUID(uuidString: uniqueID),
|
||||
workspace.terminalPanel(for: terminalId) != nil else {
|
||||
return nil
|
||||
}
|
||||
return ScriptTerminal(workspaceId: tabId, terminalId: terminalId)
|
||||
}
|
||||
|
||||
@objc(handleSelectTabCommand:)
|
||||
func handleSelectTab(_ command: NSScriptCommand) -> Any? {
|
||||
guard NSApp.validateScript(command: command) else { return nil }
|
||||
|
||||
guard let state,
|
||||
let workspace else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.workspaceUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
state.tabManager.selectWorkspace(workspace)
|
||||
return nil
|
||||
}
|
||||
|
||||
@objc(handleCloseTabCommand:)
|
||||
func handleCloseTab(_ command: NSScriptCommand) -> Any? {
|
||||
guard NSApp.validateScript(command: command) else { return nil }
|
||||
|
||||
guard let state,
|
||||
let workspace else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.workspaceUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
if state.tabManager.tabs.count > 1 {
|
||||
state.tabManager.closeWorkspace(workspace)
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let window = state.window else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.windowUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
window.performClose(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
override var objectSpecifier: NSScriptObjectSpecifier? {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let windowClassDescription = window.classDescription as? NSScriptClassDescription,
|
||||
let windowSpecifier = window.objectSpecifier else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return NSUniqueIDSpecifier(
|
||||
containerClassDescription: windowClassDescription,
|
||||
containerSpecifier: windowSpecifier,
|
||||
key: "tabs",
|
||||
uniqueID: tabId.uuidString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc(CmuxScriptTerminal)
|
||||
final class ScriptTerminal: NSObject {
|
||||
let workspaceId: UUID
|
||||
let terminalId: UUID
|
||||
|
||||
init(workspaceId: UUID, terminalId: UUID) {
|
||||
self.workspaceId = workspaceId
|
||||
self.terminalId = terminalId
|
||||
}
|
||||
|
||||
private var state: AppDelegate.ScriptableMainWindowState? {
|
||||
AppDelegate.shared?.scriptableMainWindowForTab(workspaceId)
|
||||
}
|
||||
|
||||
private var workspace: Workspace? {
|
||||
state?.tabManager.tabs.first(where: { $0.id == workspaceId })
|
||||
}
|
||||
|
||||
private var terminal: TerminalPanel? {
|
||||
workspace?.terminalPanel(for: terminalId)
|
||||
}
|
||||
|
||||
@objc(id)
|
||||
var stableID: String {
|
||||
guard NSApp.isAppleScriptEnabled else { return "" }
|
||||
return terminalId.uuidString
|
||||
}
|
||||
|
||||
@objc(title)
|
||||
var title: String {
|
||||
guard NSApp.isAppleScriptEnabled else { return "" }
|
||||
return terminal?.displayTitle ?? ""
|
||||
}
|
||||
|
||||
@objc(workingDirectory)
|
||||
var workingDirectory: String {
|
||||
guard NSApp.isAppleScriptEnabled else { return "" }
|
||||
return terminal?.directory ?? ""
|
||||
}
|
||||
|
||||
func input(text: String) -> Bool {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let terminal else {
|
||||
return false
|
||||
}
|
||||
terminal.sendText(text)
|
||||
return true
|
||||
}
|
||||
|
||||
func perform(action: String) -> Bool {
|
||||
guard NSApp.isAppleScriptEnabled else { return false }
|
||||
return terminal?.performBindingAction(action) ?? false
|
||||
}
|
||||
|
||||
@objc(handleSplitCommand:)
|
||||
func handleSplit(_ command: NSScriptCommand) -> Any? {
|
||||
guard NSApp.validateScript(command: command) else { return nil }
|
||||
|
||||
guard let directionCode = command.evaluatedArguments?["direction"] as? UInt32,
|
||||
let direction = ScriptSplitDirection(code: directionCode)?.splitDirection else {
|
||||
command.scriptErrorNumber = errAEParamMissed
|
||||
command.scriptErrorString = AppleScriptStrings.missingSplitDirection
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let state,
|
||||
let workspace,
|
||||
terminal != nil else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.terminalUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let newPanelId = state.tabManager.newSplit(tabId: workspaceId, surfaceId: terminalId, direction: direction),
|
||||
workspace.terminalPanel(for: newPanelId) != nil else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.failedToCreateSplit
|
||||
return nil
|
||||
}
|
||||
|
||||
return ScriptTerminal(workspaceId: workspaceId, terminalId: newPanelId)
|
||||
}
|
||||
|
||||
@objc(handleFocusCommand:)
|
||||
func handleFocus(_ command: NSScriptCommand) -> Any? {
|
||||
guard NSApp.validateScript(command: command) else { return nil }
|
||||
|
||||
guard let state,
|
||||
let workspace,
|
||||
terminal != nil else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.terminalUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
if let app = AppDelegate.shared {
|
||||
_ = app.focusScriptableMainWindow(windowId: state.windowId, bringToFront: true)
|
||||
}
|
||||
state.tabManager.selectWorkspace(workspace)
|
||||
workspace.focusPanel(terminalId)
|
||||
return nil
|
||||
}
|
||||
|
||||
@objc(handleCloseCommand:)
|
||||
func handleClose(_ command: NSScriptCommand) -> Any? {
|
||||
guard NSApp.validateScript(command: command) else { return nil }
|
||||
|
||||
guard let state,
|
||||
let workspace,
|
||||
terminal != nil else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.terminalUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
if workspace.panels.count == 1 {
|
||||
if state.tabManager.tabs.count > 1 {
|
||||
state.tabManager.closeWorkspace(workspace)
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let window = state.window else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.windowUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
window.performClose(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
guard workspace.closePanel(terminalId, force: true) else {
|
||||
command.scriptErrorNumber = errAEEventFailed
|
||||
command.scriptErrorString = AppleScriptStrings.terminalUnavailable
|
||||
return nil
|
||||
}
|
||||
|
||||
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspaceId, surfaceId: terminalId)
|
||||
return nil
|
||||
}
|
||||
|
||||
override var objectSpecifier: NSScriptObjectSpecifier? {
|
||||
guard NSApp.isAppleScriptEnabled,
|
||||
let appClassDescription = NSApplication.shared.classDescription as? NSScriptClassDescription else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return NSUniqueIDSpecifier(
|
||||
containerClassDescription: appClassDescription,
|
||||
containerSpecifier: nil,
|
||||
key: "terminals",
|
||||
uniqueID: terminalId.uuidString
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@objc(CmuxScriptInputTextCommand)
|
||||
final class ScriptInputTextCommand: NSScriptCommand {
|
||||
override func performDefaultImplementation() -> Any? {
|
||||
guard NSApp.validateScript(command: self) else { return nil }
|
||||
|
||||
guard let text = directParameter as? String else {
|
||||
scriptErrorNumber = errAEParamMissed
|
||||
scriptErrorString = AppleScriptStrings.missingInputText
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let terminal = evaluatedArguments?["terminal"] as? ScriptTerminal else {
|
||||
scriptErrorNumber = errAEParamMissed
|
||||
scriptErrorString = AppleScriptStrings.missingTerminalTarget
|
||||
return nil
|
||||
}
|
||||
|
||||
guard terminal.input(text: text) else {
|
||||
scriptErrorNumber = errAEEventFailed
|
||||
scriptErrorString = AppleScriptStrings.terminalUnavailable
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private enum ScriptSplitDirection {
|
||||
case right
|
||||
case left
|
||||
case down
|
||||
case up
|
||||
|
||||
init?(code: UInt32) {
|
||||
switch code {
|
||||
case "GSrt".fourCharCode: self = .right
|
||||
case "GSlf".fourCharCode: self = .left
|
||||
case "GSdn".fourCharCode: self = .down
|
||||
case "GSup".fourCharCode: self = .up
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var splitDirection: SplitDirection {
|
||||
switch self {
|
||||
case .right: return .right
|
||||
case .left: return .left
|
||||
case .down: return .down
|
||||
case .up: return .up
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import WebKit
|
|||
|
||||
private var cmuxWindowBrowserPortalKey: UInt8 = 0
|
||||
private var cmuxWindowBrowserPortalCloseObserverKey: UInt8 = 0
|
||||
private var cmuxBrowserSearchOverlayPanelIdAssociationKey: UInt8 = 0
|
||||
|
||||
#if DEBUG
|
||||
private func browserPortalDebugToken(_ view: NSView?) -> String {
|
||||
|
|
@ -31,6 +32,17 @@ private extension NSObject {
|
|||
}
|
||||
}
|
||||
|
||||
private extension NSResponder {
|
||||
var browserPortalOwningView: NSView? {
|
||||
if let editor = self as? NSTextView,
|
||||
editor.isFieldEditor,
|
||||
let editedView = editor.delegate as? NSView {
|
||||
return editedView
|
||||
}
|
||||
return self as? NSView
|
||||
}
|
||||
}
|
||||
|
||||
private extension WKWebView {
|
||||
func browserPortalNotifyHidden(reason: String) {
|
||||
let firedSelectors = ["viewDidHide", "_exitInWindow"].filter {
|
||||
|
|
@ -81,6 +93,115 @@ private extension WKWebView {
|
|||
}
|
||||
}
|
||||
|
||||
enum HostedInspectorDockSide {
|
||||
case leading
|
||||
case trailing
|
||||
|
||||
static func resolve(
|
||||
pageFrame: NSRect,
|
||||
inspectorFrame: NSRect,
|
||||
epsilon: CGFloat = 1
|
||||
) -> Self? {
|
||||
if pageFrame.maxX <= inspectorFrame.minX + epsilon {
|
||||
return .trailing
|
||||
}
|
||||
if inspectorFrame.maxX <= pageFrame.minX + epsilon {
|
||||
return .leading
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dividerX(pageFrame: NSRect, inspectorFrame: NSRect) -> CGFloat {
|
||||
switch self {
|
||||
case .leading:
|
||||
return inspectorFrame.maxX
|
||||
case .trailing:
|
||||
return inspectorFrame.minX
|
||||
}
|
||||
}
|
||||
|
||||
func dividerHitRect(
|
||||
in bounds: NSRect,
|
||||
pageFrame: NSRect,
|
||||
inspectorFrame: NSRect,
|
||||
expansion: CGFloat
|
||||
) -> NSRect {
|
||||
let minY = max(bounds.minY, min(pageFrame.minY, inspectorFrame.minY))
|
||||
let maxY = min(bounds.maxY, max(pageFrame.maxY, inspectorFrame.maxY))
|
||||
return NSRect(
|
||||
x: dividerX(pageFrame: pageFrame, inspectorFrame: inspectorFrame) - expansion,
|
||||
y: minY,
|
||||
width: expansion * 2,
|
||||
height: max(0, maxY - minY)
|
||||
)
|
||||
}
|
||||
|
||||
func clampedDividerX(
|
||||
_ proposedDividerX: CGFloat,
|
||||
containerBounds: NSRect,
|
||||
pageFrame: NSRect,
|
||||
minimumInspectorWidth: CGFloat
|
||||
) -> CGFloat {
|
||||
switch self {
|
||||
case .leading:
|
||||
let minDividerX = min(containerBounds.maxX, containerBounds.minX + minimumInspectorWidth)
|
||||
let maxDividerX = max(minDividerX, min(containerBounds.maxX, pageFrame.maxX))
|
||||
return max(minDividerX, min(maxDividerX, proposedDividerX))
|
||||
case .trailing:
|
||||
let minDividerX = max(containerBounds.minX, pageFrame.minX)
|
||||
let maxDividerX = max(minDividerX, containerBounds.maxX - minimumInspectorWidth)
|
||||
return max(minDividerX, min(maxDividerX, proposedDividerX))
|
||||
}
|
||||
}
|
||||
|
||||
func inspectorWidth(forDividerX dividerX: CGFloat, in containerBounds: NSRect) -> CGFloat {
|
||||
switch self {
|
||||
case .leading:
|
||||
return max(0, dividerX - containerBounds.minX)
|
||||
case .trailing:
|
||||
return max(0, containerBounds.maxX - dividerX)
|
||||
}
|
||||
}
|
||||
|
||||
func resizedFrames(
|
||||
preferredWidth: CGFloat,
|
||||
in containerBounds: NSRect,
|
||||
pageFrame: NSRect,
|
||||
inspectorFrame: NSRect,
|
||||
minimumInspectorWidth _: CGFloat
|
||||
) -> (pageFrame: NSRect, inspectorFrame: NSRect) {
|
||||
switch self {
|
||||
case .leading:
|
||||
let maximumInspectorWidth = max(0, containerBounds.width)
|
||||
let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth))
|
||||
let dividerX = min(containerBounds.maxX, containerBounds.minX + clampedInspectorWidth)
|
||||
|
||||
var nextPageFrame = pageFrame
|
||||
nextPageFrame.origin.x = dividerX
|
||||
nextPageFrame.size.width = max(0, containerBounds.maxX - dividerX)
|
||||
|
||||
var nextInspectorFrame = inspectorFrame
|
||||
nextInspectorFrame.origin.x = containerBounds.minX
|
||||
nextInspectorFrame.size.width = max(0, dividerX - containerBounds.minX)
|
||||
return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame)
|
||||
|
||||
case .trailing:
|
||||
let maximumInspectorWidth = max(0, containerBounds.width)
|
||||
let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth))
|
||||
let dividerX = max(containerBounds.minX, containerBounds.maxX - clampedInspectorWidth)
|
||||
|
||||
var nextPageFrame = pageFrame
|
||||
nextPageFrame.origin.x = containerBounds.minX
|
||||
nextPageFrame.size.width = max(0, dividerX - containerBounds.minX)
|
||||
|
||||
var nextInspectorFrame = inspectorFrame
|
||||
nextInspectorFrame.origin.x = dividerX
|
||||
nextInspectorFrame.size.width = max(0, containerBounds.maxX - dividerX)
|
||||
return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class WindowBrowserHostView: NSView {
|
||||
private struct DividerRegion {
|
||||
let rectInWindow: NSRect
|
||||
|
|
@ -97,6 +218,7 @@ final class WindowBrowserHostView: NSView {
|
|||
let containerView: NSView
|
||||
let pageView: NSView
|
||||
let inspectorView: NSView
|
||||
let dockSide: HostedInspectorDockSide
|
||||
}
|
||||
|
||||
private struct HostedInspectorDividerDragState {
|
||||
|
|
@ -104,6 +226,7 @@ final class WindowBrowserHostView: NSView {
|
|||
let containerView: NSView
|
||||
let pageView: NSView
|
||||
let inspectorView: NSView
|
||||
let dockSide: HostedInspectorDockSide
|
||||
let initialWindowX: CGFloat
|
||||
let initialPageFrame: NSRect
|
||||
let initialInspectorFrame: NSRect
|
||||
|
|
@ -131,6 +254,7 @@ final class WindowBrowserHostView: NSView {
|
|||
private var trackingArea: NSTrackingArea?
|
||||
private var activeDividerCursorKind: DividerCursorKind?
|
||||
private var hostedInspectorDividerDrag: HostedInspectorDividerDragState?
|
||||
private var lastHostedInspectorLayoutBoundsSize: NSSize?
|
||||
|
||||
deinit {
|
||||
if let trackingArea {
|
||||
|
|
@ -200,6 +324,11 @@ final class WindowBrowserHostView: NSView {
|
|||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
if let previousSize = lastHostedInspectorLayoutBoundsSize,
|
||||
Self.sizeApproximatelyEqual(previousSize, bounds.size, epsilon: 0.5) {
|
||||
return
|
||||
}
|
||||
lastHostedInspectorLayoutBoundsSize = bounds.size
|
||||
reapplyHostedInspectorDividersIfNeeded(reason: "host.layout")
|
||||
}
|
||||
|
||||
|
|
@ -378,11 +507,13 @@ final class WindowBrowserHostView: NSView {
|
|||
return
|
||||
}
|
||||
|
||||
hostedInspectorHit.slotView.isHostedInspectorDividerDragActive = true
|
||||
hostedInspectorDividerDrag = HostedInspectorDividerDragState(
|
||||
slotView: hostedInspectorHit.slotView,
|
||||
containerView: hostedInspectorHit.containerView,
|
||||
pageView: hostedInspectorHit.pageView,
|
||||
inspectorView: hostedInspectorHit.inspectorView,
|
||||
dockSide: hostedInspectorHit.dockSide,
|
||||
initialWindowX: event.locationInWindow.x,
|
||||
initialPageFrame: hostedInspectorHit.pageView.frame,
|
||||
initialInspectorFrame: hostedInspectorHit.inspectorView.frame
|
||||
|
|
@ -404,6 +535,7 @@ final class WindowBrowserHostView: NSView {
|
|||
return
|
||||
}
|
||||
guard dragState.slotView.window === window else {
|
||||
dragState.slotView.isHostedInspectorDividerDragActive = false
|
||||
hostedInspectorDividerDrag = nil
|
||||
super.mouseDragged(with: event)
|
||||
return
|
||||
|
|
@ -414,20 +546,31 @@ final class WindowBrowserHostView: NSView {
|
|||
Self.minimumHostedInspectorWidth,
|
||||
max(60, dragState.initialInspectorFrame.width)
|
||||
)
|
||||
let minDividerX = max(containerBounds.minX, dragState.initialPageFrame.minX)
|
||||
let maxDividerX = max(minDividerX, containerBounds.maxX - minimumInspectorWidth)
|
||||
let proposedDividerX = dragState.initialInspectorFrame.minX + (event.locationInWindow.x - dragState.initialWindowX)
|
||||
let clampedDividerX = max(minDividerX, min(maxDividerX, proposedDividerX))
|
||||
let inspectorWidth = max(0, containerBounds.maxX - clampedDividerX)
|
||||
let initialDividerX = dragState.dockSide.dividerX(
|
||||
pageFrame: dragState.initialPageFrame,
|
||||
inspectorFrame: dragState.initialInspectorFrame
|
||||
)
|
||||
let proposedDividerX = initialDividerX + (event.locationInWindow.x - dragState.initialWindowX)
|
||||
let clampedDividerX = dragState.dockSide.clampedDividerX(
|
||||
proposedDividerX,
|
||||
containerBounds: containerBounds,
|
||||
pageFrame: dragState.initialPageFrame,
|
||||
minimumInspectorWidth: minimumInspectorWidth
|
||||
)
|
||||
let inspectorWidth = dragState.dockSide.inspectorWidth(
|
||||
forDividerX: clampedDividerX,
|
||||
in: containerBounds
|
||||
)
|
||||
|
||||
dragState.slotView.preferredHostedInspectorWidth = inspectorWidth
|
||||
dragState.slotView.recordPreferredHostedInspectorWidth(inspectorWidth, containerBounds: containerBounds)
|
||||
let appliedFrames = applyHostedInspectorDividerWidth(
|
||||
inspectorWidth,
|
||||
to: HostedInspectorDividerHit(
|
||||
slotView: dragState.slotView,
|
||||
containerView: dragState.containerView,
|
||||
pageView: dragState.pageView,
|
||||
inspectorView: dragState.inspectorView
|
||||
inspectorView: dragState.inspectorView,
|
||||
dockSide: dragState.dockSide
|
||||
),
|
||||
reason: "drag"
|
||||
)
|
||||
|
|
@ -438,7 +581,8 @@ final class WindowBrowserHostView: NSView {
|
|||
slotView: dragState.slotView,
|
||||
containerView: dragState.containerView,
|
||||
pageView: dragState.pageView,
|
||||
inspectorView: dragState.inspectorView
|
||||
inspectorView: dragState.inspectorView,
|
||||
dockSide: dragState.dockSide
|
||||
)
|
||||
)
|
||||
#if DEBUG
|
||||
|
|
@ -453,6 +597,7 @@ final class WindowBrowserHostView: NSView {
|
|||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
if let dragState = hostedInspectorDividerDrag {
|
||||
dragState.slotView.isHostedInspectorDividerDragActive = false
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.manualInspectorDrag stage=end slot=\(browserPortalDebugToken(dragState.slotView)) " +
|
||||
|
|
@ -710,22 +855,31 @@ final class WindowBrowserHostView: NSView {
|
|||
while let inspectorView = current, inspectorView !== slot {
|
||||
guard let containerView = inspectorView.superview else { break }
|
||||
|
||||
let pageCandidates = containerView.subviews.filter { candidate in
|
||||
guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return false }
|
||||
guard candidate !== inspectorView else { return false }
|
||||
guard candidate.frame.maxX <= inspectorView.frame.minX + 1 else { return false }
|
||||
return Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8
|
||||
let pageCandidates = containerView.subviews.compactMap { candidate -> (view: NSView, dockSide: HostedInspectorDockSide)? in
|
||||
guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return nil }
|
||||
guard candidate !== inspectorView else { return nil }
|
||||
guard Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 else {
|
||||
return nil
|
||||
}
|
||||
guard let dockSide = HostedInspectorDockSide.resolve(
|
||||
pageFrame: candidate.frame,
|
||||
inspectorFrame: inspectorView.frame
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
return (view: candidate, dockSide: dockSide)
|
||||
}
|
||||
|
||||
if let pageView = pageCandidates.max(by: {
|
||||
hostedInspectorPageCandidateScore($0, inspectorView: inspectorView)
|
||||
< hostedInspectorPageCandidateScore($1, inspectorView: inspectorView)
|
||||
if let pageCandidate = pageCandidates.max(by: {
|
||||
hostedInspectorPageCandidateScore($0.view, inspectorView: inspectorView)
|
||||
< hostedInspectorPageCandidateScore($1.view, inspectorView: inspectorView)
|
||||
}) {
|
||||
bestHit = HostedInspectorDividerHit(
|
||||
slotView: slot,
|
||||
containerView: containerView,
|
||||
pageView: pageView,
|
||||
inspectorView: inspectorView
|
||||
pageView: pageCandidate.view,
|
||||
inspectorView: inspectorView,
|
||||
dockSide: pageCandidate.dockSide
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -739,13 +893,11 @@ final class WindowBrowserHostView: NSView {
|
|||
let slotBounds = hit.slotView.bounds
|
||||
let pageFrame = hit.slotView.convert(hit.pageView.bounds, from: hit.pageView)
|
||||
let inspectorFrame = hit.slotView.convert(hit.inspectorView.bounds, from: hit.inspectorView)
|
||||
let minY = max(slotBounds.minY, min(pageFrame.minY, inspectorFrame.minY))
|
||||
let maxY = min(slotBounds.maxY, max(pageFrame.maxY, inspectorFrame.maxY))
|
||||
return NSRect(
|
||||
x: inspectorFrame.minX - Self.hostedInspectorDividerHitExpansion,
|
||||
y: minY,
|
||||
width: Self.hostedInspectorDividerHitExpansion * 2,
|
||||
height: max(0, maxY - minY)
|
||||
return hit.dockSide.dividerHitRect(
|
||||
in: slotBounds,
|
||||
pageFrame: pageFrame,
|
||||
inspectorFrame: inspectorFrame,
|
||||
expansion: Self.hostedInspectorDividerHitExpansion
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -779,10 +931,24 @@ final class WindowBrowserHostView: NSView {
|
|||
}
|
||||
}
|
||||
|
||||
fileprivate func reapplyHostedInspectorDividerIfNeeded(in slot: WindowBrowserSlotView, reason: String) {
|
||||
guard let preferredWidth = slot.preferredHostedInspectorWidth else { return }
|
||||
guard let hit = hostedInspectorDividerCandidate(in: slot) else { return }
|
||||
@discardableResult
|
||||
fileprivate func reapplyHostedInspectorDividerIfNeeded(in slot: WindowBrowserSlotView, reason: String) -> Bool {
|
||||
guard !slot.isHostedInspectorDividerDragActive else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.manualInspectorDrag stage=skipReapply slot=\(browserPortalDebugToken(slot)) " +
|
||||
"reason=\(reason)"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
guard let preferredWidth = slot.resolvedPreferredHostedInspectorWidth(in: slot.bounds) else { return false }
|
||||
guard let hit = hostedInspectorDividerCandidate(in: slot) else { return false }
|
||||
let oldPageFrame = hit.pageView.frame
|
||||
let oldInspectorFrame = hit.inspectorView.frame
|
||||
_ = applyHostedInspectorDividerWidth(preferredWidth, to: hit, reason: reason)
|
||||
return !Self.rectApproximatelyEqual(oldPageFrame, hit.pageView.frame, epsilon: 0.5) ||
|
||||
!Self.rectApproximatelyEqual(oldInspectorFrame, hit.inspectorView.frame, epsilon: 0.5)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
|
@ -792,19 +958,20 @@ final class WindowBrowserHostView: NSView {
|
|||
reason: String
|
||||
) -> (pageFrame: NSRect, inspectorFrame: NSRect) {
|
||||
let containerBounds = hit.containerView.bounds
|
||||
let maximumInspectorWidth = max(0, containerBounds.maxX - hit.pageView.frame.minX)
|
||||
let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth))
|
||||
let dividerX = max(hit.pageView.frame.minX, containerBounds.maxX - clampedInspectorWidth)
|
||||
let nextFrames = hit.dockSide.resizedFrames(
|
||||
preferredWidth: preferredWidth,
|
||||
in: containerBounds,
|
||||
pageFrame: hit.pageView.frame,
|
||||
inspectorFrame: hit.inspectorView.frame,
|
||||
minimumInspectorWidth: 0
|
||||
)
|
||||
let pageFrame = nextFrames.pageFrame
|
||||
let inspectorFrame = nextFrames.inspectorFrame
|
||||
|
||||
var pageFrame = hit.pageView.frame
|
||||
pageFrame.size.width = max(0, dividerX - pageFrame.minX)
|
||||
|
||||
var inspectorFrame = hit.inspectorView.frame
|
||||
inspectorFrame.origin.x = dividerX
|
||||
inspectorFrame.size.width = max(0, containerBounds.maxX - dividerX)
|
||||
|
||||
let pageChanged = !Self.rectApproximatelyEqual(pageFrame, hit.pageView.frame, epsilon: 0.5)
|
||||
let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, hit.inspectorView.frame, epsilon: 0.5)
|
||||
let oldPageFrame = hit.pageView.frame
|
||||
let oldInspectorFrame = hit.inspectorView.frame
|
||||
let pageChanged = !Self.rectApproximatelyEqual(pageFrame, oldPageFrame, epsilon: 0.5)
|
||||
let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, oldInspectorFrame, epsilon: 0.5)
|
||||
guard pageChanged || inspectorChanged else {
|
||||
return (pageFrame, inspectorFrame)
|
||||
}
|
||||
|
|
@ -817,15 +984,23 @@ final class WindowBrowserHostView: NSView {
|
|||
CATransaction.commit()
|
||||
hit.slotView.isApplyingHostedInspectorLayout = false
|
||||
|
||||
hit.pageView.needsLayout = true
|
||||
hit.inspectorView.needsLayout = true
|
||||
hit.containerView.needsLayout = true
|
||||
hit.slotView.needsLayout = true
|
||||
let isLiveDrag = reason == "drag"
|
||||
hit.pageView.needsDisplay = true
|
||||
hit.pageView.setNeedsDisplay(hit.pageView.bounds)
|
||||
hit.inspectorView.needsDisplay = true
|
||||
hit.inspectorView.setNeedsDisplay(hit.inspectorView.bounds)
|
||||
hit.containerView.needsDisplay = true
|
||||
hit.containerView.setNeedsDisplay(hit.containerView.bounds)
|
||||
hit.slotView.needsDisplay = true
|
||||
hit.slotView.setNeedsDisplay(hit.slotView.bounds)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.manualInspectorDrag stage=reapply slot=\(browserPortalDebugToken(hit.slotView)) " +
|
||||
"container=\(browserPortalDebugToken(hit.containerView)) reason=\(reason) " +
|
||||
"preferredWidth=\(String(format: "%.1f", preferredWidth)) " +
|
||||
"liveDrag=\(isLiveDrag ? 1 : 0) " +
|
||||
"pageChanged=\(pageChanged ? 1 : 0) inspectorChanged=\(inspectorChanged ? 1 : 0) " +
|
||||
"oldPageFrame=\(browserPortalDebugFrame(oldPageFrame)) oldInspectorFrame=\(browserPortalDebugFrame(oldInspectorFrame)) " +
|
||||
"pageFrame=\(browserPortalDebugFrame(pageFrame)) " +
|
||||
"inspectorFrame=\(browserPortalDebugFrame(inspectorFrame))"
|
||||
)
|
||||
|
|
@ -903,6 +1078,11 @@ final class WindowBrowserHostView: NSView {
|
|||
abs(lhs.size.height - rhs.size.height) <= epsilon
|
||||
}
|
||||
|
||||
private static func sizeApproximatelyEqual(_ lhs: NSSize, _ rhs: NSSize, epsilon: CGFloat = 0.01) -> Bool {
|
||||
abs(lhs.width - rhs.width) <= epsilon &&
|
||||
abs(lhs.height - rhs.height) <= epsilon
|
||||
}
|
||||
|
||||
private static func visibleDescendants(in root: NSView) -> [NSView] {
|
||||
var descendants: [NSView] = []
|
||||
var stack = Array(root.subviews.reversed())
|
||||
|
|
@ -978,9 +1158,12 @@ private final class BrowserDropZoneOverlayView: NSView {
|
|||
struct BrowserPortalSearchOverlayConfiguration {
|
||||
let panelId: UUID
|
||||
let searchState: BrowserSearchState
|
||||
let focusRequestGeneration: UInt64
|
||||
let canApplyFocusRequest: (UInt64) -> Bool
|
||||
let onNext: () -> Void
|
||||
let onPrevious: () -> Void
|
||||
let onClose: () -> Void
|
||||
let onFieldDidFocus: () -> Void
|
||||
}
|
||||
|
||||
struct BrowserPaneDropContext: Equatable {
|
||||
|
|
@ -1359,8 +1542,11 @@ final class WindowBrowserSlotView: NSView {
|
|||
private var isRefreshingInteractionLayers = false
|
||||
private var paneTopChromeHeight: CGFloat = 0
|
||||
var preferredHostedInspectorWidth: CGFloat?
|
||||
private var preferredHostedInspectorWidthFraction: CGFloat?
|
||||
fileprivate var isHostedInspectorDividerDragActive = false
|
||||
var onHostedInspectorLayout: ((WindowBrowserSlotView) -> Void)?
|
||||
fileprivate var isApplyingHostedInspectorLayout = false
|
||||
private var lastHostedInspectorLayoutBoundsSize: NSSize?
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
|
|
@ -1390,6 +1576,11 @@ final class WindowBrowserSlotView: NSView {
|
|||
paneDropTargetView.frame = bounds
|
||||
applyResolvedDropZoneOverlay()
|
||||
guard !isApplyingHostedInspectorLayout else { return }
|
||||
if let previousSize = lastHostedInspectorLayoutBoundsSize,
|
||||
Self.sizeApproximatelyEqual(previousSize, bounds.size) {
|
||||
return
|
||||
}
|
||||
lastHostedInspectorLayoutBoundsSize = bounds.size
|
||||
onHostedInspectorLayout?(self)
|
||||
}
|
||||
|
||||
|
|
@ -1399,6 +1590,27 @@ final class WindowBrowserSlotView: NSView {
|
|||
applyResolvedDropZoneOverlay()
|
||||
}
|
||||
|
||||
func recordPreferredHostedInspectorWidth(_ width: CGFloat, containerBounds: NSRect) {
|
||||
preferredHostedInspectorWidth = width
|
||||
guard containerBounds.width > 0 else {
|
||||
preferredHostedInspectorWidthFraction = nil
|
||||
return
|
||||
}
|
||||
preferredHostedInspectorWidthFraction = width / containerBounds.width
|
||||
}
|
||||
|
||||
func resolvedPreferredHostedInspectorWidth(in containerBounds: NSRect) -> CGFloat? {
|
||||
if let preferredHostedInspectorWidthFraction, containerBounds.width > 0 {
|
||||
return max(0, containerBounds.width * preferredHostedInspectorWidthFraction)
|
||||
}
|
||||
return preferredHostedInspectorWidth
|
||||
}
|
||||
|
||||
private static func sizeApproximatelyEqual(_ lhs: NSSize, _ rhs: NSSize, epsilon: CGFloat = 0.5) -> Bool {
|
||||
abs(lhs.width - rhs.width) <= epsilon &&
|
||||
abs(lhs.height - rhs.height) <= epsilon
|
||||
}
|
||||
|
||||
func setDropZoneOverlay(zone: DropZone?) {
|
||||
forwardedDropZone = zone
|
||||
applyResolvedDropZoneOverlay()
|
||||
|
|
@ -1420,23 +1632,63 @@ final class WindowBrowserSlotView: NSView {
|
|||
applyResolvedDropZoneOverlay()
|
||||
}
|
||||
|
||||
private func logSearchOverlayEvent(_ action: String, panelId: UUID?) {
|
||||
#if DEBUG
|
||||
let firstResponderSummary: String = {
|
||||
guard let firstResponder = window?.firstResponder else { return "nil" }
|
||||
if let editor = firstResponder as? NSTextView, editor.isFieldEditor {
|
||||
let delegateSummary = editor.delegate.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
return "fieldEditor(delegate=\(delegateSummary))"
|
||||
}
|
||||
return String(describing: type(of: firstResponder))
|
||||
}()
|
||||
dlog(
|
||||
"browser.findbar.portal action=\(action) " +
|
||||
"panel=\(panelId?.uuidString.prefix(5) ?? "nil") " +
|
||||
"window=\(window?.windowNumber ?? -1) " +
|
||||
"firstResponder=\(firstResponderSummary) " +
|
||||
"hasOverlay=\(searchOverlayHostingView != nil ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func setSearchOverlay(_ configuration: BrowserPortalSearchOverlayConfiguration?) {
|
||||
guard let configuration else {
|
||||
logSearchOverlayEvent("remove", panelId: nil)
|
||||
if let overlay = searchOverlayHostingView {
|
||||
objc_setAssociatedObject(
|
||||
overlay,
|
||||
&cmuxBrowserSearchOverlayPanelIdAssociationKey,
|
||||
nil,
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
||||
)
|
||||
}
|
||||
searchOverlayHostingView?.removeFromSuperview()
|
||||
searchOverlayHostingView = nil
|
||||
return
|
||||
}
|
||||
|
||||
logSearchOverlayEvent("set", panelId: configuration.panelId)
|
||||
let rootView = BrowserSearchOverlay(
|
||||
panelId: configuration.panelId,
|
||||
searchState: configuration.searchState,
|
||||
focusRequestGeneration: configuration.focusRequestGeneration,
|
||||
canApplyFocusRequest: configuration.canApplyFocusRequest,
|
||||
onNext: configuration.onNext,
|
||||
onPrevious: configuration.onPrevious,
|
||||
onClose: configuration.onClose
|
||||
onClose: configuration.onClose,
|
||||
onFieldDidFocus: configuration.onFieldDidFocus
|
||||
)
|
||||
|
||||
if let overlay = searchOverlayHostingView {
|
||||
logSearchOverlayEvent("updateExisting", panelId: configuration.panelId)
|
||||
overlay.rootView = rootView
|
||||
objc_setAssociatedObject(
|
||||
overlay,
|
||||
&cmuxBrowserSearchOverlayPanelIdAssociationKey,
|
||||
configuration.panelId,
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
||||
)
|
||||
if overlay.superview !== self {
|
||||
overlay.removeFromSuperview()
|
||||
addSubview(overlay)
|
||||
|
|
@ -1452,6 +1704,12 @@ final class WindowBrowserSlotView: NSView {
|
|||
|
||||
let overlay = NSHostingView(rootView: rootView)
|
||||
overlay.translatesAutoresizingMaskIntoConstraints = false
|
||||
objc_setAssociatedObject(
|
||||
overlay,
|
||||
&cmuxBrowserSearchOverlayPanelIdAssociationKey,
|
||||
configuration.panelId,
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
||||
)
|
||||
addSubview(overlay)
|
||||
NSLayoutConstraint.activate([
|
||||
overlay.topAnchor.constraint(equalTo: topAnchor),
|
||||
|
|
@ -1460,36 +1718,79 @@ final class WindowBrowserSlotView: NSView {
|
|||
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
])
|
||||
searchOverlayHostingView = overlay
|
||||
logSearchOverlayEvent("create", panelId: configuration.panelId)
|
||||
}
|
||||
|
||||
func searchOverlayPanelId(for responder: NSResponder) -> UUID? {
|
||||
guard let overlay = searchOverlayHostingView,
|
||||
let view = responder.browserPortalOwningView,
|
||||
view.isDescendant(of: overlay) else {
|
||||
return nil
|
||||
}
|
||||
return objc_getAssociatedObject(overlay, &cmuxBrowserSearchOverlayPanelIdAssociationKey) as? UUID
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func yieldSearchOverlayFocusIfOwned(by panelId: UUID, in window: NSWindow) -> Bool {
|
||||
guard let firstResponder = window.firstResponder,
|
||||
searchOverlayPanelId(for: firstResponder) == panelId else {
|
||||
return false
|
||||
}
|
||||
return window.makeFirstResponder(nil)
|
||||
}
|
||||
|
||||
func pinHostedWebView(_ webView: WKWebView) {
|
||||
guard webView.superview === self else { return }
|
||||
|
||||
let needsNewConstraints =
|
||||
let needsPlainWebViewFrameReset =
|
||||
!Self.hasWebKitCompanionSubview(in: self, primaryWebView: webView) &&
|
||||
Self.frameDiffersFromBounds(webView.frame, bounds: bounds)
|
||||
let needsFrameHosting =
|
||||
hostedWebView !== webView ||
|
||||
hostedWebViewConstraints.isEmpty ||
|
||||
webView.translatesAutoresizingMaskIntoConstraints
|
||||
guard needsNewConstraints else {
|
||||
!hostedWebViewConstraints.isEmpty ||
|
||||
needsPlainWebViewFrameReset ||
|
||||
!webView.translatesAutoresizingMaskIntoConstraints ||
|
||||
webView.autoresizingMask != [.width, .height]
|
||||
guard needsFrameHosting else {
|
||||
needsLayout = true
|
||||
layoutSubtreeIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
NSLayoutConstraint.deactivate(hostedWebViewConstraints)
|
||||
hostedWebViewConstraints = []
|
||||
hostedWebView = webView
|
||||
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||
webView.autoresizingMask = []
|
||||
hostedWebViewConstraints = [
|
||||
webView.topAnchor.constraint(equalTo: topAnchor),
|
||||
webView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
webView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
webView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
]
|
||||
NSLayoutConstraint.activate(hostedWebViewConstraints)
|
||||
// Attached Web Inspector mutates the moved WKWebView's frame directly.
|
||||
// Re-pin plain web views after cross-host reattach, but preserve the
|
||||
// WebKit-managed split frame when docked DevTools siblings are present.
|
||||
webView.translatesAutoresizingMaskIntoConstraints = true
|
||||
webView.autoresizingMask = [.width, .height]
|
||||
webView.frame = bounds
|
||||
needsLayout = true
|
||||
layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
private static func frameDiffersFromBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool {
|
||||
abs(frame.minX - bounds.minX) > epsilon ||
|
||||
abs(frame.minY - bounds.minY) > epsilon ||
|
||||
abs(frame.width - bounds.width) > epsilon ||
|
||||
abs(frame.height - bounds.height) > epsilon
|
||||
}
|
||||
|
||||
private static func hasWebKitCompanionSubview(in host: NSView, primaryWebView: WKWebView) -> Bool {
|
||||
var stack = host.subviews.filter { $0 !== primaryWebView }
|
||||
while let current = stack.popLast() {
|
||||
if current.isDescendant(of: primaryWebView) {
|
||||
continue
|
||||
}
|
||||
if String(describing: type(of: current)).contains("WK") {
|
||||
return true
|
||||
}
|
||||
stack.append(contentsOf: current.subviews)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func effectivePaneTopChromeHeight() -> CGFloat {
|
||||
paneTopChromeHeight
|
||||
}
|
||||
|
|
@ -1687,6 +1988,18 @@ final class WindowBrowserPortal: NSObject {
|
|||
_ = ensureInstalled()
|
||||
}
|
||||
|
||||
static func shouldTreatSplitResizeAsExternalGeometry(
|
||||
_ splitView: NSSplitView,
|
||||
window: NSWindow,
|
||||
hostView: WindowBrowserHostView
|
||||
) -> Bool {
|
||||
guard splitView.window === window else { return false }
|
||||
// WebKit's attached DevTools uses internal NSSplitView instances for the
|
||||
// side/bottom inspector layout. Those resizes are local to hosted content
|
||||
// and should not trigger a full portal re-sync/refresh pass.
|
||||
return !splitView.isDescendant(of: hostView)
|
||||
}
|
||||
|
||||
private func installGeometryObservers(for window: NSWindow) {
|
||||
guard geometryObservers.isEmpty else { return }
|
||||
|
||||
|
|
@ -1718,7 +2031,11 @@ final class WindowBrowserPortal: NSObject {
|
|||
guard let self,
|
||||
let splitView = notification.object as? NSSplitView,
|
||||
let window = self.window,
|
||||
splitView.window === window else { return }
|
||||
Self.shouldTreatSplitResizeAsExternalGeometry(
|
||||
splitView,
|
||||
window: window,
|
||||
hostView: self.hostView
|
||||
) else { return }
|
||||
self.scheduleExternalGeometrySynchronize()
|
||||
}
|
||||
})
|
||||
|
|
@ -1753,6 +2070,7 @@ final class WindowBrowserPortal: NSObject {
|
|||
guard let webView = entry.webView,
|
||||
let containerView = entry.containerView,
|
||||
!containerView.isHidden else { continue }
|
||||
guard webView.superview === containerView else { continue }
|
||||
refreshHostedWebViewPresentation(
|
||||
webView,
|
||||
in: containerView,
|
||||
|
|
@ -1872,7 +2190,9 @@ final class WindowBrowserPortal: NSObject {
|
|||
case (nil, nil):
|
||||
return true
|
||||
case let (lhs?, rhs?):
|
||||
return lhs.panelId == rhs.panelId && lhs.searchState === rhs.searchState
|
||||
return lhs.panelId == rhs.panelId &&
|
||||
lhs.searchState === rhs.searchState &&
|
||||
lhs.focusRequestGeneration == rhs.focusRequestGeneration
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
|
@ -1966,6 +2286,15 @@ final class WindowBrowserPortal: NSObject {
|
|||
phase: String
|
||||
) {
|
||||
guard !containerView.isHidden else { return }
|
||||
guard !containerView.isHostedInspectorDividerDragActive else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.refresh.skip web=\(browserPortalDebugToken(webView)) " +
|
||||
"container=\(browserPortalDebugToken(containerView)) reason=\(reason) phase=\(phase) drag=1"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
containerView.needsLayout = true
|
||||
containerView.needsDisplay = true
|
||||
|
|
@ -2043,7 +2372,12 @@ final class WindowBrowserPortal: NSObject {
|
|||
// UI state does not get orphaned in the old host during split churn.
|
||||
let relatedSubviews = sourceSuperview.subviews.filter { view in
|
||||
if view === primaryWebView { return true }
|
||||
return String(describing: type(of: view)).contains("WK")
|
||||
let className = String(describing: type(of: view))
|
||||
guard className.contains("WK") else { return false }
|
||||
if className.contains("WKInspector") {
|
||||
return !view.isHidden && view.alphaValue > 0 && view.frame.width > 1 && view.frame.height > 1
|
||||
}
|
||||
return true
|
||||
}
|
||||
guard !relatedSubviews.isEmpty else { return }
|
||||
#if DEBUG
|
||||
|
|
@ -2144,6 +2478,26 @@ final class WindowBrowserPortal: NSObject {
|
|||
entry.containerView?.setSearchOverlay(configuration)
|
||||
}
|
||||
|
||||
func searchOverlayPanelId(for responder: NSResponder) -> UUID? {
|
||||
for entry in entriesByWebViewId.values {
|
||||
if let panelId = entry.containerView?.searchOverlayPanelId(for: responder) {
|
||||
return panelId
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func yieldSearchOverlayFocusIfOwned(by panelId: UUID) -> Bool {
|
||||
guard let window else { return false }
|
||||
for entry in entriesByWebViewId.values {
|
||||
if entry.containerView?.yieldSearchOverlayFocusIfOwned(by: panelId, in: window) == true {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func updatePaneTopChromeHeight(forWebViewId webViewId: ObjectIdentifier, height: CGFloat) {
|
||||
guard var entry = entriesByWebViewId[webViewId] else { return }
|
||||
let resolvedHeight = max(0, height)
|
||||
|
|
@ -2406,7 +2760,7 @@ final class WindowBrowserPortal: NSObject {
|
|||
containerView.setPaneTopChromeHeight(0)
|
||||
containerView.setSearchOverlay(nil)
|
||||
containerView.setDropZoneOverlay(zone: nil)
|
||||
if !containerView.isHidden {
|
||||
if !containerView.isHidden, webView.superview === containerView {
|
||||
webView.browserPortalNotifyHidden(reason: reason)
|
||||
}
|
||||
containerView.isHidden = true
|
||||
|
|
@ -2508,7 +2862,18 @@ final class WindowBrowserPortal: NSObject {
|
|||
hostView.addSubview(containerView, positioned: .above, relativeTo: nil)
|
||||
refreshReasons.append("syncAttachContainer")
|
||||
}
|
||||
if webView.superview !== containerView {
|
||||
let shouldPreserveExternalHostForHiddenEntry =
|
||||
!entry.visibleInUI &&
|
||||
webView.superview !== containerView
|
||||
if shouldPreserveExternalHostForHiddenEntry {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.reparent.skip web=\(browserPortalDebugToken(webView)) " +
|
||||
"reason=hiddenEntryExternalHost super=\(browserPortalDebugToken(webView.superview)) " +
|
||||
"container=\(browserPortalDebugToken(containerView))"
|
||||
)
|
||||
#endif
|
||||
} else if webView.superview !== containerView {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.reparent web=\(browserPortalDebugToken(webView)) " +
|
||||
|
|
@ -2699,15 +3064,16 @@ final class WindowBrowserPortal: NSObject {
|
|||
refreshReasons.append("bounds")
|
||||
}
|
||||
|
||||
let containerOwnsWebView = webView.superview === containerView
|
||||
let containerBounds = containerView.bounds
|
||||
let preNormalizeWebFrame = webView.frame
|
||||
let preNormalizeWebFrame = containerOwnsWebView ? webView.frame : .zero
|
||||
let inspectorHeightFromInsets = max(0, containerBounds.height - preNormalizeWebFrame.height)
|
||||
let inspectorHeightFromOverflow = max(0, preNormalizeWebFrame.maxY - containerBounds.maxY)
|
||||
let inspectorHeightApprox = max(inspectorHeightFromInsets, inspectorHeightFromOverflow)
|
||||
#if DEBUG
|
||||
let inspectorSubviews = Self.inspectorSubviewCount(in: containerView)
|
||||
#endif
|
||||
if Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) {
|
||||
if containerOwnsWebView && Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) {
|
||||
let oldWebFrame = preNormalizeWebFrame
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
|
|
@ -2766,14 +3132,31 @@ final class WindowBrowserPortal: NSObject {
|
|||
if transientRecoveryReason == nil {
|
||||
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
|
||||
}
|
||||
if !shouldHide, !refreshReasons.isEmpty {
|
||||
refreshHostedWebViewPresentation(
|
||||
webView,
|
||||
in: containerView,
|
||||
reason: "\(source):" + refreshReasons.joined(separator: ",")
|
||||
)
|
||||
let hostedInspectorAdjustedDuringSync =
|
||||
containerOwnsWebView &&
|
||||
hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync")
|
||||
if !shouldHide, containerOwnsWebView, !refreshReasons.isEmpty {
|
||||
if hostedInspectorAdjustedDuringSync {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.refresh.skip web=\(browserPortalDebugToken(webView)) " +
|
||||
"container=\(browserPortalDebugToken(containerView)) reason=\(source):" +
|
||||
"\(refreshReasons.joined(separator: ",")) adjustedDuringSync=1"
|
||||
)
|
||||
#endif
|
||||
} else {
|
||||
refreshHostedWebViewPresentation(
|
||||
webView,
|
||||
in: containerView,
|
||||
reason: "\(source):" + refreshReasons.joined(separator: ",")
|
||||
)
|
||||
}
|
||||
}
|
||||
if containerOwnsWebView, !hostedInspectorAdjustedDuringSync {
|
||||
// Keep the existing post-sync pass for cases where the inspector candidate
|
||||
// appears only after WebKit settles, but avoid a second apply when sync already clamped it.
|
||||
_ = hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync.postRefresh")
|
||||
}
|
||||
hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync")
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.sync.result web=\(browserPortalDebugToken(webView)) source=\(source) " +
|
||||
|
|
@ -2783,6 +3166,8 @@ final class WindowBrowserPortal: NSObject {
|
|||
"old=\(browserPortalDebugFrame(oldFrame)) raw=\(browserPortalDebugFrame(frameInHost)) " +
|
||||
"target=\(browserPortalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " +
|
||||
"entryVisible=\(entry.visibleInUI ? 1 : 0) " +
|
||||
"containerOwnsWeb=\(containerOwnsWebView ? 1 : 0) " +
|
||||
"inspectorAdjusted=\(hostedInspectorAdjustedDuringSync ? 1 : 0) " +
|
||||
"containerHidden=\(containerView.isHidden ? 1 : 0) webHidden=\(webView.isHidden ? 1 : 0) " +
|
||||
"containerBounds=\(browserPortalDebugFrame(containerView.bounds)) " +
|
||||
"preWebFrame=\(browserPortalDebugFrame(preNormalizeWebFrame)) " +
|
||||
|
|
@ -2856,6 +3241,19 @@ final class WindowBrowserPortal: NSObject {
|
|||
}
|
||||
#endif
|
||||
|
||||
func debugSnapshot(forWebViewId webViewId: ObjectIdentifier) -> BrowserWindowPortalRegistry.DebugSnapshot? {
|
||||
guard let entry = entriesByWebViewId[webViewId] else { return nil }
|
||||
let frameInWindow: CGRect = {
|
||||
guard let container = entry.containerView, container.window != nil else { return .zero }
|
||||
return container.convert(container.bounds, to: nil)
|
||||
}()
|
||||
return BrowserWindowPortalRegistry.DebugSnapshot(
|
||||
visibleInUI: entry.visibleInUI,
|
||||
containerHidden: entry.containerView?.isHidden ?? true,
|
||||
frameInWindow: frameInWindow
|
||||
)
|
||||
}
|
||||
|
||||
func webViewAtWindowPoint(_ windowPoint: NSPoint) -> WKWebView? {
|
||||
guard ensureInstalled() else { return nil }
|
||||
let point = hostView.convert(windowPoint, from: nil)
|
||||
|
|
@ -2875,6 +3273,12 @@ final class WindowBrowserPortal: NSObject {
|
|||
|
||||
@MainActor
|
||||
enum BrowserWindowPortalRegistry {
|
||||
struct DebugSnapshot {
|
||||
let visibleInUI: Bool
|
||||
let containerHidden: Bool
|
||||
let frameInWindow: CGRect
|
||||
}
|
||||
|
||||
private static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:]
|
||||
private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:]
|
||||
|
||||
|
|
@ -3012,6 +3416,19 @@ enum BrowserWindowPortalRegistry {
|
|||
portal.updateSearchOverlay(forWebViewId: webViewId, configuration: configuration)
|
||||
}
|
||||
|
||||
static func searchOverlayPanelId(for responder: NSResponder, in window: NSWindow) -> UUID? {
|
||||
let windowId = ObjectIdentifier(window)
|
||||
guard let portal = portalsByWindowId[windowId] else { return nil }
|
||||
return portal.searchOverlayPanelId(for: responder)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func yieldSearchOverlayFocusIfOwned(by panelId: UUID, in window: NSWindow) -> Bool {
|
||||
let windowId = ObjectIdentifier(window)
|
||||
guard let portal = portalsByWindowId[windowId] else { return false }
|
||||
return portal.yieldSearchOverlayFocusIfOwned(by: panelId)
|
||||
}
|
||||
|
||||
static func updatePaneTopChromeHeight(for webView: WKWebView, height: CGFloat) {
|
||||
let webViewId = ObjectIdentifier(webView)
|
||||
guard let windowId = webViewToWindowId[webViewId],
|
||||
|
|
@ -3038,6 +3455,13 @@ enum BrowserWindowPortalRegistry {
|
|||
portal.forceRefreshWebView(withId: webViewId, reason: reason)
|
||||
}
|
||||
|
||||
static func debugSnapshot(for webView: WKWebView) -> DebugSnapshot? {
|
||||
let webViewId = ObjectIdentifier(webView)
|
||||
guard let windowId = webViewToWindowId[webViewId],
|
||||
let portal = portalsByWindowId[windowId] else { return nil }
|
||||
return portal.debugSnapshot(forWebViewId: webViewId)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func debugPortalCount() -> Int {
|
||||
portalsByWindowId.count
|
||||
|
|
|
|||
|
|
@ -82,6 +82,40 @@ func sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor {
|
|||
let clampedOpacity = max(0, min(opacity, 1))
|
||||
return NSColor.white.withAlphaComponent(clampedOpacity)
|
||||
}
|
||||
|
||||
#if compiler(>=6.2)
|
||||
@available(macOS 26.0, *)
|
||||
enum InternalTabDragConfigurationProvider {
|
||||
// These drags only make sense inside cmux. Outside the app, Finder should
|
||||
// reject them instead of materializing placeholder files from the payload.
|
||||
static let value = DragConfiguration(
|
||||
operationsWithinApp: .init(allowCopy: false, allowMove: true, allowDelete: false),
|
||||
operationsOutsideApp: .init(allowCopy: false, allowMove: false, allowDelete: false)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
private struct InternalTabDragConfigurationModifier: ViewModifier {
|
||||
@ViewBuilder
|
||||
func body(content: Content) -> some View {
|
||||
#if compiler(>=6.2)
|
||||
if #available(macOS 26.0, *) {
|
||||
content.dragConfiguration(InternalTabDragConfigurationProvider.value)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
#else
|
||||
content
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func internalOnlyTabDrag() -> some View {
|
||||
modifier(InternalTabDragConfigurationModifier())
|
||||
}
|
||||
}
|
||||
|
||||
struct ShortcutHintPillBackground: View {
|
||||
var emphasis: Double = 1.0
|
||||
|
||||
|
|
@ -1398,15 +1432,10 @@ struct ContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private enum CommandPaletteRestoreFocusIntent {
|
||||
case panel
|
||||
case browserAddressBar
|
||||
}
|
||||
|
||||
private struct CommandPaletteRestoreFocusTarget {
|
||||
let workspaceId: UUID
|
||||
let panelId: UUID
|
||||
let intent: CommandPaletteRestoreFocusIntent
|
||||
let intent: PanelFocusIntent
|
||||
}
|
||||
|
||||
private enum CommandPaletteInputFocusTarget {
|
||||
|
|
@ -5337,7 +5366,7 @@ struct ContentView: View {
|
|||
static func shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
|
||||
focusedPanelIsBrowser: Bool,
|
||||
focusedBrowserAddressBarPanelId: UUID?,
|
||||
focusedPanelId: UUID
|
||||
focusedPanelId: UUID?
|
||||
) -> Bool {
|
||||
focusedPanelIsBrowser && focusedBrowserAddressBarPanelId == focusedPanelId
|
||||
}
|
||||
|
|
@ -5383,15 +5412,10 @@ struct ContentView: View {
|
|||
|
||||
private func presentCommandPalette(initialQuery: String) {
|
||||
if let panelContext = focusedPanelContext {
|
||||
let shouldRestoreBrowserAddressBar = Self.shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
|
||||
focusedPanelIsBrowser: panelContext.panel.panelType == .browser,
|
||||
focusedBrowserAddressBarPanelId: AppDelegate.shared?.focusedBrowserAddressBarPanelId(),
|
||||
focusedPanelId: panelContext.panelId
|
||||
)
|
||||
commandPaletteRestoreFocusTarget = CommandPaletteRestoreFocusTarget(
|
||||
workspaceId: panelContext.workspace.id,
|
||||
panelId: panelContext.panelId,
|
||||
intent: shouldRestoreBrowserAddressBar ? .browserAddressBar : .panel
|
||||
intent: panelContext.panel.captureFocusIntent(in: observedWindow)
|
||||
)
|
||||
} else {
|
||||
commandPaletteRestoreFocusTarget = nil
|
||||
|
|
@ -5468,7 +5492,7 @@ struct ContentView: View {
|
|||
if let clickedFocusTarget {
|
||||
dlog(
|
||||
"palette.dismiss.backdrop focusTarget panel=\(clickedFocusTarget.panelId.uuidString.prefix(5)) " +
|
||||
"workspace=\(clickedFocusTarget.workspaceId.uuidString.prefix(5)) intent=\(clickedFocusTarget.intent == .browserAddressBar ? "addressBar" : "panel")"
|
||||
"workspace=\(clickedFocusTarget.workspaceId.uuidString.prefix(5)) intent=\(debugCommandPaletteFocusIntent(clickedFocusTarget.intent))"
|
||||
)
|
||||
} else {
|
||||
dlog("palette.dismiss.backdrop focusTarget=nil")
|
||||
|
|
@ -5507,10 +5531,11 @@ struct ContentView: View {
|
|||
let workspaceId = terminalView.tabId,
|
||||
let panelId = terminalView.terminalSurface?.id,
|
||||
tabManager.tabs.contains(where: { $0.id == workspaceId }) {
|
||||
return CommandPaletteRestoreFocusTarget(
|
||||
return commandPaletteRestoreFocusTarget(
|
||||
workspaceId: workspaceId,
|
||||
panelId: panelId,
|
||||
intent: .panel
|
||||
fallbackIntent: .terminal(.surface),
|
||||
in: window
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -5522,10 +5547,11 @@ struct ContentView: View {
|
|||
let workspaceId = terminalView.tabId,
|
||||
let panelId = terminalView.terminalSurface?.id,
|
||||
tabManager.tabs.contains(where: { $0.id == workspaceId }) {
|
||||
return CommandPaletteRestoreFocusTarget(
|
||||
return commandPaletteRestoreFocusTarget(
|
||||
workspaceId: workspaceId,
|
||||
panelId: panelId,
|
||||
intent: .panel
|
||||
fallbackIntent: .terminal(.surface),
|
||||
in: observedWindow
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -5563,16 +5589,35 @@ struct ContentView: View {
|
|||
continue
|
||||
}
|
||||
|
||||
return CommandPaletteRestoreFocusTarget(
|
||||
return commandPaletteRestoreFocusTarget(
|
||||
workspaceId: workspace.id,
|
||||
panelId: panelId,
|
||||
intent: .panel
|
||||
fallbackIntent: .browser(.webView),
|
||||
in: observedWindow
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func commandPaletteRestoreFocusTarget(
|
||||
workspaceId: UUID,
|
||||
panelId: UUID,
|
||||
fallbackIntent: PanelFocusIntent,
|
||||
in window: NSWindow?
|
||||
) -> CommandPaletteRestoreFocusTarget {
|
||||
let intent = tabManager.tabs
|
||||
.first(where: { $0.id == workspaceId })?
|
||||
.panels[panelId]?
|
||||
.captureFocusIntent(in: window) ?? fallbackIntent
|
||||
|
||||
return CommandPaletteRestoreFocusTarget(
|
||||
workspaceId: workspaceId,
|
||||
panelId: panelId,
|
||||
intent: intent
|
||||
)
|
||||
}
|
||||
|
||||
private func restoreCommandPaletteFocus(
|
||||
target: CommandPaletteRestoreFocusTarget,
|
||||
attemptsRemaining: Int
|
||||
|
|
@ -5588,8 +5633,9 @@ struct ContentView: View {
|
|||
if let context = focusedPanelContext,
|
||||
context.workspace.id == target.workspaceId,
|
||||
context.panelId == target.panelId {
|
||||
restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6)
|
||||
return
|
||||
if context.panel.restoreFocusIntent(target.intent) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard attemptsRemaining > 0 else { return }
|
||||
|
|
@ -5598,33 +5644,32 @@ struct ContentView: View {
|
|||
if let context = focusedPanelContext,
|
||||
context.workspace.id == target.workspaceId,
|
||||
context.panelId == target.panelId {
|
||||
restoreCommandPaletteInputFocusIfNeeded(target: target, attemptsRemaining: 6)
|
||||
return
|
||||
if context.panel.restoreFocusIntent(target.intent) {
|
||||
return
|
||||
}
|
||||
}
|
||||
restoreCommandPaletteFocus(target: target, attemptsRemaining: attemptsRemaining - 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreCommandPaletteInputFocusIfNeeded(
|
||||
target: CommandPaletteRestoreFocusTarget,
|
||||
attemptsRemaining: Int
|
||||
) {
|
||||
guard !isCommandPalettePresented else { return }
|
||||
guard target.intent == .browserAddressBar else { return }
|
||||
guard attemptsRemaining > 0 else { return }
|
||||
guard let appDelegate = AppDelegate.shared else { return }
|
||||
|
||||
if appDelegate.requestBrowserAddressBarFocus(panelId: target.panelId) {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) {
|
||||
restoreCommandPaletteInputFocusIfNeeded(
|
||||
target: target,
|
||||
attemptsRemaining: attemptsRemaining - 1
|
||||
)
|
||||
#if DEBUG
|
||||
private func debugCommandPaletteFocusIntent(_ intent: PanelFocusIntent) -> String {
|
||||
switch intent {
|
||||
case .panel:
|
||||
return "panel"
|
||||
case .terminal(.surface):
|
||||
return "terminal.surface"
|
||||
case .terminal(.findField):
|
||||
return "terminal.findField"
|
||||
case .browser(.webView):
|
||||
return "browser.webView"
|
||||
case .browser(.addressBar):
|
||||
return "browser.addressBar"
|
||||
case .browser(.findField):
|
||||
return "browser.findField"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private func resetCommandPaletteSearchFocus() {
|
||||
applyCommandPaletteInputFocusPolicy(.search)
|
||||
|
|
@ -8079,6 +8124,7 @@ private enum SidebarHelpMenuAction {
|
|||
case githubIssues
|
||||
case checkForUpdates
|
||||
case sendFeedback
|
||||
case welcome
|
||||
}
|
||||
|
||||
private struct SidebarFeedbackComposerSheet: View {
|
||||
|
|
@ -8455,6 +8501,122 @@ private struct SidebarFeedbackComposerSheet: View {
|
|||
}
|
||||
}
|
||||
|
||||
enum FeedbackComposerBridgeError: LocalizedError {
|
||||
case invalidEmail
|
||||
case emptyMessage
|
||||
case messageTooLong
|
||||
case tooManyImages
|
||||
case invalidImagePath(String)
|
||||
case submissionFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidEmail:
|
||||
return "Enter a valid email address."
|
||||
case .emptyMessage:
|
||||
return "Enter a message before sending."
|
||||
case .messageTooLong:
|
||||
return "Your message is too long."
|
||||
case .tooManyImages:
|
||||
return "You can attach up to 10 images."
|
||||
case .invalidImagePath(let path):
|
||||
return "Could not attach image: \(path)"
|
||||
case .submissionFailed(let message):
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum FeedbackComposerBridge {
|
||||
static func openComposer(in window: NSWindow? = NSApp.keyWindow ?? NSApp.mainWindow) {
|
||||
NotificationCenter.default.post(name: .feedbackComposerRequested, object: window)
|
||||
}
|
||||
|
||||
static func submit(
|
||||
email: String,
|
||||
message: String,
|
||||
imagePaths: [String]
|
||||
) async throws -> Int {
|
||||
let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard isValidEmail(trimmedEmail) else {
|
||||
throw FeedbackComposerBridgeError.invalidEmail
|
||||
}
|
||||
guard normalizedMessage.isEmpty == false else {
|
||||
throw FeedbackComposerBridgeError.emptyMessage
|
||||
}
|
||||
guard message.count <= FeedbackComposerSettings.maxMessageLength else {
|
||||
throw FeedbackComposerBridgeError.messageTooLong
|
||||
}
|
||||
guard imagePaths.count <= FeedbackComposerSettings.maxAttachmentCount else {
|
||||
throw FeedbackComposerBridgeError.tooManyImages
|
||||
}
|
||||
|
||||
let attachments = try imagePaths.map { rawPath in
|
||||
let resolvedURL = URL(fileURLWithPath: rawPath).standardizedFileURL
|
||||
do {
|
||||
return try FeedbackComposerAttachment(url: resolvedURL)
|
||||
} catch {
|
||||
throw FeedbackComposerBridgeError.invalidImagePath(resolvedURL.path)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try await FeedbackComposerClient.submit(
|
||||
email: trimmedEmail,
|
||||
message: normalizedMessage,
|
||||
attachments: attachments
|
||||
)
|
||||
} catch {
|
||||
throw FeedbackComposerBridgeError.submissionFailed(userFacingMessage(for: error))
|
||||
}
|
||||
|
||||
UserDefaults.standard.set(trimmedEmail, forKey: FeedbackComposerSettings.storedEmailKey)
|
||||
return attachments.count
|
||||
}
|
||||
|
||||
private static func isValidEmail(_ rawValue: String) -> Bool {
|
||||
let email = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard email.isEmpty == false else { return false }
|
||||
let pattern = #"^[A-Z0-9a-z._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$"#
|
||||
return NSPredicate(format: "SELF MATCHES %@", pattern).evaluate(with: email)
|
||||
}
|
||||
|
||||
private static func userFacingMessage(for error: Error) -> String {
|
||||
guard let submissionError = error as? FeedbackComposerSubmissionError else {
|
||||
return "Couldn't send feedback. Please try again."
|
||||
}
|
||||
|
||||
switch submissionError {
|
||||
case .invalidEndpoint:
|
||||
return "Feedback is unavailable right now. Email founders@manaflow.com instead."
|
||||
case .invalidResponse:
|
||||
return "Couldn't send feedback. Please try again."
|
||||
case .attachmentReadFailed:
|
||||
return "One of the selected files could not be attached."
|
||||
case .attachmentPreparationFailed:
|
||||
return "These images are too large to send together. Remove a few and try again."
|
||||
case .transport(let transportError):
|
||||
if transportError.code == .notConnectedToInternet || transportError.code == .networkConnectionLost {
|
||||
return "Couldn't send feedback. Check your connection and try again."
|
||||
}
|
||||
return "Couldn't send feedback. Please try again."
|
||||
case .rejected(let statusCode):
|
||||
switch statusCode {
|
||||
case 400, 413, 415:
|
||||
return "Check your message and attachments, then try again."
|
||||
case 429:
|
||||
return "Too many feedback attempts. Please try again later."
|
||||
case 500...599:
|
||||
return "Feedback is unavailable right now. Email founders@manaflow.com instead."
|
||||
default:
|
||||
return "Couldn't send feedback. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarHelpMenuButton: View {
|
||||
private let docsURL = URL(string: "https://cmux.dev/docs")
|
||||
private let changelogURL = URL(string: "https://cmux.dev/docs/changelog")
|
||||
|
|
@ -8503,6 +8665,12 @@ private struct SidebarHelpMenuButton: View {
|
|||
|
||||
private var helpPopover: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
helpOptionButton(
|
||||
title: String(localized: "sidebar.help.welcome", defaultValue: "Welcome"),
|
||||
action: .welcome,
|
||||
accessibilityIdentifier: "SidebarHelpMenuOptionWelcome",
|
||||
isExternalLink: false
|
||||
)
|
||||
helpOptionButton(
|
||||
title: String(localized: "sidebar.help.sendFeedback", defaultValue: "Send Feedback"),
|
||||
action: .sendFeedback,
|
||||
|
|
@ -8614,14 +8782,17 @@ private struct SidebarHelpMenuButton: View {
|
|||
private func perform(_ action: SidebarHelpMenuAction) {
|
||||
switch action {
|
||||
case .keyboardShortcuts:
|
||||
Task { @MainActor in
|
||||
if let appDelegate = AppDelegate.shared {
|
||||
appDelegate.openPreferencesWindow(
|
||||
debugSource: "sidebarHelpMenu.keyboardShortcuts",
|
||||
navigationTarget: .keyboardShortcuts
|
||||
)
|
||||
} else {
|
||||
AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts)
|
||||
isPopoverPresented = false
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
|
||||
Task { @MainActor in
|
||||
if let appDelegate = AppDelegate.shared {
|
||||
appDelegate.openPreferencesWindow(
|
||||
debugSource: "sidebarHelpMenu.keyboardShortcuts",
|
||||
navigationTarget: .keyboardShortcuts
|
||||
)
|
||||
} else {
|
||||
AppDelegate.presentPreferencesWindow(navigationTarget: .keyboardShortcuts)
|
||||
}
|
||||
}
|
||||
}
|
||||
case .docs:
|
||||
|
|
@ -8643,6 +8814,13 @@ private struct SidebarHelpMenuButton: View {
|
|||
case .sendFeedback:
|
||||
isPopoverPresented = false
|
||||
onSendFeedback()
|
||||
case .welcome:
|
||||
isPopoverPresented = false
|
||||
Task { @MainActor in
|
||||
if let appDelegate = AppDelegate.shared {
|
||||
appDelegate.openWelcomeWorkspace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -9538,6 +9716,7 @@ private struct TabItemView: View {
|
|||
dropIndicator = nil
|
||||
return SidebarTabDragPayload.provider(for: tab.id)
|
||||
}
|
||||
.internalOnlyTabDrag()
|
||||
.onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate(
|
||||
targetTabId: tab.id,
|
||||
tabManager: tabManager,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import AppKit
|
||||
import Bonsplit
|
||||
import SwiftUI
|
||||
|
||||
struct BrowserSearchOverlay: View {
|
||||
let panelId: UUID
|
||||
@ObservedObject var searchState: BrowserSearchState
|
||||
let focusRequestGeneration: UInt64
|
||||
let canApplyFocusRequest: (UInt64) -> Bool
|
||||
let onNext: () -> Void
|
||||
let onPrevious: () -> Void
|
||||
let onClose: () -> Void
|
||||
let onFieldDidFocus: () -> Void
|
||||
@State private var corner: Corner = .topRight
|
||||
@State private var dragOffset: CGSize = .zero
|
||||
@State private var barSize: CGSize = .zero
|
||||
|
|
@ -14,12 +18,58 @@ struct BrowserSearchOverlay: View {
|
|||
|
||||
private let padding: CGFloat = 8
|
||||
|
||||
private func requestSearchFieldFocus(maxAttempts: Int = 3) {
|
||||
#if DEBUG
|
||||
private func debugFirstResponderSummary() -> String {
|
||||
guard let window = NSApp.keyWindow else { return "nil" }
|
||||
guard let firstResponder = window.firstResponder else { return "nil" }
|
||||
if let editor = firstResponder as? NSTextView, editor.isFieldEditor {
|
||||
let delegateSummary = editor.delegate.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
return "fieldEditor(delegate=\(delegateSummary))"
|
||||
}
|
||||
return String(describing: type(of: firstResponder))
|
||||
}
|
||||
#endif
|
||||
|
||||
private func logFocusState(_ event: String) {
|
||||
#if DEBUG
|
||||
let keyWindow = NSApp.keyWindow
|
||||
dlog(
|
||||
"browser.findbar.focus panel=\(panelId.uuidString.prefix(5)) " +
|
||||
"event=\(event) keyWindow=\(keyWindow?.windowNumber ?? -1) " +
|
||||
"firstResponder=\(debugFirstResponderSummary()) " +
|
||||
"focused=\(isSearchFieldFocused ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func requestSearchFieldFocus(maxAttempts: Int = 3, origin: String) {
|
||||
guard maxAttempts > 0 else { return }
|
||||
guard canApplyFocusRequest(focusRequestGeneration) else {
|
||||
#if DEBUG
|
||||
logFocusState("request.skip origin=\(origin) generation=\(focusRequestGeneration)")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
logFocusState("request.begin origin=\(origin) remaining=\(maxAttempts)")
|
||||
isSearchFieldFocused = true
|
||||
#if DEBUG
|
||||
DispatchQueue.main.async {
|
||||
guard canApplyFocusRequest(focusRequestGeneration) else {
|
||||
logFocusState("request.skipAsync origin=\(origin) generation=\(focusRequestGeneration)")
|
||||
return
|
||||
}
|
||||
logFocusState("request.afterAsync origin=\(origin) remaining=\(maxAttempts)")
|
||||
}
|
||||
#endif
|
||||
guard maxAttempts > 1 else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
requestSearchFieldFocus(maxAttempts: maxAttempts - 1)
|
||||
guard canApplyFocusRequest(focusRequestGeneration) else {
|
||||
#if DEBUG
|
||||
logFocusState("request.skipRetry origin=\(origin) generation=\(focusRequestGeneration)")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
requestSearchFieldFocus(maxAttempts: maxAttempts - 1, origin: origin)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -102,16 +152,24 @@ struct BrowserSearchOverlay: View {
|
|||
.clipShape(clipShape)
|
||||
.shadow(radius: 4)
|
||||
.onAppear {
|
||||
#if DEBUG
|
||||
#if DEBUG
|
||||
dlog("browser.findbar.appear panel=\(panelId.uuidString.prefix(5))")
|
||||
#endif
|
||||
requestSearchFieldFocus()
|
||||
#endif
|
||||
logFocusState("appear")
|
||||
requestSearchFieldFocus(origin: "appear")
|
||||
}
|
||||
.onChange(of: isSearchFieldFocused) { _, focused in
|
||||
logFocusState("focusState.change next=\(focused ? 1 : 0)")
|
||||
if focused {
|
||||
onFieldDidFocus()
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .browserSearchFocus)) { notification in
|
||||
guard let notifiedPanelId = notification.object as? UUID,
|
||||
notifiedPanelId == panelId else { return }
|
||||
logFocusState("notification.received")
|
||||
DispatchQueue.main.async {
|
||||
requestSearchFieldFocus()
|
||||
requestSearchFieldFocus(origin: "notification")
|
||||
}
|
||||
}
|
||||
.background(
|
||||
|
|
|
|||
|
|
@ -328,10 +328,19 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable {
|
|||
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)")
|
||||
dlog(
|
||||
"find.nativeField.searchFocusNotification surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " +
|
||||
"alreadyFocused=\(alreadyFocused) firstResponder=\(String(describing: fr))"
|
||||
)
|
||||
#endif
|
||||
guard !alreadyFocused else { return }
|
||||
window.makeFirstResponder(field)
|
||||
let result = window.makeFirstResponder(field)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"find.nativeField.searchFocusApply surface=\(coordinator.parent.surfaceId.uuidString.prefix(5)) " +
|
||||
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
return field
|
||||
|
|
|
|||
|
|
@ -708,6 +708,7 @@ class GhosttyApp {
|
|||
private let backgroundLogLock = NSLock()
|
||||
private var backgroundLogSequence: UInt64 = 0
|
||||
private var appObservers: [NSObjectProtocol] = []
|
||||
private var bellAudioSound: NSSound?
|
||||
private var backgroundEventCounter: UInt64 = 0
|
||||
private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped
|
||||
private var defaultBackgroundScopeSource: String = "initialize"
|
||||
|
|
@ -1524,6 +1525,75 @@ class GhosttyApp {
|
|||
return found && enabled
|
||||
}
|
||||
|
||||
func appleScriptAutomationEnabled() -> Bool {
|
||||
guard let config else { return false }
|
||||
var enabled = false
|
||||
let key = "macos-applescript"
|
||||
_ = ghostty_config_get(config, &enabled, key, UInt(key.lengthOfBytes(using: .utf8)))
|
||||
return enabled
|
||||
}
|
||||
|
||||
fileprivate func shellIntegrationMode() -> String {
|
||||
guard let config else { return "detect" }
|
||||
var value: UnsafePointer<Int8>?
|
||||
let key = "shell-integration"
|
||||
guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))),
|
||||
let value else {
|
||||
return "detect"
|
||||
}
|
||||
return String(cString: value)
|
||||
}
|
||||
|
||||
private func bellFeatures() -> CUnsignedInt {
|
||||
guard let config else { return 0 }
|
||||
var features: CUnsignedInt = 0
|
||||
let key = "bell-features"
|
||||
_ = ghostty_config_get(config, &features, key, UInt(key.lengthOfBytes(using: .utf8)))
|
||||
return features
|
||||
}
|
||||
|
||||
private func bellAudioPath() -> String? {
|
||||
guard let config else { return nil }
|
||||
var value = ghostty_config_path_s()
|
||||
let key = "bell-audio-path"
|
||||
guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))),
|
||||
let rawPath = value.path else {
|
||||
return nil
|
||||
}
|
||||
let path = String(cString: rawPath)
|
||||
return path.isEmpty ? nil : path
|
||||
}
|
||||
|
||||
private func bellAudioVolume() -> Float {
|
||||
guard let config else { return 0.5 }
|
||||
var value: Double = 0.5
|
||||
let key = "bell-audio-volume"
|
||||
_ = ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8)))
|
||||
return Float(min(1.0, max(0.0, value)))
|
||||
}
|
||||
|
||||
private func ringBell() {
|
||||
let features = bellFeatures()
|
||||
|
||||
if (features & (1 << 0)) != 0 {
|
||||
NSSound.beep()
|
||||
}
|
||||
|
||||
if (features & (1 << 1)) != 0,
|
||||
let path = bellAudioPath(),
|
||||
let sound = NSSound(contentsOfFile: path, byReference: false) {
|
||||
sound.volume = bellAudioVolume()
|
||||
bellAudioSound = sound
|
||||
if !sound.play() {
|
||||
bellAudioSound = nil
|
||||
}
|
||||
}
|
||||
|
||||
if (features & (1 << 2)) != 0 {
|
||||
NSApp.requestUserAttention(.informationalRequest)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyDefaultBackground(
|
||||
color: NSColor,
|
||||
opacity: Double,
|
||||
|
|
@ -1690,6 +1760,13 @@ class GhosttyApp {
|
|||
}
|
||||
}
|
||||
|
||||
if action.tag == GHOSTTY_ACTION_RING_BELL {
|
||||
performOnMain {
|
||||
self.ringBell()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG {
|
||||
let soft = action.action.reload_config.soft
|
||||
logThemeAction("reload request target=app soft=\(soft)")
|
||||
|
|
@ -1797,6 +1874,11 @@ class GhosttyApp {
|
|||
guard let tabManager = AppDelegate.shared?.tabManager else { return false }
|
||||
return tabManager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
|
||||
}
|
||||
case GHOSTTY_ACTION_RING_BELL:
|
||||
performOnMain {
|
||||
self.ringBell()
|
||||
}
|
||||
return true
|
||||
case GHOSTTY_ACTION_GOTO_SPLIT:
|
||||
guard let tabId = surfaceView.tabId,
|
||||
let surfaceId = surfaceView.terminalSurface?.id,
|
||||
|
|
@ -2766,6 +2848,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
|
|||
?? "/bin/zsh"
|
||||
let shellName = URL(fileURLWithPath: shell).lastPathComponent
|
||||
if shellName == "zsh" {
|
||||
if GhosttyApp.shared.shellIntegrationMode() != "none" {
|
||||
env["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1"
|
||||
}
|
||||
let candidateZdotdir = (env["ZDOTDIR"]?.isEmpty == false ? env["ZDOTDIR"] : nil)
|
||||
?? getenv("ZDOTDIR").map { String(cString: $0) }
|
||||
?? (ProcessInfo.processInfo.environment["ZDOTDIR"]?.isEmpty == false ? ProcessInfo.processInfo.environment["ZDOTDIR"] : nil)
|
||||
|
|
@ -3310,9 +3395,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
private var eventMonitor: Any?
|
||||
private var trackingArea: NSTrackingArea?
|
||||
private var windowObserver: NSObjectProtocol?
|
||||
private var lastScrollEventTime: CFTimeInterval = 0
|
||||
private var lastScrollEventTime: CFTimeInterval = 0
|
||||
private var visibleInUI: Bool = true
|
||||
private var pendingSurfaceSize: CGSize?
|
||||
private var deferredSurfaceSizeRetryQueued = false
|
||||
private var lastDrawableSize: CGSize = .zero
|
||||
private var isFindEscapeSuppressionArmed = false
|
||||
#if DEBUG
|
||||
|
|
@ -3610,11 +3696,39 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
return currentBounds
|
||||
}
|
||||
|
||||
private static func hasActiveTabDragPasteboard() -> Bool {
|
||||
private static func hasTabDragPasteboardTypes() -> Bool {
|
||||
let types = NSPasteboard(name: .drag).types ?? []
|
||||
return types.contains(tabTransferPasteboardType) || types.contains(sidebarTabReorderPasteboardType)
|
||||
}
|
||||
|
||||
private static func isDragResizeEvent(_ eventType: NSEvent.EventType?) -> Bool {
|
||||
switch eventType {
|
||||
case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func shouldDeferSurfaceResizeForActiveDrag() -> Bool {
|
||||
// The drag pasteboard can retain tab-transfer UTIs briefly after a split command
|
||||
// or other layout churn. Only defer terminal resizes while an actual drag event
|
||||
// is in flight; otherwise pre-existing panes can stay stuck at their old size.
|
||||
guard hasTabDragPasteboardTypes() else { return false }
|
||||
return isDragResizeEvent(NSApp.currentEvent?.type)
|
||||
}
|
||||
|
||||
private func scheduleDeferredSurfaceSizeRetryIfNeeded() {
|
||||
guard window != nil else { return }
|
||||
guard !deferredSurfaceSizeRetryQueued else { return }
|
||||
deferredSurfaceSizeRetryQueued = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.deferredSurfaceSizeRetryQueued = false
|
||||
_ = self.updateSurfaceSize()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func updateSurfaceSize(size: CGSize? = nil) -> Bool {
|
||||
guard let terminalSurface = terminalSurface else { return false }
|
||||
|
|
@ -3634,7 +3748,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
return false
|
||||
}
|
||||
pendingSurfaceSize = size
|
||||
guard !Self.hasActiveTabDragPasteboard() else {
|
||||
guard !Self.shouldDeferSurfaceResizeForActiveDrag() else {
|
||||
scheduleDeferredSurfaceSizeRetryIfNeeded()
|
||||
#if DEBUG
|
||||
let signature = "tabDrag-\(Int(size.width.rounded()))x\(Int(size.height.rounded()))"
|
||||
if lastSizeSkipSignature != signature {
|
||||
|
|
@ -4376,6 +4491,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
super.keyDown(with: event)
|
||||
return
|
||||
}
|
||||
if let terminalSurface {
|
||||
AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
|
||||
tabId: terminalSurface.tabId,
|
||||
surfaceId: terminalSurface.id
|
||||
)
|
||||
}
|
||||
if event.keyCode != 53 {
|
||||
endFindEscapeSuppression()
|
||||
}
|
||||
|
|
@ -4537,6 +4658,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
|
||||
// Use accumulated text from insertText (for IME), or compute text for key
|
||||
let accumulatedText = keyTextAccumulator ?? []
|
||||
var shouldRefreshAfterTextInput = false
|
||||
if !accumulatedText.isEmpty {
|
||||
// Accumulated text comes from insertText (IME composition result).
|
||||
// These never have "composing" set to true because these are the
|
||||
|
|
@ -4544,6 +4666,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
keyEvent.composing = false
|
||||
for text in accumulatedText {
|
||||
if shouldSendText(text) {
|
||||
shouldRefreshAfterTextInput = true
|
||||
text.withCString { ptr in
|
||||
keyEvent.text = ptr
|
||||
_ = ghostty_surface_key(surface, keyEvent)
|
||||
|
|
@ -4564,6 +4687,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
)
|
||||
if let text = textForKeyEvent(translationEvent) {
|
||||
if shouldSendText(text), !suppressShiftSpaceFallbackText {
|
||||
shouldRefreshAfterTextInput = true
|
||||
text.withCString { ptr in
|
||||
keyEvent.text = ptr
|
||||
_ = ghostty_surface_key(surface, keyEvent)
|
||||
|
|
@ -4578,6 +4702,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
}
|
||||
}
|
||||
|
||||
if shouldRefreshAfterTextInput {
|
||||
terminalSurface?.forceRefresh(reason: "keyDown.textInput")
|
||||
}
|
||||
|
||||
// Rendering is driven by Ghostty's wakeups/renderer.
|
||||
}
|
||||
|
||||
|
|
@ -4817,11 +4945,22 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
}
|
||||
#endif
|
||||
|
||||
private func requestPointerFocusRecovery() {
|
||||
#if DEBUG
|
||||
dlog("focus.pointerDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
|
||||
#endif
|
||||
onFocus?()
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
#if DEBUG
|
||||
let debugPoint = convert(event.locationInWindow, from: nil)
|
||||
dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))] clickCount=\(event.clickCount) point=(\(String(format: "%.0f", debugPoint.x)),\(String(format: "%.0f", debugPoint.y)))")
|
||||
#endif
|
||||
// Split reparent/layout churn can suppress the later `becomeFirstResponder -> onFocus`
|
||||
// callback. Treat pointer-down as explicit focus intent so clicking a ghost pane still
|
||||
// repairs workspace/pane active state before key routing runs.
|
||||
requestPointerFocusRecovery()
|
||||
window?.makeFirstResponder(self)
|
||||
if let terminalSurface {
|
||||
AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
|
||||
|
|
@ -4846,10 +4985,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
override func rightMouseDown(with event: NSEvent) {
|
||||
guard let surface = surface else { return }
|
||||
if !ghostty_surface_mouse_captured(surface) {
|
||||
requestPointerFocusRecovery()
|
||||
super.rightMouseDown(with: event)
|
||||
return
|
||||
}
|
||||
|
||||
requestPointerFocusRecovery()
|
||||
window?.makeFirstResponder(self)
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
|
||||
|
|
@ -4871,6 +5012,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
super.otherMouseDown(with: event)
|
||||
return
|
||||
}
|
||||
requestPointerFocusRecovery()
|
||||
window?.makeFirstResponder(self)
|
||||
guard let surface = surface else { return }
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
|
|
@ -5330,6 +5472,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
private var isLiveScrolling = false
|
||||
private var lastSentRow: Int?
|
||||
private var isActive = true
|
||||
private var lastFocusRefreshAt: CFTimeInterval = 0
|
||||
private var activeDropZone: DropZone?
|
||||
private var pendingDropZone: DropZone?
|
||||
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
|
||||
|
|
@ -6286,6 +6429,15 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
}
|
||||
}
|
||||
|
||||
var debugPortalVisibleInUI: Bool {
|
||||
surfaceView.isVisibleInUI
|
||||
}
|
||||
|
||||
var debugPortalFrameInWindow: CGRect {
|
||||
guard window != nil else { return .zero }
|
||||
return convert(bounds, to: nil)
|
||||
}
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
let wasActive = isActive
|
||||
isActive = active
|
||||
|
|
@ -6301,10 +6453,8 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
#endif
|
||||
if active {
|
||||
scheduleAutomaticFirstResponderApply(reason: "setActive")
|
||||
} else if let window,
|
||||
let fr = window.firstResponder as? NSView,
|
||||
fr === surfaceView || fr.isDescendant(of: surfaceView) {
|
||||
window.makeFirstResponder(nil)
|
||||
} else {
|
||||
resignOwnedFirstResponderIfNeeded(reason: "setActive(false)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -6354,15 +6504,29 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
#if DEBUG
|
||||
let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
|
||||
let searchActive = self.surfaceView.terminalSurface?.searchState != nil
|
||||
dlog("find.moveFocus to=\(surfaceShort) searchState=\(searchActive ? "active" : "nil")")
|
||||
dlog(
|
||||
"find.moveFocus to=\(surfaceShort) " +
|
||||
"from=\(previous?.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"searchState=\(searchActive ? "active" : "nil") " +
|
||||
"delayMs=\(Int((delay ?? 0) * 1000))"
|
||||
)
|
||||
#endif
|
||||
let work = { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let window = self.window else { return }
|
||||
#if DEBUG
|
||||
let before = String(describing: window.firstResponder)
|
||||
#endif
|
||||
if let previous, previous !== self {
|
||||
_ = previous.surfaceView.resignFirstResponder()
|
||||
}
|
||||
window.makeFirstResponder(self.surfaceView)
|
||||
let result = window.makeFirstResponder(self.surfaceView)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"find.moveFocus.apply to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"result=\(result ? 1 : 0) before=\(before) after=\(String(describing: window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
if let delay, delay > 0 {
|
||||
|
|
@ -6506,6 +6670,12 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
guard isActive else { return }
|
||||
guard let window else { return }
|
||||
guard surfaceView.isVisibleInUI else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"reason=not_visible attempts=\(attemptsRemaining)"
|
||||
)
|
||||
#endif
|
||||
retry()
|
||||
return
|
||||
}
|
||||
|
|
@ -6545,25 +6715,59 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
|
||||
// Search focus restoration — only after confirming this is the active tab/pane.
|
||||
if surfaceView.terminalSurface?.searchState != nil {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.ensure.search surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
|
||||
"attempts=\(attemptsRemaining) firstResponder=\(String(describing: window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
restoreSearchFocus(window: window)
|
||||
return
|
||||
}
|
||||
|
||||
if let fr = window.firstResponder as? NSView,
|
||||
fr === surfaceView || fr.isDescendant(of: surfaceView) {
|
||||
reassertTerminalSurfaceFocus(reason: "ensureFocus.alreadyFirstResponder")
|
||||
return
|
||||
}
|
||||
|
||||
if !window.isKeyWindow {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
_ = window.makeFirstResponder(surfaceView)
|
||||
let result = window.makeFirstResponder(surfaceView)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.ensure.apply surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
|
||||
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder)) " +
|
||||
"attempts=\(attemptsRemaining)"
|
||||
)
|
||||
#endif
|
||||
|
||||
if !isSurfaceViewFirstResponder() {
|
||||
retry()
|
||||
} else {
|
||||
reassertTerminalSurfaceFocus(reason: "ensureFocus.afterMakeFirstResponder")
|
||||
}
|
||||
}
|
||||
|
||||
private func matchesCurrentTerminalFocusTarget(tabId: UUID, surfaceId: UUID) -> Bool {
|
||||
guard let delegate = AppDelegate.shared,
|
||||
let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager,
|
||||
tabManager.selectedTabId == tabId,
|
||||
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
|
||||
let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId),
|
||||
let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in
|
||||
tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface })
|
||||
}) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface &&
|
||||
tab.bonsplitController.focusedPaneId == paneId
|
||||
}
|
||||
|
||||
/// Suppress the surface view's onFocus callback and ghostty_surface_set_focus during
|
||||
/// SwiftUI reparenting (programmatic splits). Call clearSuppressReparentFocus() after layout settles.
|
||||
func suppressReparentFocus() {
|
||||
|
|
@ -6596,6 +6800,33 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
}
|
||||
}
|
||||
|
||||
private func reassertTerminalSurfaceFocus(reason: String) {
|
||||
guard let terminalSurface = surfaceView.terminalSurface else { return }
|
||||
#if DEBUG
|
||||
dlog("focus.surface.reassert surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)")
|
||||
#endif
|
||||
terminalSurface.setFocus(true)
|
||||
refreshSurfaceAfterFocusIfNeeded(reason: reason)
|
||||
}
|
||||
|
||||
private func refreshSurfaceAfterFocusIfNeeded(reason: String) {
|
||||
guard let terminalSurface = surfaceView.terminalSurface,
|
||||
isActive,
|
||||
let window,
|
||||
window.isKeyWindow,
|
||||
surfaceView.isVisibleInUI else { return }
|
||||
|
||||
let now = CACurrentMediaTime()
|
||||
if now - lastFocusRefreshAt < 0.05 {
|
||||
return
|
||||
}
|
||||
lastFocusRefreshAt = now
|
||||
#if DEBUG
|
||||
dlog("focus.surface.refresh surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)")
|
||||
#endif
|
||||
terminalSurface.forceRefresh(reason: "focus.surface.\(reason)")
|
||||
}
|
||||
|
||||
private func applyFirstResponderIfNeeded() {
|
||||
let hasUsablePortalGeometry: Bool = {
|
||||
let size = bounds.size
|
||||
|
|
@ -6616,6 +6847,14 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
return
|
||||
}
|
||||
guard let window, window.isKeyWindow else { return }
|
||||
guard let tabId = surfaceView.tabId,
|
||||
let panelId = surfaceView.terminalSurface?.id,
|
||||
matchesCurrentTerminalFocusTarget(tabId: tabId, surfaceId: panelId) else {
|
||||
#if DEBUG
|
||||
dlog("focus.apply.skip surface=\(surfaceShort) reason=stale_target")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
if surfaceView.terminalSurface?.searchState != nil {
|
||||
// Find bar is open. Restore focus based on what the user last intended.
|
||||
restoreSearchFocus(window: window)
|
||||
|
|
@ -6623,6 +6862,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
}
|
||||
if let fr = window.firstResponder as? NSView,
|
||||
fr === surfaceView || fr.isDescendant(of: surfaceView) {
|
||||
reassertTerminalSurfaceFocus(reason: "applyFirstResponder.alreadyFirstResponder")
|
||||
return
|
||||
}
|
||||
// Don't steal focus from a search overlay on another surface in this window.
|
||||
|
|
@ -6636,6 +6876,9 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
dlog("find.applyFirstResponder APPLY surface=\(surfaceShort) prevFirstResponder=\(String(describing: window.firstResponder))")
|
||||
#endif
|
||||
window.makeFirstResponder(surfaceView)
|
||||
if isSurfaceViewFirstResponder() {
|
||||
reassertTerminalSurfaceFocus(reason: "applyFirstResponder.afterMakeFirstResponder")
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore focus when window becomes key and the find bar is open.
|
||||
|
|
@ -6644,6 +6887,18 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
|
||||
switch searchFocusTarget {
|
||||
case .searchField:
|
||||
if let firstResponder = window.firstResponder,
|
||||
isSearchOverlayOrDescendant(firstResponder),
|
||||
!isCurrentSurfaceSearchResponder(firstResponder) {
|
||||
surfaceView.terminalSurface?.setFocus(false)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " +
|
||||
"reason=foreignSearchResponder firstResponder=\(String(describing: firstResponder))"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
// Explicitly unfocus the terminal so cursor stops blinking immediately.
|
||||
// The notification observer also does this, but it runs async when posted from main.
|
||||
surfaceView.terminalSurface?.setFocus(false)
|
||||
|
|
@ -6653,16 +6908,152 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
|
||||
}
|
||||
#if DEBUG
|
||||
dlog("find.restoreSearchFocus surface=\(surfaceShort) target=searchField via=notification")
|
||||
dlog(
|
||||
"find.restoreSearchFocus surface=\(surfaceShort) target=searchField " +
|
||||
"via=notification firstResponder=\(String(describing: window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
case .terminal:
|
||||
window.makeFirstResponder(surfaceView)
|
||||
let result = window.makeFirstResponder(surfaceView)
|
||||
#if DEBUG
|
||||
dlog("find.restoreSearchFocus surface=\(surfaceShort) target=terminal")
|
||||
dlog(
|
||||
"find.restoreSearchFocus surface=\(surfaceShort) target=terminal " +
|
||||
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func capturePanelFocusIntent(in window: NSWindow?) -> TerminalPanelFocusIntent {
|
||||
if surfaceView.terminalSurface?.searchState != nil {
|
||||
if let firstResponder = window?.firstResponder as? NSView,
|
||||
(firstResponder === surfaceView || firstResponder.isDescendant(of: surfaceView)) {
|
||||
return .surface
|
||||
}
|
||||
if let firstResponder = window?.firstResponder,
|
||||
isCurrentSurfaceSearchResponder(firstResponder) {
|
||||
return .findField
|
||||
}
|
||||
if searchFocusTarget == .searchField {
|
||||
return .findField
|
||||
}
|
||||
}
|
||||
return .surface
|
||||
}
|
||||
|
||||
func preferredPanelFocusIntentForActivation() -> TerminalPanelFocusIntent {
|
||||
if surfaceView.terminalSurface?.searchState != nil, searchFocusTarget == .searchField {
|
||||
return .findField
|
||||
}
|
||||
return .surface
|
||||
}
|
||||
|
||||
func preparePanelFocusIntentForActivation(_ intent: TerminalPanelFocusIntent) {
|
||||
switch intent {
|
||||
case .surface:
|
||||
searchFocusTarget = .terminal
|
||||
case .findField:
|
||||
guard surfaceView.terminalSurface?.searchState != nil else { return }
|
||||
searchFocusTarget = .searchField
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"find.preparePanelFocusIntent surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"target=\(intent == .findField ? "searchField" : "terminal")"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restorePanelFocusIntent(_ intent: TerminalPanelFocusIntent) -> Bool {
|
||||
switch intent {
|
||||
case .surface:
|
||||
searchFocusTarget = .terminal
|
||||
setActive(true)
|
||||
applyFirstResponderIfNeeded()
|
||||
return true
|
||||
case .findField:
|
||||
guard let terminalSurface = surfaceView.terminalSurface,
|
||||
terminalSurface.searchState != nil else {
|
||||
return false
|
||||
}
|
||||
searchFocusTarget = .searchField
|
||||
setActive(true)
|
||||
if let window {
|
||||
restoreSearchFocus(window: window)
|
||||
} else {
|
||||
terminalSurface.setFocus(false)
|
||||
NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"find.restorePanelFocusIntent surface=\(terminalSurface.id.uuidString.prefix(5)) " +
|
||||
"target=searchField firstResponder=\(String(describing: window?.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func ownedPanelFocusIntent(for responder: NSResponder) -> TerminalPanelFocusIntent? {
|
||||
if isCurrentSurfaceSearchResponder(responder) {
|
||||
return .findField
|
||||
}
|
||||
|
||||
let resolvedResponder: NSResponder
|
||||
if let editor = responder as? NSTextView,
|
||||
editor.isFieldEditor,
|
||||
let editedView = editor.delegate as? NSView {
|
||||
resolvedResponder = editedView
|
||||
} else {
|
||||
resolvedResponder = responder
|
||||
}
|
||||
|
||||
guard let view = resolvedResponder as? NSView else { return nil }
|
||||
if view === surfaceView || view.isDescendant(of: surfaceView) {
|
||||
return .surface
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func yieldPanelFocusIntent(_ intent: TerminalPanelFocusIntent, in window: NSWindow) -> Bool {
|
||||
guard let firstResponder = window.firstResponder,
|
||||
ownedPanelFocusIntent(for: firstResponder) == intent else {
|
||||
return false
|
||||
}
|
||||
|
||||
surfaceView.terminalSurface?.setFocus(false)
|
||||
resignOwnedFirstResponderIfNeeded(reason: "yieldPanelFocusIntent")
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.handoff.yield surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"target=\(intent == .findField ? "searchField" : "terminal")"
|
||||
)
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
private func resignOwnedFirstResponderIfNeeded(reason: String) {
|
||||
guard let window,
|
||||
let firstResponder = window.firstResponder else { return }
|
||||
|
||||
let ownsSurfaceResponder: Bool = {
|
||||
guard let view = firstResponder as? NSView else { return false }
|
||||
return view === surfaceView || view.isDescendant(of: surfaceView)
|
||||
}()
|
||||
|
||||
guard ownsSurfaceResponder || isCurrentSurfaceSearchResponder(firstResponder) else { return }
|
||||
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.surface.resign surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
||||
"reason=\(reason) firstResponder=\(String(describing: firstResponder))"
|
||||
)
|
||||
#endif
|
||||
window.makeFirstResponder(nil)
|
||||
}
|
||||
|
||||
/// Check if a responder is inside a search overlay hosting view.
|
||||
/// Handles the AppKit field-editor case: when an NSTextField is being edited,
|
||||
/// window.firstResponder is the shared NSTextView field editor, not the text field.
|
||||
|
|
@ -6678,11 +7069,27 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
var current: NSView? = view
|
||||
while let v = current {
|
||||
if v is NSHostingView<SurfaceSearchOverlay> { return true }
|
||||
let typeName = String(describing: type(of: v))
|
||||
if typeName.contains("BrowserSearchOverlay") { return true }
|
||||
current = v.superview
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isCurrentSurfaceSearchResponder(_ responder: NSResponder) -> Bool {
|
||||
let resolvedResponder: NSResponder
|
||||
if let editor = responder as? NSTextView,
|
||||
editor.isFieldEditor,
|
||||
let editedView = editor.delegate as? NSView {
|
||||
resolvedResponder = editedView
|
||||
} else {
|
||||
resolvedResponder = responder
|
||||
}
|
||||
|
||||
guard let view = resolvedResponder as? NSView else { return false }
|
||||
return view.isDescendant(of: self)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct DebugRenderStats {
|
||||
let drawCount: Int
|
||||
|
|
|
|||
|
|
@ -155,10 +155,20 @@ struct NotificationsPage: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct ShortcutAnnotation: View {
|
||||
struct ShortcutAnnotation: View {
|
||||
let text: String
|
||||
var accessibilityIdentifier: String? = nil
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
if let accessibilityIdentifier {
|
||||
badge.accessibilityIdentifier(accessibilityIdentifier)
|
||||
} else {
|
||||
badge
|
||||
}
|
||||
}
|
||||
|
||||
private var badge: some View {
|
||||
Text(text)
|
||||
.font(.system(size: 10, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.primary)
|
||||
|
|
|
|||
|
|
@ -1705,10 +1705,17 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
/// cleared only after BrowserPanelView acknowledges handling it.
|
||||
@Published private(set) var pendingAddressBarFocusRequestId: UUID?
|
||||
|
||||
/// Semantic in-panel focus target used by split switching and transient overlays.
|
||||
private(set) var preferredFocusIntent: BrowserPanelFocusIntent = .webView
|
||||
|
||||
/// Incremented whenever async browser find focus ownership changes.
|
||||
@Published private(set) var searchFocusRequestGeneration: UInt64 = 0
|
||||
|
||||
/// Find-in-page state. Non-nil when the find bar is visible.
|
||||
@Published var searchState: BrowserSearchState? = nil {
|
||||
didSet {
|
||||
if let searchState {
|
||||
preferredFocusIntent = .findField
|
||||
NSLog("Find: browser search state created panel=%@", id.uuidString)
|
||||
searchNeedleCancellable = searchState.$needle
|
||||
.removeDuplicates()
|
||||
|
|
@ -1728,6 +1735,10 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
}
|
||||
} else if oldValue != nil {
|
||||
searchNeedleCancellable = nil
|
||||
if preferredFocusIntent == .findField {
|
||||
preferredFocusIntent = .webView
|
||||
}
|
||||
invalidateSearchFocusRequests(reason: "searchStateCleared")
|
||||
NSLog("Find: browser search state cleared panel=%@", id.uuidString)
|
||||
executeFindClear()
|
||||
}
|
||||
|
|
@ -1741,7 +1752,18 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
let inWindow: Bool
|
||||
let area: CGFloat
|
||||
}
|
||||
private struct PortalHostLock {
|
||||
let hostId: ObjectIdentifier
|
||||
let paneId: UUID
|
||||
}
|
||||
private enum DeveloperToolsPresentation {
|
||||
case unknown
|
||||
case attached
|
||||
case detached
|
||||
}
|
||||
private var activePortalHostLease: PortalHostLease?
|
||||
private var pendingDistinctPortalHostReplacementPaneId: UUID?
|
||||
private var lockedPortalHost: PortalHostLock?
|
||||
private var webViewCancellables = Set<AnyCancellable>()
|
||||
private var navigationDelegate: BrowserNavigationDelegate?
|
||||
private var uiDelegate: BrowserUIDelegate?
|
||||
|
|
@ -1765,7 +1787,8 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
private var insecureHTTPAlertFactory: () -> NSAlert
|
||||
private var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow }
|
||||
// Persist user intent across WebKit detach/reattach churn (split/layout updates).
|
||||
private var preferredDeveloperToolsVisible: Bool = false
|
||||
@Published private(set) var preferredDeveloperToolsVisible: Bool = false
|
||||
private var preferredDeveloperToolsPresentation: DeveloperToolsPresentation = .unknown
|
||||
private var forceDeveloperToolsRefreshOnNextAttach: Bool = false
|
||||
private var developerToolsRestoreRetryWorkItem: DispatchWorkItem?
|
||||
private var developerToolsRestoreRetryAttempt: Int = 0
|
||||
|
|
@ -1773,6 +1796,11 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
private let developerToolsRestoreRetryMaxAttempts: Int = 40
|
||||
private var remoteProxyEndpoint: BrowserProxyEndpoint?
|
||||
@Published private(set) var remoteWorkspaceStatus: BrowserRemoteWorkspaceStatus?
|
||||
private let developerToolsDetachedOpenGracePeriod: TimeInterval = 0.35
|
||||
private var developerToolsDetachedOpenGraceDeadline: Date?
|
||||
private var detachedDeveloperToolsWindowCloseObserver: NSObjectProtocol?
|
||||
private var preferredAttachedDeveloperToolsWidth: CGFloat?
|
||||
private var preferredAttachedDeveloperToolsWidthFraction: CGFloat?
|
||||
private var browserThemeMode: BrowserThemeMode
|
||||
|
||||
var displayTitle: String {
|
||||
|
|
@ -1796,6 +1824,22 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
lease.inWindow && lease.area > portalHostAreaThreshold
|
||||
}
|
||||
|
||||
func preparePortalHostReplacementForNextDistinctClaim(
|
||||
inPane paneId: PaneID,
|
||||
reason: String
|
||||
) {
|
||||
pendingDistinctPortalHostReplacementPaneId = paneId.id
|
||||
if lockedPortalHost?.paneId == paneId.id {
|
||||
lockedPortalHost = nil
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.host.rearm panel=\(id.uuidString.prefix(5)) " +
|
||||
"reason=\(reason) pane=\(paneId.id.uuidString.prefix(5))"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func claimPortalHost(
|
||||
hostId: ObjectIdentifier,
|
||||
paneId: PaneID,
|
||||
|
|
@ -1811,6 +1855,11 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
)
|
||||
|
||||
if let current = activePortalHostLease {
|
||||
if let lock = lockedPortalHost,
|
||||
(lock.hostId != current.hostId || lock.paneId != current.paneId) {
|
||||
lockedPortalHost = nil
|
||||
}
|
||||
|
||||
if current.hostId == hostId {
|
||||
activePortalHostLease = next
|
||||
return true
|
||||
|
|
@ -1818,12 +1867,47 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
|
||||
let currentUsable = Self.portalHostIsUsable(current)
|
||||
let nextUsable = Self.portalHostIsUsable(next)
|
||||
let isSamePaneReplacement = current.paneId == paneId.id
|
||||
let shouldForceDistinctReplacement =
|
||||
isSamePaneReplacement &&
|
||||
pendingDistinctPortalHostReplacementPaneId == paneId.id &&
|
||||
inWindow
|
||||
if shouldForceDistinctReplacement {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " +
|
||||
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
|
||||
"inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
|
||||
"replacingHost=\(current.hostId) replacingPane=\(current.paneId.uuidString.prefix(5)) " +
|
||||
"replacingInWin=\(current.inWindow ? 1 : 0) replacingArea=\(String(format: "%.1f", current.area)) " +
|
||||
"forced=1"
|
||||
)
|
||||
#endif
|
||||
activePortalHostLease = next
|
||||
pendingDistinctPortalHostReplacementPaneId = nil
|
||||
lockedPortalHost = PortalHostLock(hostId: hostId, paneId: paneId.id)
|
||||
return true
|
||||
}
|
||||
|
||||
let lockBlocksSamePaneReplacement =
|
||||
isSamePaneReplacement &&
|
||||
currentUsable &&
|
||||
lockedPortalHost?.hostId == current.hostId &&
|
||||
lockedPortalHost?.paneId == current.paneId
|
||||
let shouldReplace =
|
||||
current.paneId != paneId.id ||
|
||||
!currentUsable ||
|
||||
(nextUsable && next.area > (current.area * Self.portalHostReplacementAreaGainRatio))
|
||||
(
|
||||
!lockBlocksSamePaneReplacement &&
|
||||
nextUsable &&
|
||||
next.area > (current.area * Self.portalHostReplacementAreaGainRatio)
|
||||
)
|
||||
|
||||
if shouldReplace {
|
||||
if lockedPortalHost?.hostId == current.hostId &&
|
||||
lockedPortalHost?.paneId == current.paneId {
|
||||
lockedPortalHost = nil
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.host.claim panel=\(id.uuidString.prefix(5)) " +
|
||||
|
|
@ -1843,7 +1927,8 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
|
||||
"inWin=\(inWindow ? 1 : 0) size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
|
||||
"ownerHost=\(current.hostId) ownerPane=\(current.paneId.uuidString.prefix(5)) " +
|
||||
"ownerInWin=\(current.inWindow ? 1 : 0) ownerArea=\(String(format: "%.1f", current.area))"
|
||||
"ownerInWin=\(current.inWindow ? 1 : 0) ownerArea=\(String(format: "%.1f", current.area)) " +
|
||||
"locked=\(lockBlocksSamePaneReplacement ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
|
|
@ -1865,6 +1950,9 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
func releasePortalHostIfOwned(hostId: ObjectIdentifier, reason: String) -> Bool {
|
||||
guard let current = activePortalHostLease, current.hostId == hostId else { return false }
|
||||
activePortalHostLease = nil
|
||||
if lockedPortalHost?.hostId == hostId {
|
||||
lockedPortalHost = nil
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.portal.host.release panel=\(id.uuidString.prefix(5)) " +
|
||||
|
|
@ -2023,6 +2111,7 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
self.uiDelegate = browserUIDelegate
|
||||
|
||||
bindWebView(webView)
|
||||
installDetachedDeveloperToolsWindowCloseObserver()
|
||||
applyBrowserThemeModeIfNeeded()
|
||||
insecureHTTPAlertWindowProvider = { [weak self] in
|
||||
self?.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
||||
|
|
@ -2298,12 +2387,16 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
}
|
||||
|
||||
if Self.responderChainContains(window.firstResponder, target: webView) {
|
||||
noteWebViewFocused()
|
||||
return
|
||||
}
|
||||
window.makeFirstResponder(webView)
|
||||
if window.makeFirstResponder(webView) {
|
||||
noteWebViewFocused()
|
||||
}
|
||||
}
|
||||
|
||||
func unfocus() {
|
||||
invalidateSearchFocusRequests(reason: "panelUnfocus")
|
||||
guard let window = webView.window else { return }
|
||||
if Self.responderChainContains(window.firstResponder, target: webView) {
|
||||
window.makeFirstResponder(nil)
|
||||
|
|
@ -2700,6 +2793,9 @@ final class BrowserPanel: Panel, ObservableObject {
|
|||
deinit {
|
||||
developerToolsRestoreRetryWorkItem?.cancel()
|
||||
developerToolsRestoreRetryWorkItem = nil
|
||||
if let detachedDeveloperToolsWindowCloseObserver {
|
||||
NotificationCenter.default.removeObserver(detachedDeveloperToolsWindowCloseObserver)
|
||||
}
|
||||
let webView = webView
|
||||
Task { @MainActor in
|
||||
BrowserWindowPortalRegistry.detach(webView: webView)
|
||||
|
|
@ -2847,6 +2943,160 @@ extension BrowserPanel {
|
|||
webView.stopLoading()
|
||||
}
|
||||
|
||||
private static func windowContainsInspectorViews(_ root: NSView) -> Bool {
|
||||
if String(describing: type(of: root)).contains("WKInspector") {
|
||||
return true
|
||||
}
|
||||
for subview in root.subviews where windowContainsInspectorViews(subview) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func isDetachedInspectorWindow(_ window: NSWindow) -> Bool {
|
||||
guard window.title.hasPrefix("Web Inspector") else { return false }
|
||||
guard let contentView = window.contentView else { return false }
|
||||
return windowContainsInspectorViews(contentView)
|
||||
}
|
||||
|
||||
private func detachedDeveloperToolsWindows() -> [NSWindow] {
|
||||
let mainWindow = webView.window
|
||||
return NSApp.windows.filter { candidate in
|
||||
if let mainWindow, candidate === mainWindow {
|
||||
return false
|
||||
}
|
||||
return Self.isDetachedInspectorWindow(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
private func hasAttachedDeveloperToolsLayout() -> Bool {
|
||||
guard let container = webView.superview else { return false }
|
||||
return Self.visibleDescendants(in: container)
|
||||
.contains { Self.isVisibleSideDockInspectorCandidate($0) && Self.isInspectorView($0) }
|
||||
}
|
||||
|
||||
private func setPreferredDeveloperToolsPresentation(_ next: DeveloperToolsPresentation) {
|
||||
guard preferredDeveloperToolsPresentation != next else { return }
|
||||
preferredDeveloperToolsPresentation = next
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
private func syncDeveloperToolsPresentationPreferenceFromUI() {
|
||||
if !detachedDeveloperToolsWindows().isEmpty {
|
||||
setPreferredDeveloperToolsPresentation(.detached)
|
||||
} else if hasAttachedDeveloperToolsLayout() {
|
||||
setPreferredDeveloperToolsPresentation(.attached)
|
||||
developerToolsDetachedOpenGraceDeadline = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func installDetachedDeveloperToolsWindowCloseObserver() {
|
||||
guard detachedDeveloperToolsWindowCloseObserver == nil else { return }
|
||||
detachedDeveloperToolsWindowCloseObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSWindow.willCloseNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
guard let self,
|
||||
let window = notification.object as? NSWindow else { return }
|
||||
let isDetachedInspectorWindow = MainActor.assumeIsolated {
|
||||
Self.isDetachedInspectorWindow(window)
|
||||
}
|
||||
guard isDetachedInspectorWindow else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
guard self.preferredDeveloperToolsPresentation == .detached else { return }
|
||||
guard self.preferredDeveloperToolsVisible else { return }
|
||||
guard !self.isDeveloperToolsVisible() else { return }
|
||||
self.developerToolsDetachedOpenGraceDeadline = nil
|
||||
self.preferredDeveloperToolsVisible = false
|
||||
self.cancelDeveloperToolsRestoreRetry()
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.devtools detachedClose.manual panel=\(self.id.uuidString.prefix(5)) " +
|
||||
"\(self.debugDeveloperToolsStateSummary()) \(self.debugDeveloperToolsGeometrySummary())"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldDismissDetachedDeveloperToolsWindows() -> Bool {
|
||||
preferredDeveloperToolsPresentation == .attached
|
||||
}
|
||||
|
||||
private func dismissDetachedDeveloperToolsWindowsIfNeeded() {
|
||||
guard shouldDismissDetachedDeveloperToolsWindows() else { return }
|
||||
guard preferredDeveloperToolsVisible || isDeveloperToolsVisible(),
|
||||
let mainWindow = webView.window else { return }
|
||||
for window in NSApp.windows where window !== mainWindow && Self.isDetachedInspectorWindow(window) {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.devtools strayWindow.close panel=\(id.uuidString.prefix(5)) " +
|
||||
"title=\(window.title) frame=\(NSStringFromRect(window.frame))"
|
||||
)
|
||||
#endif
|
||||
window.close()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleDetachedDeveloperToolsWindowDismissal() {
|
||||
guard shouldDismissDetachedDeveloperToolsWindows() else { return }
|
||||
for delay in [0.0, 0.15] {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.dismissDetachedDeveloperToolsWindowsIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareDeveloperToolsForRevealIfNeeded(_ inspector: NSObject) {
|
||||
guard preferredDeveloperToolsPresentation == .unknown else { return }
|
||||
let attachSelector = NSSelectorFromString("attach")
|
||||
guard inspector.responds(to: attachSelector) else { return }
|
||||
inspector.cmuxCallVoid(selector: attachSelector)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func revealDeveloperTools(_ inspector: NSObject) -> Bool {
|
||||
let isVisibleSelector = NSSelectorFromString("isVisible")
|
||||
if inspector.cmuxCallBool(selector: isVisibleSelector) ?? false {
|
||||
developerToolsDetachedOpenGraceDeadline = nil
|
||||
return true
|
||||
}
|
||||
|
||||
prepareDeveloperToolsForRevealIfNeeded(inspector)
|
||||
|
||||
let showSelector = NSSelectorFromString("show")
|
||||
guard inspector.responds(to: showSelector) else { return false }
|
||||
inspector.cmuxCallVoid(selector: showSelector)
|
||||
let visibleAfterShow = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
|
||||
if preferredDeveloperToolsPresentation == .detached {
|
||||
developerToolsDetachedOpenGraceDeadline = visibleAfterShow
|
||||
? nil
|
||||
: Date().addingTimeInterval(developerToolsDetachedOpenGracePeriod)
|
||||
} else {
|
||||
developerToolsDetachedOpenGraceDeadline = nil
|
||||
}
|
||||
return visibleAfterShow
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func concealDeveloperTools(_ inspector: NSObject) -> Bool {
|
||||
let isVisibleSelector = NSSelectorFromString("isVisible")
|
||||
guard inspector.cmuxCallBool(selector: isVisibleSelector) ?? false else { return true }
|
||||
|
||||
for rawSelector in ["hide", "close"] {
|
||||
let selector = NSSelectorFromString(rawSelector)
|
||||
guard inspector.responds(to: selector) else { continue }
|
||||
inspector.cmuxCallVoid(selector: selector)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func toggleDeveloperTools() -> Bool {
|
||||
#if DEBUG
|
||||
|
|
@ -2859,14 +3109,20 @@ extension BrowserPanel {
|
|||
let isVisibleSelector = NSSelectorFromString("isVisible")
|
||||
let visible = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
|
||||
let targetVisible = !visible
|
||||
let selector = NSSelectorFromString(targetVisible ? "show" : "close")
|
||||
guard inspector.responds(to: selector) else { return false }
|
||||
inspector.cmuxCallVoid(selector: selector)
|
||||
if targetVisible {
|
||||
_ = revealDeveloperTools(inspector)
|
||||
} else {
|
||||
syncDeveloperToolsPresentationPreferenceFromUI()
|
||||
guard concealDeveloperTools(inspector) else { return false }
|
||||
developerToolsDetachedOpenGraceDeadline = nil
|
||||
}
|
||||
preferredDeveloperToolsVisible = targetVisible
|
||||
if targetVisible {
|
||||
let visibleAfterToggle = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
|
||||
if visibleAfterToggle {
|
||||
syncDeveloperToolsPresentationPreferenceFromUI()
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
scheduleDetachedDeveloperToolsWindowDismissal()
|
||||
} else {
|
||||
developerToolsRestoreRetryAttempt = 0
|
||||
scheduleDeveloperToolsRestoreRetry()
|
||||
|
|
@ -2896,13 +3152,13 @@ extension BrowserPanel {
|
|||
guard let inspector = webView.cmuxInspectorObject() else { return false }
|
||||
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
if !visible {
|
||||
let showSelector = NSSelectorFromString("show")
|
||||
guard inspector.responds(to: showSelector) else { return false }
|
||||
inspector.cmuxCallVoid(selector: showSelector)
|
||||
guard revealDeveloperTools(inspector) else { return false }
|
||||
}
|
||||
preferredDeveloperToolsVisible = true
|
||||
if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) {
|
||||
syncDeveloperToolsPresentationPreferenceFromUI()
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
scheduleDetachedDeveloperToolsWindowDismissal()
|
||||
} else {
|
||||
scheduleDeveloperToolsRestoreRetry()
|
||||
}
|
||||
|
|
@ -2934,6 +3190,8 @@ extension BrowserPanel {
|
|||
guard let inspector = webView.cmuxInspectorObject() else { return }
|
||||
guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return }
|
||||
if visible {
|
||||
developerToolsDetachedOpenGraceDeadline = nil
|
||||
syncDeveloperToolsPresentationPreferenceFromUI()
|
||||
preferredDeveloperToolsVisible = true
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
return
|
||||
|
|
@ -2962,6 +3220,8 @@ extension BrowserPanel {
|
|||
|
||||
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
if visible {
|
||||
developerToolsDetachedOpenGraceDeadline = nil
|
||||
syncDeveloperToolsPresentationPreferenceFromUI()
|
||||
#if DEBUG
|
||||
if shouldForceRefresh {
|
||||
dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
|
||||
|
|
@ -2971,26 +3231,37 @@ extension BrowserPanel {
|
|||
return
|
||||
}
|
||||
|
||||
let selector = NSSelectorFromString("show")
|
||||
guard inspector.responds(to: selector) else {
|
||||
let detachedOpenStillSettling = developerToolsDetachedOpenGraceDeadline.map { $0 > Date() } ?? false
|
||||
if preferredDeveloperToolsPresentation == .detached && !detachedOpenStillSettling {
|
||||
preferredDeveloperToolsVisible = false
|
||||
developerToolsDetachedOpenGraceDeadline = nil
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.devtools detachedClose.consume panel=\(id.uuidString.prefix(5)) " +
|
||||
"\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
if shouldForceRefresh {
|
||||
dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
|
||||
}
|
||||
#endif
|
||||
// WebKit inspector "show" can trigger transient first-responder churn while
|
||||
// WebKit inspector show can trigger transient first-responder churn while
|
||||
// panel attachment is still stabilizing. Keep this auto-restore path from
|
||||
// mutating first responder so AppKit doesn't walk tearing-down responder chains.
|
||||
cmuxWithWindowFirstResponderBypass {
|
||||
inspector.cmuxCallVoid(selector: selector)
|
||||
_ = revealDeveloperTools(inspector)
|
||||
}
|
||||
preferredDeveloperToolsVisible = true
|
||||
let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
if visibleAfterShow {
|
||||
syncDeveloperToolsPresentationPreferenceFromUI()
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
scheduleDetachedDeveloperToolsWindowDismissal()
|
||||
} else {
|
||||
scheduleDeveloperToolsRestoreRetry()
|
||||
}
|
||||
|
|
@ -3007,11 +3278,11 @@ extension BrowserPanel {
|
|||
guard let inspector = webView.cmuxInspectorObject() else { return false }
|
||||
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
|
||||
if visible {
|
||||
let selector = NSSelectorFromString("close")
|
||||
guard inspector.responds(to: selector) else { return false }
|
||||
inspector.cmuxCallVoid(selector: selector)
|
||||
syncDeveloperToolsPresentationPreferenceFromUI()
|
||||
guard concealDeveloperTools(inspector) else { return false }
|
||||
}
|
||||
preferredDeveloperToolsVisible = false
|
||||
developerToolsDetachedOpenGraceDeadline = nil
|
||||
forceDeveloperToolsRefreshOnNextAttach = false
|
||||
cancelDeveloperToolsRestoreRetry()
|
||||
return true
|
||||
|
|
@ -3036,6 +3307,38 @@ extension BrowserPanel {
|
|||
forceDeveloperToolsRefreshOnNextAttach
|
||||
}
|
||||
|
||||
func shouldPreserveDeveloperToolsIntentWhileDetached() -> Bool {
|
||||
preferredDeveloperToolsVisible &&
|
||||
(
|
||||
forceDeveloperToolsRefreshOnNextAttach ||
|
||||
developerToolsRestoreRetryWorkItem != nil ||
|
||||
webView.superview == nil ||
|
||||
webView.window == nil
|
||||
)
|
||||
}
|
||||
|
||||
func shouldUseLocalInlineDeveloperToolsHosting() -> Bool {
|
||||
guard preferredDeveloperToolsVisible || isDeveloperToolsVisible() else { return false }
|
||||
if preferredDeveloperToolsPresentation == .detached {
|
||||
return false
|
||||
}
|
||||
return detachedDeveloperToolsWindows().isEmpty
|
||||
}
|
||||
|
||||
func recordPreferredAttachedDeveloperToolsWidth(_ width: CGFloat, containerBounds: NSRect) {
|
||||
let normalizedWidth = max(0, width)
|
||||
preferredAttachedDeveloperToolsWidth = normalizedWidth
|
||||
guard containerBounds.width > 0 else {
|
||||
preferredAttachedDeveloperToolsWidthFraction = nil
|
||||
return
|
||||
}
|
||||
preferredAttachedDeveloperToolsWidthFraction = normalizedWidth / containerBounds.width
|
||||
}
|
||||
|
||||
func preferredAttachedDeveloperToolsWidthState() -> (width: CGFloat?, widthFraction: CGFloat?) {
|
||||
(preferredAttachedDeveloperToolsWidth, preferredAttachedDeveloperToolsWidthFraction)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func zoomIn() -> Bool {
|
||||
applyPageZoom(webView.pageZoom + pageZoomStep)
|
||||
|
|
@ -3082,21 +3385,52 @@ extension BrowserPanel {
|
|||
// MARK: - Find in Page
|
||||
|
||||
func startFind() {
|
||||
if searchState == nil {
|
||||
preferredFocusIntent = .findField
|
||||
let created = searchState == nil
|
||||
if created {
|
||||
searchState = BrowserSearchState()
|
||||
}
|
||||
postBrowserSearchFocusNotification()
|
||||
let generation = beginSearchFocusRequest(reason: "startFind")
|
||||
#if DEBUG
|
||||
let window = webView.window
|
||||
dlog(
|
||||
"browser.find.start panel=\(id.uuidString.prefix(5)) " +
|
||||
"created=\(created ? 1 : 0) render=\(shouldRenderWebView ? 1 : 0) " +
|
||||
"generation=\(generation) " +
|
||||
"window=\(window?.windowNumber ?? -1) key=\(NSApp.keyWindow === window ? 1 : 0) " +
|
||||
"firstResponder=\(String(describing: window?.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
postBrowserSearchFocusNotification(reason: "immediate", generation: generation)
|
||||
// Focus notification can race with portal overlay mount. Re-post on the
|
||||
// next runloop and shortly after so the find field can claim first responder.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.postBrowserSearchFocusNotification()
|
||||
self?.postBrowserSearchFocusNotification(reason: "async0", generation: generation)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
||||
self?.postBrowserSearchFocusNotification()
|
||||
self?.postBrowserSearchFocusNotification(reason: "async50ms", generation: generation)
|
||||
}
|
||||
}
|
||||
|
||||
private func postBrowserSearchFocusNotification() {
|
||||
private func postBrowserSearchFocusNotification(reason: String, generation: UInt64) {
|
||||
guard canApplySearchFocusRequest(generation) else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.find.focusNotification.skip panel=\(id.uuidString.prefix(5)) " +
|
||||
"reason=\(reason) generation=\(generation)"
|
||||
)
|
||||
#endif
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
let window = webView.window
|
||||
dlog(
|
||||
"browser.find.focusNotification panel=\(id.uuidString.prefix(5)) " +
|
||||
"generation=\(generation) " +
|
||||
"reason=\(reason) window=\(window?.windowNumber ?? -1) " +
|
||||
"firstResponder=\(String(describing: window?.firstResponder))"
|
||||
)
|
||||
#endif
|
||||
NotificationCenter.default.post(name: .browserSearchFocus, object: id)
|
||||
}
|
||||
|
||||
|
|
@ -3117,6 +3451,7 @@ extension BrowserPanel {
|
|||
}
|
||||
|
||||
func hideFind() {
|
||||
invalidateSearchFocusRequests(reason: "hideFind")
|
||||
searchState = nil
|
||||
}
|
||||
|
||||
|
|
@ -3127,7 +3462,10 @@ extension BrowserPanel {
|
|||
if replaySearch, !state.needle.isEmpty {
|
||||
executeFindSearch(state.needle)
|
||||
}
|
||||
postBrowserSearchFocusNotification()
|
||||
postBrowserSearchFocusNotification(
|
||||
reason: "restoreAfterNavigation",
|
||||
generation: searchFocusRequestGeneration
|
||||
)
|
||||
}
|
||||
|
||||
private func executeFindSearch(_ needle: String) {
|
||||
|
|
@ -3254,6 +3592,8 @@ extension BrowserPanel {
|
|||
|
||||
@discardableResult
|
||||
func requestAddressBarFocus() -> UUID {
|
||||
preferredFocusIntent = .addressBar
|
||||
invalidateSearchFocusRequests(reason: "requestAddressBarFocus")
|
||||
beginSuppressWebViewFocusForAddressBar()
|
||||
if let pendingAddressBarFocusRequestId {
|
||||
#if DEBUG
|
||||
|
|
@ -3275,6 +3615,173 @@ extension BrowserPanel {
|
|||
return requestId
|
||||
}
|
||||
|
||||
func noteWebViewFocused() {
|
||||
guard searchState == nil else { return }
|
||||
guard preferredFocusIntent != .webView else { return }
|
||||
preferredFocusIntent = .webView
|
||||
invalidateSearchFocusRequests(reason: "webViewFocused")
|
||||
}
|
||||
|
||||
func noteAddressBarFocused() {
|
||||
guard preferredFocusIntent != .addressBar else { return }
|
||||
preferredFocusIntent = .addressBar
|
||||
invalidateSearchFocusRequests(reason: "addressBarFocused")
|
||||
}
|
||||
|
||||
func noteFindFieldFocused() {
|
||||
guard preferredFocusIntent != .findField else { return }
|
||||
preferredFocusIntent = .findField
|
||||
}
|
||||
|
||||
func canApplySearchFocusRequest(_ generation: UInt64) -> Bool {
|
||||
generation != 0 &&
|
||||
generation == searchFocusRequestGeneration &&
|
||||
searchState != nil &&
|
||||
preferredFocusIntent == .findField
|
||||
}
|
||||
|
||||
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent {
|
||||
if pendingAddressBarFocusRequestId != nil || AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id {
|
||||
return .browser(.addressBar)
|
||||
}
|
||||
|
||||
if searchState != nil && preferredFocusIntent == .findField {
|
||||
return .browser(.findField)
|
||||
}
|
||||
|
||||
if let window,
|
||||
Self.responderChainContains(window.firstResponder, target: webView) {
|
||||
return .browser(.webView)
|
||||
}
|
||||
|
||||
return .browser(preferredFocusIntent)
|
||||
}
|
||||
|
||||
func preferredFocusIntentForActivation() -> PanelFocusIntent {
|
||||
if pendingAddressBarFocusRequestId != nil {
|
||||
return .browser(.addressBar)
|
||||
}
|
||||
if searchState != nil && preferredFocusIntent == .findField {
|
||||
return .browser(.findField)
|
||||
}
|
||||
return .browser(preferredFocusIntent)
|
||||
}
|
||||
|
||||
func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) {
|
||||
guard case .browser(let target) = intent else { return }
|
||||
|
||||
switch target {
|
||||
case .webView:
|
||||
preferredFocusIntent = .webView
|
||||
invalidateSearchFocusRequests(reason: "prepareWebView")
|
||||
endSuppressWebViewFocusForAddressBar()
|
||||
case .addressBar:
|
||||
preferredFocusIntent = .addressBar
|
||||
invalidateSearchFocusRequests(reason: "prepareAddressBar")
|
||||
beginSuppressWebViewFocusForAddressBar()
|
||||
case .findField:
|
||||
preferredFocusIntent = .findField
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.prepare panel=\(id.uuidString.prefix(5)) " +
|
||||
"target=\(String(describing: target)) suppressWeb=\(shouldSuppressWebViewFocus() ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool {
|
||||
guard case .browser(let target) = intent else { return false }
|
||||
|
||||
switch target {
|
||||
case .webView:
|
||||
noteWebViewFocused()
|
||||
focus()
|
||||
return true
|
||||
case .addressBar:
|
||||
let requestId = requestAddressBarFocus()
|
||||
NotificationCenter.default.post(name: .browserFocusAddressBar, object: id)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.focus.restore panel=\(id.uuidString.prefix(5)) " +
|
||||
"target=addressBar request=\(requestId.uuidString.prefix(8))"
|
||||
)
|
||||
#endif
|
||||
return true
|
||||
case .findField:
|
||||
startFind()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? {
|
||||
if AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id {
|
||||
return .browser(.addressBar)
|
||||
}
|
||||
|
||||
if BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) == id {
|
||||
return .browser(.findField)
|
||||
}
|
||||
|
||||
if Self.responderChainContains(responder, target: webView) {
|
||||
return .browser(.webView)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool {
|
||||
guard case .browser(let target) = intent else { return false }
|
||||
|
||||
switch target {
|
||||
case .findField:
|
||||
invalidateSearchFocusRequests(reason: "yieldFindField")
|
||||
let yielded = BrowserWindowPortalRegistry.yieldSearchOverlayFocusIfOwned(by: id, in: window)
|
||||
#if DEBUG
|
||||
if yielded {
|
||||
dlog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=browserFind")
|
||||
}
|
||||
#endif
|
||||
return yielded
|
||||
case .addressBar:
|
||||
guard AppDelegate.shared?.focusedBrowserAddressBarPanelId() == id else { return false }
|
||||
let yielded = window.makeFirstResponder(nil)
|
||||
#if DEBUG
|
||||
if yielded {
|
||||
dlog("focus.handoff.yield panel=\(id.uuidString.prefix(5)) target=addressBar")
|
||||
}
|
||||
#endif
|
||||
return yielded
|
||||
case .webView:
|
||||
guard Self.responderChainContains(window.firstResponder, target: webView) else { return false }
|
||||
return window.makeFirstResponder(nil)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func beginSearchFocusRequest(reason: String) -> UInt64 {
|
||||
searchFocusRequestGeneration &+= 1
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.find.focusLease.begin panel=\(id.uuidString.prefix(5)) " +
|
||||
"generation=\(searchFocusRequestGeneration) reason=\(reason)"
|
||||
)
|
||||
#endif
|
||||
return searchFocusRequestGeneration
|
||||
}
|
||||
|
||||
private func invalidateSearchFocusRequests(reason: String) {
|
||||
searchFocusRequestGeneration &+= 1
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"browser.find.focusLease.invalidate panel=\(id.uuidString.prefix(5)) " +
|
||||
"generation=\(searchFocusRequestGeneration) reason=\(reason)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func acknowledgeAddressBarFocusRequest(_ requestId: UUID) {
|
||||
guard pendingAddressBarFocusRequestId == requestId else {
|
||||
#if DEBUG
|
||||
|
|
@ -3767,7 +4274,7 @@ private extension BrowserPanel {
|
|||
}
|
||||
}
|
||||
|
||||
private extension WKWebView {
|
||||
extension WKWebView {
|
||||
func cmuxInspectorObject() -> NSObject? {
|
||||
let selector = NSSelectorFromString("_inspector")
|
||||
guard responds(to: selector),
|
||||
|
|
@ -3776,6 +4283,16 @@ private extension WKWebView {
|
|||
}
|
||||
return inspector
|
||||
}
|
||||
|
||||
func cmuxInspectorFrontendWebView() -> WKWebView? {
|
||||
guard let inspector = cmuxInspectorObject() else { return nil }
|
||||
let selector = NSSelectorFromString("inspectorWebView")
|
||||
guard inspector.responds(to: selector),
|
||||
let inspectorWebView = inspector.perform(selector)?.takeUnretainedValue() as? WKWebView else {
|
||||
return nil
|
||||
}
|
||||
return inspectorWebView
|
||||
}
|
||||
}
|
||||
|
||||
private extension NSObject {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
import AppKit
|
||||
|
||||
/// Type of panel content
|
||||
public enum PanelType: String, Codable, Sendable {
|
||||
|
|
@ -8,6 +9,23 @@ public enum PanelType: String, Codable, Sendable {
|
|||
case markdown
|
||||
}
|
||||
|
||||
public enum TerminalPanelFocusIntent: Equatable {
|
||||
case surface
|
||||
case findField
|
||||
}
|
||||
|
||||
public enum BrowserPanelFocusIntent: Equatable {
|
||||
case webView
|
||||
case addressBar
|
||||
case findField
|
||||
}
|
||||
|
||||
public enum PanelFocusIntent: Equatable {
|
||||
case panel
|
||||
case terminal(TerminalPanelFocusIntent)
|
||||
case browser(BrowserPanelFocusIntent)
|
||||
}
|
||||
|
||||
enum FocusFlashCurve: Equatable {
|
||||
case easeIn
|
||||
case easeOut
|
||||
|
|
@ -72,10 +90,63 @@ public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUI
|
|||
|
||||
/// Trigger a focus flash animation for this panel.
|
||||
func triggerFlash()
|
||||
|
||||
/// Capture the panel-local focus target that should be restored later.
|
||||
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent
|
||||
|
||||
/// Return the best focus target to restore when this panel becomes active again.
|
||||
func preferredFocusIntentForActivation() -> PanelFocusIntent
|
||||
|
||||
/// Prime panel-local focus state before activation side effects run.
|
||||
func prepareFocusIntentForActivation(_ intent: PanelFocusIntent)
|
||||
|
||||
/// Restore a previously captured focus target.
|
||||
@discardableResult
|
||||
func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool
|
||||
|
||||
/// Return the semantic focus target currently owned by this panel, if any.
|
||||
func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent?
|
||||
|
||||
/// Explicitly yield a previously owned focus target before another panel restores focus.
|
||||
@discardableResult
|
||||
func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool
|
||||
}
|
||||
|
||||
/// Extension providing default implementations
|
||||
extension Panel {
|
||||
public var displayIcon: String? { nil }
|
||||
public var isDirty: Bool { false }
|
||||
|
||||
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent {
|
||||
_ = window
|
||||
return preferredFocusIntentForActivation()
|
||||
}
|
||||
|
||||
func preferredFocusIntentForActivation() -> PanelFocusIntent {
|
||||
.panel
|
||||
}
|
||||
|
||||
func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) {
|
||||
_ = intent
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool {
|
||||
guard intent == .panel else { return false }
|
||||
focus()
|
||||
return true
|
||||
}
|
||||
|
||||
func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? {
|
||||
_ = responder
|
||||
_ = window
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool {
|
||||
_ = intent
|
||||
_ = window
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,4 +222,42 @@ final class TerminalPanel: Panel, ObservableObject {
|
|||
func applyWindowBackgroundIfActive() {
|
||||
surface.applyWindowBackgroundIfActive()
|
||||
}
|
||||
|
||||
func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent {
|
||||
.terminal(hostedView.capturePanelFocusIntent(in: window))
|
||||
}
|
||||
|
||||
func preferredFocusIntentForActivation() -> PanelFocusIntent {
|
||||
.terminal(hostedView.preferredPanelFocusIntentForActivation())
|
||||
}
|
||||
|
||||
func prepareFocusIntentForActivation(_ intent: PanelFocusIntent) {
|
||||
guard case .terminal(let target) = intent else { return }
|
||||
hostedView.preparePanelFocusIntentForActivation(target)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restoreFocusIntent(_ intent: PanelFocusIntent) -> Bool {
|
||||
switch intent {
|
||||
case .panel:
|
||||
focus()
|
||||
return true
|
||||
case .terminal(let target):
|
||||
return hostedView.restorePanelFocusIntent(target)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ownedFocusIntent(for responder: NSResponder, in window: NSWindow) -> PanelFocusIntent? {
|
||||
_ = window
|
||||
guard let intent = hostedView.ownedPanelFocusIntent(for: responder) else { return nil }
|
||||
return .terminal(intent)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func yieldFocusIntent(_ intent: PanelFocusIntent, in window: NSWindow) -> Bool {
|
||||
guard case .terminal(let target) = intent else { return false }
|
||||
return hostedView.yieldPanelFocusIntent(target, in: window)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -558,6 +558,11 @@ 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?
|
||||
|
|
@ -569,6 +574,7 @@ class TabManager: ObservableObject {
|
|||
/// 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 }
|
||||
|
|
@ -624,6 +630,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] = []
|
||||
|
|
@ -771,7 +783,8 @@ class TabManager: ObservableObject {
|
|||
initialTerminalEnvironment: [String: String] = [:],
|
||||
select: Bool = true,
|
||||
eagerLoadTerminal: Bool = false,
|
||||
placementOverride: NewWorkspacePlacement? = nil
|
||||
placementOverride: NewWorkspacePlacement? = nil,
|
||||
autoWelcomeIfNeeded: Bool = true
|
||||
) -> Workspace {
|
||||
sentryBreadcrumb("workspace.create", data: ["tabCount": tabs.count + 1])
|
||||
let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab()
|
||||
|
|
@ -811,9 +824,209 @@ class TabManager: ObservableObject {
|
|||
"selectedTabId": select ? newWorkspace.id.uuidString : (selectedTabId?.uuidString ?? "")
|
||||
])
|
||||
#endif
|
||||
if autoWelcomeIfNeeded && select && !UserDefaults.standard.bool(forKey: WelcomeSettings.shownKey) {
|
||||
if let appDelegate = AppDelegate.shared {
|
||||
appDelegate.sendWelcomeCommandWhenReady(to: newWorkspace, markShownOnSend: true)
|
||||
} else {
|
||||
sendWelcomeWhenReady(to: newWorkspace)
|
||||
}
|
||||
}
|
||||
return newWorkspace
|
||||
}
|
||||
|
||||
private func sendWelcomeWhenReady(to workspace: Workspace, attempt: Int = 0) {
|
||||
let maxAttempts = 60
|
||||
if let terminalPanel = workspace.focusedTerminalPanel,
|
||||
terminalPanel.surface.surface != nil {
|
||||
// Wait a bit more for the shell prompt to be ready
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
UserDefaults.standard.set(true, forKey: WelcomeSettings.shownKey)
|
||||
terminalPanel.sendText("cmux welcome\n")
|
||||
}
|
||||
return
|
||||
}
|
||||
guard attempt < maxAttempts else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
||||
self?.sendWelcomeWhenReady(to: workspace, attempt: attempt + 1)
|
||||
}
|
||||
}
|
||||
|
||||
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, eagerLoadTerminal: Bool = false) -> Workspace {
|
||||
|
|
@ -1000,20 +1213,6 @@ class TabManager: ObservableObject {
|
|||
return trimmed
|
||||
}
|
||||
|
||||
func requestBackgroundWorkspaceLoad(for workspaceId: UUID) {
|
||||
_ = pendingBackgroundWorkspaceLoadIds.insert(workspaceId)
|
||||
}
|
||||
|
||||
func completeBackgroundWorkspaceLoad(for workspaceId: UUID) {
|
||||
pendingBackgroundWorkspaceLoadIds.remove(workspaceId)
|
||||
}
|
||||
|
||||
func pruneBackgroundWorkspaceLoads(existingIds: Set<UUID>) {
|
||||
let pruned = pendingBackgroundWorkspaceLoadIds.intersection(existingIds)
|
||||
guard pruned != pendingBackgroundWorkspaceLoadIds else { return }
|
||||
pendingBackgroundWorkspaceLoadIds = pruned
|
||||
}
|
||||
|
||||
func closeWorkspace(_ workspace: Workspace) {
|
||||
guard tabs.count > 1 else { return }
|
||||
sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1])
|
||||
|
|
|
|||
|
|
@ -1742,6 +1742,16 @@ class TerminalController {
|
|||
case "workspace.remote.status":
|
||||
return v2Result(id: id, self.v2WorkspaceRemoteStatus(params: params))
|
||||
|
||||
// Settings
|
||||
case "settings.open":
|
||||
return v2Result(id: id, self.v2SettingsOpen(params: params))
|
||||
|
||||
// Feedback
|
||||
case "feedback.open":
|
||||
return v2Result(id: id, self.v2FeedbackOpen(params: params))
|
||||
case "feedback.submit":
|
||||
return v2Result(id: id, self.v2FeedbackSubmit(params: params))
|
||||
|
||||
|
||||
// Surfaces / input
|
||||
case "surface.list":
|
||||
|
|
@ -2093,6 +2103,9 @@ class TerminalController {
|
|||
"workspace.remote.reconnect",
|
||||
"workspace.remote.disconnect",
|
||||
"workspace.remote.status",
|
||||
"settings.open",
|
||||
"feedback.open",
|
||||
"feedback.submit",
|
||||
"surface.list",
|
||||
"surface.current",
|
||||
"surface.focus",
|
||||
|
|
@ -3956,6 +3969,9 @@ class TerminalController {
|
|||
"index_in_pane": v2OrNull(indexInPaneByPanelId[panel.id]),
|
||||
"selected_in_pane": v2OrNull(selectedInPaneByPanelId[panel.id])
|
||||
]
|
||||
if let browserPanel = panel as? BrowserPanel {
|
||||
item["developer_tools_visible"] = browserPanel.isDeveloperToolsVisible()
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
|
|
@ -5608,6 +5624,109 @@ class TerminalController {
|
|||
return .ok([:])
|
||||
}
|
||||
|
||||
private func v2FeedbackOpen(params: [String: Any]) -> V2CallResult {
|
||||
let workspaceId = v2UUID(params, "workspace_id")
|
||||
let windowId = v2UUID(params, "window_id")
|
||||
let shouldActivate = v2Bool(params, "activate") ?? false
|
||||
DispatchQueue.main.async {
|
||||
let targetWindow: NSWindow?
|
||||
if let windowId, let app = AppDelegate.shared {
|
||||
targetWindow = app.mainWindow(for: windowId)
|
||||
} else if let workspaceId, let app = AppDelegate.shared {
|
||||
targetWindow = app.mainWindowContainingWorkspace(workspaceId)
|
||||
} else {
|
||||
targetWindow = nil
|
||||
}
|
||||
|
||||
if shouldActivate {
|
||||
if let targetWindow {
|
||||
targetWindow.makeKeyAndOrderFront(nil)
|
||||
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
|
||||
} else {
|
||||
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
|
||||
}
|
||||
}
|
||||
|
||||
FeedbackComposerBridge.openComposer(in: targetWindow)
|
||||
}
|
||||
return .ok(["opened": true])
|
||||
}
|
||||
|
||||
private func v2SettingsOpen(params: [String: Any]) -> V2CallResult {
|
||||
let targetRaw = v2String(params, "target")
|
||||
let shouldActivate = v2Bool(params, "activate") ?? true
|
||||
|
||||
let navigationTarget: SettingsNavigationTarget?
|
||||
switch targetRaw {
|
||||
case nil:
|
||||
navigationTarget = nil
|
||||
case SettingsNavigationTarget.keyboardShortcuts.rawValue:
|
||||
navigationTarget = .keyboardShortcuts
|
||||
default:
|
||||
return .err(code: "invalid_params", message: "Unknown settings target", data: ["target": targetRaw ?? ""])
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if shouldActivate {
|
||||
AppDelegate.presentPreferencesWindow(navigationTarget: navigationTarget)
|
||||
} else {
|
||||
SettingsWindowController.shared.show(navigationTarget: navigationTarget)
|
||||
}
|
||||
}
|
||||
return .ok([
|
||||
"opened": true,
|
||||
"target": navigationTarget?.rawValue ?? "general",
|
||||
])
|
||||
}
|
||||
|
||||
private func v2FeedbackSubmit(params: [String: Any]) -> V2CallResult {
|
||||
guard let email = params["email"] as? String else {
|
||||
return .err(code: "invalid_params", message: "Missing email", data: ["field": "email"])
|
||||
}
|
||||
guard let body = params["body"] as? String else {
|
||||
return .err(code: "invalid_params", message: "Missing body", data: ["field": "body"])
|
||||
}
|
||||
let imagePaths = params["image_paths"] as? [String] ?? []
|
||||
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var result: V2CallResult = .err(code: "internal_error", message: "Feedback submission failed", data: nil)
|
||||
|
||||
Task {
|
||||
let resolved: V2CallResult
|
||||
do {
|
||||
let attachmentCount = try await FeedbackComposerBridge.submit(
|
||||
email: email,
|
||||
message: body,
|
||||
imagePaths: imagePaths
|
||||
)
|
||||
resolved = .ok([
|
||||
"submitted": true,
|
||||
"attachment_count": attachmentCount,
|
||||
])
|
||||
} catch let error as FeedbackComposerBridgeError {
|
||||
let code: String
|
||||
switch error {
|
||||
case .invalidEmail, .emptyMessage, .messageTooLong, .tooManyImages, .invalidImagePath:
|
||||
code = "invalid_params"
|
||||
case .submissionFailed:
|
||||
code = "request_failed"
|
||||
}
|
||||
resolved = .err(code: code, message: error.localizedDescription, data: nil)
|
||||
} catch {
|
||||
resolved = .err(code: "internal_error", message: error.localizedDescription, data: nil)
|
||||
}
|
||||
|
||||
result = resolved
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
if semaphore.wait(timeout: .now() + 35) == .timedOut {
|
||||
return .err(code: "timeout", message: "Feedback submission timed out", data: nil)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - V2 App Focus Methods
|
||||
|
||||
private func v2AppFocusOverride(params: [String: Any]) -> V2CallResult {
|
||||
|
|
|
|||
|
|
@ -901,6 +901,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
|
|||
|
||||
private struct NotificationsPopoverView: View {
|
||||
@ObservedObject var notificationStore: TerminalNotificationStore
|
||||
@AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data()
|
||||
let onDismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
|
|
@ -909,12 +910,28 @@ private struct NotificationsPopoverView: View {
|
|||
Text(String(localized: "notifications.title", defaultValue: "Notifications"))
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if !notificationStore.notifications.isEmpty {
|
||||
Button(String(localized: "notifications.clearAll", defaultValue: "Clear All")) {
|
||||
notificationStore.clearAll()
|
||||
Button(action: jumpToLatestUnread) {
|
||||
HStack(spacing: 6) {
|
||||
Text(String(localized: "notifications.jumpToLatest", defaultValue: "Jump to Latest"))
|
||||
Text(jumpToUnreadShortcut.displayString)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityIdentifier("notificationsPopover.jumpToLatest")
|
||||
.accessibilityValue(jumpToUnreadShortcut.displayString)
|
||||
.safeHelp(
|
||||
KeyboardShortcutSettings.Action.jumpToUnread.tooltip(
|
||||
String(localized: "notifications.jumpToLatest", defaultValue: "Jump to Latest")
|
||||
)
|
||||
)
|
||||
.disabled(!hasUnreadNotifications)
|
||||
|
||||
Button(String(localized: "notifications.clearAll", defaultValue: "Clear All")) {
|
||||
notificationStore.clearAll()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityIdentifier("notificationsPopover.clearAll")
|
||||
.disabled(notificationStore.notifications.isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
|
|
@ -957,6 +974,32 @@ private struct NotificationsPopoverView: View {
|
|||
AppDelegate.shared?.tabTitle(for: tabId)
|
||||
}
|
||||
|
||||
private var jumpToUnreadShortcut: StoredShortcut {
|
||||
decodeShortcut(
|
||||
from: jumpToUnreadShortcutData,
|
||||
fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut
|
||||
)
|
||||
}
|
||||
|
||||
private var hasUnreadNotifications: Bool {
|
||||
notificationStore.notifications.contains(where: { !$0.isRead })
|
||||
}
|
||||
|
||||
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
|
||||
guard !data.isEmpty,
|
||||
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
|
||||
return fallback
|
||||
}
|
||||
return shortcut
|
||||
}
|
||||
|
||||
private func jumpToLatestUnread() {
|
||||
DispatchQueue.main.async {
|
||||
AppDelegate.shared?.jumpToLatestUnread()
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private func open(_ notification: TerminalNotification) {
|
||||
// SwiftUI action closures are not guaranteed to run on the main actor.
|
||||
// Ensure window focus + tab selection happens on the main thread.
|
||||
|
|
|
|||
|
|
@ -3555,7 +3555,14 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
private var pendingPaneClosePanelIds: [UUID: [UUID]] = [:]
|
||||
private var pendingClosedBrowserRestoreSnapshots: [TabID: ClosedBrowserPanelRestoreSnapshot] = [:]
|
||||
private var isApplyingTabSelection = false
|
||||
private var pendingTabSelection: (tabId: TabID, pane: PaneID)?
|
||||
private struct PendingTabSelectionRequest {
|
||||
let tabId: TabID
|
||||
let pane: PaneID
|
||||
let reassertAppKitFocus: Bool
|
||||
let focusIntent: PanelFocusIntent?
|
||||
let previousTerminalHostedView: GhosttySurfaceScrollView?
|
||||
}
|
||||
private var pendingTabSelection: PendingTabSelectionRequest?
|
||||
private var isReconcilingFocusState = false
|
||||
private var focusReconcileScheduled = false
|
||||
#if DEBUG
|
||||
|
|
@ -5604,6 +5611,19 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}()
|
||||
let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged
|
||||
#if DEBUG
|
||||
let targetPaneShort = targetPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil"
|
||||
let focusedPaneShort = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil"
|
||||
let selectedTabShort = bonsplitController.focusedPaneId
|
||||
.flatMap { bonsplitController.selectedTab(inPane: $0)?.id }
|
||||
.map { String($0.uuid.uuidString.prefix(5)) } ?? "nil"
|
||||
let currentPanelShort = currentlyFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil"
|
||||
dlog(
|
||||
"focus.panel.begin workspace=\(id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) trigger=\(String(describing: trigger)) " +
|
||||
"targetPane=\(targetPaneShort) focusedPane=\(focusedPaneShort) selectedTab=\(selectedTabShort) " +
|
||||
"converged=\(selectionAlreadyConverged ? 1 : 0) " +
|
||||
"currentPanel=\(currentPanelShort)"
|
||||
)
|
||||
if shouldSuppressReentrantRefocus {
|
||||
dlog(
|
||||
"focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " +
|
||||
|
|
@ -5613,34 +5633,65 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
#endif
|
||||
|
||||
if let targetPaneId, !selectionAlreadyConverged {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.panel.focusPane workspace=\(id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) pane=\(targetPaneId.id.uuidString.prefix(5))"
|
||||
)
|
||||
#endif
|
||||
bonsplitController.focusPane(targetPaneId)
|
||||
}
|
||||
|
||||
if !selectionAlreadyConverged {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.panel.selectTab workspace=\(id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5))"
|
||||
)
|
||||
#endif
|
||||
bonsplitController.selectTab(tabId)
|
||||
}
|
||||
|
||||
// Also focus the underlying panel
|
||||
if let panel = panels[panelId] {
|
||||
if (currentlyFocusedPanelId != panelId || !selectionAlreadyConverged) && !shouldSuppressReentrantRefocus {
|
||||
panel.focus()
|
||||
}
|
||||
|
||||
if !shouldSuppressReentrantRefocus, let terminalPanel = panel as? TerminalPanel {
|
||||
// Avoid re-entrant focus loops when focus was initiated by AppKit first-responder
|
||||
// (becomeFirstResponder -> onFocus -> focusPanel).
|
||||
if !terminalPanel.hostedView.isSurfaceViewFirstResponder() {
|
||||
terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let targetPaneId, !shouldSuppressReentrantRefocus {
|
||||
applyTabSelection(tabId: tabId, inPane: targetPaneId)
|
||||
if let targetPaneId {
|
||||
let activationIntent = panels[panelId]?.preferredFocusIntentForActivation()
|
||||
applyTabSelection(
|
||||
tabId: tabId,
|
||||
inPane: targetPaneId,
|
||||
reassertAppKitFocus: !shouldSuppressReentrantRefocus,
|
||||
focusIntent: activationIntent,
|
||||
previousTerminalHostedView: previousTerminalHostedView
|
||||
)
|
||||
}
|
||||
|
||||
if let browserPanel = panels[panelId] as? BrowserPanel {
|
||||
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger)
|
||||
}
|
||||
|
||||
if trigger == .terminalFirstResponder,
|
||||
panels[panelId] is TerminalPanel {
|
||||
scheduleTerminalFirstResponderReassert(panelId: panelId)
|
||||
}
|
||||
}
|
||||
|
||||
/// A terminal click can arrive while AppKit and bonsplit already look converged, which takes
|
||||
/// the re-entrant focus path and skips the normal explicit `ensureFocus` call. Re-assert focus
|
||||
/// on the next couple of turns so stale callbacks from split churn can't leave keyboard input
|
||||
/// attached to the wrong surface (#1147).
|
||||
private func scheduleTerminalFirstResponderReassert(panelId: UUID, remainingPasses: Int = 2) {
|
||||
guard remainingPasses > 0 else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self,
|
||||
self.focusedPanelId == panelId,
|
||||
let terminalPanel = self.terminalPanel(for: panelId) else {
|
||||
return
|
||||
}
|
||||
|
||||
terminalPanel.hostedView.ensureFocus(for: self.id, surfaceId: panelId)
|
||||
self.scheduleTerminalFirstResponderReassert(
|
||||
panelId: panelId,
|
||||
remainingPasses: remainingPasses - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func maybeAutoFocusBrowserAddressBarOnPanelFocus(
|
||||
|
|
@ -5753,9 +5804,28 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
@discardableResult
|
||||
func toggleSplitZoom(panelId: UUID) -> Bool {
|
||||
let wasSplitZoomed = bonsplitController.isSplitZoomed
|
||||
guard let paneId = paneId(forPanelId: panelId) else { return false }
|
||||
guard bonsplitController.togglePaneZoom(inPane: paneId) else { return false }
|
||||
focusPanel(panelId)
|
||||
reconcileTerminalPortalVisibilityForCurrentRenderedLayout()
|
||||
reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: "workspace.toggleSplitZoom")
|
||||
scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(remainingPasses: 4)
|
||||
scheduleBrowserPortalVisibilityReconcileAfterSplitZoom(
|
||||
remainingPasses: 4,
|
||||
reason: "workspace.toggleSplitZoom"
|
||||
)
|
||||
scheduleTerminalGeometryReconcile()
|
||||
if let browserPanel = browserPanel(for: panelId) {
|
||||
browserPanel.preparePortalHostReplacementForNextDistinctClaim(
|
||||
inPane: paneId,
|
||||
reason: "workspace.toggleSplitZoom"
|
||||
)
|
||||
scheduleBrowserPortalReconcileAfterSplitZoom(panelId: panelId, remainingPasses: 4)
|
||||
if wasSplitZoomed && !bonsplitController.isSplitZoomed {
|
||||
scheduleBrowserSplitZoomExitFocusReassert(panelId: panelId, remainingPasses: 4)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -6012,6 +6082,248 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
private func renderedVisiblePanelIdsForCurrentLayout() -> Set<UUID> {
|
||||
let renderedPaneIds = bonsplitController.zoomedPaneId.map { [$0] } ?? bonsplitController.allPaneIds
|
||||
var visiblePanelIds: Set<UUID> = []
|
||||
|
||||
for paneId in renderedPaneIds {
|
||||
let selectedTab = bonsplitController.selectedTab(inPane: paneId) ?? bonsplitController.tabs(inPane: paneId).first
|
||||
guard let selectedTab,
|
||||
let panelId = panelIdFromSurfaceId(selectedTab.id),
|
||||
panels[panelId] != nil else {
|
||||
continue
|
||||
}
|
||||
visiblePanelIds.insert(panelId)
|
||||
}
|
||||
|
||||
if let focusedPanelId,
|
||||
panels[focusedPanelId] != nil,
|
||||
let focusedPaneId = paneId(forPanelId: focusedPanelId),
|
||||
renderedPaneIds.contains(where: { $0.id == focusedPaneId.id }) {
|
||||
visiblePanelIds.insert(focusedPanelId)
|
||||
}
|
||||
|
||||
return visiblePanelIds
|
||||
}
|
||||
|
||||
private func reconcileTerminalPortalVisibilityForCurrentRenderedLayout() {
|
||||
let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout()
|
||||
|
||||
for panel in panels.values {
|
||||
guard let terminalPanel = panel as? TerminalPanel else { continue }
|
||||
let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id)
|
||||
terminalPanel.hostedView.setVisibleInUI(shouldBeVisible)
|
||||
terminalPanel.hostedView.setActive(shouldBeVisible && focusedPanelId == terminalPanel.id)
|
||||
TerminalWindowPortalRegistry.updateEntryVisibility(
|
||||
for: terminalPanel.hostedView,
|
||||
visibleInUI: shouldBeVisible
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func terminalPortalVisibilityNeedsFollowUp() -> Bool {
|
||||
let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout()
|
||||
|
||||
for panel in panels.values {
|
||||
guard let terminalPanel = panel as? TerminalPanel else { continue }
|
||||
let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id)
|
||||
let hostedView = terminalPanel.hostedView
|
||||
|
||||
if shouldBeVisible {
|
||||
if hostedView.isHidden || hostedView.window == nil || hostedView.superview == nil {
|
||||
return true
|
||||
}
|
||||
} else if !hostedView.isHidden {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(remainingPasses: Int) {
|
||||
guard remainingPasses > 0 else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
for window in NSApp.windows {
|
||||
window.contentView?.layoutSubtreeIfNeeded()
|
||||
window.contentView?.displayIfNeeded()
|
||||
}
|
||||
|
||||
self.reconcileTerminalPortalVisibilityForCurrentRenderedLayout()
|
||||
|
||||
if self.terminalPortalVisibilityNeedsFollowUp(), remainingPasses > 1 {
|
||||
self.scheduleTerminalPortalVisibilityReconcileAfterSplitZoom(
|
||||
remainingPasses: remainingPasses - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: String) {
|
||||
let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout()
|
||||
|
||||
for panel in panels.values {
|
||||
guard let browserPanel = panel as? BrowserPanel else { continue }
|
||||
let shouldBeVisible = visiblePanelIds.contains(browserPanel.id)
|
||||
if shouldBeVisible {
|
||||
BrowserWindowPortalRegistry.updateEntryVisibility(
|
||||
for: browserPanel.webView,
|
||||
visibleInUI: true,
|
||||
zPriority: 2
|
||||
)
|
||||
let anchorView = browserPanel.portalAnchorView
|
||||
let anchorReady =
|
||||
anchorView.window != nil &&
|
||||
anchorView.superview != nil &&
|
||||
anchorView.bounds.width > 1 &&
|
||||
anchorView.bounds.height > 1
|
||||
if anchorReady {
|
||||
BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView)
|
||||
BrowserWindowPortalRegistry.refresh(
|
||||
webView: browserPanel.webView,
|
||||
reason: reason
|
||||
)
|
||||
}
|
||||
} else {
|
||||
BrowserWindowPortalRegistry.updateEntryVisibility(
|
||||
for: browserPanel.webView,
|
||||
visibleInUI: false,
|
||||
zPriority: 0
|
||||
)
|
||||
BrowserWindowPortalRegistry.hide(
|
||||
webView: browserPanel.webView,
|
||||
source: reason
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func browserPortalVisibilityNeedsFollowUp() -> Bool {
|
||||
let visiblePanelIds = renderedVisiblePanelIdsForCurrentLayout()
|
||||
|
||||
for panel in panels.values {
|
||||
guard let browserPanel = panel as? BrowserPanel else { continue }
|
||||
guard visiblePanelIds.contains(browserPanel.id) else { continue }
|
||||
let anchorView = browserPanel.portalAnchorView
|
||||
let anchorReady =
|
||||
anchorView.window != nil &&
|
||||
anchorView.superview != nil &&
|
||||
anchorView.bounds.width > 1 &&
|
||||
anchorView.bounds.height > 1
|
||||
if !anchorReady ||
|
||||
browserPanel.webView.window == nil ||
|
||||
browserPanel.webView.superview == nil ||
|
||||
!BrowserWindowPortalRegistry.isWebView(browserPanel.webView, boundTo: anchorView) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func scheduleBrowserPortalVisibilityReconcileAfterSplitZoom(
|
||||
remainingPasses: Int,
|
||||
reason: String
|
||||
) {
|
||||
guard remainingPasses > 0 else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
for window in NSApp.windows {
|
||||
window.contentView?.layoutSubtreeIfNeeded()
|
||||
window.contentView?.displayIfNeeded()
|
||||
}
|
||||
|
||||
self.reconcileBrowserPortalVisibilityForCurrentRenderedLayout(reason: reason)
|
||||
|
||||
if self.browserPortalVisibilityNeedsFollowUp(), remainingPasses > 1 {
|
||||
self.scheduleBrowserPortalVisibilityReconcileAfterSplitZoom(
|
||||
remainingPasses: remainingPasses - 1,
|
||||
reason: reason
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Browser panes host WKWebView in the window portal. After pane zoom toggles,
|
||||
// force a few post-layout sync passes so the portal does not outlive the omnibar chrome.
|
||||
private func scheduleBrowserPortalReconcileAfterSplitZoom(panelId: UUID, remainingPasses: Int) {
|
||||
guard remainingPasses > 0 else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, let browserPanel = self.browserPanel(for: panelId) else { return }
|
||||
|
||||
for window in NSApp.windows {
|
||||
window.contentView?.layoutSubtreeIfNeeded()
|
||||
window.contentView?.displayIfNeeded()
|
||||
}
|
||||
|
||||
let anchorView = browserPanel.portalAnchorView
|
||||
let anchorReady =
|
||||
anchorView.window != nil &&
|
||||
anchorView.superview != nil &&
|
||||
anchorView.bounds.width > 1 &&
|
||||
anchorView.bounds.height > 1
|
||||
|
||||
if anchorReady {
|
||||
BrowserWindowPortalRegistry.synchronizeForAnchor(anchorView)
|
||||
BrowserWindowPortalRegistry.refresh(
|
||||
webView: browserPanel.webView,
|
||||
reason: "workspace.toggleSplitZoom"
|
||||
)
|
||||
}
|
||||
|
||||
let portalNeedsFollowUpPass =
|
||||
!anchorReady ||
|
||||
browserPanel.webView.window == nil ||
|
||||
browserPanel.webView.superview == nil
|
||||
if portalNeedsFollowUpPass {
|
||||
self.scheduleBrowserPortalReconcileAfterSplitZoom(
|
||||
panelId: panelId,
|
||||
remainingPasses: remainingPasses - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Browser panes can briefly keep the portal-hosted WKWebView visible while Bonsplit is
|
||||
// still rebuilding the unzoomed pane host. Reassert pane/tab selection after layout settles
|
||||
// so the SwiftUI chrome does not remain hidden until another browser focus command runs.
|
||||
private func scheduleBrowserSplitZoomExitFocusReassert(panelId: UUID, remainingPasses: Int) {
|
||||
guard remainingPasses > 0 else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.browserPanel(for: panelId) != nil else { return }
|
||||
guard let paneId = self.paneId(forPanelId: panelId),
|
||||
let tabId = self.surfaceIdFromPanelId(panelId) else { return }
|
||||
|
||||
let selectionConverged =
|
||||
self.bonsplitController.focusedPaneId == paneId &&
|
||||
self.bonsplitController.selectedTab(inPane: paneId)?.id == tabId
|
||||
let anchorReady: Bool = {
|
||||
guard let browserPanel = self.browserPanel(for: panelId) else { return false }
|
||||
let anchorView = browserPanel.portalAnchorView
|
||||
return
|
||||
anchorView.window != nil &&
|
||||
anchorView.superview != nil &&
|
||||
anchorView.bounds.width > 1 &&
|
||||
anchorView.bounds.height > 1
|
||||
}()
|
||||
|
||||
if !selectionConverged {
|
||||
self.focusPanel(panelId)
|
||||
self.scheduleFocusReconcile()
|
||||
}
|
||||
|
||||
if !selectionConverged || !anchorReady {
|
||||
self.scheduleBrowserSplitZoomExitFocusReassert(
|
||||
panelId: panelId,
|
||||
remainingPasses: remainingPasses - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleMovedTerminalRefresh(panelId: UUID) {
|
||||
guard terminalPanel(for: panelId) != nil else { return }
|
||||
|
||||
|
|
@ -6280,8 +6592,20 @@ extension Workspace: BonsplitDelegate {
|
|||
|
||||
/// Apply the side-effects of selecting a tab (unfocus others, focus this panel, update state).
|
||||
/// bonsplit doesn't always emit didSelectTab for programmatic selection paths (e.g. createTab).
|
||||
private func applyTabSelection(tabId: TabID, inPane pane: PaneID) {
|
||||
pendingTabSelection = (tabId: tabId, pane: pane)
|
||||
private func applyTabSelection(
|
||||
tabId: TabID,
|
||||
inPane pane: PaneID,
|
||||
reassertAppKitFocus: Bool = true,
|
||||
focusIntent: PanelFocusIntent? = nil,
|
||||
previousTerminalHostedView: GhosttySurfaceScrollView? = nil
|
||||
) {
|
||||
pendingTabSelection = PendingTabSelectionRequest(
|
||||
tabId: tabId,
|
||||
pane: pane,
|
||||
reassertAppKitFocus: reassertAppKitFocus,
|
||||
focusIntent: focusIntent,
|
||||
previousTerminalHostedView: previousTerminalHostedView
|
||||
)
|
||||
guard !isApplyingTabSelection else { return }
|
||||
isApplyingTabSelection = true
|
||||
defer {
|
||||
|
|
@ -6294,12 +6618,36 @@ extension Workspace: BonsplitDelegate {
|
|||
pendingTabSelection = nil
|
||||
iterations += 1
|
||||
if iterations > 8 { break }
|
||||
applyTabSelectionNow(tabId: request.tabId, inPane: request.pane)
|
||||
applyTabSelectionNow(
|
||||
tabId: request.tabId,
|
||||
inPane: request.pane,
|
||||
reassertAppKitFocus: request.reassertAppKitFocus,
|
||||
focusIntent: request.focusIntent,
|
||||
previousTerminalHostedView: request.previousTerminalHostedView
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyTabSelectionNow(tabId: TabID, inPane pane: PaneID) {
|
||||
private func applyTabSelectionNow(
|
||||
tabId: TabID,
|
||||
inPane pane: PaneID,
|
||||
reassertAppKitFocus: Bool,
|
||||
focusIntent: PanelFocusIntent?,
|
||||
previousTerminalHostedView: GhosttySurfaceScrollView?
|
||||
) {
|
||||
let previousFocusedPanelId = focusedPanelId
|
||||
#if DEBUG
|
||||
let focusedPaneBefore = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil"
|
||||
let selectedTabBefore = bonsplitController.focusedPaneId
|
||||
.flatMap { bonsplitController.selectedTab(inPane: $0)?.id }
|
||||
.map { String($0.uuid.uuidString.prefix(5)) } ?? "nil"
|
||||
dlog(
|
||||
"focus.split.apply.begin workspace=\(id.uuidString.prefix(5)) " +
|
||||
"pane=\(pane.id.uuidString.prefix(5)) tab=\(tabId.uuid.uuidString.prefix(5)) " +
|
||||
"focusedPane=\(focusedPaneBefore) selectedTab=\(selectedTabBefore) " +
|
||||
"reassert=\(reassertAppKitFocus ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
if bonsplitController.allPaneIds.contains(pane) {
|
||||
if bonsplitController.focusedPaneId != pane {
|
||||
bonsplitController.focusPane(pane)
|
||||
|
|
@ -6334,6 +6682,8 @@ extension Workspace: BonsplitDelegate {
|
|||
if shouldTreatCurrentEventAsExplicitFocusIntent() {
|
||||
markExplicitFocusIntent(on: panelId)
|
||||
}
|
||||
let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation()
|
||||
panel.prepareFocusIntentForActivation(activationIntent)
|
||||
|
||||
syncPinnedStateForTab(selectedTabId, panelId: panelId)
|
||||
syncUnreadBadgeStateForPanel(panelId)
|
||||
|
|
@ -6343,11 +6693,24 @@ extension Workspace: BonsplitDelegate {
|
|||
p.unfocus()
|
||||
}
|
||||
|
||||
panel.focus()
|
||||
if let focusWindow = activationWindow(for: panel) {
|
||||
yieldForeignOwnedFocusIfNeeded(
|
||||
in: focusWindow,
|
||||
targetPanelId: panelId,
|
||||
targetIntent: activationIntent
|
||||
)
|
||||
}
|
||||
|
||||
activatePanel(
|
||||
panel,
|
||||
focusIntent: activationIntent,
|
||||
reassertAppKitFocus: reassertAppKitFocus
|
||||
)
|
||||
let focusIntentAllowsBrowserOmnibarAutofocus =
|
||||
shouldTreatCurrentEventAsExplicitFocusIntent() ||
|
||||
TerminalController.socketCommandAllowsInAppFocusMutations()
|
||||
if let browserPanel = panel as? BrowserPanel,
|
||||
shouldAllowBrowserOmnibarAutofocus(for: activationIntent),
|
||||
previousFocusedPanelId != panelId || focusIntentAllowsBrowserOmnibarAutofocus {
|
||||
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: .standard)
|
||||
}
|
||||
|
|
@ -6375,10 +6738,33 @@ extension Workspace: BonsplitDelegate {
|
|||
|
||||
// Converge AppKit first responder with bonsplit's selected tab in the focused pane.
|
||||
// Without this, keyboard input can remain on a different terminal than the blue tab indicator.
|
||||
if let terminalPanel = panel as? TerminalPanel {
|
||||
if reassertAppKitFocus, let terminalPanel = panel as? TerminalPanel {
|
||||
if shouldMoveTerminalSurfaceFocus(for: activationIntent),
|
||||
!terminalPanel.hostedView.isSurfaceViewFirstResponder() {
|
||||
#if DEBUG
|
||||
let previousExists = previousTerminalHostedView != nil ? 1 : 0
|
||||
dlog(
|
||||
"focus.split.moveFocus workspace=\(id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) previousExists=\(previousExists) " +
|
||||
"to=\(panelId.uuidString.prefix(5))"
|
||||
)
|
||||
#endif
|
||||
terminalPanel.hostedView.moveFocus(from: previousTerminalHostedView)
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.split.ensureFocus workspace=\(id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) pane=\(focusedPane.id.uuidString.prefix(5)) " +
|
||||
"tab=\(selectedTabId.uuid.uuidString.prefix(5)) intent=\(String(describing: activationIntent))"
|
||||
)
|
||||
#endif
|
||||
terminalPanel.hostedView.ensureFocus(for: id, surfaceId: panelId)
|
||||
}
|
||||
|
||||
if shouldRestoreFocusIntentAfterActivation(activationIntent) {
|
||||
_ = panel.restoreFocusIntent(activationIntent)
|
||||
}
|
||||
|
||||
// Update current directory if this is a terminal
|
||||
if let dir = panelDirectories[panelId] {
|
||||
currentDirectory = dir
|
||||
|
|
@ -6395,6 +6781,108 @@ extension Workspace: BonsplitDelegate {
|
|||
GhosttyNotificationKey.surfaceId: panelId
|
||||
]
|
||||
)
|
||||
#if DEBUG
|
||||
let prevPanelShort = previousFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil"
|
||||
dlog(
|
||||
"focus.split.apply.end workspace=\(id.uuidString.prefix(5)) " +
|
||||
"panel=\(panelId.uuidString.prefix(5)) type=\(String(describing: type(of: panel))) " +
|
||||
"focusedPane=\(focusedPane.id.uuidString.prefix(5)) selectedTab=\(selectedTabId.uuid.uuidString.prefix(5)) " +
|
||||
"prevPanel=\(prevPanelShort)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func activatePanel(
|
||||
_ panel: any Panel,
|
||||
focusIntent: PanelFocusIntent,
|
||||
reassertAppKitFocus: Bool
|
||||
) {
|
||||
if let terminalPanel = panel as? TerminalPanel {
|
||||
let shouldFocusTerminalSurface = shouldMoveTerminalSurfaceFocus(for: focusIntent)
|
||||
terminalPanel.surface.setFocus(shouldFocusTerminalSurface)
|
||||
terminalPanel.hostedView.setActive(true)
|
||||
if reassertAppKitFocus && shouldFocusTerminalSurface {
|
||||
terminalPanel.focus()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let browserPanel = panel as? BrowserPanel {
|
||||
guard shouldFocusBrowserWebView(for: focusIntent) else { return }
|
||||
browserPanel.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if reassertAppKitFocus {
|
||||
panel.focus()
|
||||
}
|
||||
}
|
||||
|
||||
private func activationWindow(for panel: any Panel) -> NSWindow? {
|
||||
if let terminalPanel = panel as? TerminalPanel {
|
||||
return terminalPanel.hostedView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
||||
}
|
||||
if let browserPanel = panel as? BrowserPanel {
|
||||
return browserPanel.webView.window ?? browserPanel.portalAnchorView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
||||
}
|
||||
return NSApp.keyWindow ?? NSApp.mainWindow
|
||||
}
|
||||
|
||||
private func yieldForeignOwnedFocusIfNeeded(
|
||||
in window: NSWindow,
|
||||
targetPanelId: UUID,
|
||||
targetIntent: PanelFocusIntent
|
||||
) {
|
||||
guard let firstResponder = window.firstResponder else { return }
|
||||
|
||||
for (panelId, panel) in panels where panelId != targetPanelId {
|
||||
guard let ownedIntent = panel.ownedFocusIntent(for: firstResponder, in: window) else { continue }
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.handoff.begin workspace=\(id.uuidString.prefix(5)) " +
|
||||
"fromPanel=\(panelId.uuidString.prefix(5)) toPanel=\(targetPanelId.uuidString.prefix(5)) " +
|
||||
"fromIntent=\(String(describing: ownedIntent)) toIntent=\(String(describing: targetIntent))"
|
||||
)
|
||||
#endif
|
||||
_ = panel.yieldFocusIntent(ownedIntent, in: window)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldMoveTerminalSurfaceFocus(for intent: PanelFocusIntent) -> Bool {
|
||||
switch intent {
|
||||
case .terminal(.findField):
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldFocusBrowserWebView(for intent: PanelFocusIntent) -> Bool {
|
||||
switch intent {
|
||||
case .browser(.addressBar), .browser(.findField):
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldAllowBrowserOmnibarAutofocus(for intent: PanelFocusIntent) -> Bool {
|
||||
switch intent {
|
||||
case .browser(.webView), .panel:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldRestoreFocusIntentAfterActivation(_ intent: PanelFocusIntent) -> Bool {
|
||||
switch intent {
|
||||
case .browser(.addressBar), .browser(.findField), .terminal(.findField):
|
||||
return true
|
||||
case .panel, .browser(.webView), .terminal(.surface):
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func beginNonFocusSplitFocusReassert(
|
||||
|
|
|
|||
|
|
@ -106,6 +106,11 @@ struct WorkspaceContentView: View {
|
|||
workspace.bonsplitController.focusPane(paneId)
|
||||
}
|
||||
}
|
||||
.internalOnlyTabDrag()
|
||||
// Split zoom swaps Bonsplit between the full split tree and a single pane view.
|
||||
// Recreate the Bonsplit subtree on zoom enter/exit so stale pre-zoom pane chrome
|
||||
// cannot remain stacked above portal-hosted browser content.
|
||||
.id(splitZoomRenderIdentity)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onAppear {
|
||||
syncBonsplitNotificationBadges()
|
||||
|
|
@ -174,6 +179,10 @@ struct WorkspaceContentView: View {
|
|||
}
|
||||
}
|
||||
|
||||
private var splitZoomRenderIdentity: String {
|
||||
workspace.bonsplitController.zoomedPaneId.map { "zoom:\($0.id.uuidString)" } ?? "unzoomed"
|
||||
}
|
||||
|
||||
static func resolveGhosttyAppearanceConfig(
|
||||
reason: String = "unspecified",
|
||||
backgroundOverride: NSColor? = nil,
|
||||
|
|
|
|||
|
|
@ -2815,6 +2815,10 @@ enum ClaudeCodeIntegrationSettings {
|
|||
}
|
||||
}
|
||||
|
||||
enum WelcomeSettings {
|
||||
static let shownKey = "cmuxWelcomeShown"
|
||||
}
|
||||
|
||||
enum TelemetrySettings {
|
||||
static let sendAnonymousTelemetryKey = "sendAnonymousTelemetry"
|
||||
static let defaultSendAnonymousTelemetry = true
|
||||
|
|
@ -2833,6 +2837,7 @@ enum TelemetrySettings {
|
|||
struct SettingsView: View {
|
||||
private let contentTopInset: CGFloat = 8
|
||||
private let pickerColumnWidth: CGFloat = 196
|
||||
private let notificationSoundControlWidth: CGFloat = 280
|
||||
|
||||
@AppStorage(LanguageSettings.languageKey) private var appLanguage = LanguageSettings.defaultLanguage.rawValue
|
||||
@AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue
|
||||
|
|
@ -3321,7 +3326,8 @@ struct SettingsView: View {
|
|||
|
||||
SettingsCardRow(
|
||||
String(localized: "settings.notifications.sound.title", defaultValue: "Notification Sound"),
|
||||
subtitle: String(localized: "settings.notifications.sound.subtitle", defaultValue: "Sound played when a notification arrives.")
|
||||
subtitle: String(localized: "settings.notifications.sound.subtitle", defaultValue: "Sound played when a notification arrives."),
|
||||
controlWidth: notificationSoundControlWidth
|
||||
) {
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
|
|
@ -3381,6 +3387,7 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
|
|
|||
|
|
@ -831,6 +831,54 @@ final class AppDelegateWindowContextRoutingTests: XCTestCase {
|
|||
XCTAssertTrue(resolved === manager, "Expected registered window object identity to win even if identifier string changed")
|
||||
XCTAssertTrue(app.tabManager === manager)
|
||||
}
|
||||
|
||||
func testAddWorkspaceWithoutBringToFrontPreservesActiveWindowAndSelection() {
|
||||
_ = NSApplication.shared
|
||||
let app = AppDelegate()
|
||||
|
||||
let windowAId = UUID()
|
||||
let windowBId = UUID()
|
||||
let windowA = makeMainWindow(id: windowAId)
|
||||
let windowB = makeMainWindow(id: windowBId)
|
||||
defer {
|
||||
windowA.orderOut(nil)
|
||||
windowB.orderOut(nil)
|
||||
}
|
||||
|
||||
let managerA = TabManager()
|
||||
let managerB = TabManager()
|
||||
app.registerMainWindow(
|
||||
windowA,
|
||||
windowId: windowAId,
|
||||
tabManager: managerA,
|
||||
sidebarState: SidebarState(),
|
||||
sidebarSelectionState: SidebarSelectionState()
|
||||
)
|
||||
app.registerMainWindow(
|
||||
windowB,
|
||||
windowId: windowBId,
|
||||
tabManager: managerB,
|
||||
sidebarState: SidebarState(),
|
||||
sidebarSelectionState: SidebarSelectionState()
|
||||
)
|
||||
|
||||
windowA.makeKeyAndOrderFront(nil)
|
||||
_ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA)
|
||||
XCTAssertTrue(app.tabManager === managerA)
|
||||
|
||||
let originalSelectedA = managerA.selectedTabId
|
||||
let originalSelectedB = managerB.selectedTabId
|
||||
let originalTabCountB = managerB.tabs.count
|
||||
|
||||
let createdWorkspaceId = app.addWorkspace(windowId: windowBId, bringToFront: false)
|
||||
|
||||
XCTAssertNotNil(createdWorkspaceId)
|
||||
XCTAssertTrue(app.tabManager === managerA, "Expected non-focus workspace creation to preserve active window routing")
|
||||
XCTAssertEqual(managerA.selectedTabId, originalSelectedA)
|
||||
XCTAssertEqual(managerB.selectedTabId, originalSelectedB, "Expected background workspace creation to preserve selected tab")
|
||||
XCTAssertEqual(managerB.tabs.count, originalTabCountB + 1)
|
||||
XCTAssertTrue(managerB.tabs.contains(where: { $0.id == createdWorkspaceId }))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -2389,14 +2437,26 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
|||
private final class WKInspectorProbeView: NSView {}
|
||||
|
||||
private final class FakeInspector: NSObject {
|
||||
private(set) var attachCount = 0
|
||||
private(set) var showCount = 0
|
||||
private(set) var closeCount = 0
|
||||
private var visible = false
|
||||
private var attached = false
|
||||
|
||||
@objc func isVisible() -> Bool {
|
||||
visible
|
||||
}
|
||||
|
||||
@objc func isAttached() -> Bool {
|
||||
attached
|
||||
}
|
||||
|
||||
@objc func attach() {
|
||||
attachCount += 1
|
||||
attached = true
|
||||
show()
|
||||
}
|
||||
|
||||
@objc func show() {
|
||||
showCount += 1
|
||||
visible = true
|
||||
|
|
@ -2405,6 +2465,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
|||
@objc func close() {
|
||||
closeCount += 1
|
||||
visible = false
|
||||
attached = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2420,6 +2481,18 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
|||
return (panel, inspector)
|
||||
}
|
||||
|
||||
private func findHostContainerView(in root: NSView) -> WebViewRepresentable.HostContainerView? {
|
||||
if let host = root as? WebViewRepresentable.HostContainerView {
|
||||
return host
|
||||
}
|
||||
for subview in root.subviews {
|
||||
if let host = findHostContainerView(in: subview) {
|
||||
return host
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() {
|
||||
let (panel, inspector) = makePanelWithInspector()
|
||||
|
||||
|
|
@ -2537,6 +2610,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
|||
panel: panel,
|
||||
paneId: paneId,
|
||||
shouldAttachWebView: true,
|
||||
useLocalInlineHosting: false,
|
||||
shouldFocusWebView: false,
|
||||
isPanelFocused: true,
|
||||
portalZPriority: 0,
|
||||
|
|
@ -2578,6 +2652,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
|||
panel: panel,
|
||||
paneId: paneId,
|
||||
shouldAttachWebView: true,
|
||||
useLocalInlineHosting: false,
|
||||
shouldFocusWebView: false,
|
||||
isPanelFocused: true,
|
||||
portalZPriority: 0,
|
||||
|
|
@ -2628,6 +2703,89 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
|
|||
|
||||
XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
|
||||
}
|
||||
|
||||
func testOffWindowReplacementLocalHostDoesNotStealVisibleDevToolsWebView() {
|
||||
let (panel, _) = makePanelWithInspector()
|
||||
XCTAssertTrue(panel.showDeveloperTools())
|
||||
|
||||
let paneId = PaneID(id: UUID())
|
||||
let representable = WebViewRepresentable(
|
||||
panel: panel,
|
||||
paneId: paneId,
|
||||
shouldAttachWebView: false,
|
||||
useLocalInlineHosting: true,
|
||||
shouldFocusWebView: false,
|
||||
isPanelFocused: true,
|
||||
portalZPriority: 0,
|
||||
paneDropZone: nil,
|
||||
searchOverlay: nil,
|
||||
paneTopChromeHeight: 0
|
||||
)
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
defer { window.orderOut(nil) }
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let visibleHosting = NSHostingView(rootView: representable)
|
||||
visibleHosting.frame = contentView.bounds
|
||||
visibleHosting.autoresizingMask = [.width, .height]
|
||||
contentView.addSubview(visibleHosting)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.displayIfNeeded()
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
visibleHosting.layoutSubtreeIfNeeded()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
|
||||
guard let visibleHost = findHostContainerView(in: visibleHosting) else {
|
||||
XCTFail("Expected visible local host")
|
||||
return
|
||||
}
|
||||
guard let visibleSlot = panel.webView.superview as? WindowBrowserSlotView else {
|
||||
XCTFail("Expected visible local inline slot")
|
||||
return
|
||||
}
|
||||
|
||||
let inspectorView = WKInspectorProbeView(
|
||||
frame: NSRect(x: 0, y: 0, width: visibleSlot.bounds.width, height: 72)
|
||||
)
|
||||
inspectorView.autoresizingMask = [.width]
|
||||
visibleSlot.addSubview(inspectorView)
|
||||
panel.webView.frame = NSRect(
|
||||
x: 0,
|
||||
y: inspectorView.frame.maxY,
|
||||
width: visibleSlot.bounds.width,
|
||||
height: visibleSlot.bounds.height - inspectorView.frame.height
|
||||
)
|
||||
visibleSlot.layoutSubtreeIfNeeded()
|
||||
|
||||
let detachedRoot = NSView(frame: visibleHosting.frame)
|
||||
let offWindowHosting = NSHostingView(rootView: representable)
|
||||
offWindowHosting.frame = detachedRoot.bounds
|
||||
offWindowHosting.autoresizingMask = [.width, .height]
|
||||
detachedRoot.addSubview(offWindowHosting)
|
||||
detachedRoot.layoutSubtreeIfNeeded()
|
||||
offWindowHosting.layoutSubtreeIfNeeded()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
|
||||
XCTAssertNotNil(findHostContainerView(in: offWindowHosting), "Expected off-window replacement host")
|
||||
XCTAssertTrue(visibleHost.window === window)
|
||||
XCTAssertTrue(
|
||||
panel.webView.superview === visibleSlot,
|
||||
"An off-window replacement host should not steal a visible DevTools-hosted web view during split zoom churn"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
inspectorView.superview === visibleSlot,
|
||||
"An off-window replacement host should leave DevTools companion views in the visible local host"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkspaceShortcutMapperTests: XCTestCase {
|
||||
|
|
@ -4708,6 +4866,158 @@ final class TabManagerEqualizeSplitsTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WorkspaceTerminalFocusRecoveryTests: XCTestCase {
|
||||
private func makeWindow() -> NSWindow {
|
||||
NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 360, height: 220),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
}
|
||||
|
||||
private func makeMouseEvent(
|
||||
type: NSEvent.EventType,
|
||||
location: NSPoint,
|
||||
window: NSWindow
|
||||
) -> NSEvent {
|
||||
guard let event = NSEvent.mouseEvent(
|
||||
with: type,
|
||||
location: location,
|
||||
modifierFlags: [],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
eventNumber: 0,
|
||||
clickCount: 1,
|
||||
pressure: 1.0
|
||||
) else {
|
||||
fatalError("Failed to create \(type) mouse event")
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> GhosttyNSView? {
|
||||
var stack: [NSView] = [hostedView]
|
||||
while let current = stack.popLast() {
|
||||
if let surfaceView = current as? GhosttyNSView {
|
||||
return surfaceView
|
||||
}
|
||||
stack.append(contentsOf: current.subviews)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func testTerminalFirstResponderConvergesSplitActiveStateWhenSelectionAlreadyMatches() {
|
||||
let workspace = Workspace()
|
||||
guard let leftPanelId = workspace.focusedPanelId,
|
||||
let leftPanel = workspace.terminalPanel(for: leftPanelId),
|
||||
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
|
||||
XCTFail("Expected split terminal panels")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(
|
||||
workspace.focusedPanelId,
|
||||
rightPanel.id,
|
||||
"Expected the new split panel to be selected before simulating stale focus state"
|
||||
)
|
||||
|
||||
// Simulate the split-pane failure mode: Bonsplit already points at the right panel,
|
||||
// but the active terminal state is still stale on the left panel.
|
||||
leftPanel.surface.setFocus(true)
|
||||
leftPanel.hostedView.setActive(true)
|
||||
rightPanel.surface.setFocus(false)
|
||||
rightPanel.hostedView.setActive(false)
|
||||
|
||||
workspace.focusPanel(rightPanel.id, trigger: .terminalFirstResponder)
|
||||
|
||||
XCTAssertFalse(
|
||||
leftPanel.hostedView.debugRenderStats().isActive,
|
||||
"Expected stale left-pane active state to be cleared"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
rightPanel.hostedView.debugRenderStats().isActive,
|
||||
"Expected terminal-first-responder recovery to reactivate the selected split pane"
|
||||
)
|
||||
}
|
||||
|
||||
func testTerminalClickRecoversSplitActiveStateWhenFocusCallbackIsSuppressed() {
|
||||
let workspace = Workspace()
|
||||
guard let leftPanelId = workspace.focusedPanelId,
|
||||
let leftPanel = workspace.terminalPanel(for: leftPanelId),
|
||||
let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal) else {
|
||||
XCTFail("Expected split terminal panels")
|
||||
return
|
||||
}
|
||||
|
||||
let window = makeWindow()
|
||||
defer { window.orderOut(nil) }
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
leftPanel.hostedView.frame = NSRect(x: 0, y: 0, width: 180, height: 220)
|
||||
rightPanel.hostedView.frame = NSRect(x: 180, y: 0, width: 180, height: 220)
|
||||
contentView.addSubview(leftPanel.hostedView)
|
||||
contentView.addSubview(rightPanel.hostedView)
|
||||
|
||||
leftPanel.hostedView.setVisibleInUI(true)
|
||||
rightPanel.hostedView.setVisibleInUI(true)
|
||||
leftPanel.hostedView.setFocusHandler {
|
||||
workspace.focusPanel(leftPanel.id, trigger: .terminalFirstResponder)
|
||||
}
|
||||
rightPanel.hostedView.setFocusHandler {
|
||||
workspace.focusPanel(rightPanel.id, trigger: .terminalFirstResponder)
|
||||
}
|
||||
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.displayIfNeeded()
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
|
||||
XCTAssertEqual(
|
||||
workspace.focusedPanelId,
|
||||
rightPanel.id,
|
||||
"Expected the clicked split pane to already be selected before simulating stale focus state"
|
||||
)
|
||||
|
||||
// Simulate the ghost-terminal race: the right pane is selected in Bonsplit, but stale
|
||||
// active state remains on the left and the right pane's AppKit focus callback never fires
|
||||
// after split reparent/layout churn.
|
||||
leftPanel.surface.setFocus(true)
|
||||
leftPanel.hostedView.setActive(true)
|
||||
rightPanel.surface.setFocus(false)
|
||||
rightPanel.hostedView.setActive(false)
|
||||
rightPanel.hostedView.suppressReparentFocus()
|
||||
|
||||
guard let rightSurfaceView = surfaceView(in: rightPanel.hostedView) else {
|
||||
XCTFail("Expected right terminal surface view")
|
||||
return
|
||||
}
|
||||
|
||||
let pointInWindow = rightSurfaceView.convert(NSPoint(x: 24, y: 24), to: nil)
|
||||
let event = makeMouseEvent(type: .leftMouseDown, location: pointInWindow, window: window)
|
||||
rightSurfaceView.mouseDown(with: event)
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
|
||||
XCTAssertFalse(
|
||||
leftPanel.hostedView.debugRenderStats().isActive,
|
||||
"Expected clicking the selected split pane to clear stale sibling active state even when AppKit focus callbacks are suppressed"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
rightPanel.hostedView.debugRenderStats().isActive,
|
||||
"Expected clicking the selected split pane to reactivate terminal input when focus callbacks are suppressed"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
rightPanel.hostedView.isSurfaceViewFirstResponder(),
|
||||
"Expected the clicked split pane to become first responder"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase {
|
||||
func testPrefersSelectedTerminalInTargetPaneOverFocusedTerminalElsewhere() {
|
||||
|
|
@ -6247,6 +6557,23 @@ final class VSCodeServeWebControllerTests: XCTestCase {
|
|||
}
|
||||
XCTAssertEqual(launchCalls, 2)
|
||||
}
|
||||
|
||||
func testStopRemovesOrphanedConnectionTokenFiles() throws {
|
||||
let tokenFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: tokenFileURL) }
|
||||
try Data("token".utf8).write(to: tokenFileURL)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: tokenFileURL.path))
|
||||
|
||||
let controller = VSCodeServeWebController.makeForTesting { _, _ in
|
||||
XCTFail("Expected no launch")
|
||||
return nil
|
||||
}
|
||||
controller.trackConnectionTokenFileForTesting(tokenFileURL)
|
||||
|
||||
controller.stop()
|
||||
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: tokenFileURL.path))
|
||||
}
|
||||
}
|
||||
|
||||
final class BrowserSearchEngineTests: XCTestCase {
|
||||
|
|
@ -7544,6 +7871,24 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase {
|
|||
return event
|
||||
}
|
||||
|
||||
private func makeKeyEvent(characters: String, keyCode: UInt16, window: NSWindow) -> NSEvent {
|
||||
guard let event = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: [],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
characters: characters,
|
||||
charactersIgnoringModifiers: characters,
|
||||
isARepeat: false,
|
||||
keyCode: keyCode
|
||||
) else {
|
||||
fatalError("Failed to create key event")
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
private func surfaceView(in hostedView: GhosttySurfaceScrollView) -> NSView? {
|
||||
hostedView.subviews
|
||||
.compactMap { $0 as? NSScrollView }
|
||||
|
|
@ -7624,6 +7969,76 @@ final class TerminalNotificationDirectInteractionTests: XCTestCase {
|
|||
XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
|
||||
XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1)
|
||||
}
|
||||
|
||||
func testTerminalKeyDownDismissesUnreadWhenSurfaceIsAlreadyFirstResponder() {
|
||||
let appDelegate = AppDelegate.shared ?? AppDelegate()
|
||||
let manager = TabManager()
|
||||
let store = TerminalNotificationStore.shared
|
||||
let window = makeWindow()
|
||||
|
||||
let originalTabManager = appDelegate.tabManager
|
||||
let originalNotificationStore = appDelegate.notificationStore
|
||||
let originalAppFocusOverride = AppFocusState.overrideIsFocused
|
||||
|
||||
store.replaceNotificationsForTesting([])
|
||||
store.configureNotificationDeliveryHandlerForTesting { _, _ in }
|
||||
appDelegate.tabManager = manager
|
||||
appDelegate.notificationStore = store
|
||||
|
||||
defer {
|
||||
store.replaceNotificationsForTesting([])
|
||||
store.resetNotificationDeliveryHandlerForTesting()
|
||||
appDelegate.tabManager = originalTabManager
|
||||
appDelegate.notificationStore = originalNotificationStore
|
||||
AppFocusState.overrideIsFocused = originalAppFocusOverride
|
||||
window.orderOut(nil)
|
||||
}
|
||||
|
||||
guard let workspace = manager.selectedWorkspace,
|
||||
let terminalPanel = workspace.focusedTerminalPanel else {
|
||||
XCTFail("Expected an initial focused terminal panel")
|
||||
return
|
||||
}
|
||||
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let hostedView = terminalPanel.hostedView
|
||||
hostedView.frame = contentView.bounds
|
||||
hostedView.autoresizingMask = [.width, .height]
|
||||
contentView.addSubview(hostedView)
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
hostedView.layoutSubtreeIfNeeded()
|
||||
|
||||
guard let surfaceView = surfaceView(in: hostedView) as? GhosttyNSView else {
|
||||
XCTFail("Expected terminal surface view")
|
||||
return
|
||||
}
|
||||
|
||||
GhosttySurfaceScrollView.resetFlashCounts()
|
||||
AppFocusState.overrideIsFocused = true
|
||||
XCTAssertTrue(window.makeFirstResponder(surfaceView))
|
||||
|
||||
store.addNotification(
|
||||
tabId: workspace.id,
|
||||
surfaceId: terminalPanel.id,
|
||||
title: "Unread",
|
||||
subtitle: "",
|
||||
body: ""
|
||||
)
|
||||
XCTAssertTrue(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
|
||||
|
||||
let event = makeKeyEvent(characters: "", keyCode: 122, window: window)
|
||||
surfaceView.keyDown(with: event)
|
||||
let drained = expectation(description: "flash drained")
|
||||
DispatchQueue.main.async { drained.fulfill() }
|
||||
wait(for: [drained], timeout: 1.0)
|
||||
|
||||
XCTAssertFalse(store.hasUnreadNotification(forTabId: workspace.id, surfaceId: terminalPanel.id))
|
||||
XCTAssertEqual(GhosttySurfaceScrollView.flashCount(for: terminalPanel.id), 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -8102,6 +8517,14 @@ final class WindowBrowserHostViewTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
private final class TrailingEdgeTransparentWKInspectorProbeView: NSView {
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
let localPoint = convert(point, from: superview)
|
||||
guard bounds.contains(localPoint) else { return nil }
|
||||
return localPoint.x >= bounds.maxX - 12 ? nil : self
|
||||
}
|
||||
}
|
||||
|
||||
private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {}
|
||||
|
||||
private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent {
|
||||
|
|
@ -8191,6 +8614,60 @@ final class WindowBrowserHostViewTests: XCTestCase {
|
|||
XCTAssertTrue(host.hitTest(contentPointInHost) === child)
|
||||
}
|
||||
|
||||
func testWindowBrowserPortalIgnoresHostedInspectorSplitResizeNotifications() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
defer { window.orderOut(nil) }
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
guard let container = contentView.superview else {
|
||||
XCTFail("Expected content container")
|
||||
return
|
||||
}
|
||||
|
||||
let hostFrame = container.convert(contentView.bounds, from: contentView)
|
||||
let host = WindowBrowserHostView(frame: hostFrame)
|
||||
host.autoresizingMask = [.width, .height]
|
||||
container.addSubview(host, positioned: .above, relativeTo: contentView)
|
||||
|
||||
let appSplit = NSSplitView(frame: contentView.bounds)
|
||||
appSplit.autoresizingMask = [.width, .height]
|
||||
appSplit.isVertical = true
|
||||
appSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height)))
|
||||
appSplit.addSubview(NSView(frame: NSRect(x: 121, y: 0, width: 299, height: contentView.bounds.height)))
|
||||
contentView.addSubview(appSplit)
|
||||
|
||||
let inspectorSplit = NSSplitView(frame: host.bounds)
|
||||
inspectorSplit.autoresizingMask = [.width, .height]
|
||||
inspectorSplit.isVertical = true
|
||||
inspectorSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height)))
|
||||
inspectorSplit.addSubview(NSView(frame: NSRect(x: 121, y: 0, width: 299, height: host.bounds.height)))
|
||||
host.addSubview(inspectorSplit)
|
||||
|
||||
XCTAssertTrue(
|
||||
WindowBrowserPortal.shouldTreatSplitResizeAsExternalGeometry(
|
||||
appSplit,
|
||||
window: window,
|
||||
hostView: host
|
||||
),
|
||||
"App layout splits should still trigger browser portal geometry sync"
|
||||
)
|
||||
XCTAssertFalse(
|
||||
WindowBrowserPortal.shouldTreatSplitResizeAsExternalGeometry(
|
||||
inspectorSplit,
|
||||
window: window,
|
||||
hostView: host
|
||||
),
|
||||
"Hosted DevTools/internal splits should not trigger browser portal geometry sync"
|
||||
)
|
||||
}
|
||||
|
||||
func testDragHoverEventsPassThroughForTabTransferOnBrowserHoverEvents() {
|
||||
XCTAssertTrue(
|
||||
WindowBrowserHostView.shouldPassThroughToDragTargets(
|
||||
|
|
@ -8624,6 +9101,65 @@ final class WindowBrowserHostViewTests: XCTestCase {
|
|||
XCTAssertGreaterThan(inspectorView.frame.minX, 92)
|
||||
}
|
||||
|
||||
func testHostViewFallsBackToManualHostedInspectorDragForLeftDockedInspector() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
defer { window.orderOut(nil) }
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
guard let container = contentView.superview else {
|
||||
XCTFail("Expected content container")
|
||||
return
|
||||
}
|
||||
|
||||
let hostFrame = container.convert(contentView.bounds, from: contentView)
|
||||
let host = WindowBrowserHostView(frame: hostFrame)
|
||||
host.autoresizingMask = [.width, .height]
|
||||
container.addSubview(host, positioned: .above, relativeTo: contentView)
|
||||
|
||||
let slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
|
||||
slot.autoresizingMask = [.minXMargin, .height]
|
||||
host.addSubview(slot)
|
||||
|
||||
let inspectorView = TrailingEdgeTransparentWKInspectorProbeView(
|
||||
frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height)
|
||||
)
|
||||
let pageView = PrimaryPageProbeView(
|
||||
frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height)
|
||||
)
|
||||
slot.addSubview(inspectorView)
|
||||
slot.addSubview(pageView)
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
|
||||
let dividerPointInSlot = NSPoint(x: inspectorView.frame.maxX - 2, y: slot.bounds.midY)
|
||||
let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
|
||||
let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
|
||||
|
||||
XCTAssertTrue(
|
||||
host.hitTest(dividerPointInHost) === host,
|
||||
"Host should take the manual fallback path for a left-docked divider when the native edge is not hittable"
|
||||
)
|
||||
|
||||
let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
|
||||
host.mouseDown(with: down)
|
||||
let drag = makeMouseEvent(
|
||||
type: .leftMouseDragged,
|
||||
location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y),
|
||||
window: window
|
||||
)
|
||||
host.mouseDragged(with: drag)
|
||||
host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
|
||||
|
||||
XCTAssertGreaterThan(inspectorView.frame.width, 92)
|
||||
XCTAssertGreaterThan(pageView.frame.minX, 92)
|
||||
}
|
||||
|
||||
func testHostViewClaimsCollapsedHostedInspectorSiblingDividerAtSlotLeadingEdge() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
||||
|
|
@ -8691,6 +9227,14 @@ final class BrowserPanelHostContainerViewTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
private final class TrailingEdgeTransparentWKInspectorProbeView: NSView {
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
let localPoint = convert(point, from: superview)
|
||||
guard bounds.contains(localPoint) else { return nil }
|
||||
return localPoint.x >= bounds.maxX - 12 ? nil : self
|
||||
}
|
||||
}
|
||||
|
||||
private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent {
|
||||
guard let event = NSEvent.mouseEvent(
|
||||
with: type,
|
||||
|
|
@ -8857,6 +9401,59 @@ final class BrowserPanelHostContainerViewTests: XCTestCase {
|
|||
XCTAssertGreaterThan(inspectorContainer.frame.minX, 92)
|
||||
}
|
||||
|
||||
func testBrowserPanelHostFallsBackToManualHostedInspectorDragForLeftDockedInspector() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
defer { window.orderOut(nil) }
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
|
||||
host.autoresizingMask = [.minXMargin, .height]
|
||||
contentView.addSubview(host)
|
||||
|
||||
let webViewRoot = NSView(frame: host.bounds)
|
||||
webViewRoot.autoresizingMask = [.width, .height]
|
||||
host.addSubview(webViewRoot)
|
||||
|
||||
let inspectorContainer = TrailingEdgeTransparentWKInspectorProbeView(
|
||||
frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)
|
||||
)
|
||||
let pageView = PrimaryPageProbeView(
|
||||
frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height)
|
||||
)
|
||||
webViewRoot.addSubview(inspectorContainer)
|
||||
webViewRoot.addSubview(pageView)
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
|
||||
let dividerPointInHost = NSPoint(x: inspectorContainer.frame.maxX - 2, y: host.bounds.midY)
|
||||
let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
|
||||
|
||||
XCTAssertTrue(
|
||||
host.hitTest(dividerPointInHost) === host,
|
||||
"Browser panel host should take the manual fallback path for a left-docked divider when the native edge is not hittable"
|
||||
)
|
||||
|
||||
let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
|
||||
host.mouseDown(with: down)
|
||||
let drag = makeMouseEvent(
|
||||
type: .leftMouseDragged,
|
||||
location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y),
|
||||
window: window
|
||||
)
|
||||
host.mouseDragged(with: drag)
|
||||
host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
|
||||
|
||||
XCTAssertGreaterThan(inspectorContainer.frame.width, 92)
|
||||
XCTAssertGreaterThan(pageView.frame.minX, 92)
|
||||
}
|
||||
|
||||
func testBrowserPanelHostReappliesStoredHostedInspectorWidthAfterLayoutReset() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
||||
|
|
@ -8922,6 +9519,47 @@ final class BrowserPanelHostContainerViewTests: XCTestCase {
|
|||
XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5)
|
||||
XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5)
|
||||
}
|
||||
|
||||
func testWindowBrowserSlotPinsHostedWebViewWithAutoresizingForAttachedInspector() {
|
||||
let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 240, height: 180))
|
||||
let webView = WKWebView(frame: .zero)
|
||||
slot.addSubview(webView)
|
||||
|
||||
slot.pinHostedWebView(webView)
|
||||
slot.frame = NSRect(x: 0, y: 0, width: 300, height: 220)
|
||||
slot.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertTrue(webView.translatesAutoresizingMaskIntoConstraints)
|
||||
XCTAssertEqual(webView.autoresizingMask, [.width, .height])
|
||||
XCTAssertEqual(webView.frame, slot.bounds)
|
||||
}
|
||||
|
||||
func testWindowBrowserSlotReattachesPlainWebViewAtFullBoundsAfterHiddenHostResize() {
|
||||
let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 400, height: 180))
|
||||
let webView = WKWebView(frame: .zero)
|
||||
slot.addSubview(webView)
|
||||
slot.pinHostedWebView(webView)
|
||||
XCTAssertEqual(webView.frame, slot.bounds)
|
||||
|
||||
let externalHost = NSView(frame: NSRect(x: 0, y: 0, width: 300, height: 180))
|
||||
webView.removeFromSuperview()
|
||||
externalHost.addSubview(webView)
|
||||
webView.frame = externalHost.bounds
|
||||
webView.translatesAutoresizingMaskIntoConstraints = true
|
||||
webView.autoresizingMask = [.width, .height]
|
||||
|
||||
slot.addSubview(webView)
|
||||
slot.pinHostedWebView(webView)
|
||||
|
||||
slot.frame = NSRect(x: 0, y: 0, width: 300, height: 180)
|
||||
slot.layoutSubtreeIfNeeded()
|
||||
|
||||
XCTAssertEqual(
|
||||
webView.frame,
|
||||
slot.bounds,
|
||||
"Reattaching a plain web view should restore full-bounds hosting instead of preserving a stale inset frame from a hidden host"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -8940,6 +9578,76 @@ final class CmuxWebViewDragRoutingTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
#if compiler(>=6.2)
|
||||
@available(macOS 26.0, *)
|
||||
private struct DragConfigurationOperationsSnapshot: Equatable {
|
||||
let allowCopy: Bool
|
||||
let allowMove: Bool
|
||||
let allowDelete: Bool
|
||||
let allowAlias: Bool
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
private enum DragConfigurationSnapshotError: Error {
|
||||
case missingBoolField(primary: String, fallback: String?)
|
||||
}
|
||||
|
||||
@available(macOS 26.0, *)
|
||||
private func dragConfigurationOperationsSnapshot<T>(from operations: T) throws -> DragConfigurationOperationsSnapshot {
|
||||
let mirror = Mirror(reflecting: operations)
|
||||
|
||||
func readBool(_ primary: String, fallback: String? = nil) throws -> Bool {
|
||||
if let value = mirror.descendant(primary) as? Bool {
|
||||
return value
|
||||
}
|
||||
if let fallback, let value = mirror.descendant(fallback) as? Bool {
|
||||
return value
|
||||
}
|
||||
throw DragConfigurationSnapshotError.missingBoolField(primary: primary, fallback: fallback)
|
||||
}
|
||||
|
||||
return try DragConfigurationOperationsSnapshot(
|
||||
allowCopy: readBool("allowCopy", fallback: "_allowCopy"),
|
||||
allowMove: readBool("allowMove", fallback: "_allowMove"),
|
||||
allowDelete: readBool("allowDelete", fallback: "_allowDelete"),
|
||||
allowAlias: readBool("allowAlias", fallback: "_allowAlias")
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class InternalTabDragConfigurationTests: XCTestCase {
|
||||
func testDisablesExternalOperationsForInternalTabDrags() throws {
|
||||
guard #available(macOS 26.0, *) else {
|
||||
throw XCTSkip("Requires macOS 26 drag configuration APIs")
|
||||
}
|
||||
|
||||
let configuration = InternalTabDragConfigurationProvider.value
|
||||
let withinApp = try dragConfigurationOperationsSnapshot(from: configuration.operationsWithinApp)
|
||||
let outsideApp = try dragConfigurationOperationsSnapshot(from: configuration.operationsOutsideApp)
|
||||
|
||||
XCTAssertEqual(
|
||||
withinApp,
|
||||
DragConfigurationOperationsSnapshot(
|
||||
allowCopy: false,
|
||||
allowMove: true,
|
||||
allowDelete: false,
|
||||
allowAlias: false
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
outsideApp,
|
||||
DragConfigurationOperationsSnapshot(
|
||||
allowCopy: false,
|
||||
allowMove: false,
|
||||
allowDelete: false,
|
||||
allowAlias: false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class BrowserPaneDropRoutingTests: XCTestCase {
|
||||
func testVerticalZonesFollowAppKitCoordinates() {
|
||||
|
|
@ -10025,6 +10733,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testKeyboardCopyModeIndicatorMountsAndUnmounts() {
|
||||
let surface = TerminalSurface(
|
||||
tabId: UUID(),
|
||||
|
|
@ -10035,10 +10744,10 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
|
|||
let hostedView = surface.hostedView
|
||||
XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator())
|
||||
|
||||
hostedView.setKeyboardCopyModeIndicator(visible: true)
|
||||
hostedView.syncKeyStateIndicator(text: "vim")
|
||||
XCTAssertTrue(hostedView.debugHasKeyboardCopyModeIndicator())
|
||||
|
||||
hostedView.setKeyboardCopyModeIndicator(visible: false)
|
||||
hostedView.syncKeyStateIndicator(text: nil)
|
||||
XCTAssertFalse(hostedView.debugHasKeyboardCopyModeIndicator())
|
||||
}
|
||||
|
||||
|
|
@ -10538,6 +11247,8 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
private final class WKInspectorProbeView: NSView {}
|
||||
|
||||
private func realizeWindowLayout(_ window: NSWindow) {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.displayIfNeeded()
|
||||
|
|
@ -10802,6 +11513,145 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
|
|||
XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5)
|
||||
}
|
||||
|
||||
func testPortalResizePreservesSideDockedInspectorManagedWebViewFrame() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
defer { window.orderOut(nil) }
|
||||
realizeWindowLayout(window)
|
||||
let portal = WindowBrowserPortal(window: window)
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180))
|
||||
contentView.addSubview(anchor)
|
||||
|
||||
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
||||
portal.bind(webView: webView, to: anchor, visibleInUI: true)
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
portal.synchronizeWebViewForAnchor(anchor)
|
||||
|
||||
guard let slot = webView.superview as? WindowBrowserSlotView else {
|
||||
XCTFail("Expected browser slot")
|
||||
return
|
||||
}
|
||||
|
||||
let initialInspectorWidth: CGFloat = 110
|
||||
let inspectorContainer = NSView(
|
||||
frame: NSRect(
|
||||
x: slot.bounds.width - initialInspectorWidth,
|
||||
y: 0,
|
||||
width: initialInspectorWidth,
|
||||
height: slot.bounds.height
|
||||
)
|
||||
)
|
||||
inspectorContainer.autoresizingMask = [.minXMargin, .height]
|
||||
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
|
||||
inspectorView.autoresizingMask = [.width, .height]
|
||||
inspectorContainer.addSubview(inspectorView)
|
||||
slot.addSubview(inspectorContainer)
|
||||
|
||||
webView.frame = NSRect(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: slot.bounds.width - initialInspectorWidth,
|
||||
height: slot.bounds.height
|
||||
)
|
||||
webView.autoresizingMask = [.width, .height]
|
||||
slot.layoutSubtreeIfNeeded()
|
||||
|
||||
anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180)
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
portal.synchronizeWebViewForAnchor(anchor)
|
||||
|
||||
XCTAssertFalse(slot.isHidden, "Resizing the browser pane should keep the hosted browser visible")
|
||||
XCTAssertEqual(
|
||||
webView.frame.maxX,
|
||||
inspectorContainer.frame.minX,
|
||||
accuracy: 0.5,
|
||||
"Portal sync should preserve the side-docked inspector split instead of stretching the page back over the inspector"
|
||||
)
|
||||
XCTAssertLessThan(
|
||||
webView.frame.width,
|
||||
slot.bounds.width,
|
||||
"Side-docked inspector should still own part of the slot after pane resize"
|
||||
)
|
||||
}
|
||||
|
||||
func testHiddenPortalSyncDoesNotStealLocallyHostedDevToolsWebViewDuringResize() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
defer { window.orderOut(nil) }
|
||||
realizeWindowLayout(window)
|
||||
let portal = WindowBrowserPortal(window: window)
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 260, height: 180))
|
||||
contentView.addSubview(anchor)
|
||||
|
||||
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
|
||||
portal.bind(webView: webView, to: anchor, visibleInUI: true)
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
portal.synchronizeWebViewForAnchor(anchor)
|
||||
advanceAnimations()
|
||||
|
||||
guard let hiddenPortalSlot = webView.superview as? WindowBrowserSlotView else {
|
||||
XCTFail("Expected browser slot")
|
||||
return
|
||||
}
|
||||
|
||||
portal.updateEntryVisibility(forWebViewId: ObjectIdentifier(webView), visibleInUI: false, zPriority: 0)
|
||||
portal.synchronizeWebViewForAnchor(anchor)
|
||||
advanceAnimations()
|
||||
XCTAssertTrue(hiddenPortalSlot.isHidden, "Hidden portal entry should keep its slot hidden")
|
||||
|
||||
let localInlineSlot = WindowBrowserSlotView(frame: anchor.frame)
|
||||
contentView.addSubview(localInlineSlot)
|
||||
|
||||
let inspectorView = WKInspectorProbeView(
|
||||
frame: NSRect(x: 0, y: 0, width: localInlineSlot.bounds.width, height: 72)
|
||||
)
|
||||
inspectorView.autoresizingMask = [.width]
|
||||
localInlineSlot.addSubview(inspectorView)
|
||||
|
||||
localInlineSlot.addSubview(webView)
|
||||
webView.frame = NSRect(
|
||||
x: 0,
|
||||
y: inspectorView.frame.maxY,
|
||||
width: localInlineSlot.bounds.width,
|
||||
height: localInlineSlot.bounds.height - inspectorView.frame.height
|
||||
)
|
||||
localInlineSlot.layoutSubtreeIfNeeded()
|
||||
|
||||
anchor.frame = NSRect(x: 40, y: 24, width: 220, height: 180)
|
||||
localInlineSlot.frame = anchor.frame
|
||||
contentView.layoutSubtreeIfNeeded()
|
||||
localInlineSlot.layoutSubtreeIfNeeded()
|
||||
portal.synchronizeWebViewForAnchor(anchor)
|
||||
|
||||
XCTAssertTrue(
|
||||
webView.superview === localInlineSlot,
|
||||
"Hidden portal sync should not steal a DevTools-hosted web view back out of local inline hosting during pane resize"
|
||||
)
|
||||
XCTAssertTrue(
|
||||
inspectorView.superview === localInlineSlot,
|
||||
"Hidden portal sync should leave local DevTools companion views in the local inline host"
|
||||
)
|
||||
XCTAssertTrue(hiddenPortalSlot.isHidden, "The retiring hidden portal slot should stay hidden during local inline hosting")
|
||||
}
|
||||
|
||||
func testPortalHostBoundsBecomeReadyAfterBindingInFrameDrivenHierarchy() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
|
||||
|
|
|
|||
|
|
@ -1459,3 +1459,83 @@ final class GhosttyMouseFocusTests: XCTestCase {
|
|||
XCTAssertFalse(GhosttyApp.userConfigContainsCJKCodepointMap(configPaths: [fileA.path]))
|
||||
}
|
||||
}
|
||||
|
||||
final class ZshShellIntegrationHandoffTests: XCTestCase {
|
||||
func testGhosttyPromptHooksLoadWhenCmuxRequestsZshIntegration() throws {
|
||||
let output = try runInteractiveZsh(cmuxLoadGhosttyIntegration: true)
|
||||
|
||||
XCTAssertTrue(output.contains("PRECMD=1"), output)
|
||||
XCTAssertTrue(output.contains("PREEXEC=1"), output)
|
||||
XCTAssertTrue(output.contains("PRECMDS=_ghostty_precmd"), output)
|
||||
}
|
||||
|
||||
func testGhosttyPromptHooksDoNotLoadWithoutCmuxHandoffFlag() throws {
|
||||
let output = try runInteractiveZsh(cmuxLoadGhosttyIntegration: false)
|
||||
|
||||
XCTAssertTrue(output.contains("PRECMD=0"), output)
|
||||
XCTAssertTrue(output.contains("PREEXEC=0"), output)
|
||||
}
|
||||
|
||||
private func runInteractiveZsh(cmuxLoadGhosttyIntegration: Bool) throws -> String {
|
||||
let fileManager = FileManager.default
|
||||
let root = fileManager.temporaryDirectory
|
||||
.appendingPathComponent("cmux-zsh-shell-integration-\(UUID().uuidString)")
|
||||
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? fileManager.removeItem(at: root) }
|
||||
|
||||
let userZdotdir = root.appendingPathComponent("zdotdir")
|
||||
try fileManager.createDirectory(at: userZdotdir, withIntermediateDirectories: true)
|
||||
try "\n".write(to: userZdotdir.appendingPathComponent(".zshenv"), atomically: true, encoding: .utf8)
|
||||
|
||||
let repoRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let cmuxZdotdir = repoRoot.appendingPathComponent("Resources/shell-integration")
|
||||
let ghosttyResources = repoRoot.appendingPathComponent("ghostty/src")
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||
process.arguments = [
|
||||
"-i",
|
||||
"-c",
|
||||
"(( $+functions[_ghostty_deferred_init] )) && _ghostty_deferred_init >/dev/null 2>&1; " +
|
||||
"print -r -- \"PRECMD=${+functions[_ghostty_precmd]} " +
|
||||
"PREEXEC=${+functions[_ghostty_preexec]} PRECMDS=${(j:,:)precmd_functions}\""
|
||||
]
|
||||
process.environment = [
|
||||
"HOME": root.path,
|
||||
"TERM": "xterm-256color",
|
||||
"SHELL": "/bin/zsh",
|
||||
"USER": NSUserName(),
|
||||
"ZDOTDIR": cmuxZdotdir.path,
|
||||
"CMUX_ZSH_ZDOTDIR": userZdotdir.path,
|
||||
"CMUX_SHELL_INTEGRATION": "0",
|
||||
"GHOSTTY_RESOURCES_DIR": ghosttyResources.path,
|
||||
]
|
||||
if cmuxLoadGhosttyIntegration {
|
||||
process.environment?["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1"
|
||||
}
|
||||
|
||||
let stdout = Pipe()
|
||||
let stderr = Pipe()
|
||||
process.standardOutput = stdout
|
||||
process.standardError = stderr
|
||||
|
||||
try process.run()
|
||||
let deadline = Date().addingTimeInterval(5)
|
||||
while process.isRunning && Date() < deadline {
|
||||
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
|
||||
}
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
process.waitUntilExit()
|
||||
XCTFail("Timed out waiting for zsh to exit")
|
||||
}
|
||||
|
||||
let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
let error = String(data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
|
||||
XCTAssertEqual(process.terminationStatus, 0, error)
|
||||
return output.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -554,6 +554,150 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
)
|
||||
}
|
||||
|
||||
func testCmdShiftEnterKeepsBrowserOmnibarHittableAcrossZoomRoundTripWhenWebViewFocused() {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForData(keys: ["browserPanelId", "webViewFocused"], timeout: 10.0),
|
||||
"Expected goto_split setup data to be written"
|
||||
)
|
||||
|
||||
guard let setup = loadData() else {
|
||||
XCTFail("Missing goto_split setup data")
|
||||
return
|
||||
}
|
||||
|
||||
guard let browserPanelId = setup["browserPanelId"] else {
|
||||
XCTFail("Missing browserPanelId in goto_split setup data")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(setup["webViewFocused"], "true", "Expected WKWebView to be first responder for this test")
|
||||
|
||||
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
|
||||
let pill = app.descendants(matching: .any).matching(identifier: "BrowserOmnibarPill").firstMatch
|
||||
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0), "Expected browser omnibar text field before zoom")
|
||||
XCTAssertTrue(pill.waitForExistence(timeout: 6.0), "Expected browser omnibar pill before zoom")
|
||||
|
||||
// Reproduce the loaded-page state from the bug report before toggling zoom.
|
||||
app.typeKey("l", modifierFlags: [.command])
|
||||
XCTAssertTrue(waitForElementToBecomeHittable(pill, timeout: 6.0), "Expected browser omnibar pill before navigation")
|
||||
pill.click()
|
||||
app.typeKey("a", modifierFlags: [.command])
|
||||
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
|
||||
app.typeText(zoomRoundTripPageURL)
|
||||
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForOmnibarToContain(omnibar, value: "data:text/html", timeout: 8.0),
|
||||
"Expected browser to finish navigating to the regression page before zoom. value=\(String(describing: omnibar.value))"
|
||||
)
|
||||
|
||||
let browserPane = app.otherElements["BrowserPanelContent.\(browserPanelId)"].firstMatch
|
||||
XCTAssertTrue(browserPane.waitForExistence(timeout: 6.0), "Expected browser pane content before zoom")
|
||||
browserPane.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).click()
|
||||
|
||||
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift])
|
||||
XCTAssertTrue(
|
||||
waitForDataMatch(timeout: 8.0) { data in
|
||||
data["splitZoomedAfterToggle"] == "true" &&
|
||||
data["otherTerminalHostHiddenAfterToggle"] == "true" &&
|
||||
data["otherTerminalVisibleFlagAfterToggle"] == "false"
|
||||
},
|
||||
"Expected Cmd+Shift+Enter zoom-in to hide the non-browser terminal portal. data=\(loadData() ?? [:])"
|
||||
)
|
||||
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift])
|
||||
XCTAssertTrue(
|
||||
waitForDataMatch(timeout: 8.0) { data in
|
||||
data["splitZoomedAfterToggle"] == "false" &&
|
||||
data["otherTerminalHostHiddenAfterToggle"] == "false" &&
|
||||
data["otherTerminalVisibleFlagAfterToggle"] == "true"
|
||||
},
|
||||
"Expected Cmd+Shift+Enter zoom-out to restore the non-browser terminal portal. data=\(loadData() ?? [:])"
|
||||
)
|
||||
|
||||
XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0), "Expected browser omnibar text field after Cmd+Shift+Enter zoom round-trip")
|
||||
XCTAssertTrue(pill.waitForExistence(timeout: 6.0), "Expected browser omnibar pill after Cmd+Shift+Enter zoom round-trip")
|
||||
XCTAssertTrue(
|
||||
waitForElementToBecomeHittable(pill, timeout: 6.0),
|
||||
"Expected browser omnibar to stay hittable after Cmd+Shift+Enter zoom round-trip"
|
||||
)
|
||||
let page = app.webViews.firstMatch
|
||||
XCTAssertTrue(page.waitForExistence(timeout: 6.0), "Expected browser web area after Cmd+Shift+Enter")
|
||||
XCTAssertLessThanOrEqual(
|
||||
pill.frame.maxY,
|
||||
page.frame.minY + 12,
|
||||
"Expected browser omnibar to remain above the web content after Cmd+Shift+Enter. pill=\(pill.frame) page=\(page.frame)"
|
||||
)
|
||||
|
||||
pill.click()
|
||||
app.typeKey("a", modifierFlags: [.command])
|
||||
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
|
||||
app.typeText("issue1144")
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForOmnibarToContain(omnibar, value: "issue1144", timeout: 4.0),
|
||||
"Expected browser omnibar to stay editable after Cmd+Shift+Enter. value=\(String(describing: omnibar.value))"
|
||||
)
|
||||
}
|
||||
|
||||
func testCmdShiftEnterHidesBrowserPortalWhenTerminalPaneZooms() {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1"
|
||||
launchAndEnsureForeground(app)
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForData(keys: ["terminalPaneId", "browserPanelId", "webViewFocused"], timeout: 10.0),
|
||||
"Expected goto_split setup data to be written"
|
||||
)
|
||||
|
||||
guard let setup = loadData() else {
|
||||
XCTFail("Missing goto_split setup data")
|
||||
return
|
||||
}
|
||||
|
||||
guard let expectedTerminalPaneId = setup["terminalPaneId"] else {
|
||||
XCTFail("Missing terminalPaneId in goto_split setup data")
|
||||
return
|
||||
}
|
||||
|
||||
app.typeKey("h", modifierFlags: [.command, .control])
|
||||
|
||||
XCTAssertTrue(
|
||||
waitForDataMatch(timeout: 5.0) { data in
|
||||
data["focusedPaneId"] == expectedTerminalPaneId && data["focusedPanelKind"] == "terminal"
|
||||
},
|
||||
"Expected Cmd+Ctrl+H to focus the terminal pane before zoom. data=\(loadData() ?? [:])"
|
||||
)
|
||||
|
||||
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift])
|
||||
XCTAssertTrue(
|
||||
waitForDataMatch(timeout: 8.0) { data in
|
||||
data["splitZoomedAfterToggle"] == "true" &&
|
||||
data["browserContainerHiddenAfterToggle"] == "true" &&
|
||||
data["browserVisibleFlagAfterToggle"] == "false"
|
||||
},
|
||||
"Expected Cmd+Shift+Enter zoom-in on the terminal pane to hide the browser portal. data=\(loadData() ?? [:])"
|
||||
)
|
||||
|
||||
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift])
|
||||
XCTAssertTrue(
|
||||
waitForDataMatch(timeout: 8.0) { data in
|
||||
data["splitZoomedAfterToggle"] == "false" &&
|
||||
data["browserContainerHiddenAfterToggle"] == "false" &&
|
||||
data["browserVisibleFlagAfterToggle"] == "true"
|
||||
},
|
||||
"Expected Cmd+Shift+Enter zoom-out from the terminal pane to restore the browser portal. data=\(loadData() ?? [:])"
|
||||
)
|
||||
}
|
||||
|
||||
func testCmdDSplitsRightWhenOmnibarFocused() {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
|
||||
|
|
@ -806,10 +950,25 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
|
|||
return value.contains(expectedSubstring)
|
||||
}
|
||||
|
||||
private func waitForElementToBecomeHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if element.exists && element.isHittable {
|
||||
return true
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
||||
}
|
||||
return element.exists && element.isHittable
|
||||
}
|
||||
|
||||
private var autofocusRacePageURL: String {
|
||||
"data:text/html,%3Cinput%20id%3D%22q%22%3E%3Cscript%3EsetTimeout%28function%28%29%7Bdocument.getElementById%28%22q%22%29.focus%28%29%3Blocation.hash%3D%22focused%22%3B%7D%2C700%29%3B%3C%2Fscript%3E"
|
||||
}
|
||||
|
||||
private var zoomRoundTripPageURL: String {
|
||||
"data:text/html,%3Ctitle%3EIssue%201144%3C/title%3E%3Cbody%20style%3D%22margin:0;background:%231d1f24;color:white;font-family:system-ui;height:2200px%22%3E%3Cmain%20style%3D%22padding:32px%22%3E%3Ch1%3EIssue%201144%20Regression%20Page%3C/h1%3E%3Cp%3EZoom%20should%20not%20leave%20stale%20split%20chrome%20above%20the%20browser%20omnibar.%3C/p%3E%3C/main%3E%3C/body%3E"
|
||||
}
|
||||
|
||||
private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) {
|
||||
app.launch()
|
||||
XCTAssertTrue(
|
||||
|
|
|
|||
|
|
@ -147,6 +147,31 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
XCTAssertTrue(waitForElementToDisappear(targetButton, timeout: 3.0), "Expected popover to close on Escape")
|
||||
}
|
||||
|
||||
func testNotificationsPopoverJumpToLatestButtonShowsShortcut() {
|
||||
let app = XCUIApplication()
|
||||
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_SETUP"] = "1"
|
||||
app.launchEnvironment["CMUX_UI_TEST_MULTI_WINDOW_NOTIF_PATH"] = dataPath
|
||||
app.launchEnvironment["CMUX_TAG"] = launchTag
|
||||
app.launch()
|
||||
XCTAssertTrue(
|
||||
ensureForegroundAfterLaunch(app, timeout: 12.0),
|
||||
"Expected app to launch for jump-to-latest popover test. state=\(app.state.rawValue)"
|
||||
)
|
||||
|
||||
XCTAssertTrue(waitForData(keys: ["notifId1"], timeout: 15.0), "Expected multi-window notification setup data")
|
||||
XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 6.0))
|
||||
|
||||
app.typeKey("i", modifierFlags: [.command])
|
||||
|
||||
let jumpButton = app.buttons["notificationsPopover.jumpToLatest"]
|
||||
XCTAssertTrue(jumpButton.waitForExistence(timeout: 6.0), "Expected Jump to Latest button in notifications popover")
|
||||
let shortcutValue = jumpButton.value as? String
|
||||
XCTAssertNotNil(shortcutValue, "Expected Jump to Latest shortcut badge")
|
||||
XCTAssertTrue(shortcutValue?.contains("⌘") == true, "Expected Jump to Latest shortcut to include Command")
|
||||
XCTAssertTrue(shortcutValue?.contains("⇧") == true, "Expected Jump to Latest shortcut to include Shift")
|
||||
XCTAssertTrue(shortcutValue?.uppercased().contains("U") == true, "Expected Jump to Latest shortcut to include U")
|
||||
}
|
||||
|
||||
func testEmptyNotificationsPopoverBlocksTerminalTyping() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments += ["-socketControlMode", "allowAll"]
|
||||
|
|
@ -175,6 +200,12 @@ final class MultiWindowNotificationsUITests: XCTestCase {
|
|||
|
||||
app.typeKey("i", modifierFlags: [.command])
|
||||
XCTAssertTrue(app.staticTexts["No notifications yet"].waitForExistence(timeout: 6.0), "Expected empty notifications popover state")
|
||||
let jumpButton = app.buttons["notificationsPopover.jumpToLatest"]
|
||||
XCTAssertTrue(jumpButton.waitForExistence(timeout: 2.0), "Expected Jump to Latest button in empty notifications popover")
|
||||
XCTAssertFalse(jumpButton.isEnabled, "Expected Jump to Latest button to be disabled with no notifications")
|
||||
let clearAllButton = app.buttons["notificationsPopover.clearAll"]
|
||||
XCTAssertTrue(clearAllButton.waitForExistence(timeout: 2.0), "Expected Clear All button in empty notifications popover")
|
||||
XCTAssertFalse(clearAllButton.isEnabled, "Expected Clear All button to be disabled with no notifications")
|
||||
|
||||
let marker = "cmux_notif_block_\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8))"
|
||||
let before = readCurrentTerminalText() ?? ""
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ When we change the fork, update this document and the parent submodule SHA.
|
|||
|
||||
## Current fork changes
|
||||
|
||||
Fork rebased onto upstream `v1.3.0` plus newer `main` commits as of March 9, 2026.
|
||||
|
||||
### 1) OSC 99 (kitty) notification parser
|
||||
|
||||
- Commit: `4713b7e23` (Add OSC 99 notification parser)
|
||||
- Commit: `a2252e7a9` (Add OSC 99 notification parser)
|
||||
- Files:
|
||||
- `src/terminal/osc.zig`
|
||||
- `src/terminal/osc/parsers.zig`
|
||||
|
|
@ -24,13 +26,49 @@ When we change the fork, update this document and the parent submodule SHA.
|
|||
|
||||
### 2) macOS display link restart on display changes
|
||||
|
||||
- Commit: `7c2562cbe` (macos: restart display link after display ID change)
|
||||
- Commit: `c07e6c5a5` (macos: restart display link after display ID change)
|
||||
- Files:
|
||||
- `src/renderer/generic.zig`
|
||||
- Summary:
|
||||
- Restarts the CVDisplayLink when `setMacOSDisplayID` updates the current CGDisplay.
|
||||
- Prevents a rare state where vsync is "running" but no callbacks arrive, which can look like a frozen surface until focus/occlusion changes.
|
||||
|
||||
### 3) Keyboard copy mode selection C API
|
||||
|
||||
- Commit: `a50579bd5` (Add C API for keyboard copy mode selection)
|
||||
- Files:
|
||||
- `src/Surface.zig`
|
||||
- `src/apprt/embedded.zig`
|
||||
- Summary:
|
||||
- Restores `ghostty_surface_select_cursor_cell` and `ghostty_surface_clear_selection`.
|
||||
- Keeps cmux keyboard copy mode working against the refreshed Ghostty base.
|
||||
|
||||
### 4) macOS resize stale-frame mitigation
|
||||
|
||||
Sections 3 and 4 are grouped by feature, not by commit order. The fork branch HEAD is the
|
||||
section 3 copy-mode commit, even though the section 4 resize commits were applied earlier.
|
||||
|
||||
- Commits:
|
||||
- `769bbf7a9` (macos: reduce transient blank/scaled frames during resize)
|
||||
- `9efcdfdf8` (macos: keep top-left gravity for stale-frame replay)
|
||||
- Files:
|
||||
- `pkg/macos/animation.zig`
|
||||
- `src/Surface.zig`
|
||||
- `src/apprt/embedded.zig`
|
||||
- `src/renderer/Metal.zig`
|
||||
- `src/renderer/generic.zig`
|
||||
- `src/renderer/metal/IOSurfaceLayer.zig`
|
||||
- Summary:
|
||||
- Replays the last rendered frame during resize and keeps its geometry anchored correctly.
|
||||
- Reduces transient blank or scaled frames while a macOS window is being resized.
|
||||
|
||||
## Upstreamed fork changes
|
||||
|
||||
### cursor-click-to-move respects OSC 133 click-to-move
|
||||
|
||||
- Was local in the fork as `10a585754`.
|
||||
- Landed upstream as `bb646926f`, so it is no longer carried as a fork-only patch.
|
||||
|
||||
## Merge conflict notes
|
||||
|
||||
These files change frequently upstream; be careful when rebasing the fork:
|
||||
|
|
|
|||
2
ghostty
2
ghostty
|
|
@ -1 +1 @@
|
|||
Subproject commit dfd9daa6af6ab1ecb4325dc2c35b3d3554019f15
|
||||
Subproject commit c47010b80cd9ae6d1ab744c120f011a465521ea3
|
||||
|
|
@ -463,6 +463,12 @@ typedef struct {
|
|||
|
||||
// Config types
|
||||
|
||||
// config.Path
|
||||
typedef struct {
|
||||
const char* path;
|
||||
bool optional;
|
||||
} ghostty_config_path_s;
|
||||
|
||||
// config.Color
|
||||
typedef struct {
|
||||
uint8_t r;
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@
|
|||
# Update this file in a reviewed PR whenever the ghostty submodule SHA changes.
|
||||
# Format: <ghostty_sha> <sha256>
|
||||
7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207
|
||||
a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d
|
||||
|
|
|
|||
|
|
@ -124,6 +124,11 @@ sanitize_path() {
|
|||
echo "$cleaned"
|
||||
}
|
||||
|
||||
tagged_derived_data_path() {
|
||||
local slug="$1"
|
||||
echo "$HOME/Library/Developer/Xcode/DerivedData/cmux-${slug}"
|
||||
}
|
||||
|
||||
print_tag_cleanup_reminder() {
|
||||
local current_slug="$1"
|
||||
local path=""
|
||||
|
|
@ -132,7 +137,13 @@ print_tag_cleanup_reminder() {
|
|||
local -a stale_tags=()
|
||||
|
||||
while IFS= read -r -d '' path; do
|
||||
tag="${path#/tmp/cmux-}"
|
||||
if [[ "$path" == /tmp/cmux-* ]]; then
|
||||
tag="${path#/tmp/cmux-}"
|
||||
elif [[ "$path" == "$HOME/Library/Developer/Xcode/DerivedData/cmux-"* ]]; then
|
||||
tag="${path#$HOME/Library/Developer/Xcode/DerivedData/cmux-}"
|
||||
else
|
||||
continue
|
||||
fi
|
||||
if [[ "$tag" == "$current_slug" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
|
@ -145,7 +156,10 @@ print_tag_cleanup_reminder() {
|
|||
fi
|
||||
seen="${seen}${tag} "
|
||||
stale_tags+=("$tag")
|
||||
done < <(find /tmp -maxdepth 1 -type d -name 'cmux-*' -print0 2>/dev/null)
|
||||
done < <(
|
||||
find /tmp -maxdepth 1 -name 'cmux-*' -print0 2>/dev/null
|
||||
find "$HOME/Library/Developer/Xcode/DerivedData" -maxdepth 1 -type d -name 'cmux-*' -print0 2>/dev/null
|
||||
)
|
||||
|
||||
echo
|
||||
echo "Tag cleanup status:"
|
||||
|
|
@ -161,14 +175,14 @@ print_tag_cleanup_reminder() {
|
|||
echo "Cleanup stale tags only:"
|
||||
for tag in "${stale_tags[@]}"; do
|
||||
echo " pkill -f \"cmux DEV ${tag}.app/Contents/MacOS/cmux DEV\""
|
||||
echo " rm -rf \"/tmp/cmux-${tag}\" \"/tmp/cmux-debug-${tag}.sock\""
|
||||
echo " rm -rf \"$(tagged_derived_data_path "$tag")\" \"/tmp/cmux-${tag}\" \"/tmp/cmux-debug-${tag}.sock\""
|
||||
echo " rm -f \"/tmp/cmux-debug-${tag}.log\""
|
||||
echo " rm -f \"$HOME/Library/Application Support/cmux/cmuxd-dev-${tag}.sock\""
|
||||
done
|
||||
fi
|
||||
echo "After you verify current tag, cleanup command:"
|
||||
echo " pkill -f \"cmux DEV ${current_slug}.app/Contents/MacOS/cmux DEV\""
|
||||
echo " rm -rf \"/tmp/cmux-${current_slug}\" \"/tmp/cmux-debug-${current_slug}.sock\""
|
||||
echo " rm -rf \"$(tagged_derived_data_path "$current_slug")\" \"/tmp/cmux-${current_slug}\" \"/tmp/cmux-debug-${current_slug}.sock\""
|
||||
echo " rm -f \"/tmp/cmux-debug-${current_slug}.log\""
|
||||
echo " rm -f \"$HOME/Library/Application Support/cmux/cmuxd-dev-${current_slug}.sock\""
|
||||
}
|
||||
|
|
@ -238,7 +252,7 @@ if [[ -n "$TAG" ]]; then
|
|||
BUNDLE_ID="com.cmuxterm.app.debug.${TAG_ID}"
|
||||
fi
|
||||
if [[ "$DERIVED_SET" -eq 0 ]]; then
|
||||
DERIVED_DATA="/tmp/cmux-${TAG_SLUG}"
|
||||
DERIVED_DATA="$(tagged_derived_data_path "$TAG_SLUG")"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
|
@ -309,6 +323,15 @@ if [[ -z "${APP_PATH}" || ! -d "${APP_PATH}" ]]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "${TAG_SLUG:-}" ]]; then
|
||||
TMP_COMPAT_DERIVED_LINK="/tmp/cmux-${TAG_SLUG}"
|
||||
if [[ "$DERIVED_DATA" != "$TMP_COMPAT_DERIVED_LINK" ]]; then
|
||||
ABS_DERIVED_DATA="$(cd "$DERIVED_DATA" && pwd)"
|
||||
rm -rf "$TMP_COMPAT_DERIVED_LINK"
|
||||
ln -s "$ABS_DERIVED_DATA" "$TMP_COMPAT_DERIVED_LINK"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then
|
||||
TAG_APP_PATH="$(dirname "$APP_PATH")/${APP_NAME}.app"
|
||||
rm -rf "$TAG_APP_PATH"
|
||||
|
|
@ -386,6 +409,10 @@ if [[ -x "$CMUXD_SRC" ]]; then
|
|||
cp "$CMUXD_SRC" "$BIN_DIR/cmuxd"
|
||||
chmod +x "$BIN_DIR/cmuxd"
|
||||
fi
|
||||
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
|
||||
if [[ -x "$CLI_PATH" ]]; then
|
||||
echo "$CLI_PATH" > /tmp/cmux-last-cli-path || true
|
||||
fi
|
||||
# Avoid inheriting cmux/ghostty environment variables from the terminal that
|
||||
# runs this script (often inside another cmux instance), which can cause
|
||||
# socket and resource-path conflicts.
|
||||
|
|
|
|||
22
tests/claude_teams_test_utils.py
Normal file
22
tests/claude_teams_test_utils.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def resolve_cmux_cli() -> str:
|
||||
explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI")
|
||||
if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK):
|
||||
return explicit
|
||||
|
||||
recorded_path = Path("/tmp/cmux-last-cli-path")
|
||||
if recorded_path.exists():
|
||||
candidate = recorded_path.read_text(encoding="utf-8").strip()
|
||||
if candidate and os.path.exists(candidate) and os.access(candidate, os.X_OK):
|
||||
return candidate
|
||||
|
||||
raise RuntimeError(
|
||||
"Unable to find cmux CLI binary. Set CMUX_CLI_BIN or run ./scripts/reload.sh --tag <tag> first."
|
||||
)
|
||||
192
tests/test_cli_claude_teams_env.py
Normal file
192
tests/test_cli_claude_teams_env.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: `cmux claude-teams` injects the tmux-style auto-mode env.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from claude_teams_test_utils import resolve_cmux_cli
|
||||
|
||||
|
||||
def make_executable(path: Path, content: str) -> None:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
if not path.exists():
|
||||
return ""
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
cli_path = resolve_cmux_cli()
|
||||
except Exception as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
return 1
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="cmux-claude-teams-env-") as td:
|
||||
tmp = Path(td)
|
||||
real_bin = tmp / "real-bin"
|
||||
real_bin.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
env_log = tmp / "agent-teams.log"
|
||||
tmux_log = tmp / "tmux-path.log"
|
||||
cmux_bin_log = tmp / "cmux-bin.log"
|
||||
argv_log = tmp / "argv.log"
|
||||
tmux_env_log = tmp / "tmux-env.log"
|
||||
tmux_pane_log = tmp / "tmux-pane.log"
|
||||
term_log = tmp / "term.log"
|
||||
term_program_log = tmp / "term-program.log"
|
||||
socket_path_log = tmp / "socket-path.log"
|
||||
socket_password_log = tmp / "socket-password.log"
|
||||
fake_home = tmp / "home"
|
||||
fake_home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
make_executable(
|
||||
real_bin / "claude",
|
||||
"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\\n' "${CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS-__UNSET__}" > "$FAKE_AGENT_TEAMS_LOG"
|
||||
command -v tmux > "$FAKE_TMUX_PATH_LOG"
|
||||
printf '%s\\n' "${CMUX_CLAUDE_TEAMS_CMUX_BIN-__UNSET__}" > "$FAKE_CMUX_BIN_LOG"
|
||||
printf '%s\\n' "$@" > "$FAKE_ARGV_LOG"
|
||||
printf '%s\\n' "${TMUX-__UNSET__}" > "$FAKE_TMUX_ENV_LOG"
|
||||
printf '%s\\n' "${TMUX_PANE-__UNSET__}" > "$FAKE_TMUX_PANE_LOG"
|
||||
printf '%s\\n' "${TERM-__UNSET__}" > "$FAKE_TERM_LOG"
|
||||
printf '%s\\n' "${TERM_PROGRAM-__UNSET__}" > "$FAKE_TERM_PROGRAM_LOG"
|
||||
printf '%s\\n' "${CMUX_SOCKET_PATH-__UNSET__}" > "$FAKE_SOCKET_PATH_LOG"
|
||||
printf '%s\\n' "${CMUX_SOCKET_PASSWORD-__UNSET__}" > "$FAKE_SOCKET_PASSWORD_LOG"
|
||||
""",
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = str(fake_home)
|
||||
env["PATH"] = f"{real_bin}:/usr/bin:/bin"
|
||||
env["FAKE_AGENT_TEAMS_LOG"] = str(env_log)
|
||||
env["FAKE_TMUX_PATH_LOG"] = str(tmux_log)
|
||||
env["FAKE_CMUX_BIN_LOG"] = str(cmux_bin_log)
|
||||
env["FAKE_ARGV_LOG"] = str(argv_log)
|
||||
env["FAKE_TMUX_ENV_LOG"] = str(tmux_env_log)
|
||||
env["FAKE_TMUX_PANE_LOG"] = str(tmux_pane_log)
|
||||
env["FAKE_TERM_LOG"] = str(term_log)
|
||||
env["FAKE_TERM_PROGRAM_LOG"] = str(term_program_log)
|
||||
env["FAKE_SOCKET_PATH_LOG"] = str(socket_path_log)
|
||||
env["FAKE_SOCKET_PASSWORD_LOG"] = str(socket_password_log)
|
||||
env["TMUX"] = "__HOST_TMUX__"
|
||||
env["TMUX_PANE"] = "%999"
|
||||
env["TERM"] = "xterm-256color"
|
||||
env["TERM_PROGRAM"] = "__HOST_TERM_PROGRAM__"
|
||||
explicit_socket_path = str(tmp / "explicit-cmux.sock")
|
||||
explicit_socket_password = "topsecret"
|
||||
|
||||
proc = subprocess.run(
|
||||
[
|
||||
cli_path,
|
||||
"--socket",
|
||||
explicit_socket_path,
|
||||
"--password",
|
||||
explicit_socket_password,
|
||||
"claude-teams",
|
||||
"--version",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if proc.returncode != 0:
|
||||
print("FAIL: `cmux claude-teams --version` exited non-zero")
|
||||
print(f"exit={proc.returncode}")
|
||||
print(f"stdout={proc.stdout.strip()}")
|
||||
print(f"stderr={proc.stderr.strip()}")
|
||||
return 1
|
||||
|
||||
agent_teams_value = read_text(env_log)
|
||||
if agent_teams_value != "1":
|
||||
print(f"FAIL: expected CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1, got {agent_teams_value!r}")
|
||||
return 1
|
||||
|
||||
tmux_path = read_text(tmux_log)
|
||||
if not tmux_path:
|
||||
print("FAIL: fake claude did not observe a tmux binary in PATH")
|
||||
return 1
|
||||
|
||||
tmux_name = Path(tmux_path).name
|
||||
if tmux_name != "tmux":
|
||||
print(f"FAIL: expected tmux shim path to end with 'tmux', got {tmux_path!r}")
|
||||
return 1
|
||||
|
||||
if "claude-teams-bin" not in tmux_path:
|
||||
print(f"FAIL: expected stable tmux shim path, got {tmux_path!r}")
|
||||
return 1
|
||||
|
||||
if tmux_path.startswith(str(real_bin)):
|
||||
print(f"FAIL: expected cmux tmux shim to shadow PATH, got {tmux_path!r}")
|
||||
return 1
|
||||
|
||||
cmux_bin_value = read_text(cmux_bin_log)
|
||||
if not cmux_bin_value or cmux_bin_value == "__UNSET__":
|
||||
print("FAIL: missing CMUX_CLAUDE_TEAMS_CMUX_BIN")
|
||||
return 1
|
||||
|
||||
if not os.path.exists(cmux_bin_value):
|
||||
print(f"FAIL: CMUX_CLAUDE_TEAMS_CMUX_BIN does not exist: {cmux_bin_value!r}")
|
||||
return 1
|
||||
|
||||
argv_lines = argv_log.read_text(encoding="utf-8").splitlines()
|
||||
if argv_lines[:2] != ["--teammate-mode", "auto"]:
|
||||
print(f"FAIL: expected launcher to prepend --teammate-mode auto, got {argv_lines!r}")
|
||||
return 1
|
||||
|
||||
if "--version" not in argv_lines:
|
||||
print(f"FAIL: expected launcher to preserve user args, got {argv_lines!r}")
|
||||
return 1
|
||||
|
||||
tmux_env_value = read_text(tmux_env_log)
|
||||
if tmux_env_value in {"", "__UNSET__"}:
|
||||
print("FAIL: expected a fake TMUX env value")
|
||||
return 1
|
||||
|
||||
tmux_pane_value = read_text(tmux_pane_log)
|
||||
if tmux_pane_value in {"", "__UNSET__"} or not tmux_pane_value.startswith("%"):
|
||||
print(f"FAIL: expected a fake TMUX_PANE value, got {tmux_pane_value!r}")
|
||||
return 1
|
||||
|
||||
term_value = read_text(term_log)
|
||||
if term_value != "screen-256color":
|
||||
print(f"FAIL: expected TERM=screen-256color, got {term_value!r}")
|
||||
return 1
|
||||
|
||||
term_program_value = read_text(term_program_log)
|
||||
if term_program_value != "__UNSET__":
|
||||
print(f"FAIL: expected TERM_PROGRAM to be unset, got {term_program_value!r}")
|
||||
return 1
|
||||
|
||||
socket_path_value = read_text(socket_path_log)
|
||||
if socket_path_value != explicit_socket_path:
|
||||
print(f"FAIL: expected CMUX_SOCKET_PATH={explicit_socket_path!r}, got {socket_path_value!r}")
|
||||
return 1
|
||||
|
||||
socket_password_value = read_text(socket_password_log)
|
||||
if socket_password_value != explicit_socket_password:
|
||||
print(
|
||||
"FAIL: expected CMUX_SOCKET_PASSWORD to preserve the explicit CLI override, "
|
||||
f"got {socket_password_value!r}"
|
||||
)
|
||||
return 1
|
||||
|
||||
print("PASS: cmux claude-teams injects the auto-mode tmux env and shim")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
90
tests/test_cli_claude_teams_existing_shim.py
Normal file
90
tests/test_cli_claude_teams_existing_shim.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: `cmux claude-teams` reuses an existing tmux shim.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from claude_teams_test_utils import resolve_cmux_cli
|
||||
|
||||
|
||||
def make_executable(path: Path, content: str) -> None:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
cli_path = resolve_cmux_cli()
|
||||
except Exception as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
return 1
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="cmux-claude-teams-shim-") as td:
|
||||
tmp = Path(td)
|
||||
home = tmp / "home"
|
||||
real_bin = tmp / "real-bin"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
real_bin.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
shim_dir = home / ".cmuxterm" / "claude-teams-bin"
|
||||
shim_dir.mkdir(parents=True, exist_ok=True)
|
||||
shim_path = shim_dir / "tmux"
|
||||
shim_path.write_text(
|
||||
"#!/usr/bin/env bash\n"
|
||||
"set -euo pipefail\n"
|
||||
"exec \"${CMUX_CLAUDE_TEAMS_CMUX_BIN:-cmux}\" __tmux-compat \"$@\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
shim_path.chmod(0o555)
|
||||
shim_dir.chmod(0o555)
|
||||
|
||||
make_executable(
|
||||
real_bin / "claude",
|
||||
"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'shim=%s\\n' "$(command -v tmux)"
|
||||
""",
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = str(home)
|
||||
env["PATH"] = f"{real_bin}:/usr/bin:/bin"
|
||||
|
||||
proc = subprocess.run(
|
||||
[cli_path, "claude-teams", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
shim_dir.chmod(0o755)
|
||||
shim_path.chmod(0o755)
|
||||
|
||||
if proc.returncode != 0:
|
||||
print("FAIL: `cmux claude-teams --version` failed with an existing shim")
|
||||
print(f"exit={proc.returncode}")
|
||||
print(f"stdout={proc.stdout.strip()}")
|
||||
print(f"stderr={proc.stderr.strip()}")
|
||||
return 1
|
||||
|
||||
expected = str(shim_path)
|
||||
actual = proc.stdout.strip()
|
||||
if actual != f"shim={expected}":
|
||||
print(f"FAIL: expected existing shim path {expected!r}, got {actual!r}")
|
||||
return 1
|
||||
|
||||
print("PASS: cmux claude-teams reuses an existing tmux shim")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
86
tests/test_cli_claude_teams_help_passthrough.py
Normal file
86
tests/test_cli_claude_teams_help_passthrough.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: `cmux claude-teams --help` passes through to Claude.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from claude_teams_test_utils import resolve_cmux_cli
|
||||
|
||||
|
||||
def make_executable(path: Path, content: str) -> None:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
cli_path = resolve_cmux_cli()
|
||||
except Exception as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
return 1
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="cmux-claude-teams-help-") as td:
|
||||
tmp = Path(td)
|
||||
home = tmp / "home"
|
||||
real_bin = tmp / "real-bin"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
real_bin.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
argv_log = tmp / "argv.log"
|
||||
|
||||
make_executable(
|
||||
real_bin / "claude",
|
||||
"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\\n' "$@" > "$FAKE_ARGV_LOG"
|
||||
""",
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = str(home)
|
||||
env["PATH"] = f"{real_bin}:/usr/bin:/bin"
|
||||
env["FAKE_ARGV_LOG"] = str(argv_log)
|
||||
|
||||
proc = subprocess.run(
|
||||
[cli_path, "claude-teams", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if proc.returncode != 0:
|
||||
print("FAIL: `cmux claude-teams --help` exited non-zero")
|
||||
print(f"exit={proc.returncode}")
|
||||
print(f"stdout={proc.stdout.strip()}")
|
||||
print(f"stderr={proc.stderr.strip()}")
|
||||
return 1
|
||||
|
||||
if not argv_log.exists():
|
||||
print("FAIL: launcher intercepted --help instead of invoking Claude")
|
||||
print(f"stdout={proc.stdout.strip()}")
|
||||
print(f"stderr={proc.stderr.strip()}")
|
||||
return 1
|
||||
|
||||
argv_lines = argv_log.read_text(encoding="utf-8").splitlines()
|
||||
if argv_lines[:2] != ["--teammate-mode", "auto"]:
|
||||
print(f"FAIL: expected launcher to prepend --teammate-mode auto, got {argv_lines!r}")
|
||||
return 1
|
||||
|
||||
if "--help" not in argv_lines:
|
||||
print(f"FAIL: expected --help to reach Claude, got {argv_lines!r}")
|
||||
return 1
|
||||
|
||||
print("PASS: cmux claude-teams forwards --help to Claude")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
85
tests/test_cli_claude_teams_skips_wrapper_claude.py
Normal file
85
tests/test_cli_claude_teams_skips_wrapper_claude.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: `cmux claude-teams` skips cmux wrapper scripts on PATH.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from claude_teams_test_utils import resolve_cmux_cli
|
||||
|
||||
|
||||
def make_executable(path: Path, content: str) -> None:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
cli_path = resolve_cmux_cli()
|
||||
except Exception as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
return 1
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="cmux-claude-teams-wrapper-") as td:
|
||||
tmp = Path(td)
|
||||
wrapper_bin = tmp / "wrapper-bin"
|
||||
real_bin = tmp / "real-bin"
|
||||
logs = tmp / "logs"
|
||||
wrapper_bin.mkdir(parents=True, exist_ok=True)
|
||||
real_bin.mkdir(parents=True, exist_ok=True)
|
||||
logs.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
real_hit = logs / "real-hit.txt"
|
||||
|
||||
make_executable(
|
||||
wrapper_bin / "claude",
|
||||
"""#!/usr/bin/env bash
|
||||
# cmux claude wrapper - injects hooks and session tracking
|
||||
set -euo pipefail
|
||||
echo WRAPPER_EXECUTED >&2
|
||||
exit 91
|
||||
""",
|
||||
)
|
||||
|
||||
make_executable(
|
||||
real_bin / "claude",
|
||||
f"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'REAL\\n' > {real_hit}
|
||||
""",
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = f"{wrapper_bin}:{real_bin}:/usr/bin:/bin"
|
||||
|
||||
proc = subprocess.run(
|
||||
[cli_path, "claude-teams", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if proc.returncode != 0:
|
||||
print("FAIL: `cmux claude-teams --version` executed a wrapper instead of the real claude binary")
|
||||
print(f"exit={proc.returncode}")
|
||||
print(f"stdout={proc.stdout.strip()}")
|
||||
print(f"stderr={proc.stderr.strip()}")
|
||||
return 1
|
||||
|
||||
if not real_hit.exists():
|
||||
print("FAIL: real claude binary was not reached")
|
||||
return 1
|
||||
|
||||
print("PASS: cmux claude-teams skips cmux wrapper scripts on PATH")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
362
tests/test_cli_claude_teams_tmux_sequence.py
Normal file
362
tests/test_cli_claude_teams_tmux_sequence.py
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: `cmux claude-teams` supports Claude's tmux teammate flow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socketserver
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from claude_teams_test_utils import resolve_cmux_cli
|
||||
INITIAL_WORKSPACE_ID = "11111111-1111-4111-8111-111111111111"
|
||||
INITIAL_WINDOW_ID = "22222222-2222-4222-8222-222222222222"
|
||||
INITIAL_PANE_ID = "33333333-3333-4333-8333-333333333333"
|
||||
INITIAL_SURFACE_ID = "44444444-4444-4444-8444-444444444444"
|
||||
INITIAL_TAB_ID = "55555555-5555-4555-8555-555555555555"
|
||||
NEW_PANE_ID = "66666666-6666-4666-8666-666666666666"
|
||||
NEW_SURFACE_ID = "77777777-7777-4777-8777-777777777777"
|
||||
|
||||
|
||||
def make_executable(path: Path, content: str) -> None:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
if not path.exists():
|
||||
return ""
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
|
||||
|
||||
class FakeCmuxState:
|
||||
def __init__(self) -> None:
|
||||
self.lock = threading.Lock()
|
||||
self.requests: list[str] = []
|
||||
self.workspace = {
|
||||
"id": INITIAL_WORKSPACE_ID,
|
||||
"ref": "workspace:1",
|
||||
"index": 1,
|
||||
"title": "demo-team",
|
||||
}
|
||||
self.window = {
|
||||
"id": INITIAL_WINDOW_ID,
|
||||
"ref": "window:1",
|
||||
}
|
||||
self.current_pane_id = INITIAL_PANE_ID
|
||||
self.current_surface_id = INITIAL_SURFACE_ID
|
||||
self.panes = [
|
||||
{
|
||||
"id": INITIAL_PANE_ID,
|
||||
"ref": "pane:1",
|
||||
"index": 7,
|
||||
"surface_ids": [INITIAL_SURFACE_ID],
|
||||
}
|
||||
]
|
||||
self.surfaces = [
|
||||
{
|
||||
"id": INITIAL_SURFACE_ID,
|
||||
"ref": "surface:1",
|
||||
"pane_id": INITIAL_PANE_ID,
|
||||
"title": "leader",
|
||||
}
|
||||
]
|
||||
|
||||
def handle(self, method: str, params: dict[str, object]) -> dict[str, object]:
|
||||
with self.lock:
|
||||
self.requests.append(method)
|
||||
if method == "system.identify":
|
||||
return {
|
||||
"socket_path": str(params.get("socket_path", "")),
|
||||
"focused": {
|
||||
"workspace_id": self.workspace["id"],
|
||||
"workspace_ref": self.workspace["ref"],
|
||||
"window_id": self.window["id"],
|
||||
"window_ref": self.window["ref"],
|
||||
"pane_id": self.current_pane_id,
|
||||
"pane_ref": self._pane_ref(self.current_pane_id),
|
||||
"surface_id": self.current_surface_id,
|
||||
"surface_ref": self._surface_ref(self.current_surface_id),
|
||||
"tab_id": INITIAL_TAB_ID,
|
||||
"tab_ref": "tab:1",
|
||||
"surface_type": "terminal",
|
||||
"is_browser_surface": False,
|
||||
},
|
||||
}
|
||||
if method == "workspace.current":
|
||||
return {
|
||||
"workspace_id": self.workspace["id"],
|
||||
"workspace_ref": self.workspace["ref"],
|
||||
}
|
||||
if method == "workspace.list":
|
||||
return {
|
||||
"workspaces": [
|
||||
{
|
||||
"id": self.workspace["id"],
|
||||
"ref": self.workspace["ref"],
|
||||
"index": self.workspace["index"],
|
||||
"title": self.workspace["title"],
|
||||
}
|
||||
]
|
||||
}
|
||||
if method == "window.list":
|
||||
return {
|
||||
"windows": [
|
||||
{
|
||||
"id": self.window["id"],
|
||||
"ref": self.window["ref"],
|
||||
"workspace_id": self.workspace["id"],
|
||||
"workspace_ref": self.workspace["ref"],
|
||||
}
|
||||
]
|
||||
}
|
||||
if method == "pane.list":
|
||||
return {
|
||||
"panes": [
|
||||
{
|
||||
"id": pane["id"],
|
||||
"ref": pane["ref"],
|
||||
"index": pane["index"],
|
||||
}
|
||||
for pane in self.panes
|
||||
]
|
||||
}
|
||||
if method == "pane.surfaces":
|
||||
pane_id = str(params.get("pane_id") or "")
|
||||
pane = self._pane_by_id(pane_id)
|
||||
return {
|
||||
"surfaces": [
|
||||
{
|
||||
"id": surface_id,
|
||||
"selected": surface_id == self.current_surface_id,
|
||||
}
|
||||
for surface_id in pane["surface_ids"]
|
||||
]
|
||||
}
|
||||
if method == "surface.current":
|
||||
return {
|
||||
"workspace_id": self.workspace["id"],
|
||||
"workspace_ref": self.workspace["ref"],
|
||||
"pane_id": self.current_pane_id,
|
||||
"pane_ref": self._pane_ref(self.current_pane_id),
|
||||
"surface_id": self.current_surface_id,
|
||||
"surface_ref": self._surface_ref(self.current_surface_id),
|
||||
}
|
||||
if method == "surface.list":
|
||||
return {
|
||||
"surfaces": [
|
||||
{
|
||||
"id": surface["id"],
|
||||
"ref": surface["ref"],
|
||||
"title": surface["title"],
|
||||
"pane_id": surface["pane_id"],
|
||||
"pane_ref": self._pane_ref(surface["pane_id"]),
|
||||
}
|
||||
for surface in self.surfaces
|
||||
]
|
||||
}
|
||||
if method == "surface.split":
|
||||
self.panes.append(
|
||||
{
|
||||
"id": NEW_PANE_ID,
|
||||
"ref": "pane:2",
|
||||
"index": 8,
|
||||
"surface_ids": [NEW_SURFACE_ID],
|
||||
}
|
||||
)
|
||||
self.surfaces.append(
|
||||
{
|
||||
"id": NEW_SURFACE_ID,
|
||||
"ref": "surface:2",
|
||||
"pane_id": NEW_PANE_ID,
|
||||
"title": "teammate",
|
||||
}
|
||||
)
|
||||
return {
|
||||
"surface_id": NEW_SURFACE_ID,
|
||||
"pane_id": NEW_PANE_ID,
|
||||
}
|
||||
if method == "surface.focus":
|
||||
self.current_surface_id = str(params.get("surface_id") or self.current_surface_id)
|
||||
surface = self._surface_by_id(self.current_surface_id)
|
||||
self.current_pane_id = surface["pane_id"]
|
||||
return {"ok": True}
|
||||
if method == "pane.resize":
|
||||
return {"ok": True}
|
||||
if method == "surface.send_text":
|
||||
return {"ok": True}
|
||||
raise RuntimeError(f"Unsupported fake cmux method: {method}")
|
||||
|
||||
def _pane_by_id(self, pane_id: str) -> dict[str, object]:
|
||||
for pane in self.panes:
|
||||
if pane["id"] == pane_id or pane["ref"] == pane_id:
|
||||
return pane
|
||||
raise RuntimeError(f"Unknown pane id: {pane_id}")
|
||||
|
||||
def _surface_by_id(self, surface_id: str) -> dict[str, object]:
|
||||
for surface in self.surfaces:
|
||||
if surface["id"] == surface_id or surface["ref"] == surface_id:
|
||||
return surface
|
||||
raise RuntimeError(f"Unknown surface id: {surface_id}")
|
||||
|
||||
def _pane_ref(self, pane_id: str) -> str:
|
||||
return self._pane_by_id(pane_id)["ref"] # type: ignore[return-value]
|
||||
|
||||
def _surface_ref(self, surface_id: str) -> str:
|
||||
return self._surface_by_id(surface_id)["ref"] # type: ignore[return-value]
|
||||
|
||||
|
||||
class FakeCmuxUnixServer(socketserver.ThreadingUnixStreamServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, socket_path: str, state: FakeCmuxState) -> None:
|
||||
self.state = state
|
||||
super().__init__(socket_path, FakeCmuxHandler)
|
||||
|
||||
|
||||
class FakeCmuxHandler(socketserver.StreamRequestHandler):
|
||||
def handle(self) -> None:
|
||||
while True:
|
||||
line = self.rfile.readline()
|
||||
if not line:
|
||||
return
|
||||
request = json.loads(line.decode("utf-8"))
|
||||
response = {
|
||||
"ok": True,
|
||||
"result": self.server.state.handle( # type: ignore[attr-defined]
|
||||
request["method"],
|
||||
request.get("params", {}),
|
||||
),
|
||||
"id": request.get("id"),
|
||||
}
|
||||
self.wfile.write((json.dumps(response) + "\n").encode("utf-8"))
|
||||
self.wfile.flush()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
cli_path = resolve_cmux_cli()
|
||||
except Exception as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
return 1
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="cmux-claude-teams-seq-") as td:
|
||||
tmp = Path(td)
|
||||
home = tmp / "home"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
socket_path = tmp / "fake-cmux.sock"
|
||||
state = FakeCmuxState()
|
||||
server = FakeCmuxUnixServer(str(socket_path), state)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
real_bin = tmp / "real-bin"
|
||||
real_bin.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
tmux_pane_log = tmp / "tmux-pane.log"
|
||||
tmux_socket_log = tmp / "tmux-socket.log"
|
||||
window_target_log = tmp / "window-target.log"
|
||||
split_pane_log = tmp / "split-pane.log"
|
||||
pane_list_log = tmp / "pane-list.log"
|
||||
|
||||
make_executable(
|
||||
real_bin / "claude",
|
||||
"""#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\\n' "${TMUX_PANE-__UNSET__}" > "$FAKE_TMUX_PANE_LOG"
|
||||
printf '%s\\n' "${CMUX_SOCKET_PATH-__UNSET__}" > "$FAKE_SOCKET_LOG"
|
||||
window_target="$(tmux display-message -t "${TMUX_PANE}" -p '#{session_name}:#{window_index}')"
|
||||
printf '%s\\n' "$window_target" > "$FAKE_WINDOW_TARGET_LOG"
|
||||
split_pane="$(tmux split-window -t "${TMUX_PANE}" -h -l 70% -P -F '#{pane_id}')"
|
||||
printf '%s\\n' "$split_pane" > "$FAKE_SPLIT_PANE_LOG"
|
||||
tmux select-layout -t "$window_target" main-vertical
|
||||
tmux resize-pane -t "${TMUX_PANE}" -x 30%
|
||||
tmux list-panes -t "$window_target" -F '#{pane_id}' > "$FAKE_PANE_LIST_LOG"
|
||||
""",
|
||||
)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = str(home)
|
||||
env["PATH"] = f"{real_bin}:/usr/bin:/bin"
|
||||
env["CMUX_SOCKET_PATH"] = str(socket_path)
|
||||
env["FAKE_TMUX_PANE_LOG"] = str(tmux_pane_log)
|
||||
env["FAKE_SOCKET_LOG"] = str(tmux_socket_log)
|
||||
env["FAKE_WINDOW_TARGET_LOG"] = str(window_target_log)
|
||||
env["FAKE_SPLIT_PANE_LOG"] = str(split_pane_log)
|
||||
env["FAKE_PANE_LIST_LOG"] = str(pane_list_log)
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[cli_path, "claude-teams", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
print("FAIL: `cmux claude-teams --version` timed out")
|
||||
print(f"cmd={exc.cmd!r}")
|
||||
return 1
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
if proc.returncode != 0:
|
||||
print("FAIL: `cmux claude-teams --version` exited non-zero")
|
||||
print(f"exit={proc.returncode}")
|
||||
print(f"stdout={proc.stdout.strip()}")
|
||||
print(f"stderr={proc.stderr.strip()}")
|
||||
return 1
|
||||
|
||||
tmux_pane = read_text(tmux_pane_log)
|
||||
if tmux_pane != f"%{INITIAL_PANE_ID}":
|
||||
print(f"FAIL: expected TMUX_PANE=%{INITIAL_PANE_ID}, got {tmux_pane!r}")
|
||||
return 1
|
||||
|
||||
socket_value = read_text(tmux_socket_log)
|
||||
if socket_value != str(socket_path):
|
||||
print(f"FAIL: expected CMUX_SOCKET_PATH={socket_path}, got {socket_value!r}")
|
||||
return 1
|
||||
|
||||
window_target = read_text(window_target_log)
|
||||
if window_target != "cmux:1":
|
||||
print(f"FAIL: expected tmux window target 'cmux:1', got {window_target!r}")
|
||||
return 1
|
||||
|
||||
split_pane = read_text(split_pane_log)
|
||||
if split_pane != f"%{NEW_PANE_ID}":
|
||||
print(f"FAIL: expected split-window to print %{NEW_PANE_ID}, got {split_pane!r}")
|
||||
return 1
|
||||
|
||||
pane_lines = pane_list_log.read_text(encoding="utf-8").splitlines()
|
||||
expected_panes = [f"%{INITIAL_PANE_ID}", f"%{NEW_PANE_ID}"]
|
||||
if pane_lines != expected_panes:
|
||||
print(f"FAIL: expected list-panes output {expected_panes!r}, got {pane_lines!r}")
|
||||
return 1
|
||||
|
||||
if state.current_pane_id != INITIAL_PANE_ID:
|
||||
print(
|
||||
"FAIL: expected split-window to keep the leader pane focused, "
|
||||
f"got current pane {state.current_pane_id!r}"
|
||||
)
|
||||
return 1
|
||||
|
||||
if "surface.send_text" in state.requests:
|
||||
print("FAIL: split-window treated '-l 70%' like shell text and called surface.send_text")
|
||||
print(f"requests={state.requests!r}")
|
||||
return 1
|
||||
|
||||
print("PASS: cmux claude-teams supports Claude's tmux teammate flow")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
110
tests_v2/test_browser_devtools_visibility_stability.py
Normal file
110
tests_v2/test_browser_devtools_visibility_stability.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
#!/usr/bin/env python3
|
||||
"""v2 regression: browser DevTools stays open after a single toggle."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def _must(cond: bool, msg: str) -> None:
|
||||
if not cond:
|
||||
raise cmuxError(msg)
|
||||
|
||||
|
||||
def _wait_until(pred, timeout_s: float, label: str) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
last_exc = None
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
if pred():
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_exc = exc
|
||||
time.sleep(0.05)
|
||||
if last_exc is not None:
|
||||
raise cmuxError(f"Timed out waiting for {label}: {last_exc}")
|
||||
raise cmuxError(f"Timed out waiting for {label}")
|
||||
|
||||
|
||||
def _surface_row(c: cmux, workspace_id: str, surface_id: str) -> dict:
|
||||
payload = c._call("surface.list", {"workspace_id": workspace_id}) or {}
|
||||
for row in payload.get("surfaces") or []:
|
||||
if str(row.get("id") or "") == surface_id:
|
||||
return row
|
||||
raise cmuxError(f"surface.list missing surface {surface_id} in workspace {workspace_id}: {payload}")
|
||||
|
||||
|
||||
def _devtools_visible(c: cmux, workspace_id: str, surface_id: str) -> bool:
|
||||
row = _surface_row(c, workspace_id, surface_id)
|
||||
return bool(row.get("developer_tools_visible"))
|
||||
|
||||
|
||||
def _focus_browser_webview(c: cmux, surface_id: str, timeout_s: float = 2.0) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
last_exc = None
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
c.focus_surface(surface_id)
|
||||
c.focus_webview(surface_id)
|
||||
if c.is_webview_focused(surface_id):
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_exc = exc
|
||||
time.sleep(0.05)
|
||||
raise cmuxError(f"Timed out waiting for browser webview focus: {last_exc}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as c:
|
||||
workspace_id = c.new_workspace()
|
||||
try:
|
||||
c.select_workspace(workspace_id)
|
||||
time.sleep(0.3)
|
||||
|
||||
surface_id = c.new_surface(panel_type="browser", url="https://example.com")
|
||||
_wait_until(
|
||||
lambda: _surface_row(c, workspace_id, surface_id).get("type") == "browser",
|
||||
timeout_s=5.0,
|
||||
label="browser surface in surface.list",
|
||||
)
|
||||
_focus_browser_webview(c, surface_id, timeout_s=3.0)
|
||||
|
||||
_must(
|
||||
_devtools_visible(c, workspace_id, surface_id) is False,
|
||||
"Expected DevTools to start closed",
|
||||
)
|
||||
|
||||
c.simulate_shortcut("cmd+opt+i")
|
||||
|
||||
_wait_until(
|
||||
lambda: _devtools_visible(c, workspace_id, surface_id),
|
||||
timeout_s=3.0,
|
||||
label="DevTools visible after toggle",
|
||||
)
|
||||
|
||||
deadline = time.time() + 1.5
|
||||
while time.time() < deadline:
|
||||
_must(
|
||||
_devtools_visible(c, workspace_id, surface_id) is True,
|
||||
"DevTools reopened/closed unexpectedly after initial open",
|
||||
)
|
||||
time.sleep(0.05)
|
||||
finally:
|
||||
try:
|
||||
c.close_workspace(workspace_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("PASS: browser DevTools stays open after a single toggle")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue