From b3ed48191e1995e8671f75adc8222c2e60f45ec9 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Mon, 9 Mar 2026 19:11:37 -0700
Subject: [PATCH 01/43] Fix main CI failures
---
.github/workflows/update-homebrew.yml | 15 +++++++++++++++
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 5 +++--
2 files changed, 18 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/update-homebrew.yml b/.github/workflows/update-homebrew.yml
index 967cb442..b8c4d705 100644
--- a/.github/workflows/update-homebrew.yml
+++ b/.github/workflows/update-homebrew.yml
@@ -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: |
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 3216a55d..367b62e3 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -9957,6 +9957,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
)
}
+ @MainActor
func testKeyboardCopyModeIndicatorMountsAndUnmounts() {
let surface = TerminalSurface(
tabId: UUID(),
@@ -9967,10 +9968,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())
}
From 23484eb01aec01395c2dbe78dbc7e24d014b4cf5 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Mon, 9 Mar 2026 20:13:32 -0700
Subject: [PATCH 02/43] Add UI regression test for browser pane zoom omnibar
---
.../BrowserPaneNavigationKeybindUITests.swift | 55 +++++++++++++++++++
1 file changed, 55 insertions(+)
diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
index 5f76cb57..261a136d 100644
--- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
+++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
@@ -554,6 +554,50 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
)
}
+ func testCmdShiftEnterKeepsBrowserOmnibarHittableWhenWebViewFocused() {
+ 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
+ }
+
+ 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")
+
+ app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift])
+
+ XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0), "Expected browser omnibar text field after Cmd+Shift+Enter")
+ XCTAssertTrue(pill.waitForExistence(timeout: 6.0), "Expected browser omnibar pill after Cmd+Shift+Enter")
+ XCTAssertTrue(
+ waitForElementToBecomeHittable(pill, timeout: 6.0),
+ "Expected browser omnibar to stay hittable after Cmd+Shift+Enter"
+ )
+
+ 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 testCmdDSplitsRightWhenOmnibarFocused() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
@@ -806,6 +850,17 @@ 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"
}
From e96dc5dd830e0c1d5bb47a771e8d43cce18727e6 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Mon, 9 Mar 2026 20:14:42 -0700
Subject: [PATCH 03/43] Refresh browser portal after pane zoom
---
Sources/Workspace.swift | 43 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 43 insertions(+)
diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift
index 4993be85..9be189c3 100644
--- a/Sources/Workspace.swift
+++ b/Sources/Workspace.swift
@@ -3220,6 +3220,9 @@ final class Workspace: Identifiable, ObservableObject {
guard let paneId = paneId(forPanelId: panelId) else { return false }
guard bonsplitController.togglePaneZoom(inPane: paneId) else { return false }
focusPanel(panelId)
+ if browserPanel(for: panelId) != nil {
+ scheduleBrowserPortalReconcileAfterSplitZoom(panelId: panelId, remainingPasses: 4)
+ }
return true
}
@@ -3482,6 +3485,46 @@ final class Workspace: Identifiable, ObservableObject {
}
}
+ // 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
+ )
+ }
+ }
+ }
+
private func scheduleMovedTerminalRefresh(panelId: UUID) {
guard terminalPanel(for: panelId) != nil else { return }
From dea60ea71c4f4f008d7ec87e830d3d288884e78c Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Mon, 9 Mar 2026 21:32:54 -0700
Subject: [PATCH 04/43] Update ghostty to v1.3.0 (#1142)
* Update ghostty to v1.3.0
* Add bell handling and AppleScript support
* Add zsh shell integration handoff test
* Fix Ghostty zsh integration handoff in cmux
* Add terminal keypress notification dismissal test
* Dismiss terminal notifications on keypress
* Address PR review feedback
* Tighten notification dismissal regression test
* Pin GhosttyKit checksum for latest ghostty
---
GhosttyTabs.xcodeproj/project.pbxproj | 8 +
Resources/Info.plist | 4 +
Resources/Localizable.xcstrings | 187 +++++
Resources/cmux.sdef | 192 +++++
Resources/shell-integration/.zshenv | 11 +-
Sources/AppDelegate.swift | 86 +++
Sources/AppleScriptSupport.swift | 705 ++++++++++++++++++
Sources/GhosttyTerminalView.swift | 91 +++
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 136 ++++
cmuxTests/GhosttyConfigTests.swift | 80 ++
docs/ghostty-fork.md | 42 +-
ghostty | 2 +-
ghostty.h | 6 +
scripts/ghosttykit-checksums.txt | 1 +
14 files changed, 1542 insertions(+), 9 deletions(-)
create mode 100644 Resources/cmux.sdef
create mode 100644 Sources/AppleScriptSupport.swift
diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj
index a975a0e7..4111742b 100644
--- a/GhosttyTabs.xcodeproj/project.pbxproj
+++ b/GhosttyTabs.xcodeproj/project.pbxproj
@@ -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 = ""; };
A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; };
A5001600 /* SentryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryHelper.swift; sourceTree = ""; };
+ A5001620 /* AppleScriptSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleScriptSupport.swift; sourceTree = ""; };
A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = ""; };
A5001511 /* UITestRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestRecorder.swift; sourceTree = ""; };
A5001520 /* PostHogAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalytics.swift; sourceTree = ""; };
@@ -235,6 +238,7 @@
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = ""; };
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; };
DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; };
+ A5001622 /* cmux.sdef */ = {isa = PBXFileReference; lastKnownFileType = text.sdef; path = cmux.sdef; sourceTree = ""; };
/* 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 = "";
@@ -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 */,
diff --git a/Resources/Info.plist b/Resources/Info.plist
index 00d9fa86..f1beb4f9 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -48,6 +48,10 @@
NSPrincipalClass
NSApplication
+ NSAppleScriptEnabled
+
+ OSAScriptingDefinition
+ cmux.sdef
NSServices
diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings
index d568c9b3..8a729692 100644
--- a/Resources/Localizable.xcstrings
+++ b/Resources/Localizable.xcstrings
@@ -115,6 +115,193 @@
}
}
},
+ "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": {
diff --git a/Resources/cmux.sdef b/Resources/cmux.sdef
new file mode 100644
index 00000000..b55edd4b
--- /dev/null
+++ b/Resources/cmux.sdef
@@ -0,0 +1,192 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Resources/shell-integration/.zshenv b/Resources/shell-integration/.zshenv
index 68925a2f..74241671 100644
--- a/Resources/shell-integration/.zshenv
+++ b/Resources/shell-integration/.zshenv
@@ -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
}
diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift
index 358a5dce..550650e8 100644
--- a/Sources/AppDelegate.swift
+++ b/Sources/AppDelegate.swift
@@ -1490,6 +1490,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 +3420,86 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
windowForMainWindowId(windowId)
}
+ func scriptableMainWindows() -> [ScriptableMainWindowState] {
+ var results: [ScriptableMainWindowState] = []
+ var seen: Set = []
+
+ 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 }
diff --git a/Sources/AppleScriptSupport.swift b/Sources/AppleScriptSupport.swift
new file mode 100644
index 00000000..640750d5
--- /dev/null
+++ b/Sources/AppleScriptSupport.swift
@@ -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 = []
+
+ 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
+ }
+ }
+}
diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift
index 88a7f314..61e89c18 100644
--- a/Sources/GhosttyTerminalView.swift
+++ b/Sources/GhosttyTerminalView.swift
@@ -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?
+ 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,
@@ -2739,6 +2821,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)
@@ -4332,6 +4417,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()
}
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 367b62e3..403914da 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -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
@@ -7476,6 +7524,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 }
@@ -7556,6 +7622,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)
+ }
}
diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift
index e229b761..26d3a789 100644
--- a/cmuxTests/GhosttyConfigTests.swift
+++ b/cmuxTests/GhosttyConfigTests.swift
@@ -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)
+ }
+}
diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md
index e5ed8988..c98a76c3 100644
--- a/docs/ghostty-fork.md
+++ b/docs/ghostty-fork.md
@@ -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:
diff --git a/ghostty b/ghostty
index 7dd58982..a50579bd 160000
--- a/ghostty
+++ b/ghostty
@@ -1 +1 @@
-Subproject commit 7dd589824d4c9bda8265355718800cccaf7189a0
+Subproject commit a50579bd5ddec81c6244b9b349d4bf781f667cec
diff --git a/ghostty.h b/ghostty.h
index b54e84f1..585564d7 100644
--- a/ghostty.h
+++ b/ghostty.h
@@ -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;
diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt
index 29794d12..8ab36d3b 100644
--- a/scripts/ghosttykit-checksums.txt
+++ b/scripts/ghosttykit-checksums.txt
@@ -2,3 +2,4 @@
# Update this file in a reviewed PR whenever the ghostty submodule SHA changes.
# Format:
7dd589824d4c9bda8265355718800cccaf7189a0 3915af4256850a0a7bee671c3ba0a47cbfee5dbfc6d71caf952acefdf2ee4207
+a50579bd5ddec81c6244b9b349d4bf781f667cec f7e9c0597468a263d6b75eaf815ccecd90c7933f3cf4ae58929569ff23b2666d
From d23018000977ca914fd9e247a88f153aa6540c8f Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Tue, 10 Mar 2026 00:17:11 -0700
Subject: [PATCH 05/43] test: cover split terminal first-responder focus
recovery
---
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 37 +++++++++++++++++++
1 file changed, 37 insertions(+)
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 403914da..91c37ef4 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -4688,6 +4688,43 @@ final class TabManagerEqualizeSplitsTests: XCTestCase {
}
}
+@MainActor
+final class WorkspaceTerminalFocusRecoveryTests: XCTestCase {
+ 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"
+ )
+ }
+}
+
@MainActor
final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase {
func testPrefersSelectedTerminalInTargetPaneOverFocusedTerminalElsewhere() {
From b2d07f88f5511857e4f15a1cbe103cead8c83b5f Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Tue, 10 Mar 2026 00:19:10 -0700
Subject: [PATCH 06/43] fix: repair split terminal focus on first-responder
recovery
---
Sources/Workspace.swift | 52 +++++++++++++++++++++++++++++++++++------
1 file changed, 45 insertions(+), 7 deletions(-)
diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift
index 4993be85..0114508e 100644
--- a/Sources/Workspace.swift
+++ b/Sources/Workspace.swift
@@ -3091,8 +3091,12 @@ final class Workspace: Identifiable, ObservableObject {
}
}
}
- if let targetPaneId, !shouldSuppressReentrantRefocus {
- applyTabSelection(tabId: tabId, inPane: targetPaneId)
+ if let targetPaneId {
+ applyTabSelection(
+ tabId: tabId,
+ inPane: targetPaneId,
+ reassertAppKitFocus: !shouldSuppressReentrantRefocus
+ )
}
if let browserPanel = panels[panelId] as? BrowserPanel {
@@ -3750,7 +3754,11 @@ 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) {
+ private func applyTabSelection(
+ tabId: TabID,
+ inPane pane: PaneID,
+ reassertAppKitFocus: Bool = true
+ ) {
pendingTabSelection = (tabId: tabId, pane: pane)
guard !isApplyingTabSelection else { return }
isApplyingTabSelection = true
@@ -3764,11 +3772,19 @@ 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: reassertAppKitFocus
+ )
}
}
- private func applyTabSelectionNow(tabId: TabID, inPane pane: PaneID) {
+ private func applyTabSelectionNow(
+ tabId: TabID,
+ inPane pane: PaneID,
+ reassertAppKitFocus: Bool
+ ) {
let previousFocusedPanelId = focusedPanelId
if bonsplitController.allPaneIds.contains(pane) {
if bonsplitController.focusedPaneId != pane {
@@ -3813,7 +3829,7 @@ extension Workspace: BonsplitDelegate {
p.unfocus()
}
- panel.focus()
+ activatePanel(panel, reassertAppKitFocus: reassertAppKitFocus)
let focusIntentAllowsBrowserOmnibarAutofocus =
shouldTreatCurrentEventAsExplicitFocusIntent() ||
TerminalController.socketCommandAllowsInAppFocusMutations()
@@ -3845,7 +3861,7 @@ 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 {
terminalPanel.hostedView.ensureFocus(for: id, surfaceId: panelId)
}
@@ -3867,6 +3883,28 @@ extension Workspace: BonsplitDelegate {
)
}
+ private func activatePanel(
+ _ panel: any Panel,
+ reassertAppKitFocus: Bool
+ ) {
+ guard !reassertAppKitFocus else {
+ panel.focus()
+ return
+ }
+
+ // `GhosttyNSView.becomeFirstResponder -> onFocus -> focusPanel` is already inside
+ // AppKit's responder transition. Re-running `makeFirstResponder` here can recurse,
+ // but we still need to converge the selected panel's active/focus state and clear any
+ // stale sibling terminal activation so split-pane clicks recover cleanly.
+ if let terminalPanel = panel as? TerminalPanel {
+ terminalPanel.surface.setFocus(true)
+ terminalPanel.hostedView.setActive(true)
+ return
+ }
+
+ panel.focus()
+ }
+
private func beginNonFocusSplitFocusReassert(
preferredPanelId: UUID,
splitPanelId: UUID
From c5234e8b798c54f7094f502201f103856abac7b3 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Tue, 10 Mar 2026 00:29:03 -0700
Subject: [PATCH 07/43] test: cover split click focus recovery when callbacks
suppress
---
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 115 ++++++++++++++++++
1 file changed, 115 insertions(+)
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 91c37ef4..1dfb4378 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -4690,6 +4690,47 @@ 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,
@@ -4723,6 +4764,80 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase {
"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
From 07412f39fd0bef780014952b44c93abb4efbba54 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Tue, 10 Mar 2026 00:30:12 -0700
Subject: [PATCH 08/43] fix: recover split terminal focus on pointer down
---
Sources/GhosttyTerminalView.swift | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift
index 61e89c18..86cf40f3 100644
--- a/Sources/GhosttyTerminalView.swift
+++ b/Sources/GhosttyTerminalView.swift
@@ -4864,11 +4864,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(
@@ -4893,10 +4904,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))
@@ -4918,6 +4931,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)
From b99ec64ae3a8396f7334609f02d1f458242f4114 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Tue, 10 Mar 2026 00:40:55 -0700
Subject: [PATCH 09/43] Cover browser omnibar zoom round-trip regression
---
cmuxUITests/BrowserPaneNavigationKeybindUITests.swift | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
index 261a136d..b9d3f5b2 100644
--- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
+++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
@@ -554,7 +554,7 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
)
}
- func testCmdShiftEnterKeepsBrowserOmnibarHittableWhenWebViewFocused() {
+ func testCmdShiftEnterKeepsBrowserOmnibarHittableAcrossZoomRoundTripWhenWebViewFocused() {
let app = XCUIApplication()
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] = "1"
@@ -578,13 +578,14 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
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")
+ app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift])
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift])
- XCTAssertTrue(omnibar.waitForExistence(timeout: 6.0), "Expected browser omnibar text field after Cmd+Shift+Enter")
- XCTAssertTrue(pill.waitForExistence(timeout: 6.0), "Expected browser omnibar pill after Cmd+Shift+Enter")
+ 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"
+ "Expected browser omnibar to stay hittable after Cmd+Shift+Enter zoom round-trip"
)
pill.click()
From 034f157febd109d153a83f9d3af24aed13650467 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Tue, 10 Mar 2026 00:41:00 -0700
Subject: [PATCH 10/43] Keep browser omnibar visible across pane zoom
---
Sources/Panels/BrowserPanel.swift | 70 ++++++++++++++++++++++++++++++-
Sources/Workspace.swift | 47 ++++++++++++++++++++-
2 files changed, 114 insertions(+), 3 deletions(-)
diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift
index aaad9f23..d9c06dc6 100644
--- a/Sources/Panels/BrowserPanel.swift
+++ b/Sources/Panels/BrowserPanel.swift
@@ -1720,7 +1720,13 @@ final class BrowserPanel: Panel, ObservableObject {
let inWindow: Bool
let area: CGFloat
}
+ private struct PortalHostLock {
+ let hostId: ObjectIdentifier
+ let paneId: UUID
+ }
private var activePortalHostLease: PortalHostLease?
+ private var pendingDistinctPortalHostReplacementPaneId: UUID?
+ private var lockedPortalHost: PortalHostLock?
private var webViewCancellables = Set()
private var navigationDelegate: BrowserNavigationDelegate?
private var uiDelegate: BrowserUIDelegate?
@@ -1773,6 +1779,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,
@@ -1788,6 +1810,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
@@ -1795,12 +1822,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)) " +
@@ -1820,7 +1882,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
@@ -1842,6 +1905,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)) " +
diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift
index 9be189c3..1d8e569e 100644
--- a/Sources/Workspace.swift
+++ b/Sources/Workspace.swift
@@ -3217,11 +3217,19 @@ 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)
- if browserPanel(for: panelId) != nil {
+ 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
}
@@ -3525,6 +3533,43 @@ final class Workspace: Identifiable, ObservableObject {
}
}
+ // 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 }
From d089f6df1841313a4fd44c55b80d2316da3034bd Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Tue, 10 Mar 2026 02:01:57 -0700
Subject: [PATCH 11/43] fix: restore split terminal resize after Cmd-D
---
Sources/GhosttyTerminalView.swift | 102 +++++++++++++++++++++++++++++-
Sources/Workspace.swift | 26 ++++++++
2 files changed, 125 insertions(+), 3 deletions(-)
diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift
index 86cf40f3..4f4059ec 100644
--- a/Sources/GhosttyTerminalView.swift
+++ b/Sources/GhosttyTerminalView.swift
@@ -3351,9 +3351,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
@@ -3651,11 +3652,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 }
@@ -3675,7 +3704,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 {
@@ -4584,6 +4614,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
@@ -4591,6 +4622,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)
@@ -4611,6 +4643,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)
@@ -4625,6 +4658,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
}
+ if shouldRefreshAfterTextInput {
+ terminalSurface?.forceRefresh(reason: "keyDown.textInput")
+ }
+
// Rendering is driven by Ghostty's wakeups/renderer.
}
@@ -5391,6 +5428,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
@@ -6609,6 +6647,7 @@ final class GhosttySurfaceScrollView: NSView {
if let fr = window.firstResponder as? NSView,
fr === surfaceView || fr.isDescendant(of: surfaceView) {
+ reassertTerminalSurfaceFocus(reason: "ensureFocus.alreadyFirstResponder")
return
}
@@ -6619,9 +6658,27 @@ final class GhosttySurfaceScrollView: NSView {
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() {
@@ -6640,6 +6697,33 @@ final class GhosttySurfaceScrollView: NSView {
return fr === surfaceView || fr.isDescendant(of: surfaceView)
}
+ 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
@@ -6660,6 +6744,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)
@@ -6667,6 +6759,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.
@@ -6680,6 +6773,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.
diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift
index 0114508e..1888ff62 100644
--- a/Sources/Workspace.swift
+++ b/Sources/Workspace.swift
@@ -3109,6 +3109,32 @@ final class Workspace: Identifiable, ObservableObject {
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(
From 948b09a7c76deb7647030747e47f983baf91840d Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 02:08:20 -0700
Subject: [PATCH 12/43] Add regression test for serve-web token cleanup (#1141)
* Add regression coverage for serve-web token cleanup
* Address review feedback on token cleanup test
---
Sources/AppDelegate.swift | 26 +++++++++++++++++++
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 17 ++++++++++++
2 files changed, 43 insertions(+)
diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift
index 550650e8..10428aae 100644
--- a/Sources/AppDelegate.swift
+++ b/Sources/AppDelegate.swift
@@ -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))
}
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 1dfb4378..259eecf8 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -6379,6 +6379,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 {
From 24f35e4665bdb75808c0014b51f8bb5050a2e714 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Tue, 10 Mar 2026 12:22:59 -0700
Subject: [PATCH 13/43] Fix browser pane zoom layering
---
Sources/WorkspaceContentView.swift | 8 +++++
.../BrowserPaneNavigationKeybindUITests.swift | 34 +++++++++++++++++++
2 files changed, 42 insertions(+)
diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift
index edb26258..700e65c0 100644
--- a/Sources/WorkspaceContentView.swift
+++ b/Sources/WorkspaceContentView.swift
@@ -106,6 +106,10 @@ struct WorkspaceContentView: View {
workspace.bonsplitController.focusPane(paneId)
}
}
+ // 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 +178,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,
diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
index b9d3f5b2..4d87505f 100644
--- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
+++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
@@ -571,6 +571,11 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
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
@@ -578,6 +583,24 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
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])
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [.command, .shift])
@@ -587,6 +610,13 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
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])
@@ -866,6 +896,10 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
"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(
From df9a6ba0df0239fed4cfc2ac7c6002361fbb4ca5 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Tue, 10 Mar 2026 13:08:35 -0700
Subject: [PATCH 14/43] Add split-zoom portal visibility regression coverage
---
Sources/AppDelegate.swift | 77 +++++++++++++++++++
Sources/BrowserWindowPortal.swift | 26 +++++++
Sources/GhosttyTerminalView.swift | 9 +++
.../BrowserPaneNavigationKeybindUITests.swift | 69 +++++++++++++++++
4 files changed, 181 insertions(+)
diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift
index 10428aae..6f710549 100644
--- a/Sources/AppDelegate.swift
+++ b/Sources/AppDelegate.swift
@@ -6241,6 +6241,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)
@@ -7544,6 +7618,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSplitZoom)) {
_ = tabManager?.toggleFocusedSplitZoom()
+#if DEBUG
+ recordGotoSplitZoomIfNeeded()
+#endif
return true
}
diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift
index 97151a08..d9a8cf26 100644
--- a/Sources/BrowserWindowPortal.swift
+++ b/Sources/BrowserWindowPortal.swift
@@ -2856,6 +2856,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 +2888,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] = [:]
@@ -3038,6 +3057,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
diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift
index 4f4059ec..49c059f0 100644
--- a/Sources/GhosttyTerminalView.swift
+++ b/Sources/GhosttyTerminalView.swift
@@ -6382,6 +6382,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
diff --git a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
index 4d87505f..e024151c 100644
--- a/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
+++ b/cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
@@ -602,7 +602,23 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
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")
@@ -629,6 +645,59 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
)
}
+ 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
From 424421e51ceeb21b3f77a75858d79c610f3deba6 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Tue, 10 Mar 2026 13:08:44 -0700
Subject: [PATCH 15/43] Fix split zoom portal layering across pane types
---
Sources/Workspace.swift | 173 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 173 insertions(+)
diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift
index b89acc63..8962b2ec 100644
--- a/Sources/Workspace.swift
+++ b/Sources/Workspace.swift
@@ -3251,6 +3251,14 @@ final class Workspace: Identifiable, ObservableObject {
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,
@@ -3523,6 +3531,171 @@ final class Workspace: Identifiable, ObservableObject {
}
}
+ private func renderedVisiblePanelIdsForCurrentLayout() -> Set {
+ let renderedPaneIds = bonsplitController.zoomedPaneId.map { [$0] } ?? bonsplitController.allPaneIds
+ var visiblePanelIds: Set = []
+
+ 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) {
From 6b6d551f6257565e869897dc33b3bec1097814fd Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 18:45:02 -0700
Subject: [PATCH 16/43] Constrain notification sound picker width (#1168)
---
Sources/cmuxApp.swift | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift
index 99ea9f8f..e4c7d1fa 100644
--- a/Sources/cmuxApp.swift
+++ b/Sources/cmuxApp.swift
@@ -2833,6 +2833,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 +3322,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 +3383,7 @@ struct SettingsView: View {
}
}
}
+ .frame(maxWidth: .infinity, alignment: .trailing)
}
SettingsCardDivider()
From eea6cdc1bd866d6996c880d494d2e59851413125 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Tue, 10 Mar 2026 19:18:30 -0700
Subject: [PATCH 17/43] works
---
Sources/Panels/BrowserPanel.swift | 112 ++++++++--
Sources/Panels/BrowserPanelView.swift | 205 +++++++++++++++++-
Sources/TerminalController.swift | 3 +
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 13 ++
...t_browser_devtools_visibility_stability.py | 110 ++++++++++
5 files changed, 416 insertions(+), 27 deletions(-)
create mode 100644 tests_v2/test_browser_devtools_visibility_stability.py
diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift
index aaad9f23..513e8efe 100644
--- a/Sources/Panels/BrowserPanel.swift
+++ b/Sources/Panels/BrowserPanel.swift
@@ -1744,7 +1744,7 @@ 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 forceDeveloperToolsRefreshOnNextAttach: Bool = false
private var developerToolsRestoreRetryWorkItem: DispatchWorkItem?
private var developerToolsRestoreRetryAttempt: Int = 0
@@ -2752,6 +2752,70 @@ extension BrowserPanel {
webView.stopLoading()
}
+ private func attachDeveloperToolsIfSupported(_ inspector: NSObject) {
+ let attachSelector = NSSelectorFromString("attach")
+ if inspector.responds(to: attachSelector) {
+ inspector.cmuxCallVoid(selector: attachSelector)
+ }
+ }
+
+ private func isDeveloperToolsAttached(_ inspector: NSObject) -> Bool? {
+ inspector.cmuxCallBool(selector: NSSelectorFromString("isAttached"))
+ }
+
+ 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 dismissDetachedDeveloperToolsWindowsIfNeeded() {
+ 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() {
+ for delay in [0.0, 0.15] {
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
+ self?.dismissDetachedDeveloperToolsWindowsIfNeeded()
+ }
+ }
+ }
+
+ @discardableResult
+ private func revealDeveloperTools(_ inspector: NSObject) -> Bool {
+ attachDeveloperToolsIfSupported(inspector)
+
+ let isVisibleSelector = NSSelectorFromString("isVisible")
+ if inspector.cmuxCallBool(selector: isVisibleSelector) ?? false {
+ return true
+ }
+
+ let showSelector = NSSelectorFromString("show")
+ guard inspector.responds(to: showSelector) else { return false }
+ inspector.cmuxCallVoid(selector: showSelector)
+ return inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
+ }
+
@discardableResult
func toggleDeveloperTools() -> Bool {
#if DEBUG
@@ -2764,14 +2828,19 @@ 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 {
+ let selector = NSSelectorFromString("close")
+ guard inspector.responds(to: selector) else { return false }
+ inspector.cmuxCallVoid(selector: selector)
+ }
preferredDeveloperToolsVisible = targetVisible
if targetVisible {
let visibleAfterToggle = inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
if visibleAfterToggle {
cancelDeveloperToolsRestoreRetry()
+ scheduleDetachedDeveloperToolsWindowDismissal()
} else {
developerToolsRestoreRetryAttempt = 0
scheduleDeveloperToolsRestoreRetry()
@@ -2800,14 +2869,14 @@ extension BrowserPanel {
func showDeveloperTools() -> Bool {
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)
+ let attached = isDeveloperToolsAttached(inspector) ?? false
+ if !visible || !attached {
+ guard revealDeveloperTools(inspector) || visible else { return false }
}
preferredDeveloperToolsVisible = true
if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) {
cancelDeveloperToolsRestoreRetry()
+ scheduleDetachedDeveloperToolsWindowDismissal()
} else {
scheduleDeveloperToolsRestoreRetry()
}
@@ -2866,7 +2935,8 @@ extension BrowserPanel {
forceDeveloperToolsRefreshOnNextAttach = false
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
- if visible {
+ let attached = isDeveloperToolsAttached(inspector) ?? false
+ if visible && attached {
#if DEBUG
if shouldForceRefresh {
dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
@@ -2876,26 +2946,22 @@ extension BrowserPanel {
return
}
- let selector = NSSelectorFromString("show")
- guard inspector.responds(to: selector) else {
- cancelDeveloperToolsRestoreRetry()
- 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 attach/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 {
cancelDeveloperToolsRestoreRetry()
+ scheduleDetachedDeveloperToolsWindowDismissal()
} else {
scheduleDeveloperToolsRestoreRetry()
}
@@ -2941,6 +3007,20 @@ extension BrowserPanel {
forceDeveloperToolsRefreshOnNextAttach
}
+ func shouldPreserveDeveloperToolsIntentWhileDetached() -> Bool {
+ preferredDeveloperToolsVisible &&
+ (
+ forceDeveloperToolsRefreshOnNextAttach ||
+ developerToolsRestoreRetryWorkItem != nil ||
+ webView.superview == nil ||
+ webView.window == nil
+ )
+ }
+
+ func shouldUseLocalInlineDeveloperToolsHosting() -> Bool {
+ preferredDeveloperToolsVisible || isDeveloperToolsVisible()
+ }
+
@discardableResult
func zoomIn() -> Bool {
applyPageZoom(webView.pageZoom + pageZoomStep)
diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift
index 98ae7485..bdbf7513 100644
--- a/Sources/Panels/BrowserPanelView.swift
+++ b/Sources/Panels/BrowserPanelView.swift
@@ -313,9 +313,16 @@ struct BrowserPanelView: View {
)
}
+ private var owningWorkspace: Workspace? {
+ guard let app = AppDelegate.shared,
+ let manager = app.tabManagerFor(tabId: panel.workspaceId) else {
+ return nil
+ }
+ return manager.tabs.first(where: { $0.id == panel.workspaceId })
+ }
+
private var isCurrentPaneOwner: Bool {
- guard let workspace = AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == panel.workspaceId }),
- let currentPaneId = workspace.paneId(forPanelId: panel.id) else {
+ guard let currentPaneId = owningWorkspace?.paneId(forPanelId: panel.id) else {
return false
}
return currentPaneId.id == paneId.id
@@ -468,7 +475,10 @@ struct BrowserPanelView: View {
hideSuggestions()
setAddressBarFocused(false, reason: "panelFocus.onChange.unfocused")
}
- syncWebViewResponderPolicyWithViewState(reason: "panelFocusChanged")
+ syncWebViewResponderPolicyWithViewState(
+ reason: "panelFocusChanged",
+ isPanelFocusedOverride: focused
+ )
}
.onChange(of: addressBarFocused) { focused in
#if DEBUG
@@ -802,12 +812,18 @@ struct BrowserPanelView: View {
}
private var webView: some View {
- Group {
+ let useLocalInlineDeveloperToolsHosting =
+ panel.shouldUseLocalInlineDeveloperToolsHosting() &&
+ isVisibleInUI &&
+ isCurrentPaneOwner
+
+ return Group {
if panel.shouldRenderWebView {
WebViewRepresentable(
panel: panel,
paneId: paneId,
- shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner,
+ shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner && !useLocalInlineDeveloperToolsHosting,
+ useLocalInlineHosting: useLocalInlineDeveloperToolsHosting,
shouldFocusWebView: isFocused && !addressBarFocused,
isPanelFocused: isFocused,
portalZPriority: portalPriority,
@@ -881,15 +897,20 @@ struct BrowserPanelView: View {
}
}
- private func syncWebViewResponderPolicyWithViewState(reason: String) {
+ private func syncWebViewResponderPolicyWithViewState(
+ reason: String,
+ isPanelFocusedOverride: Bool? = nil
+ ) {
guard let cmuxWebView = panel.webView as? CmuxWebView else { return }
- let next = isFocused && !panel.shouldSuppressWebViewFocus()
+ let isPanelFocused = isPanelFocusedOverride ?? isFocused
+ let next = isPanelFocused && !panel.shouldSuppressWebViewFocus()
if cmuxWebView.allowsFirstResponderAcquisition != next {
#if DEBUG
dlog(
"browser.focus.policy.resync panel=\(panel.id.uuidString.prefix(5)) " +
"web=\(ObjectIdentifier(cmuxWebView)) old=\(cmuxWebView.allowsFirstResponderAcquisition ? 1 : 0) " +
- "new=\(next ? 1 : 0) reason=\(reason)"
+ "new=\(next ? 1 : 0) reason=\(reason) " +
+ "panelFocusedUsed=\(isPanelFocused ? 1 : 0)"
)
#endif
}
@@ -3519,6 +3540,7 @@ struct WebViewRepresentable: NSViewRepresentable {
let panel: BrowserPanel
let paneId: PaneID
let shouldAttachWebView: Bool
+ let useLocalInlineHosting: Bool
let shouldFocusWebView: Bool
let isPanelFocused: Bool
let portalZPriority: Int
@@ -3541,6 +3563,10 @@ struct WebViewRepresentable: NSViewRepresentable {
var onGeometryChanged: (() -> Void)?
private(set) var geometryRevision: UInt64 = 0
private var lastReportedGeometryState: GeometryState?
+ private weak var hostedWebView: WKWebView?
+ private var hostedWebViewConstraints: [NSLayoutConstraint] = []
+ private weak var localInlineSlotView: WindowBrowserSlotView?
+ private var localInlineSlotConstraints: [NSLayoutConstraint] = []
private struct HostedInspectorDividerHit {
let containerView: NSView
let pageView: NSView
@@ -3701,6 +3727,65 @@ struct WebViewRepresentable: NSViewRepresentable {
onGeometryChanged?()
}
+ func ensureLocalInlineSlotView() -> WindowBrowserSlotView {
+ if let localInlineSlotView, localInlineSlotView.superview === self {
+ localInlineSlotView.isHidden = false
+ return localInlineSlotView
+ }
+
+ let slotView = WindowBrowserSlotView(frame: bounds)
+ slotView.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(slotView, positioned: .above, relativeTo: nil)
+ localInlineSlotConstraints = [
+ slotView.topAnchor.constraint(equalTo: topAnchor),
+ slotView.bottomAnchor.constraint(equalTo: bottomAnchor),
+ slotView.leadingAnchor.constraint(equalTo: leadingAnchor),
+ slotView.trailingAnchor.constraint(equalTo: trailingAnchor),
+ ]
+ NSLayoutConstraint.activate(localInlineSlotConstraints)
+ localInlineSlotView = slotView
+ return slotView
+ }
+
+ func setLocalInlineSlotHidden(_ hidden: Bool) {
+ localInlineSlotView?.isHidden = hidden
+ }
+
+ func releaseHostedWebViewConstraints() {
+ NSLayoutConstraint.deactivate(hostedWebViewConstraints)
+ hostedWebViewConstraints = []
+ hostedWebView = nil
+ }
+
+ func pinHostedWebView(_ webView: WKWebView, in container: NSView) {
+ guard webView.superview === container else { return }
+
+ let needsFrameHosting =
+ hostedWebView !== webView ||
+ !hostedWebViewConstraints.isEmpty ||
+ !webView.translatesAutoresizingMaskIntoConstraints ||
+ webView.autoresizingMask != [.width, .height] ||
+ webView.frame != container.bounds
+ guard needsFrameHosting else {
+ needsLayout = true
+ layoutSubtreeIfNeeded()
+ return
+ }
+
+ NSLayoutConstraint.deactivate(hostedWebViewConstraints)
+ hostedWebViewConstraints = []
+ hostedWebView = webView
+
+ // WebKit's attached inspector does not reliably dock into a constraint-managed
+ // WKWebView hierarchy on macOS. Host the moved webview with autoresizing so
+ // the inspector can resize the content view in place.
+ webView.translatesAutoresizingMaskIntoConstraints = true
+ webView.autoresizingMask = [.width, .height]
+ webView.frame = container.bounds
+ needsLayout = true
+ layoutSubtreeIfNeeded()
+ }
+
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
@@ -4279,6 +4364,40 @@ struct WebViewRepresentable: NSViewRepresentable {
host.onGeometryChanged = nil
}
+ private static func moveWebKitRelatedSubviewsIntoHostIfNeeded(
+ from sourceSuperview: NSView,
+ to container: WindowBrowserSlotView,
+ primaryWebView: WKWebView,
+ reason: String
+ ) {
+ guard sourceSuperview !== container else { return }
+ let relatedSubviews = sourceSuperview.subviews.filter { view in
+ if view === primaryWebView { return true }
+ return String(describing: type(of: view)).contains("WK")
+ }
+ guard !relatedSubviews.isEmpty else { return }
+#if DEBUG
+ dlog(
+ "browser.localHost.reparent.batch reason=\(reason) source=\(Self.objectID(sourceSuperview)) " +
+ "container=\(Self.objectID(container)) count=\(relatedSubviews.count) " +
+ "sourceType=\(String(describing: type(of: sourceSuperview))) targetType=\(String(describing: type(of: container)))"
+ )
+#endif
+ for view in relatedSubviews {
+ let frameInWindow = sourceSuperview.convert(view.frame, to: nil)
+ let className = String(describing: type(of: view))
+ view.removeFromSuperview()
+ container.addSubview(view, positioned: .above, relativeTo: nil)
+ view.frame = container.convert(frameInWindow, from: nil)
+#if DEBUG
+ dlog(
+ "browser.localHost.reparent.batch.item reason=\(reason) class=\(className) " +
+ "view=\(Self.objectID(view))"
+ )
+#endif
+ }
+ }
+
private static func installPortalAnchorView(_ anchorView: NSView, in host: NSView) {
// SwiftUI can keep transient replacement hosts alive off-window during split
// reparenting. Never let those hosts steal the shared portal anchor, or the
@@ -4307,8 +4426,66 @@ struct WebViewRepresentable: NSViewRepresentable {
host.layoutSubtreeIfNeeded()
}
+ private func updateUsingLocalInlineHosting(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool {
+ guard let host = nsView as? HostContainerView else { return false }
+ let slotView = host.ensureLocalInlineSlotView()
+
+ let coordinator = context.coordinator
+ coordinator.desiredPortalVisibleInUI = false
+ coordinator.desiredPortalZPriority = 0
+ coordinator.attachGeneration += 1
+
+ if panel.releasePortalHostIfOwned(
+ hostId: ObjectIdentifier(host),
+ reason: "localInlineHosting"
+ ) {
+ BrowserWindowPortalRegistry.hide(
+ webView: webView,
+ source: "viewStateChanged.localInlineHosting"
+ )
+ }
+
+ if webView.superview !== slotView {
+ if let sourceSuperview = webView.superview {
+ Self.moveWebKitRelatedSubviewsIntoHostIfNeeded(
+ from: sourceSuperview,
+ to: slotView,
+ primaryWebView: webView,
+ reason: "attachLocalHost"
+ )
+ } else {
+ slotView.addSubview(webView, positioned: .above, relativeTo: nil)
+ }
+ }
+
+ slotView.isHidden = false
+ host.pinHostedWebView(webView, in: slotView)
+ coordinator.lastPortalHostId = nil
+ coordinator.lastSynchronizedHostGeometryRevision = 0
+ panel.restoreDeveloperToolsAfterAttachIfNeeded()
+ webView.needsLayout = true
+ webView.layoutSubtreeIfNeeded()
+ slotView.layoutSubtreeIfNeeded()
+ host.displayIfNeeded()
+ slotView.displayIfNeeded()
+ webView.displayIfNeeded()
+
+#if DEBUG
+ Self.logDevToolsState(
+ panel,
+ event: "localHost.update",
+ generation: coordinator.attachGeneration,
+ retryCount: 0,
+ details: Self.attachContext(webView: webView, host: host)
+ )
+#endif
+ return true
+ }
+
private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool {
guard let host = nsView as? HostContainerView else { return false }
+ host.setLocalInlineSlotHidden(true)
+ host.releaseHostedWebViewConstraints()
let coordinator = context.coordinator
let paneDropContext = currentPaneDropContext()
@@ -4431,7 +4608,9 @@ struct WebViewRepresentable: NSViewRepresentable {
if !shouldAttachWebView {
// In portal mode we no longer detach/re-attach to preserve DevTools state.
// Sync the inspector preference directly so manual closes are respected.
- panel.syncDeveloperToolsPreferenceFromInspector()
+ panel.syncDeveloperToolsPreferenceFromInspector(
+ preserveVisibleIntent: panel.shouldPreserveDeveloperToolsIntentWhileDetached()
+ )
}
if host.window != nil, portalHostAccepted {
@@ -4518,7 +4697,9 @@ struct WebViewRepresentable: NSViewRepresentable {
coordinator.webView = webView
Self.clearPortalCallbacks(for: nsView)
- let hostOwnsPortal = updateUsingWindowPortal(nsView, context: context, webView: webView)
+ let hostOwnsPortal = useLocalInlineHosting
+ ? updateUsingLocalInlineHosting(nsView, context: context, webView: webView)
+ : updateUsingWindowPortal(nsView, context: context, webView: webView)
Self.applyWebViewFirstResponderPolicy(
panel: panel,
webView: webView,
@@ -4658,7 +4839,9 @@ struct WebViewRepresentable: NSViewRepresentable {
}
private func currentPaneDropContext() -> BrowserPaneDropContext? {
- guard let workspace = AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == panel.workspaceId }),
+ guard let app = AppDelegate.shared,
+ let manager = app.tabManagerFor(tabId: panel.workspaceId),
+ let workspace = manager.tabs.first(where: { $0.id == panel.workspaceId }),
let paneId = workspace.paneId(forPanelId: panel.id) else {
return nil
}
diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift
index 1f134353..3c3b5905 100644
--- a/Sources/TerminalController.swift
+++ b/Sources/TerminalController.swift
@@ -3656,6 +3656,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
}
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 3216a55d..ae969468 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -2389,14 +2389,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 +2417,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
@objc func close() {
closeCount += 1
visible = false
+ attached = false
}
}
diff --git a/tests_v2/test_browser_devtools_visibility_stability.py b/tests_v2/test_browser_devtools_visibility_stability.py
new file mode 100644
index 00000000..01ca9e32
--- /dev/null
+++ b/tests_v2/test_browser_devtools_visibility_stability.py
@@ -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())
From 527dfa6292acbcbebe41ad58a00e1b91761b5e75 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 19:30:17 -0700
Subject: [PATCH 18/43] Add Jump to Latest to the notifications popover (#1167)
* Add jump-to-latest button to notifications popover
* Fix jump-to-latest popover accessibility
* Relax shortcut badge UI test expectation
* Stabilize jump-to-latest UI coverage
* Keep popover notification actions visible when empty
* Inline jump-to-latest shortcut label
* Match jump-to-latest shortcut label styling
---
Resources/Localizable.xcstrings | 17 +++++++
Sources/NotificationsPage.swift | 12 ++++-
Sources/Update/UpdateTitlebarAccessory.swift | 51 +++++++++++++++++--
.../MultiWindowNotificationsUITests.swift | 31 +++++++++++
4 files changed, 106 insertions(+), 5 deletions(-)
diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings
index 8a729692..2d15e651 100644
--- a/Resources/Localizable.xcstrings
+++ b/Resources/Localizable.xcstrings
@@ -38961,6 +38961,23 @@
}
}
},
+ "notifications.jumpToLatest": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Jump to Latest"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "最新へジャンプ"
+ }
+ }
+ }
+ },
"notifications.jumpToLatestUnread": {
"extractionState": "manual",
"localizations": {
diff --git a/Sources/NotificationsPage.swift b/Sources/NotificationsPage.swift
index 096dce20..91f77793 100644
--- a/Sources/NotificationsPage.swift
+++ b/Sources/NotificationsPage.swift
@@ -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)
diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift
index 1e4795ac..1df2b75a 100644
--- a/Sources/Update/UpdateTitlebarAccessory.swift
+++ b/Sources/Update/UpdateTitlebarAccessory.swift
@@ -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.
diff --git a/cmuxUITests/MultiWindowNotificationsUITests.swift b/cmuxUITests/MultiWindowNotificationsUITests.swift
index 632ad44d..d6f282a9 100644
--- a/cmuxUITests/MultiWindowNotificationsUITests.swift
+++ b/cmuxUITests/MultiWindowNotificationsUITests.swift
@@ -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() ?? ""
From ec10dfdaecab45832d7679e6e5c1196229c299db Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 19:31:52 -0700
Subject: [PATCH 19/43] Add browser find focus debug logs (#1162)
* Add browser find focus debug logs
* Allow browser find bar focus in portal host
* Add split and terminal find debug logs
* Avoid stealing search focus across splits
* Generalize panel focus restore intent
* Unify split focus intent activation
* Address focus restore review feedback
* Yield inactive panel focus before restore
* Gate browser find focus retries by generation
* Avoid repeated browser focus invalidation
* Keep browser find ownership while find bar is open
---
Sources/AppDelegate.swift | 9 +-
Sources/BrowserWindowPortal.swift | 119 +++++++++++-
Sources/ContentView.swift | 95 +++++-----
Sources/Find/BrowserSearchOverlay.swift | 70 ++++++-
Sources/Find/SurfaceSearchOverlay.swift | 13 +-
Sources/GhosttyTerminalView.swift | 217 ++++++++++++++++++++-
Sources/Panels/BrowserPanel.swift | 233 ++++++++++++++++++++++-
Sources/Panels/BrowserPanelView.swift | 32 +++-
Sources/Panels/Panel.swift | 71 +++++++
Sources/Panels/TerminalPanel.swift | 38 ++++
Sources/Workspace.swift | 238 +++++++++++++++++++-----
11 files changed, 1019 insertions(+), 116 deletions(-)
diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift
index 6f710549..1447c796 100644
--- a/Sources/AppDelegate.swift
+++ b/Sources/AppDelegate.swift
@@ -10327,7 +10327,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
}
diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift
index d9a8cf26..992c5195 100644
--- a/Sources/BrowserWindowPortal.swift
+++ b/Sources/BrowserWindowPortal.swift
@@ -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 {
@@ -978,9 +990,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 {
@@ -1420,23 +1435,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 +1507,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,6 +1521,25 @@ 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) {
@@ -1872,7 +1952,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
}
@@ -2144,6 +2226,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)
@@ -3031,6 +3133,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],
diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift
index 6248cd0d..3473c398 100644
--- a/Sources/ContentView.swift
+++ b/Sources/ContentView.swift
@@ -1398,15 +1398,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 +5332,7 @@ struct ContentView: View {
static func shouldRestoreBrowserAddressBarAfterCommandPaletteDismiss(
focusedPanelIsBrowser: Bool,
focusedBrowserAddressBarPanelId: UUID?,
- focusedPanelId: UUID
+ focusedPanelId: UUID?
) -> Bool {
focusedPanelIsBrowser && focusedBrowserAddressBarPanelId == focusedPanelId
}
@@ -5383,15 +5378,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 +5458,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 +5497,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 +5513,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 +5555,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 +5599,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 +5610,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)
diff --git a/Sources/Find/BrowserSearchOverlay.swift b/Sources/Find/BrowserSearchOverlay.swift
index b7f874ea..5fde1163 100644
--- a/Sources/Find/BrowserSearchOverlay.swift
+++ b/Sources/Find/BrowserSearchOverlay.swift
@@ -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(
diff --git a/Sources/Find/SurfaceSearchOverlay.swift b/Sources/Find/SurfaceSearchOverlay.swift
index aee272e9..f6ad9a40 100644
--- a/Sources/Find/SurfaceSearchOverlay.swift
+++ b/Sources/Find/SurfaceSearchOverlay.swift
@@ -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
diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift
index 49c059f0..f43efdb6 100644
--- a/Sources/GhosttyTerminalView.swift
+++ b/Sources/GhosttyTerminalView.swift
@@ -6406,10 +6406,8 @@ final class GhosttySurfaceScrollView: NSView {
#endif
if active {
applyFirstResponderIfNeeded()
- } else if let window,
- let fr = window.firstResponder as? NSView,
- fr === surfaceView || fr.isDescendant(of: surfaceView) {
- window.makeFirstResponder(nil)
+ } else {
+ resignOwnedFirstResponderIfNeeded(reason: "setActive(false)")
}
}
@@ -6459,15 +6457,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 {
@@ -6611,6 +6623,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
}
@@ -6650,6 +6668,13 @@ 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
}
@@ -6663,7 +6688,15 @@ final class GhosttySurfaceScrollView: NSView {
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()
@@ -6793,6 +6826,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)
@@ -6802,16 +6847,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.
@@ -6827,11 +7008,27 @@ final class GhosttySurfaceScrollView: NSView {
var current: NSView? = view
while let v = current {
if v is NSHostingView { 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
diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift
index d9c06dc6..37714bbb 100644
--- a/Sources/Panels/BrowserPanel.swift
+++ b/Sources/Panels/BrowserPanel.swift
@@ -1684,10 +1684,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()
@@ -1707,6 +1714,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()
}
@@ -2298,12 +2309,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)
@@ -3043,21 +3058,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)
}
@@ -3078,6 +3124,7 @@ extension BrowserPanel {
}
func hideFind() {
+ invalidateSearchFocusRequests(reason: "hideFind")
searchState = nil
}
@@ -3088,7 +3135,10 @@ extension BrowserPanel {
if replaySearch, !state.needle.isEmpty {
executeFindSearch(state.needle)
}
- postBrowserSearchFocusNotification()
+ postBrowserSearchFocusNotification(
+ reason: "restoreAfterNavigation",
+ generation: searchFocusRequestGeneration
+ )
}
private func executeFindSearch(_ needle: String) {
@@ -3215,6 +3265,8 @@ extension BrowserPanel {
@discardableResult
func requestAddressBarFocus() -> UUID {
+ preferredFocusIntent = .addressBar
+ invalidateSearchFocusRequests(reason: "requestAddressBarFocus")
beginSuppressWebViewFocusForAddressBar()
if let pendingAddressBarFocusRequestId {
#if DEBUG
@@ -3236,6 +3288,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
diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift
index 98ae7485..d452bb52 100644
--- a/Sources/Panels/BrowserPanelView.swift
+++ b/Sources/Panels/BrowserPanelView.swift
@@ -338,9 +338,14 @@ struct BrowserPanelView: View {
BrowserSearchOverlay(
panelId: panel.id,
searchState: searchState,
+ focusRequestGeneration: panel.searchFocusRequestGeneration,
+ canApplyFocusRequest: { generation in
+ panel.canApplySearchFocusRequest(generation)
+ },
onNext: { panel.findNext() },
onPrevious: { panel.findPrevious() },
- onClose: { panel.hideFind() }
+ onClose: { panel.hideFind() },
+ onFieldDidFocus: { panel.noteFindFieldFocused() }
)
}
}
@@ -816,9 +821,14 @@ struct BrowserPanelView: View {
BrowserPortalSearchOverlayConfiguration(
panelId: panel.id,
searchState: searchState,
+ focusRequestGeneration: panel.searchFocusRequestGeneration,
+ canApplyFocusRequest: { generation in
+ panel.canApplySearchFocusRequest(generation)
+ },
onNext: { panel.findNext() },
onPrevious: { panel.findPrevious() },
- onClose: { panel.hideFind() }
+ onClose: { panel.hideFind() },
+ onFieldDidFocus: { panel.noteFindFieldFocused() }
)
},
paneTopChromeHeight: addressBarHeight
@@ -911,6 +921,9 @@ struct BrowserPanelView: View {
}
#endif
addressBarFocused = focused
+ if focused {
+ panel.noteAddressBarFocused()
+ }
}
private func browserFocusResponderChainContains(
@@ -1514,6 +1527,9 @@ struct BrowserPanelView: View {
syncWebViewResponderPolicyWithViewState(reason: "effects.blurToWebView.handoff")
panel.clearWebViewFocusSuppression()
let focusedWebView = window.makeFirstResponder(panel.webView)
+ if focusedWebView {
+ panel.noteWebViewFocused()
+ }
#if DEBUG
dlog(
"browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " +
@@ -1531,10 +1547,11 @@ struct BrowserPanelView: View {
NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id)
return
}
- let hasWebViewResponder =
+ var hasWebViewResponder =
browserFocusResponderChainContains(window.firstResponder, target: panel.webView)
if !hasWebViewResponder {
let fallbackFocusedWebView = window.makeFirstResponder(panel.webView)
+ hasWebViewResponder = fallbackFocusedWebView
#if DEBUG
dlog(
"browser.focus.addressBar.exit.handoff panel=\(panel.id.uuidString.prefix(5)) " +
@@ -1543,6 +1560,9 @@ struct BrowserPanelView: View {
)
#endif
}
+ if hasWebViewResponder {
+ panel.noteWebViewFocused()
+ }
NotificationCenter.default.post(name: .browserDidExitAddressBar, object: panel.id)
}
}
@@ -4552,6 +4572,9 @@ struct WebViewRepresentable: NSViewRepresentable {
#endif
return
}
+ if isPanelFocused && responderChainContains(window.firstResponder, target: webView) {
+ panel.noteWebViewFocused()
+ }
if shouldFocusWebView {
if panel.shouldSuppressWebViewFocus() {
#if DEBUG
@@ -4572,6 +4595,9 @@ struct WebViewRepresentable: NSViewRepresentable {
return
}
let result = window.makeFirstResponder(webView)
+ if result {
+ panel.noteWebViewFocused()
+ }
#if DEBUG
dlog(
"browser.focus.content.apply panel=\(panel.id.uuidString.prefix(5)) " +
diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift
index 09ec66b6..bcbf5b7d 100644
--- a/Sources/Panels/Panel.swift
+++ b/Sources/Panels/Panel.swift
@@ -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
+ }
}
diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift
index 7e863f5d..f9d197a3 100644
--- a/Sources/Panels/TerminalPanel.swift
+++ b/Sources/Panels/TerminalPanel.swift
@@ -197,4 +197,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)
+ }
}
diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift
index 8962b2ec..007c1883 100644
--- a/Sources/Workspace.swift
+++ b/Sources/Workspace.swift
@@ -1230,7 +1230,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
@@ -3061,6 +3068,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)) " +
@@ -3070,46 +3090,36 @@ 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 {
+ let activationIntent = panels[panelId]?.preferredFocusIntentForActivation()
applyTabSelection(
tabId: tabId,
inPane: targetPaneId,
- reassertAppKitFocus: !shouldSuppressReentrantRefocus
+ reassertAppKitFocus: !shouldSuppressReentrantRefocus,
+ focusIntent: activationIntent,
+ previousTerminalHostedView: previousTerminalHostedView
)
}
- if let browserPanel = panels[panelId] as? BrowserPanel {
- // Keep browser find focus behavior aligned with terminal find behavior.
- // When switching back to a pane with an already-open find bar, reassert
- // focus to that field instead of leaving first responder stale.
- if browserPanel.searchState != nil {
- browserPanel.startFind()
- } else {
- maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger)
- }
- }
-
if trigger == .terminalFirstResponder,
panels[panelId] is TerminalPanel {
scheduleTerminalFirstResponderReassert(panelId: panelId)
@@ -4044,9 +4054,17 @@ extension Workspace: BonsplitDelegate {
private func applyTabSelection(
tabId: TabID,
inPane pane: PaneID,
- reassertAppKitFocus: Bool = true
+ reassertAppKitFocus: Bool = true,
+ focusIntent: PanelFocusIntent? = nil,
+ previousTerminalHostedView: GhosttySurfaceScrollView? = nil
) {
- pendingTabSelection = (tabId: tabId, pane: pane)
+ pendingTabSelection = PendingTabSelectionRequest(
+ tabId: tabId,
+ pane: pane,
+ reassertAppKitFocus: reassertAppKitFocus,
+ focusIntent: focusIntent,
+ previousTerminalHostedView: previousTerminalHostedView
+ )
guard !isApplyingTabSelection else { return }
isApplyingTabSelection = true
defer {
@@ -4062,7 +4080,9 @@ extension Workspace: BonsplitDelegate {
applyTabSelectionNow(
tabId: request.tabId,
inPane: request.pane,
- reassertAppKitFocus: reassertAppKitFocus
+ reassertAppKitFocus: request.reassertAppKitFocus,
+ focusIntent: request.focusIntent,
+ previousTerminalHostedView: request.previousTerminalHostedView
)
}
}
@@ -4070,9 +4090,23 @@ extension Workspace: BonsplitDelegate {
private func applyTabSelectionNow(
tabId: TabID,
inPane pane: PaneID,
- reassertAppKitFocus: Bool
+ 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)
@@ -4107,6 +4141,8 @@ extension Workspace: BonsplitDelegate {
if shouldTreatCurrentEventAsExplicitFocusIntent() {
markExplicitFocusIntent(on: panelId)
}
+ let activationIntent = focusIntent ?? panel.preferredFocusIntentForActivation()
+ panel.prepareFocusIntentForActivation(activationIntent)
syncPinnedStateForTab(selectedTabId, panelId: panelId)
syncUnreadBadgeStateForPanel(panelId)
@@ -4116,11 +4152,24 @@ extension Workspace: BonsplitDelegate {
p.unfocus()
}
- activatePanel(panel, reassertAppKitFocus: reassertAppKitFocus)
+ 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)
}
@@ -4149,9 +4198,32 @@ 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 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
@@ -4168,28 +4240,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
) {
- guard !reassertAppKitFocus else {
- panel.focus()
- return
- }
-
- // `GhosttyNSView.becomeFirstResponder -> onFocus -> focusPanel` is already inside
- // AppKit's responder transition. Re-running `makeFirstResponder` here can recurse,
- // but we still need to converge the selected panel's active/focus state and clear any
- // stale sibling terminal activation so split-pane clicks recover cleanly.
if let terminalPanel = panel as? TerminalPanel {
- terminalPanel.surface.setFocus(true)
+ let shouldFocusTerminalSurface = shouldMoveTerminalSurfaceFocus(for: focusIntent)
+ terminalPanel.surface.setFocus(shouldFocusTerminalSurface)
terminalPanel.hostedView.setActive(true)
+ if reassertAppKitFocus && shouldFocusTerminalSurface {
+ terminalPanel.focus()
+ }
return
}
- panel.focus()
+ 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(
From 682e5bfca801878e277f4992f23a23c2d58764e9 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Tue, 10 Mar 2026 19:48:38 -0700
Subject: [PATCH 20/43] Add regression coverage for side-docked DevTools resize
---
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 142 ++++++++++++++++++
1 file changed, 142 insertions(+)
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index ae969468..8c9b02cd 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -8047,6 +8047,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 {
@@ -8569,6 +8577,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),
@@ -8636,6 +8703,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,
@@ -8802,6 +8877,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),
@@ -8867,6 +8995,20 @@ 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)
+ }
}
@MainActor
From 91061ce341c1a29335e0976e49dcedbe65ea4ce8 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Tue, 10 Mar 2026 19:49:06 -0700
Subject: [PATCH 21/43] Fix side-docked browser DevTools resizing
---
Sources/BrowserWindowPortal.swift | 238 ++++++++++++++----
Sources/Panels/BrowserPanelView.swift | 94 ++++---
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 54 ++++
3 files changed, 302 insertions(+), 84 deletions(-)
diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift
index 97151a08..1f02faf4 100644
--- a/Sources/BrowserWindowPortal.swift
+++ b/Sources/BrowserWindowPortal.swift
@@ -81,6 +81,113 @@ 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
+ ) -> (pageFrame: NSRect, inspectorFrame: NSRect) {
+ switch self {
+ case .leading:
+ let maximumInspectorWidth = max(0, pageFrame.maxX - containerBounds.minX)
+ let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth))
+ let dividerX = min(pageFrame.maxX, containerBounds.minX + clampedInspectorWidth)
+
+ var nextPageFrame = pageFrame
+ nextPageFrame.origin.x = dividerX
+ nextPageFrame.size.width = max(0, pageFrame.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.maxX - pageFrame.minX)
+ let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth))
+ let dividerX = max(pageFrame.minX, containerBounds.maxX - clampedInspectorWidth)
+
+ var nextPageFrame = pageFrame
+ nextPageFrame.size.width = max(0, dividerX - pageFrame.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 +204,7 @@ final class WindowBrowserHostView: NSView {
let containerView: NSView
let pageView: NSView
let inspectorView: NSView
+ let dockSide: HostedInspectorDockSide
}
private struct HostedInspectorDividerDragState {
@@ -104,6 +212,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
@@ -383,6 +492,7 @@ final class WindowBrowserHostView: NSView {
containerView: hostedInspectorHit.containerView,
pageView: hostedInspectorHit.pageView,
inspectorView: hostedInspectorHit.inspectorView,
+ dockSide: hostedInspectorHit.dockSide,
initialWindowX: event.locationInWindow.x,
initialPageFrame: hostedInspectorHit.pageView.frame,
initialInspectorFrame: hostedInspectorHit.inspectorView.frame
@@ -414,11 +524,21 @@ 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
let appliedFrames = applyHostedInspectorDividerWidth(
@@ -427,7 +547,8 @@ final class WindowBrowserHostView: NSView {
slotView: dragState.slotView,
containerView: dragState.containerView,
pageView: dragState.pageView,
- inspectorView: dragState.inspectorView
+ inspectorView: dragState.inspectorView,
+ dockSide: dragState.dockSide
),
reason: "drag"
)
@@ -438,7 +559,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
@@ -710,22 +832,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 +870,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
)
}
@@ -792,16 +921,14 @@ 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)
-
- 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 nextFrames = hit.dockSide.resizedFrames(
+ preferredWidth: preferredWidth,
+ in: containerBounds,
+ pageFrame: hit.pageView.frame,
+ inspectorFrame: hit.inspectorView.frame
+ )
+ let pageFrame = nextFrames.pageFrame
+ let inspectorFrame = nextFrames.inspectorFrame
let pageChanged = !Self.rectApproximatelyEqual(pageFrame, hit.pageView.frame, epsilon: 0.5)
let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, hit.inspectorView.frame, epsilon: 0.5)
@@ -1465,27 +1592,26 @@ final class WindowBrowserSlotView: NSView {
func pinHostedWebView(_ webView: WKWebView) {
guard webView.superview === self else { return }
- let needsNewConstraints =
+ let needsFrameHosting =
hostedWebView !== webView ||
- hostedWebViewConstraints.isEmpty ||
- webView.translatesAutoresizingMaskIntoConstraints
- guard needsNewConstraints else {
+ !hostedWebViewConstraints.isEmpty ||
+ !webView.translatesAutoresizingMaskIntoConstraints ||
+ webView.autoresizingMask != [.width, .height] ||
+ !Self.rectApproximatelyEqual(webView.frame, bounds)
+ 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.
+ // Edge constraints fight side-docked resizing and cause visible churn.
+ webView.translatesAutoresizingMaskIntoConstraints = true
+ webView.autoresizingMask = [.width, .height]
+ webView.frame = bounds
needsLayout = true
layoutSubtreeIfNeeded()
}
@@ -1687,6 +1813,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 +1856,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()
}
})
diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift
index bdbf7513..fa10ff45 100644
--- a/Sources/Panels/BrowserPanelView.swift
+++ b/Sources/Panels/BrowserPanelView.swift
@@ -3571,6 +3571,7 @@ struct WebViewRepresentable: NSViewRepresentable {
let containerView: NSView
let pageView: NSView
let inspectorView: NSView
+ let dockSide: HostedInspectorDockSide
}
private struct GeometryState: Equatable {
@@ -3584,6 +3585,7 @@ struct WebViewRepresentable: NSViewRepresentable {
let containerView: NSView
let pageView: NSView
let inspectorView: NSView
+ let dockSide: HostedInspectorDockSide
let initialWindowX: CGFloat
let initialPageFrame: NSRect
let initialInspectorFrame: NSRect
@@ -3917,6 +3919,7 @@ struct WebViewRepresentable: NSViewRepresentable {
containerView: hostedInspectorHit.containerView,
pageView: hostedInspectorHit.pageView,
inspectorView: hostedInspectorHit.inspectorView,
+ dockSide: hostedInspectorHit.dockSide,
initialWindowX: event.locationInWindow.x,
initialPageFrame: hostedInspectorHit.pageView.frame,
initialInspectorFrame: hostedInspectorHit.inspectorView.frame
@@ -3937,18 +3940,29 @@ struct WebViewRepresentable: NSViewRepresentable {
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
+ )
preferredHostedInspectorWidth = inspectorWidth
_ = applyHostedInspectorDividerWidth(
inspectorWidth,
to: HostedInspectorDividerHit(
containerView: dragState.containerView,
pageView: dragState.pageView,
- inspectorView: dragState.inspectorView
+ inspectorView: dragState.inspectorView,
+ dockSide: dragState.dockSide
),
reason: "drag"
)
@@ -3959,7 +3973,8 @@ struct WebViewRepresentable: NSViewRepresentable {
hit: HostedInspectorDividerHit(
containerView: dragState.containerView,
pageView: dragState.pageView,
- inspectorView: dragState.inspectorView
+ inspectorView: dragState.inspectorView,
+ dockSide: dragState.dockSide
)
)
#endif
@@ -3968,7 +3983,8 @@ struct WebViewRepresentable: NSViewRepresentable {
hostedInspectorHit: HostedInspectorDividerHit(
containerView: dragState.containerView,
pageView: dragState.pageView,
- inspectorView: dragState.inspectorView
+ inspectorView: dragState.inspectorView,
+ dockSide: dragState.dockSide
)
)
}
@@ -3983,7 +3999,8 @@ struct WebViewRepresentable: NSViewRepresentable {
let finalHit = HostedInspectorDividerHit(
containerView: finalDragState.containerView,
pageView: finalDragState.pageView,
- inspectorView: finalDragState.inspectorView
+ inspectorView: finalDragState.inspectorView,
+ dockSide: finalDragState.dockSide
)
debugLogHostedInspectorFrames(
stage: "drag.end",
@@ -4104,13 +4121,11 @@ struct WebViewRepresentable: NSViewRepresentable {
private func hostedInspectorDividerHitRect(for hit: HostedInspectorDividerHit) -> NSRect {
let pageFrame = convert(hit.pageView.bounds, from: hit.pageView)
let inspectorFrame = convert(hit.inspectorView.bounds, from: hit.inspectorView)
- let minY = max(bounds.minY, min(pageFrame.minY, inspectorFrame.minY))
- let maxY = min(bounds.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: bounds,
+ pageFrame: pageFrame,
+ inspectorFrame: inspectorFrame,
+ expansion: Self.hostedInspectorDividerHitExpansion
)
}
@@ -4121,21 +4136,30 @@ struct WebViewRepresentable: NSViewRepresentable {
while let inspectorView = current, inspectorView !== self {
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(
containerView: containerView,
- pageView: pageView,
- inspectorView: inspectorView
+ pageView: pageCandidate.view,
+ inspectorView: inspectorView,
+ dockSide: pageCandidate.dockSide
)
}
@@ -4194,16 +4218,14 @@ struct WebViewRepresentable: NSViewRepresentable {
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)
-
- 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 nextFrames = hit.dockSide.resizedFrames(
+ preferredWidth: preferredWidth,
+ in: containerBounds,
+ pageFrame: hit.pageView.frame,
+ inspectorFrame: hit.inspectorView.frame
+ )
+ let pageFrame = nextFrames.pageFrame
+ let inspectorFrame = nextFrames.inspectorFrame
let pageChanged = !Self.rectApproximatelyEqual(pageFrame, hit.pageView.frame, epsilon: 0.5)
let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, hit.inspectorView.frame, epsilon: 0.5)
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 8c9b02cd..90860cf2 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -8144,6 +8144,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(
From b8cec19257499c6b523ad912a0108759fc2fede2 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Tue, 10 Mar 2026 19:57:31 -0700
Subject: [PATCH 22/43] Fix CI WebViewRepresentable test initializers
---
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 2 ++
1 file changed, 2 insertions(+)
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 90860cf2..b84ea96c 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -2550,6 +2550,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
panel: panel,
paneId: paneId,
shouldAttachWebView: true,
+ useLocalInlineHosting: false,
shouldFocusWebView: false,
isPanelFocused: true,
portalZPriority: 0,
@@ -2591,6 +2592,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
panel: panel,
paneId: paneId,
shouldAttachWebView: true,
+ useLocalInlineHosting: false,
shouldFocusWebView: false,
isPanelFocused: true,
portalZPriority: 0,
From 0ca6569e006d67d5abf5fe5941f80a6d88dfadb1 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 20:38:51 -0700
Subject: [PATCH 23/43] Add failing browser devtools resize regression test
---
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 72 +++++++++++++++++++
1 file changed, 72 insertions(+)
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 409a06d1..916ed174 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -10987,6 +10987,8 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
}
}
+ private final class WKInspectorProbeView: NSView {}
+
private func realizeWindowLayout(_ window: NSWindow) {
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
@@ -11251,6 +11253,76 @@ 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 testPortalHostBoundsBecomeReadyAfterBindingInFrameDrivenHierarchy() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
From 359ada8a3f2ff03babe51e906bf22834aeeb0098 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 20:42:26 -0700
Subject: [PATCH 24/43] Preserve side-docked browser devtools layout on pane
resize
---
Sources/BrowserWindowPortal.swift | 6 +++---
Sources/Panels/BrowserPanelView.swift | 7 +++----
2 files changed, 6 insertions(+), 7 deletions(-)
diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift
index 40007065..42e6c3c0 100644
--- a/Sources/BrowserWindowPortal.swift
+++ b/Sources/BrowserWindowPortal.swift
@@ -1676,8 +1676,7 @@ final class WindowBrowserSlotView: NSView {
hostedWebView !== webView ||
!hostedWebViewConstraints.isEmpty ||
!webView.translatesAutoresizingMaskIntoConstraints ||
- webView.autoresizingMask != [.width, .height] ||
- !Self.rectApproximatelyEqual(webView.frame, bounds)
+ webView.autoresizingMask != [.width, .height]
guard needsFrameHosting else {
needsLayout = true
layoutSubtreeIfNeeded()
@@ -1688,7 +1687,8 @@ final class WindowBrowserSlotView: NSView {
hostedWebViewConstraints = []
hostedWebView = webView
// Attached Web Inspector mutates the moved WKWebView's frame directly.
- // Edge constraints fight side-docked resizing and cause visible churn.
+ // Re-pin only when hosting mode changes, not when WebKit resizes the page
+ // inside the slot for side-docked DevTools.
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
webView.frame = bounds
diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift
index c9130d59..16aeed0f 100644
--- a/Sources/Panels/BrowserPanelView.swift
+++ b/Sources/Panels/BrowserPanelView.swift
@@ -3786,8 +3786,7 @@ struct WebViewRepresentable: NSViewRepresentable {
hostedWebView !== webView ||
!hostedWebViewConstraints.isEmpty ||
!webView.translatesAutoresizingMaskIntoConstraints ||
- webView.autoresizingMask != [.width, .height] ||
- webView.frame != container.bounds
+ webView.autoresizingMask != [.width, .height]
guard needsFrameHosting else {
needsLayout = true
layoutSubtreeIfNeeded()
@@ -3799,8 +3798,8 @@ struct WebViewRepresentable: NSViewRepresentable {
hostedWebView = webView
// WebKit's attached inspector does not reliably dock into a constraint-managed
- // WKWebView hierarchy on macOS. Host the moved webview with autoresizing so
- // the inspector can resize the content view in place.
+ // WKWebView hierarchy on macOS. Host the moved webview with autoresizing and
+ // keep WebKit-owned page frames intact when DevTools is side-docked.
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
webView.frame = container.bounds
From 52783bddf0f4ec9377dab8b0075bb80c0a48767e Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 20:59:34 -0700
Subject: [PATCH 25/43] Polish welcome, feedback, and shortcuts flows (#1169)
* Add cmux welcome command with ASCII logo and shortcuts
Adds `cmux welcome` CLI command that prints a blue-to-purple gradient
chevron logo, version info, and key shortcuts. Auto-runs once on first
workspace creation via UserDefaults. Adds "Welcome" to the ? help menu
in the sidebar footer, which opens a new workspace running the command.
* Polish welcome, feedback, and shortcuts flows
---
CLI/cmux.swift | 299 +++++++++++++++++++++++++++++--
Resources/Localizable.xcstrings | 17 ++
Sources/AppDelegate.swift | 44 ++++-
Sources/ContentView.swift | 149 ++++++++++++++-
Sources/TabManager.swift | 27 ++-
Sources/TerminalController.swift | 116 ++++++++++++
Sources/cmuxApp.swift | 4 +
7 files changed, 629 insertions(+), 27 deletions(-)
diff --git a/CLI/cmux.swift b/CLI/cmux.swift
index 02896822..94c90c5e 100644
--- a/CLI/cmux.swift
+++ b/CLI/cmux.swift
@@ -29,7 +29,7 @@ private final class CLISocketSentryTelemetry {
self.command = command.lowercased()
self.subcommand = commandArgs.first?.lowercased() ?? "help"
self.socketPath = socketPath
- self.envSocketPath = processEnv["CMUX_SOCKET_PATH"]
+ self.envSocketPath = processEnv["CMUX_SOCKET_PATH"] ?? processEnv["CMUX_SOCKET"]
self.workspaceId = processEnv["CMUX_WORKSPACE_ID"]
self.surfaceId = processEnv["CMUX_SURFACE_ID"]
self.disabledByEnv =
@@ -124,7 +124,7 @@ private final class CLISocketSentryTelemetry {
if socketPath == "/tmp/cmux.sock",
(envSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true),
!taggedSockets.isEmpty {
- context["possible_root_cause"] = "CMUX_SOCKET_PATH missing while tagged sockets exist"
+ context["possible_root_cause"] = "CMUX_SOCKET_PATH/CMUX_SOCKET missing while tagged sockets exist"
}
return context
@@ -794,9 +794,14 @@ struct CMUXCLI {
func run() throws {
let processEnv = ProcessInfo.processInfo.environment
let envSocketPath: String? = {
- guard let raw = processEnv["CMUX_SOCKET_PATH"] else { return nil }
- let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- return trimmed.isEmpty ? nil : trimmed
+ for key in ["CMUX_SOCKET_PATH", "CMUX_SOCKET"] {
+ guard let raw = processEnv[key] else { continue }
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty {
+ return trimmed
+ }
+ }
+ return nil
}()
var socketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath
var socketPathSource: CLISocketPathSource
@@ -902,6 +907,31 @@ struct CMUXCLI {
return
}
+ if command == "welcome" {
+ printWelcome()
+ return
+ }
+
+ if command == "shortcuts" {
+ try runShortcuts(
+ commandArgs: commandArgs,
+ socketPath: resolvedSocketPath,
+ explicitPassword: socketPasswordArg,
+ jsonOutput: jsonOutput
+ )
+ return
+ }
+
+ if command == "feedback" {
+ try runFeedback(
+ commandArgs: commandArgs,
+ socketPath: resolvedSocketPath,
+ explicitPassword: socketPasswordArg,
+ jsonOutput: jsonOutput
+ )
+ return
+ }
+
let client = SocketClient(path: resolvedSocketPath)
if resolvedSocketPath != socketPath {
cliTelemetry.breadcrumb(
@@ -929,13 +959,7 @@ struct CMUXCLI {
}
defer { client.close() }
- if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg) {
- let authResponse = try client.send(command: "auth \(socketPassword)")
- if authResponse.hasPrefix("ERROR:"),
- !authResponse.contains("Unknown command 'auth'") {
- throw CLIError(message: authResponse)
- }
- }
+ try authenticateClientIfNeeded(client, explicitPassword: socketPasswordArg)
let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg)
@@ -1933,6 +1957,139 @@ struct CMUXCLI {
try activateApp()
}
+ private func runFeedback(
+ commandArgs: [String],
+ socketPath: String,
+ explicitPassword: String?,
+ jsonOutput: Bool
+ ) throws {
+ let (emailOpt, rem0) = parseOption(commandArgs, name: "--email")
+ let (bodyOpt, rem1) = parseOption(rem0, name: "--body")
+ let (imagePaths, rem2) = parseRepeatedOption(rem1, name: "--image")
+ let remaining = rem2.filter { $0 != "--" }
+
+ if let unknown = remaining.first {
+ throw CLIError(message: "feedback: unknown flag '\(unknown)'. Known flags: --email , --body , --image ")
+ }
+
+ let client = try connectClient(
+ socketPath: socketPath,
+ explicitPassword: explicitPassword,
+ launchIfNeeded: true
+ )
+ defer { client.close() }
+
+ if emailOpt == nil && bodyOpt == nil && imagePaths.isEmpty {
+ var params: [String: Any] = [:]
+ let env = ProcessInfo.processInfo.environment
+ if let workspaceId = env["CMUX_WORKSPACE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !workspaceId.isEmpty {
+ params["workspace_id"] = workspaceId
+ params["activate"] = false
+ } else {
+ params["activate"] = true
+ }
+ let response = try client.sendV2(method: "feedback.open", params: params)
+ if jsonOutput {
+ print(jsonString(response))
+ } else {
+ print("OK")
+ }
+ return
+ }
+
+ guard let email = emailOpt?.trimmingCharacters(in: .whitespacesAndNewlines),
+ email.isEmpty == false else {
+ throw CLIError(message: "feedback requires --email when sending feedback")
+ }
+ guard let body = bodyOpt, body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
+ throw CLIError(message: "feedback requires --body when sending feedback")
+ }
+
+ let resolvedImages = imagePaths.map(resolvePath)
+ let response = try client.sendV2(method: "feedback.submit", params: [
+ "email": email,
+ "body": body,
+ "image_paths": resolvedImages,
+ ])
+ if jsonOutput {
+ print(jsonString(response))
+ } else {
+ print("OK")
+ }
+ }
+
+ private func runShortcuts(
+ commandArgs: [String],
+ socketPath: String,
+ explicitPassword: String?,
+ jsonOutput: Bool
+ ) throws {
+ let remaining = commandArgs.filter { $0 != "--" }
+ if let unknown = remaining.first {
+ throw CLIError(message: "shortcuts: unknown flag '\(unknown)'")
+ }
+
+ let client = try connectClient(
+ socketPath: socketPath,
+ explicitPassword: explicitPassword,
+ launchIfNeeded: true
+ )
+ defer { client.close() }
+
+ let response = try client.sendV2(method: "settings.open", params: [
+ "target": "keyboardShortcuts",
+ "activate": true,
+ ])
+ if jsonOutput {
+ print(jsonString(response))
+ } else {
+ print("OK")
+ }
+ }
+
+ private func connectClient(
+ socketPath: String,
+ explicitPassword: String?,
+ launchIfNeeded: Bool
+ ) throws -> SocketClient {
+ let client = SocketClient(path: socketPath)
+ if launchIfNeeded && (try? client.connect()) == nil {
+ client.close()
+ try launchApp()
+
+ let pollClient = SocketClient(path: socketPath)
+ var connected = false
+ for _ in 0..<100 {
+ if (try? pollClient.connect()) != nil {
+ connected = true
+ break
+ }
+ pollClient.close()
+ Thread.sleep(forTimeInterval: 0.1)
+ }
+ guard connected else {
+ throw CLIError(message: "cmux app did not start in time (socket not found at \(socketPath))")
+ }
+ try authenticateClientIfNeeded(pollClient, explicitPassword: explicitPassword)
+ return pollClient
+ }
+
+ try client.connect()
+ try authenticateClientIfNeeded(client, explicitPassword: explicitPassword)
+ return client
+ }
+
+ private func authenticateClientIfNeeded(_ client: SocketClient, explicitPassword: String?) throws {
+ if let socketPassword = SocketPasswordResolver.resolve(explicit: explicitPassword) {
+ let authResponse = try client.send(command: "auth \(socketPassword)")
+ if authResponse.hasPrefix("ERROR:"),
+ !authResponse.contains("Unknown command 'auth'") {
+ throw CLIError(message: authResponse)
+ }
+ }
+ }
+
private func launchApp() throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
@@ -4056,6 +4213,37 @@ struct CMUXCLI {
Show top-level CLI usage and command list.
"""
+ case "welcome":
+ return """
+ Usage: cmux welcome
+
+ Show a welcome screen with the cmux logo and useful shortcuts.
+ Auto-runs once on first launch.
+ """
+ case "shortcuts":
+ return """
+ Usage: cmux shortcuts
+
+ Open the Settings window to Keyboard Shortcuts.
+ """
+ case "feedback":
+ return """
+ Usage: cmux feedback
+ cmux feedback --email --body [--image ...]
+
+ Without args, open the Send Feedback modal in the running app.
+
+ With args, submit feedback through the app using the same feedback pipeline as the modal.
+
+ Flags:
+ --email Contact email for follow-up
+ --body Feedback body
+ --image Attach an image file, repeat for multiple images
+
+ Coding agents:
+ Double check with the end user before sending anything. Review the message and attachments for secrets,
+ private code, credentials, tokens, and other sensitive information first.
+ """
case "identify":
return """
Usage: cmux identify [--workspace ] [--surface ] [--no-caller]
@@ -5140,6 +5328,31 @@ struct CMUXCLI {
return (value, remaining)
}
+ private func parseRepeatedOption(_ args: [String], name: String) -> ([String], [String]) {
+ var remaining: [String] = []
+ var values: [String] = []
+ var skipNext = false
+ var pastTerminator = false
+ for (idx, arg) in args.enumerated() {
+ if skipNext {
+ skipNext = false
+ continue
+ }
+ if arg == "--" {
+ pastTerminator = true
+ remaining.append(arg)
+ continue
+ }
+ if !pastTerminator, arg == name, idx + 1 < args.count {
+ values.append(args[idx + 1])
+ skipNext = true
+ continue
+ }
+ remaining.append(arg)
+ }
+ return (values, remaining)
+ }
+
private func optionValue(_ args: [String], name: String) -> String? {
guard let index = args.firstIndex(of: name), index + 1 < args.count else { return nil }
return args[index + 1]
@@ -6652,6 +6865,61 @@ struct CMUXCLI {
return "\(baseSummary) [\(commit)]"
}
+ private func printWelcome() {
+ let reset = "\u{001B}[0m"
+ let bold = "\u{001B}[1m"
+ let dim = "\u{001B}[2m"
+ func trueColor(_ red: Int, _ green: Int, _ blue: Int) -> String {
+ "\u{001B}[38;2;\(red);\(green);\(blue)m"
+ }
+ let c1 = trueColor(0, 212, 255)
+ let c2 = trueColor(24, 181, 250)
+ let c3 = trueColor(48, 150, 245)
+ let c4 = trueColor(72, 119, 241)
+ let c5 = trueColor(96, 88, 239)
+ let c6 = trueColor(110, 73, 238)
+ let c7 = trueColor(124, 58, 237)
+ let tagline = trueColor(130, 130, 140)
+
+ let logo = """
+ \(c1) ::\(reset)
+ \(c2) ::::\(reset) \(c1)c\(c2)m\(c3)u\(c7)x\(reset)
+ \(c3) ::::::\(reset)
+ \(c4) ::::::\(reset) \(tagline)the open source terminal\(reset)
+ \(c5) ::::::\(reset) \(tagline)built for coding agents\(reset)
+ \(c6) ::::\(reset)
+ \(c7) ::\(reset)
+ """
+
+ let shortcuts = """
+ \(bold)Shortcuts\(reset)
+
+ \(bold)\u{2318}N\(reset)\(dim) New workspace\(reset)
+ \(bold)\u{2318}P\(reset)\(dim) Go to workspace\(reset)
+ \(bold)\u{2318}D\(reset)\(dim) Split right\(reset)
+ \(bold)\u{2318}\u{21E7}D\(reset)\(dim) Split down\(reset)
+ \(bold)\u{2318}\u{21E7}P\(reset)\(dim) Command palette\(reset)
+ \(bold)\u{2318}\u{21E7}R\(reset)\(dim) Rename workspace\(reset)
+ \(bold)\u{2318}\u{21E7}L\(reset)\(dim) New browser\(reset)
+ \(bold)\u{2318}\u{21E7}U\(reset)\(dim) Jump to latest unread\(reset)
+ """
+
+ print()
+ print(logo)
+ print()
+ print(shortcuts)
+ print()
+ print(" \(bold)Docs\(reset)\(dim) https://cmux.dev/docs\(reset)")
+ print(" \(bold)Discord\(reset)\(dim) https://discord.gg/xsgFEVrWCZ\(reset)")
+ print(" \(bold)GitHub\(reset)\(dim) https://github.com/manaflow-ai/cmux (please leave a star ⭐)\(reset)")
+ print(" \(bold)Email\(reset)\(dim) founders@manaflow.com\(reset)")
+ print()
+ print(" \(dim)Run \(reset)\(bold)cmux --help\(reset)\(dim) for all commands.\(reset)")
+ print(" \(dim)Run \(reset)\(bold)cmux shortcuts\(reset)\(dim) to edit shortcuts.\(reset)")
+ print(" \(dim)Run \(reset)\(bold)cmux feedback\(reset)\(dim) to report a bug.\(reset)")
+ print()
+ }
+
private func resolvedVersionInfo() -> [String: String] {
var info: [String: String] = [:]
if let main = versionInfo(from: Bundle.main.infoDictionary) {
@@ -6924,7 +7192,7 @@ struct CMUXCLI {
cmux [global-options] [options]
Handle Inputs:
- For most v2-backed commands you can use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes.
+ Use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes where commands accept window, workspace, pane, or surface inputs.
`tab-action` also accepts `tab:` in addition to `surface:`.
Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs.
@@ -6933,6 +7201,9 @@ struct CMUXCLI {
Commands:
version
+ welcome
+ shortcuts
+ feedback [--email --body [--image ...]]
ping
capabilities
identify [--workspace ] [--surface ] [--no-caller]
@@ -7058,8 +7329,6 @@ struct CMUXCLI {
CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface.
CMUX_SOCKET_PATH Override the Unix socket path. Without this, the CLI defaults
to /tmp/cmux.sock and auto-discovers tagged/debug sockets.
- CMUX_CLI_SENTRY_DISABLED
- Set to 1 to disable CLI Sentry socket diagnostics.
"""
}
}
diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings
index 2d15e651..337a9e16 100644
--- a/Resources/Localizable.xcstrings
+++ b/Resources/Localizable.xcstrings
@@ -805,6 +805,23 @@
}
}
},
+ "sidebar.help.welcome": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Welcome"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "ようこそ"
+ }
+ }
+ }
+ },
"sidebar.help.changelog": {
"extractionState": "manual",
"localizations": {
diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift
index 1447c796..10240c7d 100644
--- a/Sources/AppDelegate.swift
+++ b/Sources/AppDelegate.swift
@@ -3446,6 +3446,15 @@ 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 = []
@@ -4897,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()
@@ -5026,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
@@ -5034,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
@@ -5464,9 +5501,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
)
}
- private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0) {
+ 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
}
@@ -5475,7 +5513,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
- self?.sendTextWhenReady(text, to: tab, attempt: attempt + 1)
+ self?.sendTextWhenReady(text, to: tab, attempt: attempt + 1, beforeSend: beforeSend)
}
}
diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift
index 3473c398..6c03b213 100644
--- a/Sources/ContentView.swift
+++ b/Sources/ContentView.swift
@@ -8090,6 +8090,7 @@ private enum SidebarHelpMenuAction {
case githubIssues
case checkForUpdates
case sendFeedback
+ case welcome
}
private struct SidebarFeedbackComposerSheet: View {
@@ -8466,6 +8467,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")
@@ -8514,6 +8631,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,
@@ -8625,14 +8748,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:
@@ -8654,6 +8780,13 @@ private struct SidebarHelpMenuButton: View {
case .sendFeedback:
isPopoverPresented = false
onSendFeedback()
+ case .welcome:
+ isPopoverPresented = false
+ Task { @MainActor in
+ if let appDelegate = AppDelegate.shared {
+ appDelegate.openWelcomeWorkspace()
+ }
+ }
}
}
diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift
index 07b13a75..31c1198a 100644
--- a/Sources/TabManager.swift
+++ b/Sources/TabManager.swift
@@ -813,7 +813,8 @@ class TabManager: ObservableObject {
workingDirectory overrideWorkingDirectory: String? = nil,
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 explicitWorkingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory)
@@ -861,9 +862,33 @@ 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,
diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift
index 8f5a2ed3..44fd70cf 100644
--- a/Sources/TerminalController.swift
+++ b/Sources/TerminalController.swift
@@ -1737,6 +1737,16 @@ class TerminalController {
case "workspace.last":
return v2Result(id: id, self.v2WorkspaceLast(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":
@@ -2096,6 +2106,9 @@ class TerminalController {
"workspace.next",
"workspace.previous",
"workspace.last",
+ "settings.open",
+ "feedback.open",
+ "feedback.submit",
"surface.list",
"surface.current",
"surface.focus",
@@ -5382,6 +5395,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 {
diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift
index e4c7d1fa..a85a5e94 100644
--- a/Sources/cmuxApp.swift
+++ b/Sources/cmuxApp.swift
@@ -2815,6 +2815,10 @@ enum ClaudeCodeIntegrationSettings {
}
}
+enum WelcomeSettings {
+ static let shownKey = "cmuxWelcomeShown"
+}
+
enum TelemetrySettings {
static let sendAnonymousTelemetryKey = "sendAnonymousTelemetry"
static let defaultSendAnonymousTelemetry = true
From 0e916906c617b5d9bf8b4bff44201c03e0669647 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 20:59:41 -0700
Subject: [PATCH 26/43] Sync all translated READMEs with English version
(#1172)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add missing sections: features table with images, social badges,
demo video link, The Zen of cmux, Documentation, updated keyboard
shortcuts (rename workspace, Safari devtools), Nightly Builds,
Session restore, Star History, Contributing, Community, and
Founder's Edition. Update screenshot path to main-first-image.png.
Preserves the casual 해요체 tone from the community Korean PR.
---
README.ar.md | 142 +++++++++++++++++++++++++++++++++---
README.bs.md | 142 +++++++++++++++++++++++++++++++++---
README.da.md | 142 +++++++++++++++++++++++++++++++++---
README.de.md | 140 ++++++++++++++++++++++++++++++++---
README.es.md | 140 ++++++++++++++++++++++++++++++++---
README.fr.md | 140 ++++++++++++++++++++++++++++++++---
README.it.md | 142 +++++++++++++++++++++++++++++++++---
README.ja.md | 142 +++++++++++++++++++++++++++++++++---
README.ko.md | 146 +++++++++++++++++++++++++++++++++----
README.no.md | 190 +++++++++++++++++++++++++++++++++++++++---------
README.pl.md | 142 +++++++++++++++++++++++++++++++++---
README.pt-BR.md | 142 +++++++++++++++++++++++++++++++++---
README.ru.md | 142 +++++++++++++++++++++++++++++++++---
README.th.md | 142 +++++++++++++++++++++++++++++++++---
README.tr.md | 142 +++++++++++++++++++++++++++++++++---
README.zh-CN.md | 140 ++++++++++++++++++++++++++++++++---
README.zh-TW.md | 140 ++++++++++++++++++++++++++++++++---
17 files changed, 2237 insertions(+), 219 deletions(-)
diff --git a/README.ar.md b/README.ar.md
index 0c29c0ed..efac1977 100644
--- a/README.ar.md
+++ b/README.ar.md
@@ -1,9 +1,5 @@
> تمت هذه الترجمة بواسطة Claude. إذا كانت لديك اقتراحات للتحسين، يرجى فتح PR.
-
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
-
-
cmux
تطبيق طرفية لنظام macOS مبني على Ghostty مع علامات تبويب عمودية وإشعارات لوكلاء البرمجة بالذكاء الاصطناعي
@@ -14,16 +10,63 @@
-
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ فيديو توضيحي · فلسفة cmux
## الميزات
-- **علامات تبويب عمودية** — يعرض الشريط الجانبي فرع git ومجلد العمل والمنافذ المستمعة وآخر نص إشعار
-- **حلقات الإشعارات** — تحصل الأجزاء على حلقة زرقاء وتضيء علامات التبويب عندما يحتاج وكلاء الذكاء الاصطناعي (Claude Code، OpenCode) انتباهك
-- **لوحة الإشعارات** — عرض جميع الإشعارات المعلقة في مكان واحد، والانتقال إلى أحدث إشعار غير مقروء
-- **أجزاء مقسمة** — تقسيم أفقي وعمودي
-- **متصفح مدمج** — قسّم متصفحاً بجانب الطرفية مع API قابل للبرمجة مأخوذ من [agent-browser](https://github.com/vercel-labs/agent-browser)
+
+
+
+حلقات الإشعارات
+تحصل الأجزاء على حلقة زرقاء وتضيء علامات التبويب عندما يحتاج وكلاء البرمجة انتباهك
+
+
+
+
+
+
+
+لوحة الإشعارات
+عرض جميع الإشعارات المعلقة في مكان واحد، والانتقال إلى أحدث إشعار غير مقروء
+
+
+
+
+
+
+
+متصفح مدمج
+قسّم متصفحاً بجانب الطرفية مع API قابل للبرمجة مأخوذ من agent-browser
+
+
+
+
+
+
+
+علامات تبويب عمودية + أفقية
+يعرض الشريط الجانبي فرع git وحالة/رقم طلب السحب المرتبط ومجلد العمل والمنافذ المستمعة وآخر نص إشعار. تقسيم أفقي وعمودي.
+
+
+
+
+
+
+
- **قابل للبرمجة** — 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 وحالة/رقم طلب السحب المرتبط ومجلد العمل والمنافذ المستمعة وآخر نص إشعار لكل مساحة عمل. يلتقط نظام الإشعارات تسلسلات الطرفية (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 النشطة لا يتم استئنافها بعد إعادة التشغيل بعد.
+
+## تاريخ النجوم
+
+
+
+
+
+
+
+
+
+## المساهمة
+
+طرق للمشاركة:
+
+- تابعنا على 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`).
diff --git a/README.bs.md b/README.bs.md
index ef113fe0..603978dc 100644
--- a/README.bs.md
+++ b/README.bs.md
@@ -1,9 +1,5 @@
> Ovaj prijevod je generisan od strane Claude. Ako imate prijedloge za poboljšanje, otvorite PR.
-
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
-
-
cmux
macOS terminal baziran na Ghostty sa vertikalnim tabovima i obavještenjima za AI agente za programiranje
@@ -14,16 +10,63 @@
-
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ Demo video · The Zen of cmux
## 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)
+
+
+
+Prstenovi obavještenja
+Paneli dobijaju plavi prsten, a tabovi se osvjetljavaju kada agenti za programiranje trebaju vašu pažnju
+
+
+
+
+
+
+
+Panel obavještenja
+Pregledajte sva obavještenja na čekanju na jednom mjestu, skočite na najnovije nepročitano
+
+
+
+
+
+
+
+Ugrađeni preglednik
+Podijelite preglednik pored terminala sa skriptabilnim API portiranim iz agent-browser
+
+
+
+
+
+
+
+Vertikalni + horizontalni tabovi
+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.
+
+
+
+
+
+
+
- **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
+
+
+
+
+
+
+
+
+
+## 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`).
diff --git a/README.da.md b/README.da.md
index 4012df15..588d9c09 100644
--- a/README.da.md
+++ b/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.
-
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
-
-
cmux
En Ghostty-baseret macOS-terminal med lodrette faner og notifikationer til AI-kodningsagenter
@@ -14,16 +10,63 @@
-
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ Demovideo · The Zen of cmux
## 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)
+
+
+
+Notifikationsringe
+Paneler får en blå ring, og faner lyser op, når kodningsagenter har brug for din opmærksomhed
+
+
+
+
+
+
+
+Notifikationspanel
+Se alle ventende notifikationer ét sted, hop til den seneste ulæste
+
+
+
+
+
+
+
+Indbygget browser
+Del en browser ved siden af din terminal med en scriptbar API porteret fra agent-browser
+
+
+
+
+
+
+
+Lodrette + vandrette faner
+Sidebjælken viser git-branch, tilknyttet PR-status/nummer, arbejdsmappe, lyttende porte og seneste notifikationstekst. Del vandret og lodret.
+
+
+
+
+
+
+
- **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
+
+
+
+
+
+
+
+
+
+## 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`).
diff --git a/README.de.md b/README.de.md
index 62ee43cd..b04fd471 100644
--- a/README.de.md
+++ b/README.de.md
@@ -1,7 +1,5 @@
> Diese Übersetzung wurde von Claude erstellt. Verbesserungsvorschläge sind als PR willkommen.
-English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
-
cmux
Ein Ghostty-basiertes macOS-Terminal mit vertikalen Tabs und Benachrichtigungen für AI-Coding-Agenten
@@ -12,16 +10,63 @@
-
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ Demo-Video · The Zen of cmux
## 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)
+
+
+
+Benachrichtigungsringe
+Bereiche erhalten einen blauen Ring und Tabs leuchten auf, wenn Coding-Agenten Ihre Aufmerksamkeit benötigen
+
+
+
+
+
+
+
+Benachrichtigungspanel
+Alle ausstehenden Benachrichtigungen auf einen Blick sehen und zur neuesten ungelesenen springen
+
+
+
+
+
+
+
+Integrierter Browser
+Teilen Sie einen Browser neben Ihrem Terminal mit einer skriptfähigen API, portiert von agent-browser
+
+
+
+
+
+
+
+Vertikale + horizontale Tabs
+Die Seitenleiste zeigt Git-Branch, verknüpften PR-Status/Nummer, Arbeitsverzeichnis, lauschende Ports und den neuesten Benachrichtigungstext. Horizontal und vertikal teilen.
+
+
+
+
+
+
+
- **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
+
+
+
+
+
+
+
+
+
+## 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.
diff --git a/README.es.md b/README.es.md
index 3e91ba59..503d376a 100644
--- a/README.es.md
+++ b/README.es.md
@@ -1,7 +1,5 @@
> Esta traducción fue generada por Claude. Si tienes sugerencias de mejora, abre un PR.
-English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
-
cmux
Un terminal macOS basado en Ghostty con pestañas verticales y notificaciones para agentes de programación con IA
@@ -12,16 +10,63 @@
-
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ Video de demostración · The Zen of cmux
## 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)
+
+
+
+Anillos de notificación
+Los paneles obtienen un anillo azul y las pestañas se iluminan cuando los agentes de programación 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
+
+
+
+
+
+
+
+Navegador integrado
+Divide un navegador junto a tu terminal con una API programable portada de agent-browser
+
+
+
+
+
+
+
+Pestañas verticales + horizontales
+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.
+
+
+
+
+
+
+
- **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
+
+
+
+
+
+
+
+
+
+## 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`).
diff --git a/README.fr.md b/README.fr.md
index fd003b6d..421a388a 100644
--- a/README.fr.md
+++ b/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.
-English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
-
cmux
Un terminal macOS basé sur Ghostty avec des onglets verticaux et des notifications pour les agents de programmation IA
@@ -12,16 +10,63 @@
-
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ Vidéo de démonstration · The Zen of cmux
## 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)
+
+
+
+Anneaux de notification
+Les panneaux reçoivent un anneau bleu et les onglets s'illuminent lorsque les agents de programmation 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
+
+
+
+
+
+
+
+Navigateur intégré
+Divisez un navigateur à côté de votre terminal avec une API scriptable portée depuis agent-browser
+
+
+
+
+
+
+
+Onglets verticaux + horizontaux
+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.
+
+
+
+
+
+
+
- **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 opiniâtre 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 | Ouvrir 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
+
+
+
+
+
+
+
+
+
+## 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`).
diff --git a/README.it.md b/README.it.md
index a2f07906..dbbf57d6 100644
--- a/README.it.md
+++ b/README.it.md
@@ -1,9 +1,5 @@
> Questa traduzione è stata generata da Claude. Se hai suggerimenti per migliorarla, apri una PR.
-
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
-
-
cmux
Un terminale macOS basato su Ghostty con schede verticali e notifiche per agenti di programmazione AI
@@ -14,16 +10,63 @@
-
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ Video demo · The Zen of cmux
## 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)
+
+
+
+Anelli di notifica
+I pannelli ricevono un anello blu e le schede si illuminano quando gli agenti di programmazione richiedono la tua attenzione
+
+
+
+
+
+
+
+Pannello notifiche
+Visualizza tutte le notifiche in sospeso in un unico posto, salta alla più recente non letta
+
+
+
+
+
+
+
+Browser integrato
+Dividi un browser accanto al tuo terminale con un'API scriptabile derivata da agent-browser
+
+
+
+
+
+
+
+Schede verticali + orizzontali
+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.
+
+
+
+
+
+
+
- **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 | Apri 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
+
+
+
+
+
+
+
+
+
+## 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`).
diff --git a/README.ja.md b/README.ja.md
index 3c304c32..fabe91d4 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -1,9 +1,5 @@
> この翻訳は Claude によって生成されました。改善の提案がある場合は、PR を作成してください。
-
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
-
-
cmux
AIコーディングエージェント向けの縦タブと通知機能を備えたGhosttyベースのmacOSターミナル
@@ -14,16 +10,63 @@
-
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ デモ動画 · The Zen of cmux
## 機能
-- **縦タブ** — サイドバーにgitブランチ、作業ディレクトリ、リッスン中のポート、最新の通知テキストを表示
-- **通知リング** — AIエージェント(Claude Code、OpenCode)があなたの注意を必要とするとき、ペインに青いリングが表示され、タブが点灯
-- **通知パネル** — 保留中のすべての通知を一か所で確認、最新の未読にジャンプ
-- **分割ペイン** — 水平・垂直分割
-- **アプリ内ブラウザ** — [agent-browser](https://github.com/vercel-labs/agent-browser)から移植されたスクリプタブルなAPIで、ターミナルの横にブラウザを分割表示
+
+
+
+通知リング
+コーディングエージェントがあなたの注意を必要とするとき、ペインに青いリングが表示され、タブが点灯します
+
+
+
+
+
+
+
+通知パネル
+保留中のすべての通知を一か所で確認、最新の未読にジャンプ
+
+
+
+
+
+
+
+アプリ内ブラウザ
+agent-browser から移植されたスクリプタブルなAPIで、ターミナルの横にブラウザを分割表示
+
+
+
+
+
+
+
+縦タブ + 横タブ
+サイドバーにgitブランチ、リンクされたPRのステータス/番号、作業ディレクトリ、リッスン中のポート、最新の通知テキストを表示。水平・垂直に分割可能。
+
+
+
+
+
+
+
- **スクリプタブル** — 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
+
+
+
+
+
+
+
+
+
+## コントリビューション
+
+参加方法:
+
+- 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`)の下でライセンスされています。
diff --git a/README.ko.md b/README.ko.md
index f5ec7119..7f0406eb 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -1,7 +1,5 @@
> 이 문서는 Claude가 번역했어요. 개선할 부분이 있다면 PR을 보내주세요.
-English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
-
cmux
세로 탭과 알림을 지원하는 AI 코딩 에이전트용 Ghostty 기반 macOS 터미널
@@ -12,20 +10,67 @@
-
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ 데모 영상 · The Zen of cmux
## 기능
-- **세로 탭** — 사이드바에서 git 브랜치, 작업 디렉토리, 수신 포트, 최근 알림 텍스트를 한눈에 볼 수 있어요.
-- **알림 링** — AI 에이전트(Claude Code, OpenCode)가 입력을 기다리면 패널에 파란색 링이 뜨고 탭이 강조돼요.
-- **알림 패널** — 대기 중인 알림을 한곳에서 확인하고, 가장 최근 읽지 않은 알림으로 바로 이동할 수 있어요.
-- **분할 패널** — 수평·수직 분할을 지원해요.
-- **내장 브라우저** — [agent-browser](https://github.com/vercel-labs/agent-browser)에서 포팅된 스크립팅 API를 갖춘 브라우저를 터미널 옆에 띄울 수 있어요.
-- **스크립팅** — CLI와 socket API로 워크스페이스 생성, 패널 분할, 키 입력 전송, 브라우저 자동화가 가능해요.
+
+
+
+알림 링
+코딩 에이전트가 입력을 기다리면 패널에 파란색 링이 뜨고 탭이 강조돼요
+
+
+
+
+
+
+
+알림 패널
+대기 중인 알림을 한곳에서 확인하고, 가장 최근 읽지 않은 알림으로 바로 이동할 수 있어요
+
+
+
+
+
+
+
+내장 브라우저
+agent-browser 에서 포팅된 스크립팅 API를 갖춘 브라우저를 터미널 옆에 띄울 수 있어요
+
+
+
+
+
+
+
+세로 + 가로 탭
+사이드바에서 git 브랜치, 연결된 PR 상태/번호, 작업 디렉토리, 수신 포트, 최근 알림 텍스트를 한눈에 볼 수 있어요. 수평·수직 분할을 지원해요.
+
+
+
+
+
+
+
+- **스크립팅** — 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
+
+
+
+
+
+
+
+
+
+## 기여하기
+
+참여 방법:
+
+- 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`)으로 배포돼요.
diff --git a/README.no.md b/README.no.md
index 3ebec22c..15605c94 100644
--- a/README.no.md
+++ b/README.no.md
@@ -1,9 +1,5 @@
> Denne oversettelsen ble generert av Claude. Hvis du har forslag til forbedringer, send gjerne en PR.
-
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
-
-
cmux
En Ghostty-basert macOS-terminal med vertikale faner og varsler for AI-kodeagenter
@@ -14,17 +10,64 @@
-
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ Demovideo · The Zen of cmux
## 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
+
+
+
+Varselringer
+Paneler får en blå ring og faner lyser opp når kodeagenter trenger oppmerksomheten din
+
+
+
+
+
+
+
+Varselpanel
+Se alle ventende varsler på ett sted, hopp til det nyeste uleste
+
+
+
+
+
+
+
+Innebygd nettleser
+Del en nettleser ved siden av terminalen med et skriptbart API portet fra agent-browser
+
+
+
+
+
+
+
+Vertikale + horisontale faner
+Sidefeltet viser git-gren, tilknyttet PR-status/nummer, arbeidsmappe, lyttende porter og siste varselstekst. Del horisontalt og vertikalt.
+
+
+
+
+
+
+
+- **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 @@
-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
+
+
+
+
+
+
+
+
+
+## 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`).
diff --git a/README.pl.md b/README.pl.md
index 1aa37f7f..c0469734 100644
--- a/README.pl.md
+++ b/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.
-
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
-
-
cmux
Terminal macOS oparty na Ghostty z pionowymi kartami i powiadomieniami dla agentów kodowania AI
@@ -14,16 +10,63 @@
-
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ Film demonstracyjny · The Zen of cmux
## 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)
+
+
+
+Pierścienie powiadomień
+Panele otrzymują niebieski pierścień, a karty podświetlają się, gdy agenci kodowania potrzebują Twojej uwagi
+
+
+
+
+
+
+
+Panel powiadomień
+Zobacz wszystkie oczekujące powiadomienia w jednym miejscu, przeskocz do najnowszego nieprzeczytanego
+
+
+
+
+
+
+
+Wbudowana przeglądarka
+Podziel przeglądarkę obok terminala ze skryptowalnym API przeniesionym z agent-browser
+
+
+
+
+
+
+
+Pionowe + poziome karty
+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.
+
+
+
+
+
+
+
- **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
+
+
+
+
+
+
+
+
+
+## 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 uczestncz 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`).
diff --git a/README.pt-BR.md b/README.pt-BR.md
index cc1767ba..49d4edaa 100644
--- a/README.pt-BR.md
+++ b/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.
-
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
-
-
cmux
Um terminal macOS baseado em Ghostty com abas verticais e notificações para agentes de programação com IA
@@ -14,16 +10,63 @@
-
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ Vídeo de demonstração · O Zen do cmux
## 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)
+
+
+
+Anéis de notificação
+Os painéis recebem um anel azul e as abas acendem quando agentes de programação 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
+
+
+
+
+
+
+
+Navegador integrado
+Divida um navegador ao lado do seu terminal com uma API programável portada do agent-browser
+
+
+
+
+
+
+
+Abas verticais + horizontais
+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.
+
+
+
+
+
+
+
- **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,12 +103,26 @@ 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
@@ -78,6 +135,7 @@ 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
@@ -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
+
+
+
+
+
+
+
+
+
+## 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`).
diff --git a/README.ru.md b/README.ru.md
index 328a5163..61d049d0 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -1,9 +1,5 @@
> Этот перевод создан Claude. Если у вас есть предложения по улучшению, откройте PR.
-
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
-
-
cmux
Терминал macOS на базе Ghostty с вертикальными вкладками и уведомлениями для AI-агентов программирования
@@ -14,16 +10,63 @@
-
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ Демо-видео · The Zen of cmux
## Возможности
-- **Вертикальные вкладки** — Боковая панель показывает ветку git, рабочий каталог, прослушиваемые порты и текст последнего уведомления
-- **Кольца уведомлений** — Панели получают синее кольцо, а вкладки подсвечиваются, когда AI-агенты (Claude Code, OpenCode) нуждаются в вашем внимании
-- **Панель уведомлений** — Просматривайте все ожидающие уведомления в одном месте, переходите к последнему непрочитанному
-- **Разделённые панели** — Горизонтальное и вертикальное разделение
-- **Встроенный браузер** — Разделите браузер рядом с терминалом со скриптуемым API, портированным из [agent-browser](https://github.com/vercel-labs/agent-browser)
+
+
+
+Кольца уведомлений
+Панели получают синее кольцо, а вкладки подсвечиваются, когда агенты программирования нуждаются в вашем внимании
+
+
+
+
+
+
+
+Панель уведомлений
+Просматривайте все ожидающие уведомления в одном месте, переходите к последнему непрочитанному
+
+
+
+
+
+
+
+Встроенный браузер
+Разделите браузер рядом с терминалом со скриптуемым API, портированным из agent-browser
+
+
+
+
+
+
+
+Вертикальные + горизонтальные вкладки
+Боковая панель показывает ветку git, статус/номер связанного PR, рабочий каталог, прослушиваемые порты и текст последнего уведомления. Горизонтальное и вертикальное разделение.
+
+
+
+
+
+
+
- **Скриптуемость** — 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 пока не возобновляются после перезапуска.
+
+## История звёзд
+
+
+
+
+
+
+
+
+
+## Участие
+
+Способы принять участие:
+
+- Подписывайтесь на нас в 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`).
diff --git a/README.th.md b/README.th.md
index f8c6155b..5fe0ba2c 100644
--- a/README.th.md
+++ b/README.th.md
@@ -1,9 +1,5 @@
> การแปลนี้สร้างโดย Claude หากมีข้อเสนอแนะในการปรับปรุง กรุณาเปิด PR
-
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
-
-
cmux
เทอร์มินัล macOS ที่ใช้ Ghostty พร้อมแท็บแนวตั้งและการแจ้งเตือนสำหรับเอเจนต์เขียนโค้ด AI
@@ -14,16 +10,63 @@
-
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ วิดีโอสาธิต · The Zen of cmux
## คุณสมบัติ
-- **แท็บแนวตั้ง** — แถบด้านข้างแสดง git branch, ไดเรกทอรีทำงาน, พอร์ตที่กำลังฟัง และข้อความแจ้งเตือนล่าสุด
-- **วงแหวนแจ้งเตือน** — แผงจะมีวงแหวนสีน้ำเงินและแท็บจะสว่างขึ้นเมื่อเอเจนต์ AI (Claude Code, OpenCode) ต้องการความสนใจของคุณ
-- **แผงแจ้งเตือน** — ดูการแจ้งเตือนที่รอดำเนินการทั้งหมดในที่เดียว ข้ามไปยังรายการที่ยังไม่ได้อ่านล่าสุด
-- **แผงแบ่ง** — แบ่งแนวนอนและแนวตั้ง
-- **เบราว์เซอร์ในแอป** — แบ่งเบราว์เซอร์ข้างเทอร์มินัลพร้อม API ที่เขียนสคริปต์ได้ ย้ายมาจาก [agent-browser](https://github.com/vercel-labs/agent-browser)
+
+
+
+วงแหวนแจ้งเตือน
+แผงจะมีวงแหวนสีน้ำเงินและแท็บจะสว่างขึ้นเมื่อเอเจนต์เขียนโค้ดต้องการความสนใจของคุณ
+
+
+
+
+
+
+
+แผงแจ้งเตือน
+ดูการแจ้งเตือนที่รอดำเนินการทั้งหมดในที่เดียว ข้ามไปยังรายการที่ยังไม่ได้อ่านล่าสุด
+
+
+
+
+
+
+
+เบราว์เซอร์ในแอป
+แบ่งเบราว์เซอร์ข้างเทอร์มินัลพร้อม API ที่เขียนสคริปต์ได้ ย้ายมาจาก agent-browser
+
+
+
+
+
+
+
+แท็บแนวตั้ง + แนวนอน
+แถบด้านข้างแสดง git branch, สถานะ/หมายเลข PR ที่เชื่อมโยง, ไดเรกทอรีทำงาน, พอร์ตที่กำลังฟัง และข้อความแจ้งเตือนล่าสุด แบ่งแนวนอนและแนวตั้ง
+
+
+
+
+
+
+
- **เขียนสคริปต์ได้** — 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,6 +135,7 @@ brew upgrade --cask cmux
| ⌃ ⌘ ] | เวิร์กสเปซถัดไป |
| ⌃ ⌘ [ | เวิร์กสเปซก่อนหน้า |
| ⌘ ⇧ W | ปิดเวิร์กสเปซ |
+| ⌘ ⇧ R | เปลี่ยนชื่อเวิร์กสเปซ |
| ⌘ B | สลับแถบด้านข้าง |
### Surfaces
@@ -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 ที่กำลังทำงานอยู่จะยังไม่ถูกกู้คืนหลังจากรีสตาร์ท
+
+## ประวัติดาว
+
+
+
+
+
+
+
+
+
+## การมีส่วนร่วม
+
+วิธีเข้าร่วม:
+
+- ติดตามเราบน 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`)
diff --git a/README.tr.md b/README.tr.md
index 0c4278b8..d317b7e9 100644
--- a/README.tr.md
+++ b/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.
-
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
-
-
cmux
AI kodlama ajanları için dikey sekmeler ve bildirimler içeren Ghostty tabanlı macOS terminali
@@ -14,16 +10,63 @@
-
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ Demo videosu · The Zen of cmux
## Ö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
+
+
+
+Bildirim halkaları
+Kodlama ajanları 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
+
+
+
+
+
+
+
+Uygulama içi tarayıcı
+agent-browser 'dan aktarılmış betiklenebilir bir API ile terminalinizin yanında bir tarayıcı bölün
+
+
+
+
+
+
+
+Dikey + yatay sekmeler
+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.
+
+
+
+
+
+
+
- **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
+
+
+
+
+
+
+
+
+
+## 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.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 703c453b..d0435a4f 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -1,7 +1,5 @@
> 此翻译由 Claude 生成。如有改进建议,欢迎提交 PR。
-English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
-
cmux
基于 Ghostty 的 macOS 终端,带有垂直标签页和为 AI 编程代理设计的通知系统
@@ -12,16 +10,63 @@
-
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ 演示视频 · The Zen of cmux
## 功能特性
-- **垂直标签页** — 侧边栏显示 git 分支、工作目录、监听端口和最新通知文本
-- **通知提示环** — 当 AI 代理(Claude Code、OpenCode)需要您注意时,窗格会显示蓝色光环,标签页会高亮
-- **通知面板** — 在一处查看所有待处理通知,快速跳转到最新未读通知
-- **分割窗格** — 支持水平和垂直分割
-- **内置浏览器** — 在终端旁边分割出浏览器窗格,提供从 [agent-browser](https://github.com/vercel-labs/agent-browser) 移植的可脚本化 API
+
+
+
+通知提示环
+当编程代理需要您注意时,窗格会显示蓝色光环,标签页会高亮
+
+
+
+
+
+
+
+通知面板
+在一处查看所有待处理通知,快速跳转到最新未读通知
+
+
+
+
+
+
+
+内置浏览器
+在终端旁边分割出浏览器窗格,提供从 agent-browser 移植的可脚本化 API
+
+
+
+
+
+
+
+垂直 + 水平标签页
+侧边栏显示 git 分支、关联 PR 状态/编号、工作目录、监听端口和最新通知文本。支持水平和垂直分割。
+
+
+
+
+
+
+
- **可脚本化** — 通过 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
+
+
+
+
+
+
+
+
+
+## 参与贡献
+
+参与方式:
+
+- 在 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`)授权。
diff --git a/README.zh-TW.md b/README.zh-TW.md
index 6cb4bf37..7547e4ec 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -1,7 +1,5 @@
> 此翻譯由 Claude 生成。如有改進建議,歡迎提交 PR。
-English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
-
cmux
基於 Ghostty 的 macOS 終端機,具備垂直分頁和為 AI 程式設計代理設計的通知系統
@@ -12,16 +10,63 @@
-
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ 示範影片 · The Zen of cmux
## 功能特色
-- **垂直分頁** — 側邊欄顯示 git 分支、工作目錄、監聽連接埠和最新通知文字
-- **通知提示環** — 當 AI 代理(Claude Code、OpenCode)需要您注意時,窗格會顯示藍色光環,分頁會亮起
-- **通知面板** — 在同一處檢視所有待處理通知,快速跳轉到最新未讀通知
-- **分割窗格** — 支援水平和垂直分割
-- **內建瀏覽器** — 在終端機旁分割出瀏覽器窗格,提供從 [agent-browser](https://github.com/vercel-labs/agent-browser) 移植的可腳本化 API
+
+
+
+通知提示環
+當 AI 代理需要您注意時,窗格會顯示藍色光環,分頁會亮起
+
+
+
+
+
+
+
+通知面板
+在同一處檢視所有待處理通知,快速跳轉到最新未讀通知
+
+
+
+
+
+
+
+內建瀏覽器
+在終端機旁分割出瀏覽器窗格,提供從 agent-browser 移植的可腳本化 API
+
+
+
+
+
+
+
+垂直 + 水平分頁
+側邊欄顯示 git 分支、關聯的 PR 狀態/編號、工作目錄、監聽連接埠和最新通知文字。支援水平和垂直分割。
+
+
+
+
+
+
+
- **可腳本化** — 透過 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 歷史
+
+
+
+
+
+
+
+
+
+## 參與貢獻
+
+參與方式:
+
+- 在 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`)授權。
From d881c29105fdd7f4e5df00b1fab1f3d4d9e54987 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 21:02:42 -0700
Subject: [PATCH 27/43] Add failing hidden portal devtools resize regression
test
---
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 67 +++++++++++++++++++
1 file changed, 67 insertions(+)
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 916ed174..84a453c2 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -11323,6 +11323,73 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
)
}
+ 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)
+ contentView.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),
From 6f8f5a6ac08bf27c7f517cec38f538f2b2e9ff20 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 21:04:08 -0700
Subject: [PATCH 28/43] Keep hidden portal sync from stealing devtools host
---
Sources/BrowserWindowPortal.swift | 25 ++++++++++++++++++++-----
1 file changed, 20 insertions(+), 5 deletions(-)
diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift
index 42e6c3c0..915467e2 100644
--- a/Sources/BrowserWindowPortal.swift
+++ b/Sources/BrowserWindowPortal.swift
@@ -2752,7 +2752,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)) " +
@@ -2943,15 +2954,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)
@@ -3010,14 +3022,16 @@ final class WindowBrowserPortal: NSObject {
if transientRecoveryReason == nil {
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
}
- if !shouldHide, !refreshReasons.isEmpty {
+ if !shouldHide, containerOwnsWebView, !refreshReasons.isEmpty {
refreshHostedWebViewPresentation(
webView,
in: containerView,
reason: "\(source):" + refreshReasons.joined(separator: ",")
)
}
- hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync")
+ if containerOwnsWebView {
+ hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync")
+ }
#if DEBUG
dlog(
"browser.portal.sync.result web=\(browserPortalDebugToken(webView)) source=\(source) " +
@@ -3027,6 +3041,7 @@ 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) " +
"containerHidden=\(containerView.isHidden ? 1 : 0) webHidden=\(webView.isHidden ? 1 : 0) " +
"containerBounds=\(browserPortalDebugFrame(containerView.bounds)) " +
"preWebFrame=\(browserPortalDebugFrame(preNormalizeWebFrame)) " +
From 5f797cb0198dda81bc38f75d8faf3aa345833fe5 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 21:04:19 -0700
Subject: [PATCH 29/43] Fix translated README review comments (#1174)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ar: Normalize tanwīn to consistent superscript form (حلًا not حلاً)
- fr: Fix "opiniâtre" → "préconçue", DevTools verb to toggle
- it: DevTools verb to toggle (Mostra/Nascondi)
- pt-BR: Translate section headers Workspaces → Áreas de Trabalho,
Surfaces → Superfícies
- th: Translate Surfaces section header and table cells to Thai
- pl: Fix typo "uczestncz" → "uczestniczaj"
---
README.ar.md | 20 ++++++++++----------
README.fr.md | 4 ++--
README.it.md | 2 +-
README.pl.md | 2 +-
README.pt-BR.md | 4 ++--
README.th.md | 18 +++++++++---------
6 files changed, 25 insertions(+), 25 deletions(-)
diff --git a/README.ar.md b/README.ar.md
index efac1977..82a77bd0 100644
--- a/README.ar.md
+++ b/README.ar.md
@@ -50,7 +50,7 @@
متصفح مدمج
-قسّم متصفحاً بجانب الطرفية مع API قابل للبرمجة مأخوذ من agent-browser
+قسّم متصفحًا بجانب الطرفية مع API قابل للبرمجة مأخوذ من agent-browser
@@ -80,7 +80,7 @@
-افتح ملف `.dmg` واسحب cmux إلى مجلد التطبيقات. يتم تحديث cmux تلقائياً عبر Sparkle، لذا تحتاج للتحميل مرة واحدة فقط.
+افتح ملف `.dmg` واسحب cmux إلى مجلد التطبيقات. يتم تحديث cmux تلقائيًا عبر Sparkle، لذا تحتاج للتحميل مرة واحدة فقط.
### Homebrew
@@ -89,7 +89,7 @@ brew tap manaflow-ai/cmux
brew install --cask cmux
```
-للتحديث لاحقاً:
+للتحديث لاحقًا:
```bash
brew upgrade --cask cmux
@@ -99,7 +99,7 @@ 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 الحالية للسمات والخطوط والألوان.
@@ -113,9 +113,9 @@ brew upgrade --cask cmux
cmux لا يفرض على المطورين طريقة استخدام أدواتهم. إنه طرفية ومتصفح مع واجهة سطر أوامر، والباقي متروك لك.
-cmux هو لبنة أساسية وليس حلاً جاهزاً. يمنحك طرفية ومتصفحاً وإشعارات ومساحات عمل وأقساماً وعلامات تبويب وواجهة سطر أوامر للتحكم في كل ذلك. cmux لا يجبرك على طريقة محددة لاستخدام وكلاء البرمجة. ما تبنيه باستخدام هذه اللبنات الأساسية هو ملكك.
+cmux هو لبنة أساسية وليس حلًا جاهزًا. يمنحك طرفية ومتصفحًا وإشعارات ومساحات عمل وأقسامًا وعلامات تبويب وواجهة سطر أوامر للتحكم في كل ذلك. cmux لا يجبرك على طريقة محددة لاستخدام وكلاء البرمجة. ما تبنيه باستخدام هذه اللبنات الأساسية هو ملكك.
-أفضل المطورين دائماً ما بنوا أدواتهم الخاصة. لم يكتشف أحد بعد أفضل طريقة للعمل مع الوكلاء، والفرق التي تبني منتجات مغلقة لم تكتشفها أيضاً بالتأكيد. المطورون الأقرب لقواعد بياناتهم الخاصة سيكتشفونها أولاً.
+أفضل المطورين دائمًا ما بنوا أدواتهم الخاصة. لم يكتشف أحد بعد أفضل طريقة للعمل مع الوكلاء، والفرق التي تبني منتجات مغلقة لم تكتشفها أيضًا بالتأكيد. المطورون الأقرب لقواعد بياناتهم الخاصة سيكتشفونها أولًا.
أعطِ مليون مطور لبنات أساسية قابلة للتركيب وسيجدون بشكل جماعي أكثر سير العمل كفاءة أسرع مما يمكن لأي فريق منتج تصميمه من الأعلى إلى الأسفل.
@@ -213,11 +213,11 @@ cmux هو لبنة أساسية وليس حلاً جاهزاً. يمنحك طر
[تحميل cmux NIGHTLY](https://github.com/manaflow-ai/cmux/releases/download/nightly/cmux-nightly-macos.dmg)
-cmux NIGHTLY هو تطبيق منفصل بمعرّف حزمة خاص به، لذا يعمل بجانب الإصدار المستقر. يُبنى تلقائياً من أحدث commit على فرع `main` ويتم تحديثه تلقائياً عبر Sparkle الخاص به.
+cmux NIGHTLY هو تطبيق منفصل بمعرّف حزمة خاص به، لذا يعمل بجانب الإصدار المستقر. يُبنى تلقائيًا من أحدث commit على فرع `main` ويتم تحديثه تلقائيًا عبر Sparkle الخاص به.
## استعادة الجلسة (السلوك الحالي)
-عند إعادة التشغيل، يستعيد cmux حالياً تخطيط التطبيق والبيانات الوصفية فقط:
+عند إعادة التشغيل، يستعيد cmux حاليًا تخطيط التطبيق والبيانات الوصفية فقط:
- تخطيط النوافذ/مساحات العمل/الأجزاء
- مجلدات العمل
- سجل تمرير الطرفية (أفضل جهد)
@@ -255,12 +255,12 @@ cmux **لا** يستعيد حالة العمليات الحية داخل تطب
## إصدار المؤسسين
-cmux مجاني ومفتوح المصدر وسيظل كذلك دائماً. إذا كنت ترغب في دعم التطوير والحصول على وصول مبكر لما هو قادم:
+cmux مجاني ومفتوح المصدر وسيظل كذلك دائمًا. إذا كنت ترغب في دعم التطوير والحصول على وصول مبكر لما هو قادم:
**[احصل على إصدار المؤسسين](https://buy.stripe.com/3cI00j2Ld0it5OU33r5EY0q)**
- **أولوية لطلبات الميزات/إصلاح الأخطاء**
-- **وصول مبكر: ذكاء اصطناعي لـ cmux يمنحك سياقاً عن كل مساحة عمل وعلامة تبويب ولوحة**
+- **وصول مبكر: ذكاء اصطناعي لـ cmux يمنحك سياقًا عن كل مساحة عمل وعلامة تبويب ولوحة**
- **وصول مبكر: تطبيق iOS مع مزامنة الطرفيات بين سطح المكتب والهاتف**
- **وصول مبكر: أجهزة افتراضية سحابية**
- **وصول مبكر: وضع الصوت**
diff --git a/README.fr.md b/README.fr.md
index 421a388a..59c049b8 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -113,7 +113,7 @@ Tout est scriptable via le CLI et l'API socket — créer des espaces de travail
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 opiniâtre d'utiliser les agents de programmation. Ce que vous construisez avec ces primitives 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.
@@ -171,7 +171,7 @@ Les raccourcis des outils de développement du navigateur suivent les valeurs pa
| ⌘ [ | Reculer |
| ⌘ ] | Avancer |
| ⌘ R | Recharger la page |
-| ⌥ ⌘ I | Ouvrir les outils de développement (par défaut Safari) |
+| ⌥ ⌘ I | Basculer les outils de développement (par défaut Safari) |
| ⌥ ⌘ C | Afficher la console JavaScript (par défaut Safari) |
### Notifications
diff --git a/README.it.md b/README.it.md
index dbbf57d6..a6546587 100644
--- a/README.it.md
+++ b/README.it.md
@@ -171,7 +171,7 @@ Le scorciatoie degli strumenti di sviluppo del browser seguono i valori predefin
| ⌘ [ | Indietro |
| ⌘ ] | Avanti |
| ⌘ R | Ricarica pagina |
-| ⌥ ⌘ I | Apri Strumenti di Sviluppo (predefinito Safari) |
+| ⌥ ⌘ I | Mostra/Nascondi Strumenti di Sviluppo (predefinito Safari) |
| ⌥ ⌘ C | Mostra Console JavaScript (predefinito Safari) |
### Notifiche
diff --git a/README.pl.md b/README.pl.md
index c0469734..ba28fd2d 100644
--- a/README.pl.md
+++ b/README.pl.md
@@ -241,7 +241,7 @@ 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 uczestncz w [zgłoszeniach GitHub](https://github.com/manaflow-ai/cmux/issues) i [dyskusjach](https://github.com/manaflow-ai/cmux/discussions)
+- 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ść
diff --git a/README.pt-BR.md b/README.pt-BR.md
index 49d4edaa..bd79e450 100644
--- a/README.pt-BR.md
+++ b/README.pt-BR.md
@@ -125,7 +125,7 @@ Para mais informações sobre como configurar o cmux, [acesse nossa documentaç
## Atalhos de Teclado
-### Workspaces
+### Áreas de Trabalho
| Atalho | Ação |
|----------|--------|
@@ -138,7 +138,7 @@ Para mais informações sobre como configurar o cmux, [acesse nossa documentaç
| ⌘ ⇧ R | Renomear workspace |
| ⌘ B | Alternar barra lateral |
-### Surfaces
+### Superfícies
| Atalho | Ação |
|----------|--------|
diff --git a/README.th.md b/README.th.md
index 5fe0ba2c..f77aea0b 100644
--- a/README.th.md
+++ b/README.th.md
@@ -138,18 +138,18 @@ cmux เป็นส่วนประกอบพื้นฐาน ไม่
| ⌘ ⇧ 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 | ปิดเซอร์เฟซ |
### แผงแบ่ง
From 89db360ee0761206ce73f082d32e97a722d69895 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 21:11:39 -0700
Subject: [PATCH 30/43] Add failing off-window local host devtools regression
test
---
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 95 +++++++++++++++++++
1 file changed, 95 insertions(+)
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 84a453c2..28a2d5b8 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -2481,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()
@@ -2691,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 {
From 3ca11a5e00862718e1e41e21d03b7e2e04f689cb Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 21:12:18 -0700
Subject: [PATCH 31/43] Keep off-window local hosts from stealing devtools view
---
Sources/Panels/BrowserPanelView.swift | 28 +++++++++++++++++++++++++++
1 file changed, 28 insertions(+)
diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift
index 16aeed0f..1b962264 100644
--- a/Sources/Panels/BrowserPanelView.swift
+++ b/Sources/Panels/BrowserPanelView.swift
@@ -4486,6 +4486,34 @@ struct WebViewRepresentable: NSViewRepresentable {
)
}
+ let shouldPreserveExistingExternalLocalHost =
+ host.window == nil &&
+ webView.superview != nil &&
+ webView.superview !== slotView
+ if shouldPreserveExistingExternalLocalHost {
+ // Split zoom can instantiate a replacement local host before it joins a window.
+ // Never let that off-window host steal the live page + inspector hierarchy away
+ // from the currently visible local host.
+ host.setLocalInlineSlotHidden(true)
+ coordinator.lastPortalHostId = nil
+ coordinator.lastSynchronizedHostGeometryRevision = 0
+#if DEBUG
+ dlog(
+ "browser.localHost.reparent.skip web=\(Self.objectID(webView)) " +
+ "reason=offWindowReplacementHost super=\(Self.objectID(webView.superview)) " +
+ "host=\(Self.objectID(host)) slot=\(Self.objectID(slotView))"
+ )
+ Self.logDevToolsState(
+ panel,
+ event: "localHost.skip",
+ generation: coordinator.attachGeneration,
+ retryCount: 0,
+ details: Self.attachContext(webView: webView, host: host)
+ )
+#endif
+ return false
+ }
+
if webView.superview !== slotView {
if let sourceSuperview = webView.superview {
Self.moveWebKitRelatedSubviewsIntoHostIfNeeded(
From 9fdf3d0f84509ae56895ee238be40b175b59d3cb Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 21:29:56 -0700
Subject: [PATCH 32/43] Fix nightly workflow main push cancellation
---
.github/workflows/nightly.yml | 36 ++++++++++++++++++++++++++++++++---
1 file changed, 33 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index f645b45c..5b3e21a9 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -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
From c4e2d0c63cb22d184141c990bb3733964e265ef8 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 21:41:01 -0700
Subject: [PATCH 33/43] Add failing Finder drag regression test
---
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 23 +++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 409a06d1..786368c2 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -9388,6 +9388,29 @@ final class CmuxWebViewDragRoutingTests: XCTestCase {
}
}
+#if compiler(>=6.2)
+@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
+
+ XCTAssertFalse(configuration.operationsWithinApp.allowCopy)
+ XCTAssertTrue(configuration.operationsWithinApp.allowMove)
+ XCTAssertFalse(configuration.operationsWithinApp.allowDelete)
+ XCTAssertFalse(configuration.operationsWithinApp.allowAlias)
+
+ XCTAssertFalse(configuration.operationsOutsideApp.allowCopy)
+ XCTAssertFalse(configuration.operationsOutsideApp.allowMove)
+ XCTAssertFalse(configuration.operationsOutsideApp.allowDelete)
+ XCTAssertFalse(configuration.operationsOutsideApp.allowAlias)
+ }
+}
+#endif
+
@MainActor
final class BrowserPaneDropRoutingTests: XCTestCase {
func testVerticalZonesFollowAppKitCoordinates() {
From 1c25c6bd306cb7ec6b068c433c414b684c5f5a8a Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 21:41:01 -0700
Subject: [PATCH 34/43] Keep internal tab drags out of Finder
---
Resources/Info.plist | 14 +-----------
Sources/ContentView.swift | 35 ++++++++++++++++++++++++++++++
Sources/WorkspaceContentView.swift | 1 +
3 files changed, 37 insertions(+), 13 deletions(-)
diff --git a/Resources/Info.plist b/Resources/Info.plist
index f1beb4f9..48d4f800 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -93,27 +93,15 @@
- UTExportedTypeDeclarations
+ UTImportedTypeDeclarations
UTTypeIdentifier
com.splittabbar.tabtransfer
- UTTypeDescription
- Bonsplit Tab Transfer
- UTTypeConformsTo
-
- public.data
-
UTTypeIdentifier
com.cmux.sidebar-tab-reorder
- UTTypeDescription
- cmux Sidebar Tab Reorder
- UTTypeConformsTo
-
- public.data
-
NSAppTransportSecurity
diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift
index 6c03b213..01adb67d 100644
--- a/Sources/ContentView.swift
+++ b/Sources/ContentView.swift
@@ -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
@@ -9662,6 +9696,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,
diff --git a/Sources/WorkspaceContentView.swift b/Sources/WorkspaceContentView.swift
index 700e65c0..0b955943 100644
--- a/Sources/WorkspaceContentView.swift
+++ b/Sources/WorkspaceContentView.swift
@@ -106,6 +106,7 @@ 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.
From a58cd76e2e034aaa6b9f5b472af20bfcd58b01fc Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 21:41:46 -0700
Subject: [PATCH 35/43] Add failing portal reattach regression tests
---
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 29 +++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 28a2d5b8..7f7c981f 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -9465,6 +9465,33 @@ final class BrowserPanelHostContainerViewTests: XCTestCase {
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
@@ -11471,7 +11498,9 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase {
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(
From 83c7c9ee1968c0871b9004136fbafa8ba8dcdf51 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 21:41:49 -0700
Subject: [PATCH 36/43] Guard portal refreshes and reset plain reattached web
views
---
Sources/BrowserWindowPortal.swift | 32 ++++++++++++++++++++++++++++---
1 file changed, 29 insertions(+), 3 deletions(-)
diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift
index 915467e2..10e0b8b0 100644
--- a/Sources/BrowserWindowPortal.swift
+++ b/Sources/BrowserWindowPortal.swift
@@ -1672,9 +1672,13 @@ final class WindowBrowserSlotView: NSView {
func pinHostedWebView(_ webView: WKWebView) {
guard webView.superview === self else { return }
+ let needsPlainWebViewFrameReset =
+ !Self.hasWebKitCompanionSubview(in: self, primaryWebView: webView) &&
+ Self.frameDiffersFromBounds(webView.frame, bounds: bounds)
let needsFrameHosting =
hostedWebView !== webView ||
!hostedWebViewConstraints.isEmpty ||
+ needsPlainWebViewFrameReset ||
!webView.translatesAutoresizingMaskIntoConstraints ||
webView.autoresizingMask != [.width, .height]
guard needsFrameHosting else {
@@ -1687,8 +1691,8 @@ final class WindowBrowserSlotView: NSView {
hostedWebViewConstraints = []
hostedWebView = webView
// Attached Web Inspector mutates the moved WKWebView's frame directly.
- // Re-pin only when hosting mode changes, not when WebKit resizes the page
- // inside the slot for side-docked DevTools.
+ // 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
@@ -1696,6 +1700,27 @@ final class WindowBrowserSlotView: NSView {
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
}
@@ -1975,6 +2000,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,
@@ -2650,7 +2676,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
From 0c3d53129d01a6269a11d0d937c31a283bce5abc Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 21:48:36 -0700
Subject: [PATCH 37/43] Fix nightly release build welcome command helper
---
Sources/AppDelegate.swift | 32 ++++++++++++++++----------------
1 file changed, 16 insertions(+), 16 deletions(-)
diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift
index 10240c7d..73831654 100644
--- a/Sources/AppDelegate.swift
+++ b/Sources/AppDelegate.swift
@@ -5168,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 - "
@@ -5501,22 +5517,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
)
}
- 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("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, beforeSend: beforeSend)
- }
- }
-
@objc func triggerSentryTestCrash(_ sender: Any?) {
SentrySDK.crash()
}
From dfc0c4462441eb5e35b660db44b729f5ed955336 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Tue, 10 Mar 2026 22:18:29 -0700
Subject: [PATCH 38/43] Fix drag configuration test compatibility
---
cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 63 ++++++++++++++++---
1 file changed, 55 insertions(+), 8 deletions(-)
diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
index 4d4eda6f..3db83f10 100644
--- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
+++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift
@@ -9511,6 +9511,41 @@ 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(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 {
@@ -9519,16 +9554,28 @@ final class InternalTabDragConfigurationTests: XCTestCase {
}
let configuration = InternalTabDragConfigurationProvider.value
+ let withinApp = try dragConfigurationOperationsSnapshot(from: configuration.operationsWithinApp)
+ let outsideApp = try dragConfigurationOperationsSnapshot(from: configuration.operationsOutsideApp)
- XCTAssertFalse(configuration.operationsWithinApp.allowCopy)
- XCTAssertTrue(configuration.operationsWithinApp.allowMove)
- XCTAssertFalse(configuration.operationsWithinApp.allowDelete)
- XCTAssertFalse(configuration.operationsWithinApp.allowAlias)
+ XCTAssertEqual(
+ withinApp,
+ DragConfigurationOperationsSnapshot(
+ allowCopy: false,
+ allowMove: true,
+ allowDelete: false,
+ allowAlias: false
+ )
+ )
- XCTAssertFalse(configuration.operationsOutsideApp.allowCopy)
- XCTAssertFalse(configuration.operationsOutsideApp.allowMove)
- XCTAssertFalse(configuration.operationsOutsideApp.allowDelete)
- XCTAssertFalse(configuration.operationsOutsideApp.allowAlias)
+ XCTAssertEqual(
+ outsideApp,
+ DragConfigurationOperationsSnapshot(
+ allowCopy: false,
+ allowMove: false,
+ allowDelete: false,
+ allowAlias: false
+ )
+ )
}
}
#endif
From 00587ed8569d6c9e2cd9875b608c688f603a1782 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Wed, 11 Mar 2026 02:42:33 -0700
Subject: [PATCH 39/43] Add cmux claude-teams launcher (#1179)
* Add claude-teams CLI command
* Add claude-teams launcher regression test
* Exec claude-teams launcher in place
* Add existing-shim claude-teams regression test
* Reuse claude-teams shim and refresh dev CLI
* Add wrapper-selection claude-teams regression test
* Launch real claude binary for claude-teams
* Add claude-teams auto-mode launcher regression test
* Default claude-teams to fake tmux auto mode
* Build tagged reloads under DerivedData
* Add claude-teams tmux sequence regression test
* Fix claude-teams tmux teammate compatibility
* Add claude-teams split focus regression test
* Keep claude-teams leader pane focused
* Tighten claude-teams review fixes
* Pass claude-teams help through to Claude
* Use sentinel TERM_PROGRAM in claude-teams test
---
CLI/cmux.swift | 1170 ++++++++++++++++-
Resources/Localizable.xcstrings | 17 +
scripts/reload.sh | 37 +-
tests/claude_teams_test_utils.py | 22 +
tests/test_cli_claude_teams_env.py | 192 +++
tests/test_cli_claude_teams_existing_shim.py | 90 ++
.../test_cli_claude_teams_help_passthrough.py | 86 ++
...t_cli_claude_teams_skips_wrapper_claude.py | 85 ++
tests/test_cli_claude_teams_tmux_sequence.py | 362 +++++
9 files changed, 2055 insertions(+), 6 deletions(-)
create mode 100644 tests/claude_teams_test_utils.py
create mode 100644 tests/test_cli_claude_teams_env.py
create mode 100644 tests/test_cli_claude_teams_existing_shim.py
create mode 100644 tests/test_cli_claude_teams_help_passthrough.py
create mode 100644 tests/test_cli_claude_teams_skips_wrapper_claude.py
create mode 100644 tests/test_cli_claude_teams_tmux_sequence.py
diff --git a/CLI/cmux.swift b/CLI/cmux.swift
index 94c90c5e..17308aed 100644
--- a/CLI/cmux.swift
+++ b/CLI/cmux.swift
@@ -899,7 +899,9 @@ struct CMUXCLI {
// Check for --help/-h on subcommands before connecting to the socket,
// so help text is available even when cmux is not running.
- if commandArgs.contains("--help") || commandArgs.contains("-h") {
+ if command != "__tmux-compat",
+ command != "claude-teams",
+ (commandArgs.contains("--help") || commandArgs.contains("-h")) {
if dispatchSubcommandHelp(command: command, commandArgs: commandArgs) {
return
}
@@ -932,6 +934,15 @@ struct CMUXCLI {
return
}
+ if command == "claude-teams" {
+ try runClaudeTeams(
+ commandArgs: commandArgs,
+ socketPath: resolvedSocketPath,
+ explicitPassword: socketPasswordArg
+ )
+ return
+ }
+
let client = SocketClient(path: resolvedSocketPath)
if resolvedSocketPath != socketPath {
cliTelemetry.breadcrumb(
@@ -1672,6 +1683,15 @@ struct CMUXCLI {
let response = try sendV1Command("simulate_app_active", client: client)
print(response)
+ case "__tmux-compat":
+ try runClaudeTeamsTmuxCompat(
+ commandArgs: commandArgs,
+ client: client,
+ jsonOutput: jsonOutput,
+ idFormat: idFormat,
+ windowOverride: windowId
+ )
+
case "capture-pane",
"resize-pane",
"pipe-pane",
@@ -4244,6 +4264,27 @@ struct CMUXCLI {
Double check with the end user before sending anything. Review the message and attachments for secrets,
private code, credentials, tokens, and other sensitive information first.
"""
+ case "claude-teams":
+ return String(localized: "cli.claude-teams.usage", defaultValue: """
+ Usage: cmux claude-teams [claude-args...]
+
+ Launch Claude Code with agent teams enabled.
+
+ This command:
+ - defaults Claude teammate mode to auto
+ - sets a tmux-like environment so Claude auto mode uses cmux splits
+ - sets CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
+ - prepends a private tmux shim to PATH
+ - forwards all remaining arguments to claude
+
+ The tmux shim translates supported tmux window/pane commands into cmux
+ workspace and split operations in the current cmux session.
+
+ Examples:
+ cmux claude-teams
+ cmux claude-teams --continue
+ cmux claude-teams --model sonnet
+ """)
case "identify":
return """
Usage: cmux identify [--workspace ] [--surface ] [--no-caller]
@@ -5948,6 +5989,1132 @@ struct CMUXCLI {
return output
}
+ private struct TmuxParsedArguments {
+ var flags: Set = []
+ var options: [String: [String]] = [:]
+ var positional: [String] = []
+
+ func hasFlag(_ flag: String) -> Bool {
+ flags.contains(flag)
+ }
+
+ func value(_ flag: String) -> String? {
+ options[flag]?.last
+ }
+ }
+
+ private func parseTmuxArguments(
+ _ args: [String],
+ valueFlags: Set,
+ boolFlags: Set
+ ) throws -> TmuxParsedArguments {
+ var parsed = TmuxParsedArguments()
+ var index = 0
+ var pastTerminator = false
+
+ while index < args.count {
+ let arg = args[index]
+ if pastTerminator {
+ parsed.positional.append(arg)
+ index += 1
+ continue
+ }
+ if arg == "--" {
+ pastTerminator = true
+ index += 1
+ continue
+ }
+ if !arg.hasPrefix("-") || arg == "-" {
+ parsed.positional.append(arg)
+ index += 1
+ continue
+ }
+ if arg.hasPrefix("--") {
+ parsed.positional.append(arg)
+ index += 1
+ continue
+ }
+
+ let cluster = Array(arg.dropFirst())
+ var cursor = 0
+ var recognizedArgument = false
+ while cursor < cluster.count {
+ let flag = "-" + String(cluster[cursor])
+ if boolFlags.contains(flag) {
+ parsed.flags.insert(flag)
+ cursor += 1
+ recognizedArgument = true
+ continue
+ }
+ if valueFlags.contains(flag) {
+ let remainder = String(cluster.dropFirst(cursor + 1))
+ let value: String
+ if !remainder.isEmpty {
+ value = remainder
+ } else {
+ guard index + 1 < args.count else {
+ throw CLIError(message: "\(flag) requires a value")
+ }
+ index += 1
+ value = args[index]
+ }
+ parsed.options[flag, default: []].append(value)
+ recognizedArgument = true
+ cursor = cluster.count
+ continue
+ }
+
+ recognizedArgument = false
+ break
+ }
+
+ if !recognizedArgument {
+ parsed.positional.append(arg)
+ }
+ index += 1
+ }
+
+ return parsed
+ }
+
+ private func splitTmuxCommand(_ args: [String]) throws -> (command: String, args: [String]) {
+ var index = 0
+ let globalValueFlags: Set = ["-L", "-S", "-f"]
+
+ while index < args.count {
+ let arg = args[index]
+ if !arg.hasPrefix("-") || arg == "-" {
+ return (arg.lowercased(), Array(args.dropFirst(index + 1)))
+ }
+ if arg == "--" {
+ break
+ }
+ if let flag = globalValueFlags.first(where: { arg == $0 || arg.hasPrefix($0) }) {
+ if arg == flag {
+ index += 1
+ }
+ }
+ index += 1
+ }
+
+ throw CLIError(message: "tmux shim requires a command")
+ }
+
+ private func normalizedTmuxTarget(_ raw: String?) -> String? {
+ guard let raw else { return nil }
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+
+ private func tmuxWindowSelector(from raw: String?) -> String? {
+ guard let trimmed = normalizedTmuxTarget(raw) else { return nil }
+ if trimmed.hasPrefix("%") || trimmed.hasPrefix("pane:") {
+ return nil
+ }
+ if let dot = trimmed.lastIndex(of: ".") {
+ return String(trimmed[.. String? {
+ guard let trimmed = normalizedTmuxTarget(raw) else { return nil }
+ if trimmed.hasPrefix("%") {
+ return String(trimmed.dropFirst())
+ }
+ if trimmed.hasPrefix("pane:") {
+ return trimmed
+ }
+ if let dot = trimmed.lastIndex(of: ".") {
+ return String(trimmed[trimmed.index(after: dot)...])
+ }
+ return nil
+ }
+
+ private func tmuxWorkspaceItems(client: SocketClient) throws -> [[String: Any]] {
+ let payload = try client.sendV2(method: "workspace.list")
+ return payload["workspaces"] as? [[String: Any]] ?? []
+ }
+
+ private func tmuxCallerWorkspaceHandle() -> String? {
+ normalizedTmuxTarget(ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"])
+ }
+
+ private func tmuxCallerPaneHandle() -> String? {
+ guard let pane = normalizedTmuxTarget(ProcessInfo.processInfo.environment["TMUX_PANE"])
+ ?? normalizedTmuxTarget(ProcessInfo.processInfo.environment["CMUX_PANE_ID"]) else {
+ return nil
+ }
+ return pane.hasPrefix("%") ? String(pane.dropFirst()) : pane
+ }
+
+ private func tmuxCallerSurfaceHandle() -> String? {
+ normalizedTmuxTarget(ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"])
+ }
+
+ private func tmuxCanonicalPaneId(
+ _ handle: String,
+ workspaceId: String,
+ client: SocketClient
+ ) throws -> String {
+ if isUUID(handle) {
+ return handle
+ }
+
+ let payload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId])
+ let panes = payload["panes"] as? [[String: Any]] ?? []
+ for pane in panes {
+ if (pane["ref"] as? String) == handle || (pane["id"] as? String) == handle {
+ if let id = pane["id"] as? String {
+ return id
+ }
+ }
+ }
+
+ if let index = Int(handle) {
+ for pane in panes where intFromAny(pane["index"]) == index {
+ if let id = pane["id"] as? String {
+ return id
+ }
+ }
+ }
+
+ throw CLIError(message: "Pane target not found")
+ }
+
+ private func tmuxCanonicalSurfaceId(
+ _ handle: String,
+ workspaceId: String,
+ client: SocketClient
+ ) throws -> String {
+ if isUUID(handle) {
+ return handle
+ }
+
+ let payload = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId])
+ let surfaces = payload["surfaces"] as? [[String: Any]] ?? []
+ for surface in surfaces {
+ if (surface["ref"] as? String) == handle || (surface["id"] as? String) == handle {
+ if let id = surface["id"] as? String {
+ return id
+ }
+ }
+ }
+
+ if let index = Int(handle) {
+ for surface in surfaces where intFromAny(surface["index"]) == index {
+ if let id = surface["id"] as? String {
+ return id
+ }
+ }
+ }
+
+ throw CLIError(message: "Surface target not found")
+ }
+
+ private func tmuxWorkspaceIdForPaneHandle(_ handle: String, client: SocketClient) throws -> String? {
+ guard isUUID(handle) || isHandleRef(handle) else {
+ return nil
+ }
+
+ let workspaces = try tmuxWorkspaceItems(client: client)
+ for workspace in workspaces {
+ guard let workspaceId = workspace["id"] as? String else { continue }
+ let payload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId])
+ let panes = payload["panes"] as? [[String: Any]] ?? []
+ if panes.contains(where: { ($0["id"] as? String) == handle || ($0["ref"] as? String) == handle }) {
+ return workspaceId
+ }
+ }
+
+ return nil
+ }
+
+ private func tmuxFocusedPaneId(workspaceId: String, client: SocketClient) throws -> String {
+ let payload = try client.sendV2(method: "surface.current", params: ["workspace_id": workspaceId])
+ if let paneId = payload["pane_id"] as? String {
+ return paneId
+ }
+ if let paneRef = payload["pane_ref"] as? String {
+ return try tmuxCanonicalPaneId(paneRef, workspaceId: workspaceId, client: client)
+ }
+ throw CLIError(message: "Pane target not found")
+ }
+
+ private func tmuxResolveWorkspaceTarget(_ raw: String?, client: SocketClient) throws -> String {
+ guard var token = normalizedTmuxTarget(raw) else {
+ if let callerWorkspace = tmuxCallerWorkspaceHandle() {
+ return try resolveWorkspaceId(callerWorkspace, client: client)
+ }
+ return try resolveWorkspaceId(nil, client: client)
+ }
+
+ if token == "!" || token == "^" || token == "-" {
+ let payload = try client.sendV2(method: "workspace.last")
+ if let workspaceId = payload["workspace_id"] as? String {
+ return workspaceId
+ }
+ throw CLIError(message: "Previous workspace not found")
+ }
+
+ if let dot = token.lastIndex(of: ".") {
+ token = String(token[.. (workspaceId: String, paneId: String) {
+ let paneSelector = tmuxPaneSelector(from: raw)
+ let workspaceSelector = tmuxWindowSelector(from: raw)
+ let workspaceId: String = {
+ if let workspaceSelector {
+ return (try? tmuxResolveWorkspaceTarget(workspaceSelector, client: client)) ?? ""
+ }
+ if let paneSelector,
+ let workspaceId = try? tmuxWorkspaceIdForPaneHandle(paneSelector, client: client) {
+ return workspaceId
+ }
+ return (try? tmuxResolveWorkspaceTarget(nil, client: client)) ?? ""
+ }()
+ guard !workspaceId.isEmpty else {
+ throw CLIError(message: "Workspace target not found")
+ }
+ let paneId: String
+ if let paneSelector {
+ paneId = try tmuxCanonicalPaneId(paneSelector, workspaceId: workspaceId, client: client)
+ } else if tmuxCallerWorkspaceHandle() == workspaceId,
+ let callerPane = tmuxCallerPaneHandle(),
+ let callerPaneId = try? tmuxCanonicalPaneId(callerPane, workspaceId: workspaceId, client: client) {
+ paneId = callerPaneId
+ } else {
+ paneId = try tmuxFocusedPaneId(workspaceId: workspaceId, client: client)
+ }
+ return (workspaceId, paneId)
+ }
+
+ private func tmuxSelectedSurfaceId(
+ workspaceId: String,
+ paneId: String,
+ client: SocketClient
+ ) throws -> String {
+ let payload = try client.sendV2(
+ method: "pane.surfaces",
+ params: ["workspace_id": workspaceId, "pane_id": paneId]
+ )
+ let surfaces = payload["surfaces"] as? [[String: Any]] ?? []
+ if let selected = surfaces.first(where: { ($0["selected"] as? Bool) == true }),
+ let id = selected["id"] as? String {
+ return id
+ }
+ if let first = surfaces.first?["id"] as? String {
+ return first
+ }
+ throw CLIError(message: "Pane has no surface to target")
+ }
+
+ private func tmuxResolveSurfaceTarget(
+ _ raw: String?,
+ client: SocketClient
+ ) throws -> (workspaceId: String, paneId: String?, surfaceId: String) {
+ if tmuxPaneSelector(from: raw) != nil {
+ let resolved = try tmuxResolvePaneTarget(raw, client: client)
+ let surfaceId = try tmuxSelectedSurfaceId(
+ workspaceId: resolved.workspaceId,
+ paneId: resolved.paneId,
+ client: client
+ )
+ return (resolved.workspaceId, resolved.paneId, surfaceId)
+ }
+
+ let workspaceId = try tmuxResolveWorkspaceTarget(tmuxWindowSelector(from: raw), client: client)
+ if tmuxWindowSelector(from: raw) == nil,
+ tmuxCallerWorkspaceHandle() == workspaceId,
+ let callerSurface = tmuxCallerSurfaceHandle(),
+ let surfaceId = try? tmuxCanonicalSurfaceId(callerSurface, workspaceId: workspaceId, client: client) {
+ return (workspaceId, nil, surfaceId)
+ }
+ let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
+ return (workspaceId, nil, surfaceId)
+ }
+
+ private func tmuxRenderFormat(
+ _ format: String?,
+ context: [String: String],
+ fallback: String
+ ) -> String {
+ guard let format, !format.isEmpty else { return fallback }
+ var rendered = format
+ for (key, value) in context {
+ rendered = rendered.replacingOccurrences(of: "#{\(key)}", with: value)
+ }
+ rendered = rendered.replacingOccurrences(
+ of: "#\\{[^}]+\\}",
+ with: "",
+ options: .regularExpression
+ )
+ let trimmed = rendered.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? fallback : trimmed
+ }
+
+ private func tmuxFormatContext(
+ workspaceId: String,
+ paneId: String? = nil,
+ surfaceId: String? = nil,
+ client: SocketClient
+ ) throws -> [String: String] {
+ let canonicalWorkspaceId = try resolveWorkspaceId(workspaceId, client: client)
+ var context: [String: String] = [
+ "session_name": "cmux",
+ "window_id": "@\(canonicalWorkspaceId)",
+ "window_uuid": canonicalWorkspaceId
+ ]
+
+ let workspaceItems = try tmuxWorkspaceItems(client: client)
+ if let workspace = workspaceItems.first(where: {
+ ($0["id"] as? String) == canonicalWorkspaceId || ($0["ref"] as? String) == workspaceId
+ }) {
+ if let index = intFromAny(workspace["index"]) {
+ context["window_index"] = String(index)
+ }
+ let title = ((workspace["title"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ if !title.isEmpty {
+ context["window_name"] = title
+ }
+ }
+
+ let currentPayload = try client.sendV2(method: "surface.current", params: ["workspace_id": canonicalWorkspaceId])
+ let resolvedPaneId: String? = try {
+ if let paneId {
+ return try tmuxCanonicalPaneId(paneId, workspaceId: canonicalWorkspaceId, client: client)
+ }
+ if let currentPaneId = currentPayload["pane_id"] as? String {
+ return currentPaneId
+ }
+ if let currentPaneRef = currentPayload["pane_ref"] as? String {
+ return try tmuxCanonicalPaneId(currentPaneRef, workspaceId: canonicalWorkspaceId, client: client)
+ }
+ return nil
+ }()
+ let resolvedSurfaceId: String? = try {
+ if let surfaceId {
+ return try tmuxCanonicalSurfaceId(surfaceId, workspaceId: canonicalWorkspaceId, client: client)
+ }
+ if let resolvedPaneId {
+ return try tmuxSelectedSurfaceId(
+ workspaceId: canonicalWorkspaceId,
+ paneId: resolvedPaneId,
+ client: client
+ )
+ }
+ return currentPayload["surface_id"] as? String
+ }()
+
+ if let resolvedPaneId {
+ context["pane_id"] = "%\(resolvedPaneId)"
+ context["pane_uuid"] = resolvedPaneId
+ let panePayload = try client.sendV2(method: "pane.list", params: ["workspace_id": canonicalWorkspaceId])
+ let panes = panePayload["panes"] as? [[String: Any]] ?? []
+ if let pane = panes.first(where: { ($0["id"] as? String) == resolvedPaneId }),
+ let index = intFromAny(pane["index"]) {
+ context["pane_index"] = String(index)
+ }
+ }
+
+ if let resolvedSurfaceId {
+ context["surface_id"] = resolvedSurfaceId
+ let surfacePayload = try client.sendV2(method: "surface.list", params: ["workspace_id": canonicalWorkspaceId])
+ let surfaces = surfacePayload["surfaces"] as? [[String: Any]] ?? []
+ if let surface = surfaces.first(where: { ($0["id"] as? String) == resolvedSurfaceId }) {
+ let title = ((surface["title"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ if !title.isEmpty {
+ context["pane_title"] = title
+ context["window_name"] = context["window_name"] ?? title
+ }
+ }
+ }
+
+ return context
+ }
+
+ private func tmuxShellQuote(_ value: String) -> String {
+ "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
+ }
+
+ private func tmuxShellCommandText(commandTokens: [String], cwd: String?) -> String? {
+ let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let commandText = commandTokens.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
+ guard (trimmedCwd?.isEmpty == false) || !commandText.isEmpty else {
+ return nil
+ }
+
+ var pieces: [String] = []
+ if let trimmedCwd, !trimmedCwd.isEmpty {
+ pieces.append("cd -- \(tmuxShellQuote(resolvePath(trimmedCwd)))")
+ }
+ if !commandText.isEmpty {
+ pieces.append(commandText)
+ }
+ return pieces.joined(separator: " && ") + "\r"
+ }
+
+ private func tmuxSpecialKeyText(_ token: String) -> String? {
+ switch token.lowercased() {
+ case "enter", "c-m", "kpenter":
+ return "\r"
+ case "tab", "c-i":
+ return "\t"
+ case "space":
+ return " "
+ case "bspace", "backspace":
+ return "\u{7f}"
+ case "escape", "esc", "c-[":
+ return "\u{1b}"
+ case "c-c":
+ return "\u{03}"
+ case "c-d":
+ return "\u{04}"
+ case "c-z":
+ return "\u{1a}"
+ case "c-l":
+ return "\u{0c}"
+ default:
+ return nil
+ }
+ }
+
+ private func tmuxSendKeysText(from tokens: [String], literal: Bool) -> String {
+ if literal {
+ return tokens.joined(separator: " ")
+ }
+
+ var result = ""
+ var pendingSpace = false
+ for token in tokens {
+ if let special = tmuxSpecialKeyText(token) {
+ result += special
+ pendingSpace = false
+ continue
+ }
+ if pendingSpace {
+ result += " "
+ }
+ result += token
+ pendingSpace = true
+ }
+ return result
+ }
+
+ private func prependPathEntries(_ newEntries: [String], to currentPath: String?) -> String {
+ var ordered: [String] = []
+ var seen: Set = []
+ for entry in newEntries + (currentPath?.split(separator: ":").map(String.init) ?? []) where !entry.isEmpty {
+ if seen.insert(entry).inserted {
+ ordered.append(entry)
+ }
+ }
+ return ordered.joined(separator: ":")
+ }
+
+ private struct ClaudeTeamsFocusedContext {
+ let socketPath: String
+ let workspaceId: String
+ let windowId: String?
+ let paneHandle: String
+ let paneId: String?
+ let surfaceId: String?
+ }
+
+ private func claudeTeamsResolvedSocketPath(processEnvironment: [String: String]) -> String {
+ let envSocketPath: String? = {
+ for key in ["CMUX_SOCKET_PATH", "CMUX_SOCKET"] {
+ guard let raw = processEnvironment[key] else { continue }
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmed.isEmpty {
+ return trimmed
+ }
+ }
+ return nil
+ }()
+
+ let requestedSocketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath
+ let source: CLISocketPathSource
+ if let envSocketPath {
+ source = envSocketPath == CLISocketPathResolver.defaultSocketPath ? .implicitDefault : .environment
+ } else {
+ source = .implicitDefault
+ }
+
+ return CLISocketPathResolver.resolve(
+ requestedPath: requestedSocketPath,
+ source: source,
+ environment: processEnvironment
+ )
+ }
+
+ private func claudeTeamsFocusedContext(
+ processEnvironment: [String: String],
+ explicitPassword: String?
+ ) -> ClaudeTeamsFocusedContext? {
+ let socketPath = claudeTeamsResolvedSocketPath(processEnvironment: processEnvironment)
+ let client = SocketClient(path: socketPath)
+
+ do {
+ try client.connect()
+ try authenticateClientIfNeeded(client, explicitPassword: explicitPassword)
+ defer { client.close() }
+
+ let payload = try client.sendV2(method: "system.identify")
+ let focused = payload["focused"] as? [String: Any] ?? [:]
+
+ let workspaceId = (focused["workspace_id"] as? String)
+ ?? (focused["workspace_ref"] as? String)
+ let paneId = (focused["pane_id"] as? String)
+ ?? (focused["pane_ref"] as? String)
+
+ guard let workspaceId, let paneId else {
+ return nil
+ }
+
+ let paneHandle = paneId.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !paneHandle.isEmpty else {
+ return nil
+ }
+
+ let windowId = (focused["window_id"] as? String)
+ ?? (focused["window_ref"] as? String)
+ let surfaceId = (focused["surface_id"] as? String)
+ ?? (focused["surface_ref"] as? String)
+
+ return ClaudeTeamsFocusedContext(
+ socketPath: socketPath,
+ workspaceId: workspaceId,
+ windowId: windowId,
+ paneHandle: paneHandle,
+ paneId: focused["pane_id"] as? String,
+ surfaceId: surfaceId
+ )
+ } catch {
+ client.close()
+ return nil
+ }
+ }
+
+ private func isCmuxClaudeWrapper(at path: String) -> Bool {
+ guard let data = FileManager.default.contents(atPath: path) else { return false }
+ let prefixData = data.prefix(512)
+ guard let prefix = String(data: prefixData, encoding: .utf8) else { return false }
+ return prefix.contains("cmux claude wrapper - injects hooks and session tracking")
+ }
+
+ private func resolveClaudeExecutable(searchPath: String?) -> String? {
+ let entries = searchPath?.split(separator: ":").map(String.init) ?? []
+ for entry in entries where !entry.isEmpty {
+ let candidate = URL(fileURLWithPath: entry, isDirectory: true)
+ .appendingPathComponent("claude", isDirectory: false)
+ .path
+ guard FileManager.default.isExecutableFile(atPath: candidate) else { continue }
+ guard !isCmuxClaudeWrapper(at: candidate) else { continue }
+ return candidate
+ }
+ return nil
+ }
+
+ private func claudeTeamsHasExplicitTeammateMode(commandArgs: [String]) -> Bool {
+ commandArgs.contains { arg in
+ arg == "--teammate-mode" || arg.hasPrefix("--teammate-mode=")
+ }
+ }
+
+ private func claudeTeamsLaunchArguments(commandArgs: [String]) -> [String] {
+ guard !claudeTeamsHasExplicitTeammateMode(commandArgs: commandArgs) else {
+ return commandArgs
+ }
+ return ["--teammate-mode", "auto"] + commandArgs
+ }
+
+ private func configureClaudeTeamsEnvironment(
+ processEnvironment: [String: String],
+ shimDirectory: URL,
+ executablePath: String,
+ socketPath: String,
+ explicitPassword: String?,
+ focusedContext: ClaudeTeamsFocusedContext?
+ ) {
+ let updatedPath = prependPathEntries(
+ [shimDirectory.path],
+ to: processEnvironment["PATH"]
+ )
+ let fakeTmuxValue: String = {
+ if let focusedContext {
+ let windowToken = focusedContext.windowId ?? focusedContext.workspaceId
+ return "/tmp/cmux-claude-teams/\(focusedContext.workspaceId),\(windowToken),\(focusedContext.paneHandle)"
+ }
+ return processEnvironment["TMUX"] ?? "/tmp/cmux-claude-teams/default,0,0"
+ }()
+ let fakeTmuxPane = focusedContext.map { "%\($0.paneHandle)" }
+ ?? processEnvironment["TMUX_PANE"]
+ ?? "%1"
+ let fakeTerm = processEnvironment["CMUX_CLAUDE_TEAMS_TERM"] ?? "screen-256color"
+
+ setenv("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", "1", 1)
+ setenv("CMUX_CLAUDE_TEAMS_CMUX_BIN", executablePath, 1)
+ setenv("PATH", updatedPath, 1)
+ setenv("TMUX", fakeTmuxValue, 1)
+ setenv("TMUX_PANE", fakeTmuxPane, 1)
+ setenv("TERM", fakeTerm, 1)
+ setenv("CMUX_SOCKET_PATH", socketPath, 1)
+ setenv("CMUX_SOCKET", socketPath, 1)
+ if let explicitPassword,
+ !explicitPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ setenv("CMUX_SOCKET_PASSWORD", explicitPassword, 1)
+ }
+ unsetenv("TERM_PROGRAM")
+ if let focusedContext {
+ setenv("CMUX_WORKSPACE_ID", focusedContext.workspaceId, 1)
+ if let surfaceId = focusedContext.surfaceId, !surfaceId.isEmpty {
+ setenv("CMUX_SURFACE_ID", surfaceId, 1)
+ }
+ }
+ }
+
+ private func createClaudeTeamsShimDirectory() throws -> URL {
+ let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
+ let rootPath = URL(fileURLWithPath: homePath, isDirectory: true)
+ .appendingPathComponent(".cmuxterm", isDirectory: true)
+ .appendingPathComponent("claude-teams-bin", isDirectory: true)
+ .path
+ let root = URL(fileURLWithPath: rootPath, isDirectory: true)
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, attributes: nil)
+ let tmuxURL = root.appendingPathComponent("tmux", isDirectory: false)
+ let script = """
+ #!/usr/bin/env bash
+ set -euo pipefail
+ exec "${CMUX_CLAUDE_TEAMS_CMUX_BIN:-cmux}" __tmux-compat "$@"
+ """
+ let normalizedScript = script.trimmingCharacters(in: .whitespacesAndNewlines)
+ let existingScript = try? String(contentsOf: tmuxURL, encoding: .utf8)
+ if existingScript?.trimmingCharacters(in: .whitespacesAndNewlines) != normalizedScript {
+ try script.write(to: tmuxURL, atomically: false, encoding: .utf8)
+ }
+ try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: tmuxURL.path)
+ return root
+ }
+
+ private func runClaudeTeams(
+ commandArgs: [String],
+ socketPath: String,
+ explicitPassword: String?
+ ) throws {
+ let processEnvironment = ProcessInfo.processInfo.environment
+ var launcherEnvironment = processEnvironment
+ launcherEnvironment["CMUX_SOCKET_PATH"] = socketPath
+ launcherEnvironment["CMUX_SOCKET"] = socketPath
+ if let explicitPassword,
+ !explicitPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ launcherEnvironment["CMUX_SOCKET_PASSWORD"] = explicitPassword
+ }
+ let shimDirectory = try createClaudeTeamsShimDirectory()
+ let executablePath = resolvedExecutableURL()?.path ?? (args.first ?? "cmux")
+ let focusedContext = claudeTeamsFocusedContext(
+ processEnvironment: launcherEnvironment,
+ explicitPassword: explicitPassword
+ )
+ let bundledClaudePath = resolvedExecutableURL()?
+ .deletingLastPathComponent()
+ .appendingPathComponent("claude", isDirectory: false)
+ .path
+ let claudeExecutablePath = resolveClaudeExecutable(searchPath: launcherEnvironment["PATH"])
+ ?? {
+ guard let bundledClaudePath,
+ FileManager.default.isExecutableFile(atPath: bundledClaudePath) else { return nil }
+ return bundledClaudePath
+ }()
+ configureClaudeTeamsEnvironment(
+ processEnvironment: launcherEnvironment,
+ shimDirectory: shimDirectory,
+ executablePath: executablePath,
+ socketPath: socketPath,
+ explicitPassword: explicitPassword,
+ focusedContext: focusedContext
+ )
+
+ let launchPath = claudeExecutablePath ?? "claude"
+ let launchArguments = claudeTeamsLaunchArguments(commandArgs: commandArgs)
+ var argv = ([launchPath] + launchArguments).map { strdup($0) }
+ defer {
+ for item in argv {
+ free(item)
+ }
+ }
+ argv.append(nil)
+
+ if claudeExecutablePath != nil {
+ execv(launchPath, &argv)
+ } else {
+ execvp("claude", &argv)
+ }
+ let code = errno
+ throw CLIError(message: "Failed to launch claude: \(String(cString: strerror(code)))")
+ }
+
+ private func runClaudeTeamsTmuxCompat(
+ commandArgs: [String],
+ client: SocketClient,
+ jsonOutput: Bool,
+ idFormat: CLIIDFormat,
+ windowOverride: String?
+ ) throws {
+ let (command, rawArgs) = try splitTmuxCommand(commandArgs)
+
+ switch command {
+ case "new-session", "new":
+ let parsed = try parseTmuxArguments(
+ rawArgs,
+ valueFlags: ["-c", "-F", "-n", "-s"],
+ boolFlags: ["-A", "-d", "-P"]
+ )
+ if parsed.hasFlag("-A") {
+ throw CLIError(message: "new-session -A is not supported in cmux claude-teams mode")
+ }
+ var params: [String: Any] = ["focus": false]
+ if let cwd = parsed.value("-c") {
+ params["cwd"] = resolvePath(cwd)
+ }
+ let created = try client.sendV2(method: "workspace.create", params: params)
+ guard let workspaceId = created["workspace_id"] as? String else {
+ throw CLIError(message: "workspace.create did not return workspace_id")
+ }
+ if let title = parsed.value("-n") ?? parsed.value("-s"),
+ !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ _ = try client.sendV2(method: "workspace.rename", params: [
+ "workspace_id": workspaceId,
+ "title": title
+ ])
+ }
+ if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) {
+ Thread.sleep(forTimeInterval: 0.3)
+ let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
+ _ = try client.sendV2(method: "surface.send_text", params: [
+ "workspace_id": workspaceId,
+ "surface_id": surfaceId,
+ "text": text
+ ])
+ }
+ if parsed.hasFlag("-P") {
+ let context = try tmuxFormatContext(workspaceId: workspaceId, client: client)
+ print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: "@\(workspaceId)"))
+ }
+
+ case "new-window", "neww":
+ let parsed = try parseTmuxArguments(
+ rawArgs,
+ valueFlags: ["-c", "-F", "-n", "-t"],
+ boolFlags: ["-d", "-P"]
+ )
+ if parsed.value("-t") != nil {
+ throw CLIError(message: "new-window -t is not supported in cmux claude-teams mode")
+ }
+ var params: [String: Any] = ["focus": false]
+ if let cwd = parsed.value("-c") {
+ params["cwd"] = resolvePath(cwd)
+ }
+ let created = try client.sendV2(method: "workspace.create", params: params)
+ guard let workspaceId = created["workspace_id"] as? String else {
+ throw CLIError(message: "workspace.create did not return workspace_id")
+ }
+ if let title = parsed.value("-n"),
+ !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ _ = try client.sendV2(method: "workspace.rename", params: [
+ "workspace_id": workspaceId,
+ "title": title
+ ])
+ }
+ if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) {
+ Thread.sleep(forTimeInterval: 0.3)
+ let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client)
+ _ = try client.sendV2(method: "surface.send_text", params: [
+ "workspace_id": workspaceId,
+ "surface_id": surfaceId,
+ "text": text
+ ])
+ }
+ if parsed.hasFlag("-P") {
+ let context = try tmuxFormatContext(workspaceId: workspaceId, client: client)
+ print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: "@\(workspaceId)"))
+ }
+
+ case "split-window", "splitw":
+ let parsed = try parseTmuxArguments(
+ rawArgs,
+ valueFlags: ["-c", "-F", "-l", "-t"],
+ boolFlags: ["-P", "-b", "-d", "-h", "-v"]
+ )
+ let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client)
+ let direction: String
+ if parsed.hasFlag("-h") {
+ direction = parsed.hasFlag("-b") ? "left" : "right"
+ } else {
+ direction = parsed.hasFlag("-b") ? "up" : "down"
+ }
+ let created = try client.sendV2(method: "surface.split", params: [
+ "workspace_id": target.workspaceId,
+ "surface_id": target.surfaceId,
+ "direction": direction
+ ])
+ guard let surfaceId = created["surface_id"] as? String else {
+ throw CLIError(message: "surface.split did not return surface_id")
+ }
+ let paneId = created["pane_id"] as? String
+ // Keep the leader pane focused while Claude starts teammates beside it.
+ if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) {
+ Thread.sleep(forTimeInterval: 0.3)
+ _ = try client.sendV2(method: "surface.send_text", params: [
+ "workspace_id": target.workspaceId,
+ "surface_id": surfaceId,
+ "text": text
+ ])
+ }
+ if parsed.hasFlag("-P") {
+ let context = try tmuxFormatContext(
+ workspaceId: target.workspaceId,
+ paneId: paneId,
+ surfaceId: surfaceId,
+ client: client
+ )
+ let fallback = context["pane_id"] ?? surfaceId
+ print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: fallback))
+ }
+
+ case "select-window", "selectw":
+ let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
+ let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
+ _ = try client.sendV2(method: "workspace.select", params: ["workspace_id": workspaceId])
+
+ case "select-pane", "selectp":
+ let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-P", "-T", "-t"], boolFlags: [])
+ if parsed.value("-P") != nil || parsed.value("-T") != nil {
+ return
+ }
+ let target = try tmuxResolvePaneTarget(parsed.value("-t"), client: client)
+ _ = try client.sendV2(method: "pane.focus", params: [
+ "workspace_id": target.workspaceId,
+ "pane_id": target.paneId
+ ])
+
+ case "kill-window", "killw":
+ let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
+ let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
+ _ = try client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId])
+
+ case "kill-pane", "killp":
+ let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
+ let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client)
+ _ = try client.sendV2(method: "surface.close", params: [
+ "workspace_id": target.workspaceId,
+ "surface_id": target.surfaceId
+ ])
+
+ case "send-keys", "send":
+ let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: ["-l"])
+ let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client)
+ let text = tmuxSendKeysText(from: parsed.positional, literal: parsed.hasFlag("-l"))
+ if !text.isEmpty {
+ _ = try client.sendV2(method: "surface.send_text", params: [
+ "workspace_id": target.workspaceId,
+ "surface_id": target.surfaceId,
+ "text": text
+ ])
+ }
+
+ case "capture-pane", "capturep":
+ let parsed = try parseTmuxArguments(
+ rawArgs,
+ valueFlags: ["-E", "-S", "-t"],
+ boolFlags: ["-J", "-N", "-p"]
+ )
+ let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client)
+ var params: [String: Any] = [
+ "workspace_id": target.workspaceId,
+ "surface_id": target.surfaceId,
+ "scrollback": true
+ ]
+ if let start = parsed.value("-S"), let lines = Int(start), lines < 0 {
+ params["lines"] = abs(lines)
+ }
+ let payload = try client.sendV2(method: "surface.read_text", params: params)
+ let text = (payload["text"] as? String) ?? ""
+ if parsed.hasFlag("-p") {
+ print(text)
+ } else {
+ var store = loadTmuxCompatStore()
+ store.buffers["default"] = text
+ try saveTmuxCompatStore(store)
+ }
+
+ case "display-message", "display", "displayp":
+ let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-F", "-t"], boolFlags: ["-p"])
+ let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client)
+ let context = try tmuxFormatContext(
+ workspaceId: target.workspaceId,
+ paneId: target.paneId,
+ surfaceId: target.surfaceId,
+ client: client
+ )
+ let format = parsed.positional.isEmpty ? parsed.value("-F") : parsed.positional.joined(separator: " ")
+ let rendered = tmuxRenderFormat(format, context: context, fallback: "")
+ if parsed.hasFlag("-p") || !rendered.isEmpty {
+ print(rendered)
+ }
+
+ case "list-windows", "lsw":
+ let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-F", "-t"], boolFlags: [])
+ let items = try tmuxWorkspaceItems(client: client)
+ for item in items {
+ guard let workspaceId = item["id"] as? String else { continue }
+ let context = try tmuxFormatContext(workspaceId: workspaceId, client: client)
+ let fallback = [
+ context["window_index"] ?? "?",
+ context["window_name"] ?? workspaceId
+ ].joined(separator: " ")
+ print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: fallback))
+ }
+
+ case "list-panes", "lsp":
+ let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-F", "-t"], boolFlags: [])
+ let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
+ let payload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId])
+ let panes = payload["panes"] as? [[String: Any]] ?? []
+ for pane in panes {
+ guard let paneId = pane["id"] as? String else { continue }
+ let context = try tmuxFormatContext(workspaceId: workspaceId, paneId: paneId, client: client)
+ let fallback = context["pane_id"] ?? paneId
+ print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: fallback))
+ }
+
+ case "rename-window", "renamew":
+ let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
+ let title = parsed.positional.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !title.isEmpty else {
+ throw CLIError(message: "rename-window requires a title")
+ }
+ let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
+ _ = try client.sendV2(method: "workspace.rename", params: [
+ "workspace_id": workspaceId,
+ "title": title
+ ])
+
+ case "resize-pane", "resizep":
+ let parsed = try parseTmuxArguments(
+ rawArgs,
+ valueFlags: ["-t", "-x", "-y"],
+ boolFlags: ["-D", "-L", "-R", "-U"]
+ )
+ let hasDirectionalFlags = parsed.hasFlag("-L")
+ || parsed.hasFlag("-R")
+ || parsed.hasFlag("-U")
+ || parsed.hasFlag("-D")
+ if !hasDirectionalFlags {
+ return
+ }
+ let target = try tmuxResolvePaneTarget(parsed.value("-t"), client: client)
+ let direction: String
+ if parsed.hasFlag("-L") {
+ direction = "left"
+ } else if parsed.hasFlag("-U") {
+ direction = "up"
+ } else if parsed.hasFlag("-D") {
+ direction = "down"
+ } else {
+ direction = "right"
+ }
+ let rawAmount = (parsed.value("-x") ?? parsed.value("-y") ?? "5")
+ .replacingOccurrences(of: "%", with: "")
+ let amount = Int(rawAmount) ?? 5
+ _ = try client.sendV2(method: "pane.resize", params: [
+ "workspace_id": target.workspaceId,
+ "pane_id": target.paneId,
+ "direction": direction,
+ "amount": max(1, amount)
+ ])
+
+ case "wait-for":
+ try runTmuxCompatCommand(
+ command: "wait-for",
+ commandArgs: rawArgs,
+ client: client,
+ jsonOutput: jsonOutput,
+ idFormat: idFormat,
+ windowOverride: windowOverride
+ )
+
+ case "last-pane":
+ let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
+ let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
+ _ = try client.sendV2(method: "pane.last", params: ["workspace_id": workspaceId])
+
+ case "show-buffer", "showb":
+ let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-b"], boolFlags: [])
+ let name = parsed.value("-b") ?? "default"
+ let store = loadTmuxCompatStore()
+ if let buffer = store.buffers[name] {
+ print(buffer)
+ }
+
+ case "save-buffer", "saveb":
+ let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-b"], boolFlags: [])
+ let name = parsed.value("-b") ?? "default"
+ let store = loadTmuxCompatStore()
+ guard let buffer = store.buffers[name] else {
+ throw CLIError(message: "Buffer not found: \(name)")
+ }
+ if let outputPath = parsed.positional.last, !outputPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ try buffer.write(toFile: resolvePath(outputPath), atomically: true, encoding: .utf8)
+ } else {
+ print(buffer)
+ }
+
+ case "last-window", "next-window", "previous-window", "set-hook", "set-buffer", "list-buffers":
+ try runTmuxCompatCommand(
+ command: command,
+ commandArgs: rawArgs,
+ client: client,
+ jsonOutput: jsonOutput,
+ idFormat: idFormat,
+ windowOverride: windowOverride
+ )
+
+ case "has-session", "has":
+ let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: [])
+ _ = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client)
+
+ case "select-layout", "set-option", "set", "set-window-option", "setw", "source-file", "refresh-client", "attach-session", "detach-client":
+ return
+
+ default:
+ throw CLIError(message: "Unsupported tmux compatibility command: \(command)")
+ }
+ }
+
private struct TmuxCompatStore: Codable {
var buffers: [String: String] = [:]
var hooks: [String: String] = [:]
@@ -7204,6 +8371,7 @@ struct CMUXCLI {
welcome
shortcuts
feedback [--email --body [--image ...]]
+ claude-teams [claude-args...]
ping
capabilities
identify [--workspace ] [--surface ] [--no-caller]
diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings
index 337a9e16..61272b0c 100644
--- a/Resources/Localizable.xcstrings
+++ b/Resources/Localizable.xcstrings
@@ -115,6 +115,23 @@
}
}
},
+ "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": {
diff --git a/scripts/reload.sh b/scripts/reload.sh
index 43a58863..4e758a88 100755
--- a/scripts/reload.sh
+++ b/scripts/reload.sh
@@ -45,6 +45,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=""
@@ -53,7 +58,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
@@ -66,7 +77,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:"
@@ -82,14 +96,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\""
}
@@ -159,7 +173,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
@@ -230,6 +244,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"
@@ -292,6 +315,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.
diff --git a/tests/claude_teams_test_utils.py b/tests/claude_teams_test_utils.py
new file mode 100644
index 00000000..ab5e42e9
--- /dev/null
+++ b/tests/claude_teams_test_utils.py
@@ -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 first."
+ )
diff --git a/tests/test_cli_claude_teams_env.py b/tests/test_cli_claude_teams_env.py
new file mode 100644
index 00000000..03e1c7b4
--- /dev/null
+++ b/tests/test_cli_claude_teams_env.py
@@ -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())
diff --git a/tests/test_cli_claude_teams_existing_shim.py b/tests/test_cli_claude_teams_existing_shim.py
new file mode 100644
index 00000000..3eadd8e8
--- /dev/null
+++ b/tests/test_cli_claude_teams_existing_shim.py
@@ -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())
diff --git a/tests/test_cli_claude_teams_help_passthrough.py b/tests/test_cli_claude_teams_help_passthrough.py
new file mode 100644
index 00000000..73f762f9
--- /dev/null
+++ b/tests/test_cli_claude_teams_help_passthrough.py
@@ -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())
diff --git a/tests/test_cli_claude_teams_skips_wrapper_claude.py b/tests/test_cli_claude_teams_skips_wrapper_claude.py
new file mode 100644
index 00000000..fb84e3f7
--- /dev/null
+++ b/tests/test_cli_claude_teams_skips_wrapper_claude.py
@@ -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())
diff --git a/tests/test_cli_claude_teams_tmux_sequence.py b/tests/test_cli_claude_teams_tmux_sequence.py
new file mode 100644
index 00000000..f0df27ba
--- /dev/null
+++ b/tests/test_cli_claude_teams_tmux_sequence.py
@@ -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())
From 63787c26b1cbc9b6ea33eae38827d8638af68f0f Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Wed, 11 Mar 2026 04:17:37 -0700
Subject: [PATCH 40/43] Stabilize inline developer tools hosting
---
Sources/BrowserWindowPortal.swift | 154 ++++++++++++++---
Sources/Panels/BrowserPanel.swift | 196 +++++++++++++++++++---
Sources/Panels/BrowserPanelView.swift | 233 +++++++++++++++++++++-----
3 files changed, 487 insertions(+), 96 deletions(-)
diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift
index 10e0b8b0..200a9ef0 100644
--- a/Sources/BrowserWindowPortal.swift
+++ b/Sources/BrowserWindowPortal.swift
@@ -167,17 +167,18 @@ enum HostedInspectorDockSide {
preferredWidth: CGFloat,
in containerBounds: NSRect,
pageFrame: NSRect,
- inspectorFrame: NSRect
+ inspectorFrame: NSRect,
+ minimumInspectorWidth _: CGFloat
) -> (pageFrame: NSRect, inspectorFrame: NSRect) {
switch self {
case .leading:
- let maximumInspectorWidth = max(0, pageFrame.maxX - containerBounds.minX)
+ let maximumInspectorWidth = max(0, containerBounds.width)
let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth))
- let dividerX = min(pageFrame.maxX, containerBounds.minX + clampedInspectorWidth)
+ let dividerX = min(containerBounds.maxX, containerBounds.minX + clampedInspectorWidth)
var nextPageFrame = pageFrame
nextPageFrame.origin.x = dividerX
- nextPageFrame.size.width = max(0, pageFrame.maxX - dividerX)
+ nextPageFrame.size.width = max(0, containerBounds.maxX - dividerX)
var nextInspectorFrame = inspectorFrame
nextInspectorFrame.origin.x = containerBounds.minX
@@ -185,12 +186,13 @@ enum HostedInspectorDockSide {
return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame)
case .trailing:
- let maximumInspectorWidth = max(0, containerBounds.maxX - pageFrame.minX)
+ let maximumInspectorWidth = max(0, containerBounds.width)
let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth))
- let dividerX = max(pageFrame.minX, containerBounds.maxX - clampedInspectorWidth)
+ let dividerX = max(containerBounds.minX, containerBounds.maxX - clampedInspectorWidth)
var nextPageFrame = pageFrame
- nextPageFrame.size.width = max(0, dividerX - pageFrame.minX)
+ nextPageFrame.origin.x = containerBounds.minX
+ nextPageFrame.size.width = max(0, dividerX - containerBounds.minX)
var nextInspectorFrame = inspectorFrame
nextInspectorFrame.origin.x = dividerX
@@ -252,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 {
@@ -321,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")
}
@@ -499,6 +507,7 @@ final class WindowBrowserHostView: NSView {
return
}
+ hostedInspectorHit.slotView.isHostedInspectorDividerDragActive = true
hostedInspectorDividerDrag = HostedInspectorDividerDragState(
slotView: hostedInspectorHit.slotView,
containerView: hostedInspectorHit.containerView,
@@ -526,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
@@ -552,7 +562,7 @@ final class WindowBrowserHostView: NSView {
in: containerBounds
)
- dragState.slotView.preferredHostedInspectorWidth = inspectorWidth
+ dragState.slotView.recordPreferredHostedInspectorWidth(inspectorWidth, containerBounds: containerBounds)
let appliedFrames = applyHostedInspectorDividerWidth(
inspectorWidth,
to: HostedInspectorDividerHit(
@@ -587,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)) " +
@@ -920,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
@@ -937,13 +962,16 @@ final class WindowBrowserHostView: NSView {
preferredWidth: preferredWidth,
in: containerBounds,
pageFrame: hit.pageView.frame,
- inspectorFrame: hit.inspectorView.frame
+ inspectorFrame: hit.inspectorView.frame,
+ minimumInspectorWidth: 0
)
let pageFrame = nextFrames.pageFrame
let inspectorFrame = nextFrames.inspectorFrame
- 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)
}
@@ -956,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))"
)
@@ -1042,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())
@@ -1501,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)
@@ -1532,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)
}
@@ -1541,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()
@@ -2216,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
@@ -2293,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
@@ -3048,15 +3132,30 @@ final class WindowBrowserPortal: NSObject {
if transientRecoveryReason == nil {
resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry)
}
- if !shouldHide, containerOwnsWebView, !refreshReasons.isEmpty {
- refreshHostedWebViewPresentation(
- webView,
- in: containerView,
- reason: "\(source):" + refreshReasons.joined(separator: ",")
- )
- }
- if containerOwnsWebView {
+ 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")
}
#if DEBUG
dlog(
@@ -3068,6 +3167,7 @@ final class WindowBrowserPortal: NSObject {
"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)) " +
diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift
index ee70a0c6..1de11528 100644
--- a/Sources/Panels/BrowserPanel.swift
+++ b/Sources/Panels/BrowserPanel.swift
@@ -1735,6 +1735,11 @@ final class BrowserPanel: Panel, ObservableObject {
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?
@@ -1762,11 +1767,17 @@ final class BrowserPanel: Panel, ObservableObject {
private var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow }
// Persist user intent across WebKit detach/reattach churn (split/layout updates).
@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
private let developerToolsRestoreRetryDelay: TimeInterval = 0.05
private let developerToolsRestoreRetryMaxAttempts: Int = 40
+ 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 {
@@ -2068,6 +2079,7 @@ final class BrowserPanel: Panel, ObservableObject {
self.uiDelegate = browserUIDelegate
bindWebView(webView)
+ installDetachedDeveloperToolsWindowCloseObserver()
applyBrowserThemeModeIfNeeded()
insecureHTTPAlertWindowProvider = { [weak self] in
self?.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
@@ -2686,6 +2698,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)
@@ -2833,17 +2848,6 @@ extension BrowserPanel {
webView.stopLoading()
}
- private func attachDeveloperToolsIfSupported(_ inspector: NSObject) {
- let attachSelector = NSSelectorFromString("attach")
- if inspector.responds(to: attachSelector) {
- inspector.cmuxCallVoid(selector: attachSelector)
- }
- }
-
- private func isDeveloperToolsAttached(_ inspector: NSObject) -> Bool? {
- inspector.cmuxCallBool(selector: NSSelectorFromString("isAttached"))
- }
-
private static func windowContainsInspectorViews(_ root: NSView) -> Bool {
if String(describing: type(of: root)).contains("WKInspector") {
return true
@@ -2860,7 +2864,76 @@ extension BrowserPanel {
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) {
@@ -2875,6 +2948,7 @@ extension BrowserPanel {
}
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()
@@ -2882,19 +2956,50 @@ extension BrowserPanel {
}
}
+ 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 {
- attachDeveloperToolsIfSupported(inspector)
-
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)
- return inspector.cmuxCallBool(selector: isVisibleSelector) ?? false
+ 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
@@ -2912,14 +3017,15 @@ extension BrowserPanel {
if targetVisible {
_ = revealDeveloperTools(inspector)
} else {
- let selector = NSSelectorFromString("close")
- guard inspector.responds(to: selector) else { return false }
- inspector.cmuxCallVoid(selector: selector)
+ 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 {
@@ -2950,12 +3056,12 @@ extension BrowserPanel {
func showDeveloperTools() -> Bool {
guard let inspector = webView.cmuxInspectorObject() else { return false }
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
- let attached = isDeveloperToolsAttached(inspector) ?? false
- if !visible || !attached {
- guard revealDeveloperTools(inspector) || visible else { return false }
+ if !visible {
+ guard revealDeveloperTools(inspector) else { return false }
}
preferredDeveloperToolsVisible = true
if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) {
+ syncDeveloperToolsPresentationPreferenceFromUI()
cancelDeveloperToolsRestoreRetry()
scheduleDetachedDeveloperToolsWindowDismissal()
} else {
@@ -2989,6 +3095,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
@@ -3016,8 +3124,9 @@ extension BrowserPanel {
forceDeveloperToolsRefreshOnNextAttach = false
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
- let attached = isDeveloperToolsAttached(inspector) ?? false
- if visible && attached {
+ if visible {
+ developerToolsDetachedOpenGraceDeadline = nil
+ syncDeveloperToolsPresentationPreferenceFromUI()
#if DEBUG
if shouldForceRefresh {
dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
@@ -3027,12 +3136,26 @@ extension BrowserPanel {
return
}
+ 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 attach/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 {
@@ -3041,6 +3164,7 @@ extension BrowserPanel {
preferredDeveloperToolsVisible = true
let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
if visibleAfterShow {
+ syncDeveloperToolsPresentationPreferenceFromUI()
cancelDeveloperToolsRestoreRetry()
scheduleDetachedDeveloperToolsWindowDismissal()
} else {
@@ -3059,11 +3183,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
@@ -3099,7 +3223,25 @@ extension BrowserPanel {
}
func shouldUseLocalInlineDeveloperToolsHosting() -> Bool {
- preferredDeveloperToolsVisible || isDeveloperToolsVisible()
+ 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
diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift
index 1b962264..4bd87d02 100644
--- a/Sources/Panels/BrowserPanelView.swift
+++ b/Sources/Panels/BrowserPanelView.swift
@@ -3618,24 +3618,53 @@ struct WebViewRepresentable: NSViewRepresentable {
}
private static let hostedInspectorDividerHitExpansion: CGFloat = 6
- private static let minimumHostedInspectorWidth: CGFloat = 120
+ private static let minimumHostedInspectorWidth: CGFloat = 1
private var trackingArea: NSTrackingArea?
private var activeDividerCursorKind: DividerCursorKind?
private var hostedInspectorDividerDrag: HostedInspectorDividerDragState?
private var preferredHostedInspectorWidth: CGFloat?
+ private var preferredHostedInspectorWidthFraction: CGFloat?
+ var onPreferredHostedInspectorWidthChanged: ((CGFloat, CGFloat?) -> Void)?
+ private var isHostedInspectorDividerDragActive = false
private var isApplyingHostedInspectorLayout = false
+ private var hostedInspectorReapplyWorkItem: DispatchWorkItem?
+ private var lastHostedInspectorLayoutBoundsSize: NSSize?
#if DEBUG
private var lastLoggedHostedInspectorFrames: (page: NSRect, inspector: NSRect)?
private var hasLoggedMissingHostedInspectorCandidate = false
#endif
deinit {
+ hostedInspectorReapplyWorkItem?.cancel()
if let trackingArea {
removeTrackingArea(trackingArea)
}
clearActiveDividerCursor(restoreArrow: false)
}
+ private func recordPreferredHostedInspectorWidth(_ width: CGFloat, containerBounds: NSRect) {
+ preferredHostedInspectorWidth = width
+ guard containerBounds.width > 0 else {
+ preferredHostedInspectorWidthFraction = nil
+ onPreferredHostedInspectorWidthChanged?(width, nil)
+ return
+ }
+ preferredHostedInspectorWidthFraction = width / containerBounds.width
+ onPreferredHostedInspectorWidthChanged?(width, preferredHostedInspectorWidthFraction)
+ }
+
+ private func resolvedPreferredHostedInspectorWidth(in containerBounds: NSRect) -> CGFloat? {
+ if let preferredHostedInspectorWidthFraction, containerBounds.width > 0 {
+ return max(0, containerBounds.width * preferredHostedInspectorWidthFraction)
+ }
+ return preferredHostedInspectorWidth
+ }
+
+ func setPreferredHostedInspectorWidth(width: CGFloat?, widthFraction: CGFloat?) {
+ preferredHostedInspectorWidth = width
+ preferredHostedInspectorWidthFraction = widthFraction
+ }
+
#if DEBUG
private static func shouldLogPointerEvent(_ event: NSEvent?) -> Bool {
switch event?.type {
@@ -3732,6 +3761,11 @@ struct WebViewRepresentable: NSViewRepresentable {
abs(lhs.height - rhs.height) <= epsilon
}
+ 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
+ }
+
private func currentGeometryState() -> GeometryState {
GeometryState(
frame: frame,
@@ -3773,6 +3807,11 @@ struct WebViewRepresentable: NSViewRepresentable {
localInlineSlotView?.isHidden = hidden
}
+ func clearLocalInlineCallbacks() {
+ onPreferredHostedInspectorWidthChanged = nil
+ localInlineSlotView?.onHostedInspectorLayout = nil
+ }
+
func releaseHostedWebViewConstraints() {
NSLayoutConstraint.deactivate(hostedWebViewConstraints)
hostedWebViewConstraints = []
@@ -3782,9 +3821,17 @@ struct WebViewRepresentable: NSViewRepresentable {
func pinHostedWebView(_ webView: WKWebView, in container: NSView) {
guard webView.superview === container else { return }
+ let hasCompanionWKSubviews = Self.hasWebKitCompanionSubview(
+ in: container,
+ primaryWebView: webView
+ )
+ let needsPlainWebViewFrameReset =
+ !hasCompanionWKSubviews &&
+ Self.frameDiffersFromBounds(webView.frame, bounds: container.bounds)
let needsFrameHosting =
hostedWebView !== webView ||
!hostedWebViewConstraints.isEmpty ||
+ needsPlainWebViewFrameReset ||
!webView.translatesAutoresizingMaskIntoConstraints ||
webView.autoresizingMask != [.width, .height]
guard needsFrameHosting else {
@@ -3799,20 +3846,43 @@ struct WebViewRepresentable: NSViewRepresentable {
// WebKit's attached inspector does not reliably dock into a constraint-managed
// WKWebView hierarchy on macOS. Host the moved webview with autoresizing and
- // keep WebKit-owned page frames intact when DevTools is side-docked.
+ // preserve WebKit-managed split frames when docked DevTools siblings exist.
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
- webView.frame = container.bounds
+ if !hasCompanionWKSubviews {
+ webView.frame = container.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
+ }
+
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
clearActiveDividerCursor(restoreArrow: false)
} else {
- reapplyHostedInspectorDividerIfNeeded(reason: "viewDidMoveToWindow")
+ scheduleHostedInspectorDividerReapply(reason: "viewDidMoveToWindow")
}
window?.invalidateCursorRects(for: self)
onDidMoveToWindow?()
@@ -3824,7 +3894,7 @@ struct WebViewRepresentable: NSViewRepresentable {
override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
- reapplyHostedInspectorDividerIfNeeded(reason: "viewDidMoveToSuperview")
+ scheduleHostedInspectorDividerReapply(reason: "viewDidMoveToSuperview")
notifyGeometryChangedIfNeeded()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToSuperview")
@@ -3833,7 +3903,16 @@ struct WebViewRepresentable: NSViewRepresentable {
override func layout() {
super.layout()
- reapplyHostedInspectorDividerIfNeeded(reason: "layout")
+ if let previousSize = lastHostedInspectorLayoutBoundsSize,
+ Self.sizeApproximatelyEqual(previousSize, bounds.size, epsilon: 0.5) {
+ notifyGeometryChangedIfNeeded()
+#if DEBUG
+ debugLogHostedInspectorLayoutIfNeeded(reason: "layout")
+#endif
+ return
+ }
+ lastHostedInspectorLayoutBoundsSize = bounds.size
+ captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout")
notifyGeometryChangedIfNeeded()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "layout")
@@ -3843,7 +3922,6 @@ struct WebViewRepresentable: NSViewRepresentable {
override func setFrameOrigin(_ newOrigin: NSPoint) {
super.setFrameOrigin(newOrigin)
window?.invalidateCursorRects(for: self)
- reapplyHostedInspectorDividerIfNeeded(reason: "setFrameOrigin")
notifyGeometryChangedIfNeeded()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameOrigin")
@@ -3853,7 +3931,6 @@ struct WebViewRepresentable: NSViewRepresentable {
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
window?.invalidateCursorRects(for: self)
- reapplyHostedInspectorDividerIfNeeded(reason: "setFrameSize")
notifyGeometryChangedIfNeeded()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameSize")
@@ -3934,6 +4011,7 @@ struct WebViewRepresentable: NSViewRepresentable {
return
}
+ isHostedInspectorDividerDragActive = true
hostedInspectorDividerDrag = HostedInspectorDividerDragState(
containerView: hostedInspectorHit.containerView,
pageView: hostedInspectorHit.pageView,
@@ -3955,10 +4033,7 @@ struct WebViewRepresentable: NSViewRepresentable {
}
let containerBounds = dragState.containerView.bounds
- let minimumInspectorWidth = min(
- Self.minimumHostedInspectorWidth,
- max(60, dragState.initialInspectorFrame.width)
- )
+ let minimumInspectorWidth = Self.minimumHostedInspectorWidth
let initialDividerX = dragState.dockSide.dividerX(
pageFrame: dragState.initialPageFrame,
inspectorFrame: dragState.initialInspectorFrame
@@ -3974,7 +4049,7 @@ struct WebViewRepresentable: NSViewRepresentable {
forDividerX: clampedDividerX,
in: containerBounds
)
- preferredHostedInspectorWidth = inspectorWidth
+ recordPreferredHostedInspectorWidth(inspectorWidth, containerBounds: containerBounds)
_ = applyHostedInspectorDividerWidth(
inspectorWidth,
to: HostedInspectorDividerHit(
@@ -4011,6 +4086,7 @@ struct WebViewRepresentable: NSViewRepresentable {
override func mouseUp(with event: NSEvent) {
let finalDragState = hostedInspectorDividerDrag
hostedInspectorDividerDrag = nil
+ isHostedInspectorDividerDragActive = false
updateDividerCursor(at: convert(event.locationInWindow, from: nil))
scheduleHostedInspectorDividerReapply(reason: "dragEndAsync")
#if DEBUG
@@ -4028,7 +4104,7 @@ struct WebViewRepresentable: NSViewRepresentable {
)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
- self.reapplyHostedInspectorDividerIfNeeded(reason: "drag.end.async")
+ self.captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "drag.end.async")
self.debugLogHostedInspectorFrames(stage: "drag.end.async", hit: finalHit)
self.debugLogHostedInspectorLayoutIfNeeded(reason: "dragEndAsync")
}
@@ -4202,32 +4278,64 @@ struct WebViewRepresentable: NSViewRepresentable {
return (overlap * 1_000) + coverageWidth + pageView.frame.width
}
- private func scheduleHostedInspectorDividerReapply(reason: String) {
- guard preferredHostedInspectorWidth != nil else { return }
- DispatchQueue.main.async { [weak self] in
- self?.reapplyHostedInspectorDividerIfNeeded(reason: reason)
+ fileprivate func scheduleHostedInspectorDividerReapply(reason: String) {
+ hostedInspectorReapplyWorkItem?.cancel()
+ let workItem = DispatchWorkItem { [weak self] in
+ guard let self else { return }
+ self.hostedInspectorReapplyWorkItem = nil
+ self.captureHostedInspectorPreferredWidthFromCurrentLayout(reason: reason)
}
+ hostedInspectorReapplyWorkItem = workItem
+ DispatchQueue.main.async(execute: workItem)
}
- private func reapplyHostedInspectorDividerIfNeeded(reason: String) {
+ private func captureHostedInspectorPreferredWidthFromCurrentLayout(reason: String) {
guard !isApplyingHostedInspectorLayout else { return }
- guard let preferredWidth = preferredHostedInspectorWidth else { return }
+ guard !isHostedInspectorDividerDragActive else { return }
guard let hit = hostedInspectorDividerCandidate() else {
#if DEBUG
if !hasLoggedMissingHostedInspectorCandidate {
hasLoggedMissingHostedInspectorCandidate = true
+ let preferredWidthDesc = preferredHostedInspectorWidth.map {
+ String(format: "%.1f", $0)
+ } ?? "nil"
dlog(
- "browser.panel.hostedInspector stage=\(reason).reapplyMissingCandidate " +
- "host=\(Self.debugObjectID(self)) preferredWidth=\(String(format: "%.1f", preferredWidth))"
+ "browser.panel.hostedInspector stage=\(reason).captureMissingCandidate " +
+ "host=\(Self.debugObjectID(self)) preferredWidth=\(preferredWidthDesc)"
)
}
#endif
return
}
+
+ let inspectorWidth = max(0, hit.inspectorView.frame.width)
+ guard inspectorWidth > 1 else { return }
+ let currentFraction: CGFloat? = {
+ guard hit.containerView.bounds.width > 0 else { return nil }
+ return inspectorWidth / hit.containerView.bounds.width
+ }()
+ let widthMatches = preferredHostedInspectorWidth.map {
+ abs($0 - inspectorWidth) <= 0.5
+ } ?? false
+ let fractionMatches: Bool = {
+ switch (preferredHostedInspectorWidthFraction, currentFraction) {
+ case (nil, nil):
+ return true
+ case let (lhs?, rhs?):
+ return abs(lhs - rhs) <= 0.001
+ default:
+ return false
+ }
+ }()
+ guard !(widthMatches && fractionMatches) else { return }
+
#if DEBUG
hasLoggedMissingHostedInspectorCandidate = false
#endif
- _ = applyHostedInspectorDividerWidth(preferredWidth, to: hit, reason: reason)
+ recordPreferredHostedInspectorWidth(
+ inspectorWidth,
+ containerBounds: hit.containerView.bounds
+ )
}
@discardableResult
@@ -4241,13 +4349,16 @@ struct WebViewRepresentable: NSViewRepresentable {
preferredWidth: preferredWidth,
in: containerBounds,
pageFrame: hit.pageView.frame,
- inspectorFrame: hit.inspectorView.frame
+ inspectorFrame: hit.inspectorView.frame,
+ minimumInspectorWidth: 0
)
let pageFrame = nextFrames.pageFrame
let inspectorFrame = nextFrames.inspectorFrame
- 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)
}
@@ -4260,14 +4371,14 @@ struct WebViewRepresentable: NSViewRepresentable {
CATransaction.commit()
isApplyingHostedInspectorLayout = false
- hit.pageView.needsLayout = true
- hit.inspectorView.needsLayout = true
- hit.containerView.needsLayout = true
- needsLayout = true
+ let isLiveDrag = reason == "drag"
#if DEBUG
dlog(
"browser.panel.hostedInspector stage=\(reason).reapply " +
"host=\(Self.debugObjectID(self)) preferredWidth=\(String(format: "%.1f", preferredWidth)) " +
+ "liveDrag=\(isLiveDrag ? 1 : 0) " +
+ "pageChanged=\(pageChanged ? 1 : 0) inspectorChanged=\(inspectorChanged ? 1 : 0) " +
+ "oldPage=\(Self.debugRect(oldPageFrame)) oldInspector=\(Self.debugRect(oldInspectorFrame)) " +
"container=\(Self.debugObjectID(hit.containerView)) " +
"pageFrame=\(Self.debugRect(pageFrame)) inspectorFrame=\(Self.debugRect(inspectorFrame))"
)
@@ -4285,11 +4396,11 @@ struct WebViewRepresentable: NSViewRepresentable {
return descendants
}
- private static func isInspectorView(_ view: NSView) -> Bool {
+ fileprivate static func isInspectorView(_ view: NSView) -> Bool {
String(describing: type(of: view)).contains("WKInspector")
}
- private static func isVisibleHostedInspectorCandidate(_ view: NSView) -> Bool {
+ fileprivate static func isVisibleHostedInspectorCandidate(_ view: NSView) -> Bool {
!view.isHidden &&
view.alphaValue > 0 &&
view.frame.width > 1 &&
@@ -4403,6 +4514,23 @@ struct WebViewRepresentable: NSViewRepresentable {
guard let host = host as? HostContainerView else { return }
host.onDidMoveToWindow = nil
host.onGeometryChanged = nil
+ host.clearLocalInlineCallbacks()
+ }
+
+ private static func localInlineTransferRoot(for webView: WKWebView) -> NSView? {
+ var current = webView.superview
+ var last: NSView?
+ while let view = current {
+ if view is WindowBrowserSlotView {
+ return view
+ }
+ if view is HostContainerView {
+ break
+ }
+ last = view
+ current = view.superview
+ }
+ return last ?? webView.superview
}
private static func moveWebKitRelatedSubviewsIntoHostIfNeeded(
@@ -4414,7 +4542,12 @@ struct WebViewRepresentable: NSViewRepresentable {
guard sourceSuperview !== container else { return }
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
@@ -4470,6 +4603,10 @@ struct WebViewRepresentable: NSViewRepresentable {
private func updateUsingLocalInlineHosting(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool {
guard let host = nsView as? HostContainerView else { return false }
let slotView = host.ensureLocalInlineSlotView()
+ let isAlreadyInLocalHost =
+ webView.superview === slotView ||
+ (webView.superview?.isDescendant(of: slotView) ?? false)
+ let didAttachWebViewToLocalHost = !isAlreadyInLocalHost
let coordinator = context.coordinator
coordinator.desiredPortalVisibleInUI = false
@@ -4514,8 +4651,19 @@ struct WebViewRepresentable: NSViewRepresentable {
return false
}
- if webView.superview !== slotView {
- if let sourceSuperview = webView.superview {
+ host.onPreferredHostedInspectorWidthChanged = { [weak browserPanel = panel] width, _ in
+ guard let browserPanel else { return }
+ browserPanel.recordPreferredAttachedDeveloperToolsWidth(
+ width,
+ containerBounds: slotView.bounds
+ )
+ }
+ slotView.onHostedInspectorLayout = { [weak host] _ in
+ host?.scheduleHostedInspectorDividerReapply(reason: "slot.layout")
+ }
+
+ if didAttachWebViewToLocalHost {
+ if let sourceSuperview = Self.localInlineTransferRoot(for: webView) {
Self.moveWebKitRelatedSubviewsIntoHostIfNeeded(
from: sourceSuperview,
to: slotView,
@@ -4531,13 +4679,14 @@ struct WebViewRepresentable: NSViewRepresentable {
host.pinHostedWebView(webView, in: slotView)
coordinator.lastPortalHostId = nil
coordinator.lastSynchronizedHostGeometryRevision = 0
- panel.restoreDeveloperToolsAfterAttachIfNeeded()
- webView.needsLayout = true
- webView.layoutSubtreeIfNeeded()
- slotView.layoutSubtreeIfNeeded()
- host.displayIfNeeded()
- slotView.displayIfNeeded()
- webView.displayIfNeeded()
+ if didAttachWebViewToLocalHost {
+ panel.restoreDeveloperToolsAfterAttachIfNeeded()
+ webView.needsLayout = true
+ webView.layoutSubtreeIfNeeded()
+ slotView.layoutSubtreeIfNeeded()
+ host.layoutSubtreeIfNeeded()
+ host.scheduleHostedInspectorDividerReapply(reason: "localInline.update.sync")
+ }
#if DEBUG
Self.logDevToolsState(
From 8b66628b19c71953095e8e1035e20aaf27fa0aa8 Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Wed, 11 Mar 2026 04:21:49 -0700
Subject: [PATCH 41/43] Route inline inspector hits to native webkit
---
Sources/Panels/BrowserPanelView.swift | 102 +-------------------------
1 file changed, 4 insertions(+), 98 deletions(-)
diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift
index 4bd87d02..c0f81e76 100644
--- a/Sources/Panels/BrowserPanelView.swift
+++ b/Sources/Panels/BrowserPanelView.swift
@@ -3993,9 +3993,9 @@ struct WebViewRepresentable: NSViewRepresentable {
return nativeHit
}
#if DEBUG
- debugLogHitTest(stage: "hitTest.hostedInspectorManual", point: point, passThrough: false, hitView: hostedInspectorHit.inspectorView)
+ debugLogHitTest(stage: "hitTest.hostedInspectorFallback", point: point, passThrough: false, hitView: hostedInspectorHit.inspectorView)
#endif
- return self
+ return hostedInspectorHit.inspectorView
}
let hit = super.hitTest(point)
#if DEBUG
@@ -4005,111 +4005,17 @@ struct WebViewRepresentable: NSViewRepresentable {
}
override func mouseDown(with event: NSEvent) {
- let point = convert(event.locationInWindow, from: nil)
- guard let hostedInspectorHit = hostedInspectorDividerHit(at: point) else {
- super.mouseDown(with: event)
- return
- }
-
- isHostedInspectorDividerDragActive = true
- hostedInspectorDividerDrag = HostedInspectorDividerDragState(
- containerView: hostedInspectorHit.containerView,
- pageView: hostedInspectorHit.pageView,
- inspectorView: hostedInspectorHit.inspectorView,
- dockSide: hostedInspectorHit.dockSide,
- initialWindowX: event.locationInWindow.x,
- initialPageFrame: hostedInspectorHit.pageView.frame,
- initialInspectorFrame: hostedInspectorHit.inspectorView.frame
- )
-#if DEBUG
- debugLogHostedInspectorFrames(stage: "drag.start", point: point, hit: hostedInspectorHit)
-#endif
+ super.mouseDown(with: event)
}
override func mouseDragged(with event: NSEvent) {
- guard let dragState = hostedInspectorDividerDrag else {
- super.mouseDragged(with: event)
- return
- }
-
- let containerBounds = dragState.containerView.bounds
- let minimumInspectorWidth = Self.minimumHostedInspectorWidth
- 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
- )
- recordPreferredHostedInspectorWidth(inspectorWidth, containerBounds: containerBounds)
- _ = applyHostedInspectorDividerWidth(
- inspectorWidth,
- to: HostedInspectorDividerHit(
- containerView: dragState.containerView,
- pageView: dragState.pageView,
- inspectorView: dragState.inspectorView,
- dockSide: dragState.dockSide
- ),
- reason: "drag"
- )
-#if DEBUG
- debugLogHostedInspectorFrames(
- stage: "drag.update",
- point: convert(event.locationInWindow, from: nil),
- hit: HostedInspectorDividerHit(
- containerView: dragState.containerView,
- pageView: dragState.pageView,
- inspectorView: dragState.inspectorView,
- dockSide: dragState.dockSide
- )
- )
-#endif
- updateDividerCursor(
- at: convert(event.locationInWindow, from: nil),
- hostedInspectorHit: HostedInspectorDividerHit(
- containerView: dragState.containerView,
- pageView: dragState.pageView,
- inspectorView: dragState.inspectorView,
- dockSide: dragState.dockSide
- )
- )
+ super.mouseDragged(with: event)
}
override func mouseUp(with event: NSEvent) {
- let finalDragState = hostedInspectorDividerDrag
hostedInspectorDividerDrag = nil
isHostedInspectorDividerDragActive = false
updateDividerCursor(at: convert(event.locationInWindow, from: nil))
- scheduleHostedInspectorDividerReapply(reason: "dragEndAsync")
-#if DEBUG
- if let finalDragState {
- let finalHit = HostedInspectorDividerHit(
- containerView: finalDragState.containerView,
- pageView: finalDragState.pageView,
- inspectorView: finalDragState.inspectorView,
- dockSide: finalDragState.dockSide
- )
- debugLogHostedInspectorFrames(
- stage: "drag.end",
- point: convert(event.locationInWindow, from: nil),
- hit: finalHit
- )
- DispatchQueue.main.async { [weak self] in
- guard let self else { return }
- self.captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "drag.end.async")
- self.debugLogHostedInspectorFrames(stage: "drag.end.async", hit: finalHit)
- self.debugLogHostedInspectorLayoutIfNeeded(reason: "dragEndAsync")
- }
- }
-#endif
super.mouseUp(with: event)
}
From 06c5cac4df1e0ee7b6b6ffdb8082d4c563fc522c Mon Sep 17 00:00:00 2001
From: austinpower1258
Date: Wed, 11 Mar 2026 12:26:19 -0700
Subject: [PATCH 42/43] Stabilize docked devtools side resize
---
Sources/Panels/BrowserPanel.swift | 12 +-
Sources/Panels/BrowserPanelView.swift | 459 ++++++++++++++++++++++++--
2 files changed, 449 insertions(+), 22 deletions(-)
diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift
index 1de11528..5c2d7cd8 100644
--- a/Sources/Panels/BrowserPanel.swift
+++ b/Sources/Panels/BrowserPanel.swift
@@ -4162,7 +4162,7 @@ private extension BrowserPanel {
}
}
-private extension WKWebView {
+extension WKWebView {
func cmuxInspectorObject() -> NSObject? {
let selector = NSSelectorFromString("_inspector")
guard responds(to: selector),
@@ -4171,6 +4171,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 {
diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift
index c0f81e76..f1c4bbae 100644
--- a/Sources/Panels/BrowserPanelView.swift
+++ b/Sources/Panels/BrowserPanelView.swift
@@ -3579,6 +3579,10 @@ struct WebViewRepresentable: NSViewRepresentable {
}
final class HostContainerView: NSView {
+ private final class HostedInspectorSideDockContainerView: NSView {
+ override var isOpaque: Bool { false }
+ }
+
var onDidMoveToWindow: (() -> Void)?
var onGeometryChanged: (() -> Void)?
private(set) var geometryRevision: UInt64 = 0
@@ -3587,6 +3591,9 @@ struct WebViewRepresentable: NSViewRepresentable {
private var hostedWebViewConstraints: [NSLayoutConstraint] = []
private weak var localInlineSlotView: WindowBrowserSlotView?
private var localInlineSlotConstraints: [NSLayoutConstraint] = []
+ private weak var hostedInspectorSideDockContainerView: HostedInspectorSideDockContainerView?
+ private var hostedInspectorSideDockConstraints: [NSLayoutConstraint] = []
+ private weak var hostedInspectorFrontendWebView: WKWebView?
private struct HostedInspectorDividerHit {
let containerView: NSView
let pageView: NSView
@@ -3617,7 +3624,7 @@ struct WebViewRepresentable: NSViewRepresentable {
var cursor: NSCursor { .resizeLeftRight }
}
- private static let hostedInspectorDividerHitExpansion: CGFloat = 6
+ private static let hostedInspectorDividerHitExpansion: CGFloat = 10
private static let minimumHostedInspectorWidth: CGFloat = 1
private var trackingArea: NSTrackingArea?
private var activeDividerCursorKind: DividerCursorKind?
@@ -3625,9 +3632,13 @@ struct WebViewRepresentable: NSViewRepresentable {
private var preferredHostedInspectorWidth: CGFloat?
private var preferredHostedInspectorWidthFraction: CGFloat?
var onPreferredHostedInspectorWidthChanged: ((CGFloat, CGFloat?) -> Void)?
+ private weak var hostedInspectorSideDockPageView: NSView?
+ private weak var hostedInspectorSideDockInspectorView: NSView?
+ private var hostedInspectorSideDockDockSide: HostedInspectorDockSide?
private var isHostedInspectorDividerDragActive = false
private var isApplyingHostedInspectorLayout = false
private var hostedInspectorReapplyWorkItem: DispatchWorkItem?
+ private var hostedInspectorDockConfigurationSyncWorkItem: DispatchWorkItem?
private var lastHostedInspectorLayoutBoundsSize: NSSize?
#if DEBUG
private var lastLoggedHostedInspectorFrames: (page: NSRect, inspector: NSRect)?
@@ -3636,6 +3647,7 @@ struct WebViewRepresentable: NSViewRepresentable {
deinit {
hostedInspectorReapplyWorkItem?.cancel()
+ hostedInspectorDockConfigurationSyncWorkItem?.cancel()
if let trackingArea {
removeTrackingArea(trackingArea)
}
@@ -3665,6 +3677,36 @@ struct WebViewRepresentable: NSViewRepresentable {
preferredHostedInspectorWidthFraction = widthFraction
}
+ func containsManagedLocalInlineContent(_ view: NSView) -> Bool {
+ if let localInlineSlotView,
+ view === localInlineSlotView || view.isDescendant(of: localInlineSlotView) {
+ return true
+ }
+ if let hostedInspectorSideDockContainerView,
+ view === hostedInspectorSideDockContainerView || view.isDescendant(of: hostedInspectorSideDockContainerView) {
+ return true
+ }
+ return false
+ }
+
+ func currentHostedWebViewContainer(preferredSlotView: WindowBrowserSlotView) -> NSView {
+ if let hostedInspectorSideDockContainerView,
+ let hostedInspectorSideDockPageView,
+ hostedWebView?.isDescendant(of: hostedInspectorSideDockContainerView) == true,
+ hostedInspectorSideDockPageView.isDescendant(of: hostedInspectorSideDockContainerView) {
+ return hostedInspectorSideDockContainerView
+ }
+ return preferredSlotView
+ }
+
+ func setHostedInspectorFrontendWebView(_ webView: WKWebView?) {
+ hostedInspectorFrontendWebView = webView
+ }
+
+ private var hasStoredHostedInspectorWidthPreference: Bool {
+ preferredHostedInspectorWidth != nil || preferredHostedInspectorWidthFraction != nil
+ }
+
#if DEBUG
private static func shouldLogPointerEvent(_ event: NSEvent?) -> Bool {
switch event?.type {
@@ -3812,6 +3854,13 @@ struct WebViewRepresentable: NSViewRepresentable {
localInlineSlotView?.onHostedInspectorLayout = nil
}
+ func prepareForWindowPortalHosting() {
+ hostedInspectorDockConfigurationSyncWorkItem?.cancel()
+ hostedInspectorDockConfigurationSyncWorkItem = nil
+ deactivateHostedInspectorSideDockIfNeeded(reparentTo: localInlineSlotView)
+ hostedInspectorFrontendWebView = nil
+ }
+
func releaseHostedWebViewConstraints() {
NSLayoutConstraint.deactivate(hostedWebViewConstraints)
hostedWebViewConstraints = []
@@ -3819,13 +3868,14 @@ struct WebViewRepresentable: NSViewRepresentable {
}
func pinHostedWebView(_ webView: WKWebView, in container: NSView) {
- guard webView.superview === container else { return }
+ guard webView.superview === container || webView.isDescendant(of: container) else { return }
let hasCompanionWKSubviews = Self.hasWebKitCompanionSubview(
in: container,
primaryWebView: webView
)
let needsPlainWebViewFrameReset =
+ webView.superview === container &&
!hasCompanionWKSubviews &&
Self.frameDiffersFromBounds(webView.frame, bounds: container.bounds)
let needsFrameHosting =
@@ -3849,7 +3899,7 @@ struct WebViewRepresentable: NSViewRepresentable {
// preserve WebKit-managed split frames when docked DevTools siblings exist.
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
- if !hasCompanionWKSubviews {
+ if webView.superview === container && !hasCompanionWKSubviews {
webView.frame = container.bounds
}
needsLayout = true
@@ -3877,12 +3927,159 @@ struct WebViewRepresentable: NSViewRepresentable {
return false
}
+ private func ensureHostedInspectorSideDockContainerView() -> HostedInspectorSideDockContainerView {
+ if let hostedInspectorSideDockContainerView,
+ hostedInspectorSideDockContainerView.superview === self {
+ hostedInspectorSideDockContainerView.isHidden = false
+ return hostedInspectorSideDockContainerView
+ }
+
+ let containerView = HostedInspectorSideDockContainerView(frame: bounds)
+ containerView.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(containerView, positioned: .above, relativeTo: localInlineSlotView)
+ hostedInspectorSideDockConstraints = [
+ containerView.topAnchor.constraint(equalTo: topAnchor),
+ containerView.bottomAnchor.constraint(equalTo: bottomAnchor),
+ containerView.leadingAnchor.constraint(equalTo: leadingAnchor),
+ containerView.trailingAnchor.constraint(equalTo: trailingAnchor),
+ ]
+ NSLayoutConstraint.activate(hostedInspectorSideDockConstraints)
+ hostedInspectorSideDockContainerView = containerView
+ return containerView
+ }
+
+ private func moveHostedInspectorSubviewIfNeeded(_ view: NSView, to container: NSView) {
+ guard view.superview !== container else { return }
+ let frameInWindow = view.superview?.convert(view.frame, to: nil) ?? convert(view.frame, to: nil)
+ view.removeFromSuperview()
+ container.addSubview(view, positioned: .above, relativeTo: nil)
+ view.frame = container.convert(frameInWindow, from: nil)
+ }
+
+ private func isHostedInspectorSideDockActive() -> Bool {
+ guard let hostedInspectorSideDockContainerView,
+ let hostedInspectorSideDockPageView,
+ let hostedInspectorSideDockInspectorView else {
+ return false
+ }
+ return hostedInspectorSideDockPageView.superview === hostedInspectorSideDockContainerView &&
+ hostedInspectorSideDockInspectorView.superview === hostedInspectorSideDockContainerView
+ }
+
+ private func isHostedInspectorSideDockHit(_ hit: HostedInspectorDividerHit) -> Bool {
+ guard let hostedInspectorSideDockContainerView else { return false }
+ return hit.containerView === hostedInspectorSideDockContainerView
+ }
+
+ private func activateHostedInspectorSideDockIfNeeded(using hit: HostedInspectorDividerHit) {
+ let containerView = ensureHostedInspectorSideDockContainerView()
+ moveHostedInspectorSubviewIfNeeded(hit.pageView, to: containerView)
+ moveHostedInspectorSubviewIfNeeded(hit.inspectorView, to: containerView)
+ hostedInspectorSideDockPageView = hit.pageView
+ hostedInspectorSideDockInspectorView = hit.inspectorView
+ hostedInspectorSideDockDockSide = hit.dockSide
+ layoutHostedInspectorSideDockIfNeeded(reason: "sideDock.activate")
+ }
+
+ private func deactivateHostedInspectorSideDockIfNeeded(reparentTo slotView: WindowBrowserSlotView?) {
+ guard let slotView,
+ let pageView = hostedInspectorSideDockPageView,
+ let inspectorView = hostedInspectorSideDockInspectorView else {
+ hostedInspectorSideDockPageView = nil
+ hostedInspectorSideDockInspectorView = nil
+ hostedInspectorSideDockDockSide = nil
+ hostedInspectorSideDockContainerView?.isHidden = true
+ return
+ }
+
+ moveHostedInspectorSubviewIfNeeded(pageView, to: slotView)
+ moveHostedInspectorSubviewIfNeeded(inspectorView, to: slotView)
+ hostedInspectorSideDockPageView = nil
+ hostedInspectorSideDockInspectorView = nil
+ hostedInspectorSideDockDockSide = nil
+ hostedInspectorSideDockContainerView?.isHidden = true
+ }
+
+ private func layoutHostedInspectorSideDockIfNeeded(reason: String) {
+ guard let containerView = hostedInspectorSideDockContainerView,
+ let pageView = hostedInspectorSideDockPageView,
+ let inspectorView = hostedInspectorSideDockInspectorView,
+ let dockSide = hostedInspectorSideDockDockSide else {
+ return
+ }
+ let preferredWidth = resolvedPreferredHostedInspectorWidth(in: containerView.bounds) ?? max(0, inspectorView.frame.width)
+ _ = applyHostedInspectorDividerWidth(
+ preferredWidth,
+ to: HostedInspectorDividerHit(
+ containerView: containerView,
+ pageView: pageView,
+ inspectorView: inspectorView,
+ dockSide: dockSide
+ ),
+ reason: reason
+ )
+ }
+
+ fileprivate func scheduleHostedInspectorDockConfigurationSync(reason: String) {
+ hostedInspectorDockConfigurationSyncWorkItem?.cancel()
+ guard hostedInspectorFrontendWebView != nil else { return }
+ let workItem = DispatchWorkItem { [weak self] in
+ self?.syncHostedInspectorDockConfiguration(reason: reason)
+ }
+ hostedInspectorDockConfigurationSyncWorkItem = workItem
+ DispatchQueue.main.async(execute: workItem)
+ }
+
+ private func syncHostedInspectorDockConfiguration(reason: String) {
+ hostedInspectorDockConfigurationSyncWorkItem = nil
+ guard let hostedInspectorFrontendWebView else { return }
+ hostedInspectorFrontendWebView.evaluateJavaScript(
+ "typeof WI === 'undefined' ? null : WI.dockConfiguration"
+ ) { [weak self] result, _ in
+ self?.applyHostedInspectorDockConfiguration(result as? String, reason: reason)
+ }
+ }
+
+ private func applyHostedInspectorDockConfiguration(_ dockConfiguration: String?, reason: String) {
+ switch dockConfiguration {
+ case "left":
+ hostedInspectorSideDockDockSide = .leading
+ if isHostedInspectorSideDockActive() {
+ layoutHostedInspectorSideDockIfNeeded(reason: "\(reason).dockLeft")
+ } else if let slotView = localInlineSlotView,
+ let hit = hostedInspectorDividerCandidate(in: slotView),
+ hit.dockSide == .leading {
+ activateHostedInspectorSideDockIfNeeded(using: hit)
+ }
+ case "right":
+ hostedInspectorSideDockDockSide = .trailing
+ if isHostedInspectorSideDockActive() {
+ layoutHostedInspectorSideDockIfNeeded(reason: "\(reason).dockRight")
+ } else if let slotView = localInlineSlotView,
+ let hit = hostedInspectorDividerCandidate(in: slotView),
+ hit.dockSide == .trailing {
+ activateHostedInspectorSideDockIfNeeded(using: hit)
+ }
+ default:
+ if isHostedInspectorSideDockActive() {
+ deactivateHostedInspectorSideDockIfNeeded(reparentTo: localInlineSlotView)
+ if dockConfiguration == "bottom" {
+ hostedInspectorFrontendWebView?.evaluateJavaScript(
+ "typeof WI !== 'undefined' ? WI._dockBottom() : null",
+ completionHandler: nil
+ )
+ }
+ }
+ }
+ }
+
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
clearActiveDividerCursor(restoreArrow: false)
} else {
scheduleHostedInspectorDividerReapply(reason: "viewDidMoveToWindow")
+ scheduleHostedInspectorDockConfigurationSync(reason: "viewDidMoveToWindow")
}
window?.invalidateCursorRects(for: self)
onDidMoveToWindow?()
@@ -3895,6 +4092,7 @@ struct WebViewRepresentable: NSViewRepresentable {
override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
scheduleHostedInspectorDividerReapply(reason: "viewDidMoveToSuperview")
+ scheduleHostedInspectorDockConfigurationSync(reason: "viewDidMoveToSuperview")
notifyGeometryChangedIfNeeded()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToSuperview")
@@ -3905,6 +4103,11 @@ struct WebViewRepresentable: NSViewRepresentable {
super.layout()
if let previousSize = lastHostedInspectorLayoutBoundsSize,
Self.sizeApproximatelyEqual(previousSize, bounds.size, epsilon: 0.5) {
+ if isHostedInspectorSideDockActive() {
+ layoutHostedInspectorSideDockIfNeeded(reason: "host.layout.sideDock.sameSize")
+ } else if !isHostedInspectorDividerDragActive && !hasStoredHostedInspectorWidthPreference {
+ captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout.sameSize")
+ }
notifyGeometryChangedIfNeeded()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "layout")
@@ -3912,7 +4115,12 @@ struct WebViewRepresentable: NSViewRepresentable {
return
}
lastHostedInspectorLayoutBoundsSize = bounds.size
- captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout")
+ if isHostedInspectorSideDockActive() {
+ layoutHostedInspectorSideDockIfNeeded(reason: "host.layout.sideDock")
+ } else if !hasStoredHostedInspectorWidthPreference {
+ captureHostedInspectorPreferredWidthFromCurrentLayout(reason: "host.layout")
+ }
+ scheduleHostedInspectorDockConfigurationSync(reason: "layout")
notifyGeometryChangedIfNeeded()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "layout")
@@ -3921,6 +4129,9 @@ struct WebViewRepresentable: NSViewRepresentable {
override func setFrameOrigin(_ newOrigin: NSPoint) {
super.setFrameOrigin(newOrigin)
+ if isHostedInspectorSideDockActive() {
+ layoutHostedInspectorSideDockIfNeeded(reason: "setFrameOrigin.sideDock")
+ }
window?.invalidateCursorRects(for: self)
notifyGeometryChangedIfNeeded()
#if DEBUG
@@ -3930,6 +4141,9 @@ struct WebViewRepresentable: NSViewRepresentable {
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
+ if isHostedInspectorSideDockActive() {
+ layoutHostedInspectorSideDockIfNeeded(reason: "setFrameSize.sideDock")
+ }
window?.invalidateCursorRects(for: self)
notifyGeometryChangedIfNeeded()
#if DEBUG
@@ -3986,16 +4200,26 @@ struct WebViewRepresentable: NSViewRepresentable {
return nil
}
if let hostedInspectorHit {
+ let isSideDockHit = isHostedInspectorSideDockHit(hostedInspectorHit)
if let nativeHit = nativeHostedInspectorHit(at: point, hostedInspectorHit: hostedInspectorHit) {
#if DEBUG
debugLogHitTest(stage: "hitTest.hostedInspectorNative", point: point, passThrough: false, hitView: nativeHit)
#endif
- return nativeHit
+ if !isSideDockHit ||
+ (nativeHit !== hostedInspectorHit.inspectorView &&
+ !hostedInspectorHit.inspectorView.isDescendant(of: nativeHit)) {
+ return nativeHit
+ }
}
#if DEBUG
- debugLogHitTest(stage: "hitTest.hostedInspectorFallback", point: point, passThrough: false, hitView: hostedInspectorHit.inspectorView)
+ debugLogHitTest(
+ stage: isSideDockHit ? "hitTest.hostedInspectorManual" : "hitTest.hostedInspectorFallback",
+ point: point,
+ passThrough: false,
+ hitView: hostedInspectorHit.inspectorView
+ )
#endif
- return hostedInspectorHit.inspectorView
+ return isSideDockHit ? self : hostedInspectorHit.inspectorView
}
let hit = super.hitTest(point)
#if DEBUG
@@ -4005,17 +4229,106 @@ struct WebViewRepresentable: NSViewRepresentable {
}
override func mouseDown(with event: NSEvent) {
- super.mouseDown(with: event)
+ let point = convert(event.locationInWindow, from: nil)
+ guard let hostedInspectorHit = hostedInspectorDividerHit(at: point),
+ isHostedInspectorSideDockHit(hostedInspectorHit) else {
+ super.mouseDown(with: event)
+ return
+ }
+
+ hostedInspectorReapplyWorkItem?.cancel()
+ isHostedInspectorDividerDragActive = true
+ hostedInspectorDividerDrag = HostedInspectorDividerDragState(
+ containerView: hostedInspectorHit.containerView,
+ pageView: hostedInspectorHit.pageView,
+ inspectorView: hostedInspectorHit.inspectorView,
+ dockSide: hostedInspectorHit.dockSide,
+ initialWindowX: event.locationInWindow.x,
+ initialPageFrame: hostedInspectorHit.pageView.frame,
+ initialInspectorFrame: hostedInspectorHit.inspectorView.frame
+ )
+#if DEBUG
+ debugLogHostedInspectorFrames(stage: "drag.start", point: point, hit: hostedInspectorHit)
+#endif
}
override func mouseDragged(with event: NSEvent) {
- super.mouseDragged(with: event)
+ guard let dragState = hostedInspectorDividerDrag else {
+ super.mouseDragged(with: event)
+ return
+ }
+
+ let containerBounds = dragState.containerView.bounds
+ let minimumInspectorWidth = Self.minimumHostedInspectorWidth
+ 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
+ )
+ recordPreferredHostedInspectorWidth(inspectorWidth, containerBounds: containerBounds)
+ _ = applyHostedInspectorDividerWidth(
+ inspectorWidth,
+ to: HostedInspectorDividerHit(
+ containerView: dragState.containerView,
+ pageView: dragState.pageView,
+ inspectorView: dragState.inspectorView,
+ dockSide: dragState.dockSide
+ ),
+ reason: "drag"
+ )
+#if DEBUG
+ debugLogHostedInspectorFrames(
+ stage: "drag.update",
+ point: convert(event.locationInWindow, from: nil),
+ hit: HostedInspectorDividerHit(
+ containerView: dragState.containerView,
+ pageView: dragState.pageView,
+ inspectorView: dragState.inspectorView,
+ dockSide: dragState.dockSide
+ )
+ )
+#endif
+ updateDividerCursor(
+ at: convert(event.locationInWindow, from: nil),
+ hostedInspectorHit: HostedInspectorDividerHit(
+ containerView: dragState.containerView,
+ pageView: dragState.pageView,
+ inspectorView: dragState.inspectorView,
+ dockSide: dragState.dockSide
+ )
+ )
}
override func mouseUp(with event: NSEvent) {
+ let finalDragState = hostedInspectorDividerDrag
hostedInspectorDividerDrag = nil
isHostedInspectorDividerDragActive = false
updateDividerCursor(at: convert(event.locationInWindow, from: nil))
+ if let finalDragState {
+#if DEBUG
+ debugLogHostedInspectorFrames(
+ stage: "drag.end",
+ point: convert(event.locationInWindow, from: nil),
+ hit: HostedInspectorDividerHit(
+ containerView: finalDragState.containerView,
+ pageView: finalDragState.pageView,
+ inspectorView: finalDragState.inspectorView,
+ dockSide: finalDragState.dockSide
+ )
+ )
+#endif
+ layoutHostedInspectorSideDockIfNeeded(reason: "drag.end")
+ }
super.mouseUp(with: event)
}
@@ -4094,11 +4407,19 @@ struct WebViewRepresentable: NSViewRepresentable {
}
private func hostedInspectorDividerCandidate() -> HostedInspectorDividerHit? {
- let inspectorCandidates = Self.visibleDescendants(in: self)
+ hostedInspectorDividerCandidate(in: self)
+ }
+
+ private func hostedInspectorDividerCandidate(in root: NSView) -> HostedInspectorDividerHit? {
+ if let preferredHit = hostedInspectorDividerCandidateUsingKnownWebViews(in: root) {
+ return preferredHit
+ }
+
+ let inspectorCandidates = Self.visibleDescendants(in: root)
.filter { Self.isVisibleHostedInspectorCandidate($0) && Self.isInspectorView($0) }
.sorted { lhs, rhs in
- let lhsFrame = convert(lhs.bounds, from: lhs)
- let rhsFrame = convert(rhs.bounds, from: rhs)
+ let lhsFrame = root.convert(lhs.bounds, from: lhs)
+ let rhsFrame = root.convert(rhs.bounds, from: rhs)
return lhsFrame.minX < rhsFrame.minX
}
@@ -4106,7 +4427,7 @@ struct WebViewRepresentable: NSViewRepresentable {
var bestScore = -CGFloat.greatestFiniteMagnitude
for inspectorCandidate in inspectorCandidates {
- guard let candidate = hostedInspectorDividerCandidate(startingAt: inspectorCandidate) else {
+ guard let candidate = hostedInspectorDividerCandidate(in: root, startingAt: inspectorCandidate) else {
continue
}
let score = hostedInspectorDividerCandidateScore(candidate)
@@ -4119,6 +4440,59 @@ struct WebViewRepresentable: NSViewRepresentable {
return bestHit
}
+ private func hostedInspectorDividerCandidateUsingKnownWebViews(in root: NSView) -> HostedInspectorDividerHit? {
+ guard let pageLeaf = hostedWebView,
+ let inspectorLeaf = hostedInspectorFrontendWebView,
+ pageLeaf.isDescendant(of: root),
+ inspectorLeaf.isDescendant(of: root),
+ Self.isVisibleHostedInspectorCandidate(inspectorLeaf) else {
+ return nil
+ }
+ return hostedInspectorDividerCandidate(
+ in: root,
+ pageLeaf: pageLeaf,
+ inspectorLeaf: inspectorLeaf
+ )
+ }
+
+ private func hostedInspectorDividerCandidate(
+ in root: NSView,
+ pageLeaf: NSView,
+ inspectorLeaf: NSView
+ ) -> HostedInspectorDividerHit? {
+ var currentInspector: NSView? = inspectorLeaf
+
+ while let inspectorView = currentInspector, inspectorView !== root {
+ guard let containerView = inspectorView.superview else { break }
+ guard containerView === root || containerView.isDescendant(of: root) else {
+ currentInspector = containerView
+ continue
+ }
+ guard let pageView = Self.directChild(of: containerView, containing: pageLeaf) else {
+ currentInspector = containerView
+ continue
+ }
+ guard pageView !== inspectorView,
+ Self.isVisibleHostedInspectorSiblingCandidate(pageView),
+ Self.verticalOverlap(between: pageView.frame, and: inspectorView.frame) > 8,
+ let dockSide = HostedInspectorDockSide.resolve(
+ pageFrame: pageView.frame,
+ inspectorFrame: inspectorView.frame
+ ) else {
+ currentInspector = containerView
+ continue
+ }
+ return HostedInspectorDividerHit(
+ containerView: containerView,
+ pageView: pageView,
+ inspectorView: inspectorView,
+ dockSide: dockSide
+ )
+ }
+
+ return nil
+ }
+
private func hostedInspectorDividerHitRect(for hit: HostedInspectorDividerHit) -> NSRect {
let pageFrame = convert(hit.pageView.bounds, from: hit.pageView)
let inspectorFrame = convert(hit.inspectorView.bounds, from: hit.inspectorView)
@@ -4130,11 +4504,11 @@ struct WebViewRepresentable: NSViewRepresentable {
)
}
- private func hostedInspectorDividerCandidate(startingAt inspectorLeaf: NSView) -> HostedInspectorDividerHit? {
+ private func hostedInspectorDividerCandidate(in root: NSView, startingAt inspectorLeaf: NSView) -> HostedInspectorDividerHit? {
var current: NSView? = inspectorLeaf
var bestHit: HostedInspectorDividerHit?
- while let inspectorView = current, inspectorView !== self {
+ while let inspectorView = current, inspectorView !== root {
guard let containerView = inspectorView.superview else { break }
let pageCandidates = containerView.subviews.compactMap { candidate -> (view: NSView, dockSide: HostedInspectorDockSide)? in
@@ -4189,7 +4563,11 @@ struct WebViewRepresentable: NSViewRepresentable {
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.hostedInspectorReapplyWorkItem = nil
- self.captureHostedInspectorPreferredWidthFromCurrentLayout(reason: reason)
+ if self.isHostedInspectorSideDockActive() {
+ self.reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: reason)
+ } else if !self.hasStoredHostedInspectorWidthPreference {
+ self.captureHostedInspectorPreferredWidthFromCurrentLayout(reason: reason)
+ }
}
hostedInspectorReapplyWorkItem = workItem
DispatchQueue.main.async(execute: workItem)
@@ -4244,6 +4622,18 @@ struct WebViewRepresentable: NSViewRepresentable {
)
}
+ private func reapplyHostedInspectorDividerToStoredWidthIfNeeded(reason: String) {
+ guard !isApplyingHostedInspectorLayout else { return }
+ guard let hit = hostedInspectorDividerCandidate() else { return }
+ guard isHostedInspectorSideDockHit(hit) else { return }
+ guard let preferredWidth = resolvedPreferredHostedInspectorWidth(in: hit.containerView.bounds) else {
+ return
+ }
+ let currentInspectorWidth = max(0, hit.inspectorView.frame.width)
+ guard abs(currentInspectorWidth - preferredWidth) > 0.5 else { return }
+ _ = applyHostedInspectorDividerWidth(preferredWidth, to: hit, reason: reason)
+ }
+
@discardableResult
private func applyHostedInspectorDividerWidth(
_ preferredWidth: CGFloat,
@@ -4302,6 +4692,17 @@ struct WebViewRepresentable: NSViewRepresentable {
return descendants
}
+ private static func directChild(of container: NSView, containing descendant: NSView) -> NSView? {
+ var current: NSView? = descendant
+ var directChild: NSView?
+ while let view = current, view !== container {
+ directChild = view
+ current = view.superview
+ }
+ guard current === container else { return nil }
+ return directChild
+ }
+
fileprivate static func isInspectorView(_ view: NSView) -> Bool {
String(describing: type(of: view)).contains("WKInspector")
}
@@ -4509,9 +4910,7 @@ struct WebViewRepresentable: NSViewRepresentable {
private func updateUsingLocalInlineHosting(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool {
guard let host = nsView as? HostContainerView else { return false }
let slotView = host.ensureLocalInlineSlotView()
- let isAlreadyInLocalHost =
- webView.superview === slotView ||
- (webView.superview?.isDescendant(of: slotView) ?? false)
+ let isAlreadyInLocalHost = host.containsManagedLocalInlineContent(webView)
let didAttachWebViewToLocalHost = !isAlreadyInLocalHost
let coordinator = context.coordinator
@@ -4532,7 +4931,7 @@ struct WebViewRepresentable: NSViewRepresentable {
let shouldPreserveExistingExternalLocalHost =
host.window == nil &&
webView.superview != nil &&
- webView.superview !== slotView
+ !host.containsManagedLocalInlineContent(webView)
if shouldPreserveExistingExternalLocalHost {
// Split zoom can instantiate a replacement local host before it joins a window.
// Never let that off-window host steal the live page + inspector hierarchy away
@@ -4557,6 +4956,12 @@ struct WebViewRepresentable: NSViewRepresentable {
return false
}
+ let preferredAttachedWidthState = panel.preferredAttachedDeveloperToolsWidthState()
+ host.setPreferredHostedInspectorWidth(
+ width: preferredAttachedWidthState.width,
+ widthFraction: preferredAttachedWidthState.widthFraction
+ )
+ host.setHostedInspectorFrontendWebView(webView.cmuxInspectorFrontendWebView())
host.onPreferredHostedInspectorWidthChanged = { [weak browserPanel = panel] width, _ in
guard let browserPanel else { return }
browserPanel.recordPreferredAttachedDeveloperToolsWidth(
@@ -4566,6 +4971,7 @@ struct WebViewRepresentable: NSViewRepresentable {
}
slotView.onHostedInspectorLayout = { [weak host] _ in
host?.scheduleHostedInspectorDividerReapply(reason: "slot.layout")
+ host?.scheduleHostedInspectorDockConfigurationSync(reason: "slot.layout")
}
if didAttachWebViewToLocalHost {
@@ -4582,7 +4988,10 @@ struct WebViewRepresentable: NSViewRepresentable {
}
slotView.isHidden = false
- host.pinHostedWebView(webView, in: slotView)
+ host.pinHostedWebView(
+ webView,
+ in: host.currentHostedWebViewContainer(preferredSlotView: slotView)
+ )
coordinator.lastPortalHostId = nil
coordinator.lastSynchronizedHostGeometryRevision = 0
if didAttachWebViewToLocalHost {
@@ -4592,6 +5001,13 @@ struct WebViewRepresentable: NSViewRepresentable {
slotView.layoutSubtreeIfNeeded()
host.layoutSubtreeIfNeeded()
host.scheduleHostedInspectorDividerReapply(reason: "localInline.update.sync")
+ DispatchQueue.main.async { [weak host, weak webView] in
+ guard let host, let webView else { return }
+ host.setHostedInspectorFrontendWebView(webView.cmuxInspectorFrontendWebView())
+ host.scheduleHostedInspectorDockConfigurationSync(reason: "localInline.update.async")
+ }
+ } else {
+ host.scheduleHostedInspectorDockConfigurationSync(reason: "localInline.update")
}
#if DEBUG
@@ -4608,6 +5024,7 @@ struct WebViewRepresentable: NSViewRepresentable {
private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) -> Bool {
guard let host = nsView as? HostContainerView else { return false }
+ host.prepareForWindowPortalHosting()
host.setLocalInlineSlotHidden(true)
host.releaseHostedWebViewConstraints()
From 18bdbef88235d5a7b51c1aff4b355b96033a3a90 Mon Sep 17 00:00:00 2001
From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
Date: Wed, 11 Mar 2026 14:50:38 -0700
Subject: [PATCH 43/43] Add Khmer (README.km.md) localization (#1198)
* Create README-kh.md for Khmer localization
Add Khmer language support to README
* Address review feedback: rename to README.km.md, add screenshots/star history
- Rename README-kh.md to README.km.md (correct ISO 639-1 language code, dot separator)
- Add Khmer link to main README.md language switcher
- Add feature screenshot table matching original README structure
- Add browser keyboard shortcuts note
- Add Star History section
- Use relative image paths consistent with other localized READMEs
---------
Co-authored-by: im4tta <82812618+im4tta@users.noreply.github.com>
---
README.km.md | 268 +++++++++++++++++++++++++++++++++++++++++++++++++++
README.md | 2 +-
2 files changed, 269 insertions(+), 1 deletion(-)
create mode 100644 README.km.md
diff --git a/README.km.md b/README.km.md
new file mode 100644
index 00000000..65f245a3
--- /dev/null
+++ b/README.km.md
@@ -0,0 +1,268 @@
+cmux
+Terminal សម្រាប់ macOS ផ្អែកលើ Ghostty ដែលមាន tab បញ្ឈរ និងការជូនដំណឹងសម្រាប់ AI coding agents
+
+
+
+
+
+
+
+
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ
+
+
+
+
+
+
+
+
+
+
+
+
+ ▶ វីដេអូបង្ហាញពីដំណើរការ (Demo) · ទស្សនវិជ្ជារបស់ cmux (The Zen of cmux)
+
+
+## លក្ខណៈពិសេសនានា (Features)
+
+
+
+
+Notification rings
+Panes get a blue ring and tabs light up when coding agents need your attention
+
+
+
+
+
+
+
+Notification panel
+See all pending notifications in one place, jump to the most recent unread
+
+
+
+
+
+
+
+In-app browser
+Split a browser alongside your terminal with a scriptable API ported from agent-browser
+
+
+
+
+
+
+
+Vertical + horizontal tabs
+Sidebar shows git branch, linked PR status/number, working directory, listening ports, and latest notification text. Split horizontally and vertically.
+
+
+
+
+
+
+
+* **អាចសរសេរ 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
+
+
+
+
+
+
+
+
+
+## ការចូលរួមចំណែក (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` សម្រាប់អត្ថបទពេញលេញ។
diff --git a/README.md b/README.md
index 93e896bc..599277e5 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
- English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe
+ English | 简体中文 | 繁體中文 | 한국어 | Deutsch | Español | Français | Italiano | Dansk | 日本語 | Polski | Русский | Bosanski | العربية | Norsk | Português (Brasil) | ไทย | Türkçe | ភាសាខ្មែរ