From c40ec7561692e80f95c88057d9cb36fbda26add8 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:19:48 -0800 Subject: [PATCH 1/9] docs(web): refresh API access modes and socket examples (#196) * docs(web): refresh API socket and automation mode docs * docs(web): fix v1 JSON note and restore API metadata scope --- web/app/docs/api/page.tsx | 163 +++++++++++++++++++--------- web/app/docs/configuration/page.tsx | 10 +- 2 files changed, 116 insertions(+), 57 deletions(-) diff --git a/web/app/docs/api/page.tsx b/web/app/docs/api/page.tsx index 07a11b52..6f7322e7 100644 --- a/web/app/docs/api/page.tsx +++ b/web/app/docs/api/page.tsx @@ -61,15 +61,26 @@ export default function ApiPage() { /tmp/cmux-debug.sock + + Tagged debug build + + /tmp/cmux-debug-<tag>.sock + +

Override with the CMUX_SOCKET_PATH environment variable. - Commands are newline-terminated JSON: + Send one newline-terminated JSON request per call:

- {`{"command": "command-name", "arg1": "value1"} + {`{"id":"req-1","method":"workspace.list","params":{}} // Response: -{"success": true, "data": {...}}`} +{"id":"req-1","ok":true,"result":{"workspaces":[...]}}`} + + JSON socket requests must use method and{" "} + params. Legacy v1 JSON payloads such as{" "} + {`{"command":"..."}`} are not supported. +

Access modes

@@ -77,6 +88,7 @@ export default function ApiPage() { + @@ -85,24 +97,31 @@ export default function ApiPage() { Off + - + + + + -
Mode DescriptionHow to enable
Socket disabledSettings UI or CMUX_SOCKET_MODE=off
- Notifications only + cmux processes only Only notification commands allowed + Only processes spawned inside cmux terminals can connect. + Default mode in Settings UI
- Full control + allowAll + Allow any local process to connect (no ancestry check). + Environment override only: CMUX_SOCKET_MODE=allowAll All commands enabled
- On shared machines, use “Notifications only” mode to prevent - other users from controlling your terminals. + On shared machines, use Off or{" "} + cmux processes only.

CLI options

@@ -126,6 +145,12 @@ export default function ApiPage() { Output in JSON format + + + --window ID + + Target a specific window + --workspace ID @@ -138,6 +163,12 @@ export default function ApiPage() { Target a specific surface + + + --id-format refs|uuids|both + + Control identifier format in JSON output + @@ -148,32 +179,32 @@ export default function ApiPage() { desc="List all open workspaces." cli={`cmux list-workspaces cmux list-workspaces --json`} - socket={`{"command": "list-workspaces"}`} + socket={`{"id":"ws-list","method":"workspace.list","params":{}}`} /> `} - socket={`{"command": "select-workspace", "id": ""}`} + socket={`{"id":"ws-select","method":"workspace.select","params":{"workspace_id":""}}`} /> `} - socket={`{"command": "close-workspace", "id": ""}`} + socket={`{"id":"ws-close","method":"workspace.close","params":{"workspace_id":""}}`} />

Split commands

@@ -183,20 +214,20 @@ cmux current-workspace --json`} desc="Create a new split pane. Directions: left, right, up, down." cli={`cmux new-split right cmux new-split down`} - socket={`{"command": "new-split", "direction": "right"}`} + socket={`{"id":"split-new","method":"surface.split","params":{"direction":"right"}}`} /> `} - socket={`{"command": "focus-surface", "id": ""}`} + socket={`{"id":"surface-focus","method":"surface.focus","params":{"surface_id":""}}`} />

Input commands

@@ -206,25 +237,25 @@ cmux list-surfaces --json`} desc="Send text input to the focused terminal." cli={`cmux send "echo hello" cmux send "ls -la\\n"`} - socket={`{"command": "send", "text": "echo hello\\n"}`} + socket={`{"id":"send-text","method":"surface.send_text","params":{"text":"echo hello\\n"}}`} /> "command"`} - socket={`{"command": "send-surface", "id": "", "text": "command"}`} + socket={`{"id":"send-surface","method":"surface.send_text","params":{"surface_id":"","text":"command"}}`} /> enter`} - socket={`{"command": "send-key-surface", "id": "", "key": "enter"}`} + socket={`{"id":"send-key-surface","method":"surface.send_key","params":{"surface_id":"","key":"enter"}}`} />

Notification commands

@@ -234,21 +265,20 @@ cmux send "ls -la\\n"`} desc="Send a notification." cli={`cmux notify --title "Title" --body "Body" cmux notify --title "T" --subtitle "S" --body "B"`} - socket={`{"command": "notify", "title": "Title", - "subtitle": "S", "body": "Body"}`} + socket={`{"id":"notify","method":"notification.create","params":{"title":"Title","subtitle":"S","body":"Body"}}`} />

Utility commands

@@ -257,8 +287,22 @@ cmux list-notifications --json`} name="ping" desc="Check if cmux is running and responsive." cli={`cmux ping`} - socket={`{"command": "ping"} -// Response: {"success": true, "pong": true}`} + socket={`{"id":"ping","method":"system.ping","params":{}} +// Response: {"id":"ping","ok":true,"result":{"pong":true}}`} + /> + +

Environment variables

@@ -274,14 +318,16 @@ cmux list-notifications --json`} CMUX_SOCKET_PATH - Override the default socket path + Override the socket path used by CLI and integrations CMUX_SOCKET_ENABLE - Enable/disable socket (1/0) + Force-enable/disable socket (1/0,{" "} + true/false, on/ + off) @@ -289,8 +335,10 @@ cmux list-notifications --json`} CMUX_SOCKET_MODE - Override access mode (full,{" "} - notifications, off) + Override access mode (cmuxOnly,{" "} + allowAll, off). Also accepts{" "} + cmux-only/cmux_only and{" "} + allow-all/allow_all @@ -324,51 +372,60 @@ cmux list-notifications --json`} - Environment variables override app settings. Use the socket check to - distinguish cmux from regular Ghostty. + Legacy CMUX_SOCKET_MODE values full and{" "} + notifications are still accepted for compatibility.

Detecting cmux

- {`# Check for the socket -[ -S /tmp/cmux.sock ] && echo "In cmux" + {`# Prefer explicit socket path if set +SOCK="\${CMUX_SOCKET_PATH:-/tmp/cmux.sock}" +[ -S "$SOCK" ] && echo "Socket available" # Check for the CLI command -v cmux &>/dev/null && echo "cmux available" +# In cmux-managed terminals these are auto-set +[ -n "\${CMUX_WORKSPACE_ID:-}" ] && [ -n "\${CMUX_SURFACE_ID:-}" ] && echo "Inside cmux surface" + # Distinguish from regular Ghostty -[ "$TERM_PROGRAM" = "ghostty" ] && [ -S /tmp/cmux.sock ] && echo "In cmux"`} +[ "$TERM_PROGRAM" = "ghostty" ] && [ -n "\${CMUX_WORKSPACE_ID:-}" ] && echo "In cmux"`}

Examples

Python client

- {`import socket, json + {`import json +import os +import socket -def send_command(cmd): - sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect('/tmp/cmux.sock') - sock.send(json.dumps(cmd).encode() + b'\\n') - response = sock.recv(4096).decode() - sock.close() - return json.loads(response) +SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux.sock") + +def rpc(method, params=None, req_id=1): + payload = {"id": req_id, "method": method, "params": params or {}} + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.connect(SOCKET_PATH) + sock.sendall(json.dumps(payload).encode("utf-8") + b"\\n") + return json.loads(sock.recv(65536).decode("utf-8")) # List workspaces -print(send_command({"command": "list-workspaces"})) +print(rpc("workspace.list", req_id="ws")) # Send notification -send_command({ - "command": "notify", - "title": "Hello", - "body": "From Python!" -})`} +print(rpc( + "notification.create", + {"title": "Hello", "body": "From Python!"}, + req_id="notify" +))`}

Shell script

{`#!/bin/bash +SOCK="\${CMUX_SOCKET_PATH:-/tmp/cmux.sock}" + cmux_cmd() { - echo "$1" | nc -U /tmp/cmux.sock + printf "%s\\n" "$1" | nc -U "$SOCK" } -cmux_cmd '{"command": "list-workspaces"}' -cmux_cmd '{"command": "notify", "title": "Done", "body": "Task complete"}'`} +cmux_cmd '{"id":"ws","method":"workspace.list","params":{}}' +cmux_cmd '{"id":"notify","method":"notification.create","params":{"title":"Done","body":"Task complete"}}'`}

Build script with notification

{`#!/bin/bash diff --git a/web/app/docs/configuration/page.tsx b/web/app/docs/configuration/page.tsx index 4a2f9ffc..49bea384 100644 --- a/web/app/docs/configuration/page.tsx +++ b/web/app/docs/configuration/page.tsx @@ -95,15 +95,17 @@ working-directory = ~/Projects`} Off — no socket control (most secure)
  • - Notifications only — only allow notification commands + cmux processes only — only allow processes started + inside cmux terminals to connect
  • - Full control — allow all socket commands + allowAll — allow any local process to connect ( + CMUX_SOCKET_MODE=allowAll, env override only)
  • - On shared machines, consider using “Notifications only” mode - to prevent other processes from controlling your terminals. + On shared machines, consider using “Off” or + “cmux processes only” mode.

    Example config

    From ab84a02152b7f9d4e7bfbe4555bda0b2550e32ac Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:30:21 -0800 Subject: [PATCH 2/9] Fix reload config to honor legacy Ghostty config fallback (#202) --- Sources/GhosttyTerminalView.swift | 31 +++++++++++++++++-------- cmuxTests/GhosttyConfigTests.swift | 36 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index fb23ab7f..7bc8194f 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -288,9 +288,7 @@ class GhosttyApp { // Load default config (includes user config). If this fails hard (e.g. due to // invalid user config), ghostty_app_new may return nil; we fall back below. - ghostty_config_load_default_files(primaryConfig) - loadLegacyGhosttyConfigIfNeeded(primaryConfig) - ghostty_config_finalize(primaryConfig) + loadDefaultConfigFilesWithLegacyFallback(primaryConfig) updateDefaultBackground(from: primaryConfig) // Create runtime config with callbacks @@ -458,6 +456,21 @@ class GhosttyApp { #endif } + private func loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) { + ghostty_config_load_default_files(config) + loadLegacyGhosttyConfigIfNeeded(config) + ghostty_config_finalize(config) + } + + static func shouldLoadLegacyGhosttyConfig( + newConfigFileSize: Int?, + legacyConfigFileSize: Int? + ) -> Bool { + guard let newConfigFileSize, newConfigFileSize == 0 else { return false } + guard let legacyConfigFileSize, legacyConfigFileSize > 0 else { return false } + return true + } + private func loadLegacyGhosttyConfigIfNeeded(_ config: ghostty_config_t) { #if os(macOS) // Ghostty 1.3+ prefers `config.ghostty`, but some users still have their real @@ -475,8 +488,10 @@ class GhosttyApp { return size.intValue } - guard let newSize = fileSize(configNew), newSize == 0 else { return } - guard let legacySize = fileSize(configLegacy), legacySize > 0 else { return } + guard Self.shouldLoadLegacyGhosttyConfig( + newConfigFileSize: fileSize(configNew), + legacyConfigFileSize: fileSize(configLegacy) + ) else { return } configLegacy.path.withCString { path in ghostty_config_load_file(config, path) @@ -512,8 +527,7 @@ class GhosttyApp { } guard let newConfig = ghostty_config_new() else { return } - ghostty_config_load_default_files(newConfig) - ghostty_config_finalize(newConfig) + loadDefaultConfigFilesWithLegacyFallback(newConfig) ghostty_app_update_config(app, newConfig) updateDefaultBackground(from: newConfig) DispatchQueue.main.async { @@ -533,8 +547,7 @@ class GhosttyApp { } guard let newConfig = ghostty_config_new() else { return } - ghostty_config_load_default_files(newConfig) - ghostty_config_finalize(newConfig) + loadDefaultConfigFilesWithLegacyFallback(newConfig) ghostty_surface_update_config(surface, newConfig) ghostty_config_free(newConfig) } diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index b6f13d4d..dddecf16 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -126,6 +126,42 @@ final class GhosttyConfigTests: XCTestCase { XCTAssertEqual(rgb255(config.backgroundColor), RGB(red: 253, green: 246, blue: 227)) } + func testLegacyConfigFallbackUsesLegacyFileWhenConfigGhosttyIsEmpty() { + XCTAssertTrue( + GhosttyApp.shouldLoadLegacyGhosttyConfig( + newConfigFileSize: 0, + legacyConfigFileSize: 42 + ) + ) + } + + func testLegacyConfigFallbackSkipsWhenNewFileMissingOrLegacyEmpty() { + XCTAssertFalse( + GhosttyApp.shouldLoadLegacyGhosttyConfig( + newConfigFileSize: nil, + legacyConfigFileSize: 42 + ) + ) + XCTAssertFalse( + GhosttyApp.shouldLoadLegacyGhosttyConfig( + newConfigFileSize: 10, + legacyConfigFileSize: 42 + ) + ) + XCTAssertFalse( + GhosttyApp.shouldLoadLegacyGhosttyConfig( + newConfigFileSize: 0, + legacyConfigFileSize: 0 + ) + ) + XCTAssertFalse( + GhosttyApp.shouldLoadLegacyGhosttyConfig( + newConfigFileSize: 0, + legacyConfigFileSize: nil + ) + ) + } + private func rgb255(_ color: NSColor) -> RGB { let srgb = color.usingColorSpace(.sRGB)! var red: CGFloat = 0 From f3a4e4db436e974c573848e1b5deab0c4e20e65c Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:30:31 -0800 Subject: [PATCH 3/9] Cache GhosttyKit builds by ghostty commit in setup script (#204) --- scripts/setup.sh | 54 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/scripts/setup.sh b/scripts/setup.sh index 10413872..2f4f6d2a 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -1,5 +1,5 @@ -#!/bin/bash -set -e +#!/usr/bin/env bash +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" @@ -16,13 +16,53 @@ if ! command -v zig &> /dev/null; then exit 1 fi -echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..." -cd ghostty -zig build -Demit-xcframework=true -Doptimize=ReleaseFast -cd "$PROJECT_DIR" +GHOSTTY_SHA="$(git -C ghostty rev-parse HEAD)" +CACHE_ROOT="${CMUX_GHOSTTYKIT_CACHE_DIR:-$HOME/.cache/cmux/ghosttykit}" +CACHE_DIR="$CACHE_ROOT/$GHOSTTY_SHA" +CACHE_XCFRAMEWORK="$CACHE_DIR/GhosttyKit.xcframework" +LOCAL_XCFRAMEWORK="$PROJECT_DIR/ghostty/macos/GhosttyKit.xcframework" +LOCK_DIR="$CACHE_ROOT/$GHOSTTY_SHA.lock" + +mkdir -p "$CACHE_ROOT" + +echo "==> Ghostty submodule commit: $GHOSTTY_SHA" + +while ! mkdir "$LOCK_DIR" 2>/dev/null; do + echo "==> Waiting for GhosttyKit cache lock for $GHOSTTY_SHA..." + sleep 1 +done +trap 'rmdir "$LOCK_DIR" >/dev/null 2>&1 || true' EXIT + +if [ -d "$CACHE_XCFRAMEWORK" ]; then + echo "==> Reusing cached GhosttyKit.xcframework" +else + if [ -d "$LOCAL_XCFRAMEWORK" ]; then + echo "==> Seeding cache from existing local GhosttyKit.xcframework" + else + echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..." + ( + cd ghostty + zig build -Demit-xcframework=true -Doptimize=ReleaseFast + ) + fi + + SRC_XCFRAMEWORK="$LOCAL_XCFRAMEWORK" + if [ ! -d "$SRC_XCFRAMEWORK" ]; then + echo "Error: GhosttyKit.xcframework not found at $SRC_XCFRAMEWORK" + exit 1 + fi + + TMP_DIR="$(mktemp -d "$CACHE_ROOT/.ghosttykit-tmp.XXXXXX")" + mkdir -p "$CACHE_DIR" + cp -R "$SRC_XCFRAMEWORK" "$TMP_DIR/GhosttyKit.xcframework" + rm -rf "$CACHE_XCFRAMEWORK" + mv "$TMP_DIR/GhosttyKit.xcframework" "$CACHE_XCFRAMEWORK" + rmdir "$TMP_DIR" + echo "==> Cached GhosttyKit.xcframework at $CACHE_XCFRAMEWORK" +fi echo "==> Creating symlink for GhosttyKit.xcframework..." -ln -sf ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework +ln -sfn "$CACHE_XCFRAMEWORK" GhosttyKit.xcframework echo "==> Setup complete!" echo "" From 94d44fefd28c2983fe889f9db06a34249e798422 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 20 Feb 2026 14:31:13 -0800 Subject: [PATCH 4/9] chore(claude-opus-4-6): Branch name in sidebar sometimes doesn't update correctly... (#199) --- .../shell-integration/cmux-bash-integration.bash | 11 +++++++++-- Resources/shell-integration/cmux-zsh-integration.zsh | 12 ++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/Resources/shell-integration/cmux-bash-integration.bash b/Resources/shell-integration/cmux-bash-integration.bash index 8ad8d2fa..1e110f91 100644 --- a/Resources/shell-integration/cmux-bash-integration.bash +++ b/Resources/shell-integration/cmux-bash-integration.bash @@ -88,9 +88,16 @@ _cmux_prompt_command() { # Git branch/dirty can change without a directory change (e.g. `git checkout`), # so update on every prompt (still async + de-duped by the running-job check). + # When pwd changes (cd into a different repo), kill the old probe and start fresh + # so the sidebar picks up the new branch immediately. if [[ -n "$_CMUX_GIT_JOB_PID" ]] && kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then - : - else + if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]]; then + kill "$_CMUX_GIT_JOB_PID" >/dev/null 2>&1 || true + _CMUX_GIT_JOB_PID="" + fi + fi + + if [[ -z "$_CMUX_GIT_JOB_PID" ]] || ! kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then _CMUX_GIT_LAST_PWD="$pwd" _CMUX_GIT_LAST_RUN=$now { diff --git a/Resources/shell-integration/cmux-zsh-integration.zsh b/Resources/shell-integration/cmux-zsh-integration.zsh index 802e3cb3..3b5d00cc 100644 --- a/Resources/shell-integration/cmux-zsh-integration.zsh +++ b/Resources/shell-integration/cmux-zsh-integration.zsh @@ -139,11 +139,12 @@ _cmux_preexec() { _CMUX_CMD_START=$EPOCHSECONDS - # Heuristic: git commands can change branch/dirty state without changing $PWD. + # Heuristic: commands that may change git branch/dirty state without changing $PWD. local cmd="${1## }" - if [[ "$cmd" == git\ * || "$cmd" == git ]]; then - _CMUX_GIT_FORCE=1 - fi + case "$cmd" in + git\ *|git|gh\ *|lazygit|lazygit\ *|tig|tig\ *|gitui|gitui\ *|stg\ *|jj\ *) + _CMUX_GIT_FORCE=1 ;; + esac # Register TTY + kick batched port scan for foreground commands (servers). _cmux_report_tty_once @@ -196,6 +197,9 @@ _cmux_precmd() { head_mtime="$(_cmux_git_head_mtime "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || echo 0)" if [[ -n "$head_mtime" && "$head_mtime" != 0 && "$head_mtime" != "$_CMUX_GIT_HEAD_MTIME" ]]; then _CMUX_GIT_HEAD_MTIME="$head_mtime" + # Treat HEAD file change like a git command — force-replace any + # running probe so the sidebar picks up the new branch immediately. + _CMUX_GIT_FORCE=1 should_git=1 fi fi From f455af45414fec1751d7f08b489173c6b1851178 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 20 Feb 2026 14:51:44 -0800 Subject: [PATCH 5/9] Fix issue #185 overlay recursion hardening and scroll regression (#193) --- Sources/ContentView.swift | 14 ++++++-- tests/test_real_click_overlay_forwarding.py | 37 ++++++++++++++++++++- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 87e67ab5..239fe76f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -171,6 +171,7 @@ final class SidebarState: ObservableObject { final class FileDropOverlayView: NSView { /// Fallback handler when no terminal is found under the drop point. var onDrop: (([URL]) -> Bool)? + private var isForwardingMouseEvent = false override var acceptsFirstResponder: Bool { false } @@ -207,12 +208,19 @@ final class FileDropOverlayView: NSView { // window.sendEvent(), which caches the mouse target and causes infinite recursion. private func forwardEvent(_ event: NSEvent) { + guard !isForwardingMouseEvent else { return } guard let window, let contentView = window.contentView else { return } + + isForwardingMouseEvent = true isHidden = true + defer { + isHidden = false + isForwardingMouseEvent = false + } + let point = contentView.convert(event.locationInWindow, from: nil) let target = contentView.hitTest(point) - isHidden = false - guard let target else { return } + guard let target, target !== self else { return } switch event.type { case .leftMouseDown: target.mouseDown(with: event) @@ -273,9 +281,9 @@ final class FileDropOverlayView: NSView { guard let window, let contentView = window.contentView else { return nil } isHidden = true + defer { isHidden = false } let point = contentView.convert(windowPoint, from: nil) let hitView = contentView.hitTest(point) - isHidden = false var current: NSView? = hitView while let view = current { diff --git a/tests/test_real_click_overlay_forwarding.py b/tests/test_real_click_overlay_forwarding.py index f38d7381..0e52eb85 100644 --- a/tests/test_real_click_overlay_forwarding.py +++ b/tests/test_real_click_overlay_forwarding.py @@ -104,6 +104,34 @@ up?.post(tap: .cghidEventTap) ) +def post_scroll_with_cgevent(x: float, y: float, delta_y: int = 3) -> None: + ix = int(round(x)) + iy = int(round(y)) + code = f""" +import CoreGraphics +let p = CGPoint(x: {ix}, y: {iy}) +let source = CGEventSource(stateID: .hidSystemState) +if let scroll = CGEvent( + scrollWheelEvent2Source: source, + units: .line, + wheelCount: 1, + wheel1: Int32({delta_y}), + wheel2: 0, + wheel3: 0 +) {{ + scroll.location = p + scroll.post(tap: .cghidEventTap) +}} +""" + subprocess.run( + ["swift", "-e", code], + check=True, + capture_output=True, + text=True, + timeout=10, + ) + + def pick_top_bottom_terminal_panels(layout: dict) -> tuple[dict, dict]: candidates = [] for panel in layout.get("selectedPanels", []): @@ -282,7 +310,14 @@ def main() -> int: print("FAIL: real right click disrupted terminal focus routing") return 1 - print("PASS: stale file-drag overlay forwards real left/right clicks") + for _ in range(6): + post_scroll_with_cgevent(click_x, click_y, delta_y=2) + time.sleep(0.25) + if not client.is_terminal_focused(bottom_id): + print("FAIL: real scroll wheel disrupted terminal focus routing") + return 1 + + print("PASS: stale file-drag overlay forwards real left/right clicks and scroll") print(f" focused_panel={bottom_id}") return 0 finally: From 327d65806994ff0c44c95fd32e276782cfa05dec Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:53:00 -0800 Subject: [PATCH 6/9] Fix CLI v2 commands showing JSON parse error instead of actual error (#189) When the server returns a plain-text error (e.g., "ERROR: Access denied ...") before the JSON protocol starts, sendV2() would pass it through JSONSerialization which throws a confusing NSCocoaErrorDomain 3840 error. Now sendV2() checks for "ERROR:" prefix and surfaces the real message. Also includes the raw response in the fallback error for easier debugging. Fixes https://github.com/manaflow-ai/cmux/issues/188 --- CLI/cmux.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 9220afd8..bdb617b4 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -365,11 +365,19 @@ final class SocketClient { } let raw = try send(command: requestLine) + + // The server may return plain-text errors (e.g., "ERROR: Access denied ...") + // before the JSON protocol starts. Surface these directly instead of letting + // JSONSerialization throw a confusing parse error. + if raw.hasPrefix("ERROR:") { + throw CLIError(message: raw) + } + guard let responseData = raw.data(using: .utf8) else { throw CLIError(message: "Invalid UTF-8 v2 response") } guard let response = try JSONSerialization.jsonObject(with: responseData, options: []) as? [String: Any] else { - throw CLIError(message: "Invalid v2 response") + throw CLIError(message: "Invalid v2 response: \(raw)") } if let ok = response["ok"] as? Bool, ok { From 1650ba372f55131a9776baf4951ebe272e34d5ff Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:53:57 -0800 Subject: [PATCH 7/9] Fix sidebar workspace staying dim after external drop (#207) * Fix stuck sidebar dim state after external drop * Debug sidebar drag outside-drop flow with content overlay * Handle sidebar drag endings outside window * Fix stale update feed resolver tests --- Sources/ContentView.swift | 302 +++++++++++++++++- Sources/Update/UpdateDelegate.swift | 22 +- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 92 +++--- 3 files changed, 369 insertions(+), 47 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 239fe76f..6c35a10b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -419,6 +419,7 @@ struct ContentView: View { @State private var workspaceHandoffGeneration: UInt64 = 0 @State private var workspaceHandoffFallbackTask: Task? @State private var titlebarThemeGeneration: UInt64 = 0 + @State private var sidebarDraggedTabId: UUID? private var sidebarView: some View { VerticalTabsSidebar( @@ -531,6 +532,13 @@ struct ContentView: View { } } + private var terminalContentWithSidebarDropOverlay: some View { + terminalContent + .overlay { + SidebarExternalDropOverlay(draggedTabId: sidebarDraggedTabId) + } + } + @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue // Background glass settings @@ -659,7 +667,7 @@ struct ContentView: View { // Overlay mode: terminal extends full width, sidebar on top // This allows withinWindow blur to see the terminal content ZStack(alignment: .leading) { - terminalContent + terminalContentWithSidebarDropOverlay .padding(.leading, sidebarState.isVisible ? sidebarWidth : 0) if sidebarState.isVisible { sidebarView @@ -671,7 +679,7 @@ struct ContentView: View { if sidebarState.isVisible { sidebarView } - terminalContent + terminalContentWithSidebarDropOverlay } } } @@ -780,6 +788,16 @@ struct ContentView: View { } } } + .onReceive(NotificationCenter.default.publisher(for: SidebarDragLifecycleNotification.stateDidChange)) { notification in + let tabId = SidebarDragLifecycleNotification.tabId(from: notification) + sidebarDraggedTabId = tabId +#if DEBUG + dlog( + "sidebar.dragState.content tab=\(debugShortWorkspaceId(tabId)) " + + "reason=\(SidebarDragLifecycleNotification.reason(from: notification))" + ) +#endif + } .onPreferenceChange(SidebarFramePreferenceKey.self) { frame in sidebarMinX = frame.minX } @@ -1039,6 +1057,7 @@ struct VerticalTabsSidebar: View { @Binding var lastSidebarSelectionIndex: Int? @StateObject private var commandKeyMonitor = SidebarCommandKeyMonitor() @StateObject private var dragAutoScrollController = SidebarDragAutoScrollController() + @StateObject private var dragFailsafeMonitor = SidebarDragFailsafeMonitor() @State private var draggedTabId: UUID? @State private var dropIndicator: SidebarDropIndicator? @@ -1122,18 +1141,53 @@ struct VerticalTabsSidebar: View { commandKeyMonitor.start() draggedTabId = nil dropIndicator = nil + SidebarDragLifecycleNotification.postStateDidChange( + tabId: nil, + reason: "sidebar_appear" + ) } .onDisappear { commandKeyMonitor.stop() dragAutoScrollController.stop() + dragFailsafeMonitor.stop() draggedTabId = nil dropIndicator = nil + SidebarDragLifecycleNotification.postStateDidChange( + tabId: nil, + reason: "sidebar_disappear" + ) } .onChange(of: draggedTabId) { newDraggedTabId in - guard newDraggedTabId == nil else { return } + SidebarDragLifecycleNotification.postStateDidChange( + tabId: newDraggedTabId, + reason: "drag_state_change" + ) +#if DEBUG + dlog("sidebar.dragState.sidebar tab=\(debugShortSidebarTabId(newDraggedTabId))") +#endif + if newDraggedTabId != nil { + dragFailsafeMonitor.start { + SidebarDragLifecycleNotification.postClearRequest(reason: $0) + } + return + } + dragFailsafeMonitor.stop() dragAutoScrollController.stop() dropIndicator = nil } + .onReceive(NotificationCenter.default.publisher(for: SidebarDragLifecycleNotification.requestClear)) { notification in + guard draggedTabId != nil else { return } + let reason = SidebarDragLifecycleNotification.reason(from: notification) +#if DEBUG + dlog("sidebar.dragClear tab=\(debugShortSidebarTabId(draggedTabId)) reason=\(reason)") +#endif + draggedTabId = nil + } + } + + private func debugShortSidebarTabId(_ id: UUID?) -> String { + guard let id else { return "nil" } + return String(id.uuidString.prefix(5)) } } @@ -1169,6 +1223,207 @@ enum ShortcutHintDebugSettings { } } +enum SidebarDragLifecycleNotification { + static let stateDidChange = Notification.Name("cmux.sidebarDragStateDidChange") + static let requestClear = Notification.Name("cmux.sidebarDragRequestClear") + static let tabIdKey = "tabId" + static let reasonKey = "reason" + + static func postStateDidChange(tabId: UUID?, reason: String) { + var userInfo: [AnyHashable: Any] = [reasonKey: reason] + if let tabId { + userInfo[tabIdKey] = tabId + } + NotificationCenter.default.post( + name: stateDidChange, + object: nil, + userInfo: userInfo + ) + } + + static func postClearRequest(reason: String) { + NotificationCenter.default.post( + name: requestClear, + object: nil, + userInfo: [reasonKey: reason] + ) + } + + static func tabId(from notification: Notification) -> UUID? { + notification.userInfo?[tabIdKey] as? UUID + } + + static func reason(from notification: Notification) -> String { + notification.userInfo?[reasonKey] as? String ?? "unknown" + } +} + +enum SidebarOutsideDropResetPolicy { + static func shouldResetDrag(draggedTabId: UUID?, hasSidebarDragPayload: Bool) -> Bool { + draggedTabId != nil && hasSidebarDragPayload + } +} + +enum SidebarDragFailsafePolicy { + static let pollInterval: TimeInterval = 0.05 + static let clearDelay: TimeInterval = 0.15 + + static func shouldRequestClear(isDragActive: Bool, isLeftMouseButtonDown: Bool) -> Bool { + isDragActive && !isLeftMouseButtonDown + } +} + +@MainActor +private final class SidebarDragFailsafeMonitor: ObservableObject { + private static let escapeKeyCode: UInt16 = 53 + private var timer: Timer? + private var pendingClearWorkItem: DispatchWorkItem? + private var appResignObserver: NSObjectProtocol? + private var keyDownMonitor: Any? + private var onRequestClear: ((String) -> Void)? + + func start(onRequestClear: @escaping (String) -> Void) { + self.onRequestClear = onRequestClear + if timer == nil { + let timer = Timer(timeInterval: SidebarDragFailsafePolicy.pollInterval, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.tick() + } + } + self.timer = timer + RunLoop.main.add(timer, forMode: .common) + } + if appResignObserver == nil { + appResignObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.requestClearSoon(reason: "app_resign_active") + } + } + } + if keyDownMonitor == nil { + keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + if event.keyCode == Self.escapeKeyCode { + self?.requestClearSoon(reason: "escape_cancel") + } + return event + } + } + } + + func stop() { + timer?.invalidate() + timer = nil + pendingClearWorkItem?.cancel() + pendingClearWorkItem = nil + if let appResignObserver { + NotificationCenter.default.removeObserver(appResignObserver) + self.appResignObserver = nil + } + if let keyDownMonitor { + NSEvent.removeMonitor(keyDownMonitor) + self.keyDownMonitor = nil + } + onRequestClear = nil + } + + private func tick() { + let isLeftMouseButtonDown = CGEventSource.buttonState(.combinedSessionState, button: .left) + guard SidebarDragFailsafePolicy.shouldRequestClear( + isDragActive: true, // Monitor only runs while drag is active. + isLeftMouseButtonDown: isLeftMouseButtonDown + ) else { return } + requestClearSoon(reason: "mouse_up_failsafe") + } + + private func requestClearSoon(reason: String) { + guard pendingClearWorkItem == nil else { return } +#if DEBUG + dlog("sidebar.dragFailsafe.schedule reason=\(reason)") +#endif + let workItem = DispatchWorkItem { [weak self] in +#if DEBUG + dlog("sidebar.dragFailsafe.fire reason=\(reason)") +#endif + self?.pendingClearWorkItem = nil + self?.onRequestClear?(reason) + } + pendingClearWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + SidebarDragFailsafePolicy.clearDelay, execute: workItem) + } +} + +private struct SidebarExternalDropOverlay: View { + let draggedTabId: UUID? + + var body: some View { + Color.clear + .contentShape(Rectangle()) + .allowsHitTesting(draggedTabId != nil) + .onDrop( + of: [SidebarTabDragPayload.typeIdentifier], + delegate: SidebarExternalDropDelegate(draggedTabId: draggedTabId) + ) + } +} + +private struct SidebarExternalDropDelegate: DropDelegate { + let draggedTabId: UUID? + + func validateDrop(info: DropInfo) -> Bool { + let hasSidebarPayload = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) + let shouldReset = SidebarOutsideDropResetPolicy.shouldResetDrag( + draggedTabId: draggedTabId, + hasSidebarDragPayload: hasSidebarPayload + ) +#if DEBUG + dlog( + "sidebar.dropOutside.validate tab=\(debugShortSidebarTabId(draggedTabId)) " + + "hasType=\(hasSidebarPayload) allowed=\(shouldReset)" + ) +#endif + return shouldReset + } + + func dropEntered(info: DropInfo) { +#if DEBUG + dlog("sidebar.dropOutside.entered tab=\(debugShortSidebarTabId(draggedTabId))") +#endif + } + + func dropExited(info: DropInfo) { +#if DEBUG + dlog("sidebar.dropOutside.exited tab=\(debugShortSidebarTabId(draggedTabId))") +#endif + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + guard validateDrop(info: info) else { return nil } +#if DEBUG + dlog("sidebar.dropOutside.updated tab=\(debugShortSidebarTabId(draggedTabId)) op=move") +#endif + // Explicit move proposal avoids AppKit showing a copy (+) cursor. + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + guard validateDrop(info: info) else { return false } +#if DEBUG + dlog("sidebar.dropOutside.perform tab=\(debugShortSidebarTabId(draggedTabId))") +#endif + SidebarDragLifecycleNotification.postClearRequest(reason: "outside_sidebar_drop") + return true + } + + private func debugShortSidebarTabId(_ id: UUID?) -> String { + guard let id else { return "nil" } + return String(id.uuidString.prefix(5)) + } +} + @MainActor private final class SidebarCommandKeyMonitor: ObservableObject { @Published private(set) var isCommandPressed = false @@ -2394,6 +2649,9 @@ private struct SidebarTabDropDelegate: DropDelegate { } func dropExited(info: DropInfo) { +#if DEBUG + dlog("sidebar.dropExited target=\(targetTabId?.uuidString.prefix(5) ?? "end")") +#endif if dropIndicator?.tabId == targetTabId { dropIndicator = nil } @@ -2402,6 +2660,12 @@ private struct SidebarTabDropDelegate: DropDelegate { func dropUpdated(info: DropInfo) -> DropProposal? { dragAutoScrollController.updateFromDragLocation() updateDropIndicator(for: info) +#if DEBUG + dlog( + "sidebar.dropUpdated target=\(targetTabId?.uuidString.prefix(5) ?? "end") " + + "indicator=\(debugIndicator(dropIndicator))" + ) +#endif return DropProposal(operation: .move) } @@ -2414,8 +2678,18 @@ private struct SidebarTabDropDelegate: DropDelegate { #if DEBUG dlog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")") #endif - guard let draggedTabId else { return false } - guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { return false } + guard let draggedTabId else { +#if DEBUG + dlog("sidebar.drop.abort reason=missingDraggedTab") +#endif + return false + } + guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { +#if DEBUG + dlog("sidebar.drop.abort reason=draggedTabMissing tab=\(draggedTabId.uuidString.prefix(5))") +#endif + return false + } let tabIds = tabManager.tabs.map(\.id) guard let targetIndex = SidebarDropPlanner.targetIndex( draggedTabId: draggedTabId, @@ -2423,14 +2697,26 @@ private struct SidebarTabDropDelegate: DropDelegate { indicator: dropIndicator, tabIds: tabIds ) else { +#if DEBUG + dlog( + "sidebar.drop.abort reason=noTargetIndex tab=\(draggedTabId.uuidString.prefix(5)) " + + "target=\(targetTabId?.uuidString.prefix(5) ?? "end") indicator=\(debugIndicator(dropIndicator))" + ) +#endif return false } guard fromIndex != targetIndex else { +#if DEBUG + dlog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex)") +#endif syncSidebarSelection() return true } +#if DEBUG + dlog("sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)") +#endif _ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex) if let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] @@ -2461,6 +2747,12 @@ private struct SidebarTabDropDelegate: DropDelegate { lastSidebarSelectionIndex = nil } } + + private func debugIndicator(_ indicator: SidebarDropIndicator?) -> String { + guard let indicator else { return "nil" } + let tabText = indicator.tabId.map { String($0.uuidString.prefix(5)) } ?? "end" + return "\(tabText):\(indicator.edge == .top ? "top" : "bottom")" + } } /// AppKit-level double-click handler for the sidebar title-bar area. diff --git a/Sources/Update/UpdateDelegate.swift b/Sources/Update/UpdateDelegate.swift index 876a7617..32e2304c 100644 --- a/Sources/Update/UpdateDelegate.swift +++ b/Sources/Update/UpdateDelegate.swift @@ -1,6 +1,17 @@ import Sparkle import Cocoa +enum UpdateFeedResolver { + static let fallbackFeedURL = "https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml" + + static func resolvedFeedURLString(infoFeedURL: String?) -> (url: String, isNightly: Bool, usedFallback: Bool) { + guard let infoFeedURL, !infoFeedURL.isEmpty else { + return (fallbackFeedURL, false, true) + } + return (infoFeedURL, infoFeedURL.contains("/nightly/"), false) + } +} + extension UpdateDriver: SPUUpdaterDelegate { func feedURLString(for updater: SPUUpdater) -> String? { #if DEBUG @@ -14,12 +25,11 @@ extension UpdateDriver: SPUUpdaterDelegate { // The feed URL is baked into Info.plist at build time: // - Stable releases use the stable appcast URL // - cmux NIGHTLY has the nightly appcast URL injected by CI - let feedURL = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String - let isNightly = feedURL?.contains("/nightly/") == true - UpdateLogStore.shared.append("update channel: \(isNightly ? "nightly" : "stable")") - let usedFallback = feedURL == nil || feedURL?.isEmpty == true - recordFeedURLString(feedURL ?? "https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml", usedFallback: usedFallback) - return feedURL + let infoFeedURL = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String + let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeedURL) + UpdateLogStore.shared.append("update channel: \(resolved.isNightly ? "nightly" : "stable")") + recordFeedURLString(resolved.url, usedFallback: resolved.usedFallback) + return infoFeedURL } /// Called when an update is scheduled to install silently, diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 40662dab..0060171f 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -390,54 +390,26 @@ final class AppearanceSettingsTests: XCTestCase { } } -final class UpdateChannelSettingsTests: XCTestCase { - func testDefaultNightlyPreferenceIsDisabled() { - XCTAssertFalse(UpdateChannelSettings.defaultIncludeNightlyBuilds) - } - +final class UpdateFeedResolverTests: XCTestCase { func testResolvedFeedFallsBackToStableWhenInfoFeedMissing() { - let suiteName = "UpdateChannelSettingsTests.MissingInfo.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: nil, defaults: defaults) - XCTAssertEqual(resolved.url, UpdateChannelSettings.stableFeedURL) + let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: nil) + XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL) XCTAssertFalse(resolved.isNightly) XCTAssertTrue(resolved.usedFallback) } func testResolvedFeedUsesInfoFeedForStableChannel() { - let suiteName = "UpdateChannelSettingsTests.InfoFeed.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - let infoFeed = "https://example.com/custom/appcast.xml" - let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: infoFeed, defaults: defaults) + let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed) XCTAssertEqual(resolved.url, infoFeed) XCTAssertFalse(resolved.isNightly) XCTAssertFalse(resolved.usedFallback) } - func testResolvedFeedUsesNightlyWhenPreferenceEnabled() { - let suiteName = "UpdateChannelSettingsTests.Nightly.\(UUID().uuidString)" - guard let defaults = UserDefaults(suiteName: suiteName) else { - XCTFail("Failed to create isolated UserDefaults suite") - return - } - defer { defaults.removePersistentDomain(forName: suiteName) } - - defaults.set(true, forKey: UpdateChannelSettings.includeNightlyBuildsKey) - let resolved = UpdateChannelSettings.resolvedFeedURLString( - infoFeedURL: "https://example.com/custom/appcast.xml", - defaults: defaults - ) - XCTAssertEqual(resolved.url, UpdateChannelSettings.nightlyFeedURL) + func testResolvedFeedDetectsNightlyChannelFromInfoFeed() { + let infoFeed = "https://example.com/nightly/appcast.xml" + let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed) + XCTAssertEqual(resolved.url, infoFeed) XCTAssertTrue(resolved.isNightly) XCTAssertFalse(resolved.usedFallback) } @@ -781,6 +753,54 @@ final class SidebarDropPlannerTests: XCTestCase { } } +final class SidebarOutsideDropResetPolicyTests: XCTestCase { + func testOutsideDropResetsOnlyWhenDragIsActiveAndPayloadMatches() { + let tabId = UUID() + + XCTAssertTrue( + SidebarOutsideDropResetPolicy.shouldResetDrag( + draggedTabId: tabId, + hasSidebarDragPayload: true + ) + ) + XCTAssertFalse( + SidebarOutsideDropResetPolicy.shouldResetDrag( + draggedTabId: nil, + hasSidebarDragPayload: true + ) + ) + XCTAssertFalse( + SidebarOutsideDropResetPolicy.shouldResetDrag( + draggedTabId: tabId, + hasSidebarDragPayload: false + ) + ) + } +} + +final class SidebarDragFailsafePolicyTests: XCTestCase { + func testRequestsClearOnlyWhenDragIsActiveAndMouseIsUp() { + XCTAssertTrue( + SidebarDragFailsafePolicy.shouldRequestClear( + isDragActive: true, + isLeftMouseButtonDown: false + ) + ) + XCTAssertFalse( + SidebarDragFailsafePolicy.shouldRequestClear( + isDragActive: true, + isLeftMouseButtonDown: true + ) + ) + XCTAssertFalse( + SidebarDragFailsafePolicy.shouldRequestClear( + isDragActive: false, + isLeftMouseButtonDown: false + ) + ) + } +} + final class SidebarDragAutoScrollPlannerTests: XCTestCase { func testAutoScrollPlanTriggersNearTopAndBottomOnly() { let topPlan = SidebarDragAutoScrollPlanner.plan(distanceToTop: 4, distanceToBottom: 96, edgeInset: 44, minStep: 2, maxStep: 12) From 573cec4a75c79c520dfbec673aaff68b5779229a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:58:58 -0800 Subject: [PATCH 8/9] Fix inconsistent Tab/Workspace terminology in settings and menus (#187) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify user-facing labels to consistently use "Workspace" instead of mixing "Tab" and "Workspace": - Settings: "New Tab" → "New Workspace" (keyboard shortcuts section) - Menu: "Tab 1"–"Tab 9" → "Workspace 1"–"Workspace 9" (Cmd+1–9) - Sidebar context menu: "Close Tabs" → "Close Workspaces", "Close Tabs Below/Above" → "Close Workspaces Below/Above" Fixes https://github.com/manaflow-ai/cmux/issues/182 Co-authored-by: austinpower1258 --- Sources/ContentView.swift | 6 +++--- Sources/KeyboardShortcutSettings.swift | 2 +- Sources/cmuxApp.swift | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 6c35a10b..e5b5bea3 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1988,7 +1988,7 @@ private struct TabItemView: View { Divider() - Button("Close Tabs") { + Button("Close Workspaces") { closeTabs(targetIds, allowPinned: true) } .disabled(targetIds.isEmpty) @@ -1998,12 +1998,12 @@ private struct TabItemView: View { } .disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count) - Button("Close Tabs Below") { + Button("Close Workspaces Below") { closeTabsBelow(tabId: tab.id) } .disabled(index >= tabManager.tabs.count - 1) - Button("Close Tabs Above") { + Button("Close Workspaces Above") { closeTabsAbove(tabId: tab.id) } .disabled(index == 0) diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 82a7fe84..6ecc0e6c 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -35,7 +35,7 @@ enum KeyboardShortcutSettings { var label: String { switch self { case .toggleSidebar: return "Toggle Sidebar" - case .newTab: return "New Tab" + case .newTab: return "New Workspace" case .newWindow: return "New Window" case .showNotifications: return "Show Notifications" case .jumpToUnread: return "Jump to Latest Unread" diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index f0966a8c..3a2e5264 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -467,7 +467,7 @@ struct cmuxApp: App { // Cmd+1 through Cmd+9 for workspace selection (9 = last workspace) ForEach(1...9, id: \.self) { number in - Button("Tab \(number)") { + Button("Workspace \(number)") { let manager = (AppDelegate.shared?.tabManager ?? tabManager) if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: number, workspaceCount: manager.tabs.count) { manager.selectTab(at: targetIndex) From df9ba6dcd92c520ea9cc463e81b2fa5a6b810fb1 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 20 Feb 2026 15:17:00 -0800 Subject: [PATCH 9/9] Fix #155: remap-aware bonsplit tooltips + browser split shortcuts (#200) * Issue #155: remap bonsplit tooltips and add browser split shortcuts * Fix split button mousedown feedback regression * Match split button sizing with main --- Sources/AppDelegate.swift | 44 ++++++++++++ Sources/KeyboardShortcutSettings.swift | 12 ++++ Sources/TabManager.swift | 37 +++++++++- Sources/Workspace.swift | 16 +++++ Sources/cmuxApp.swift | 31 +++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 68 +++++++++++++++++++ vendor/bonsplit | 2 +- web/app/keyboard-shortcuts.tsx | 14 +++- 8 files changed, 219 insertions(+), 5 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d353ee7d..77645d1f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -187,6 +187,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var workspaceObserver: NSObjectProtocol? private var windowKeyObserver: NSObjectProtocol? private var shortcutMonitor: Any? + private var shortcutDefaultsObserver: NSObjectProtocol? private var ghosttyConfigObserver: NSObjectProtocol? private var ghosttyGotoSplitLeftShortcut: StoredShortcut? private var ghosttyGotoSplitRightShortcut: StoredShortcut? @@ -336,6 +337,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent installWindowKeyEquivalentSwizzle() installBrowserAddressBarFocusObservers() installShortcutMonitor() + installShortcutDefaultsObserver() NSApp.servicesProvider = self #if DEBUG UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) @@ -1460,6 +1462,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + private func installShortcutDefaultsObserver() { + guard shortcutDefaultsObserver == nil else { return } + shortcutDefaultsObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.refreshSplitButtonTooltipsAcrossWorkspaces() + } + } + + private func refreshSplitButtonTooltipsAcrossWorkspaces() { + var refreshedManagers: Set = [] + if let manager = tabManager { + manager.refreshSplitButtonTooltips() + refreshedManagers.insert(ObjectIdentifier(manager)) + } + for context in mainWindowContexts.values { + let manager = context.tabManager + let identifier = ObjectIdentifier(manager) + guard refreshedManagers.insert(identifier).inserted else { continue } + manager.refreshSplitButtonTooltips() + } + } + private func installGhosttyConfigObserver() { guard ghosttyConfigObserver == nil else { return } ghosttyConfigObserver = NotificationCenter.default.addObserver( @@ -1861,6 +1888,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserRight)) { + _ = performBrowserSplitShortcut(direction: .right) + return true + } + + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserDown)) { + _ = performBrowserSplitShortcut(direction: .down) + return true + } + // Surface navigation (legacy Ctrl+Tab support) if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: false, option: false, control: true)) { tabManager?.selectNextSurface() @@ -2041,6 +2078,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + @discardableResult + func performBrowserSplitShortcut(direction: SplitDirection) -> Bool { + guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { return false } + _ = focusBrowserAddressBar(panelId: panelId) + return true + } + /// Allow AppKit-backed browser surfaces (WKWebView) to route non-menu shortcuts /// through the same app-level shortcut handler used by the local key monitor. @discardableResult diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 6ecc0e6c..689b1161 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -26,6 +26,8 @@ enum KeyboardShortcutSettings { case focusDown case splitRight case splitDown + case splitBrowserRight + case splitBrowserDown // Panels case openBrowser @@ -51,6 +53,8 @@ enum KeyboardShortcutSettings { case .focusDown: return "Focus Pane Down" case .splitRight: return "Split Right" case .splitDown: return "Split Down" + case .splitBrowserRight: return "Split Browser Right" + case .splitBrowserDown: return "Split Browser Down" case .openBrowser: return "Open Browser" } } @@ -71,6 +75,8 @@ enum KeyboardShortcutSettings { case .focusDown: return "shortcut.focusDown" case .splitRight: return "shortcut.splitRight" case .splitDown: return "shortcut.splitDown" + case .splitBrowserRight: return "shortcut.splitBrowserRight" + case .splitBrowserDown: return "shortcut.splitBrowserDown" case .nextSurface: return "shortcut.nextSurface" case .prevSurface: return "shortcut.prevSurface" case .newSurface: return "shortcut.newSurface" @@ -108,6 +114,10 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "d", command: true, shift: false, option: false, control: false) case .splitDown: return StoredShortcut(key: "d", command: true, shift: true, option: false, control: false) + case .splitBrowserRight: + return StoredShortcut(key: "d", command: true, shift: false, option: true, control: false) + case .splitBrowserDown: + return StoredShortcut(key: "d", command: true, shift: true, option: true, control: false) case .nextSurface: return StoredShortcut(key: "]", command: true, shift: true, option: false, control: false) case .prevSurface: @@ -176,6 +186,8 @@ enum KeyboardShortcutSettings { static func splitRightShortcut() -> StoredShortcut { shortcut(for: .splitRight) } static func splitDownShortcut() -> StoredShortcut { shortcut(for: .splitDown) } + static func splitBrowserRightShortcut() -> StoredShortcut { shortcut(for: .splitBrowserRight) } + static func splitBrowserDownShortcut() -> StoredShortcut { shortcut(for: .splitBrowserDown) } static func nextSurfaceShortcut() -> StoredShortcut { shortcut(for: .nextSurface) } static func prevSurfaceShortcut() -> StoredShortcut { shortcut(for: .prevSurface) } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index f7585f2b..5dd3cfc3 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1253,6 +1253,28 @@ class TabManager: ObservableObject { _ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) } + /// Create a new browser split from the currently focused panel. + @discardableResult + func createBrowserSplit(direction: SplitDirection, url: URL? = nil) -> UUID? { + guard let selectedTabId, + let tab = tabs.first(where: { $0.id == selectedTabId }), + let focusedPanelId = tab.focusedPanelId else { return nil } + return newBrowserSplit( + tabId: selectedTabId, + fromPanelId: focusedPanelId, + orientation: direction.orientation, + insertFirst: direction.insertFirst, + url: url + ) + } + + /// Refresh Bonsplit right-side action button tooltips for all workspaces. + func refreshSplitButtonTooltips() { + for workspace in tabs { + workspace.refreshSplitButtonTooltips() + } + } + // MARK: - Pane Focus Navigation /// Move focus to an adjacent pane in the specified direction @@ -1393,9 +1415,20 @@ class TabManager: ObservableObject { // MARK: - Browser Panel Operations /// Create a new browser panel in a split - func newBrowserSplit(tabId: UUID, fromPanelId: UUID, orientation: SplitOrientation, url: URL? = nil) -> UUID? { + func newBrowserSplit( + tabId: UUID, + fromPanelId: UUID, + orientation: SplitOrientation, + insertFirst: Bool = false, + url: URL? = nil + ) -> UUID? { guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil } - return tab.newBrowserSplit(from: fromPanelId, orientation: orientation, url: url)?.id + return tab.newBrowserSplit( + from: fromPanelId, + orientation: orientation, + insertFirst: insertFirst, + url: url + )?.id } /// Create a new browser surface in a pane diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 7ed6e468..71d28ded 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -105,12 +105,22 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Initialization + private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips { + BonsplitConfiguration.SplitButtonTooltips( + newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"), + newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"), + splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"), + splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down") + ) + } + private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance { bonsplitAppearance(from: config.backgroundColor) } private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance { BonsplitConfiguration.Appearance( + splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, chromeColors: .init(backgroundHex: backgroundColor.hexString()) ) @@ -208,6 +218,12 @@ final class Workspace: Identifiable, ObservableObject { } } + func refreshSplitButtonTooltips() { + var configuration = bonsplitController.configuration + configuration.appearance.splitButtonTooltips = Self.currentSplitButtonTooltips() + bonsplitController.configuration = configuration + } + // MARK: - Surface ID to Panel ID Mapping /// Mapping from bonsplit TabID (surface ID) to panel UUID diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 3a2e5264..daf9ecd6 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -15,6 +15,8 @@ struct cmuxApp: App { @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey) private var splitBrowserRightShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey) private var splitBrowserDownShortcutData = Data() @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { @@ -463,6 +465,14 @@ struct cmuxApp: App { performSplitFromMenu(direction: .down) } + splitCommandButton(title: "Split Browser Right", shortcut: splitBrowserRightMenuShortcut) { + performBrowserSplitFromMenu(direction: .right) + } + + splitCommandButton(title: "Split Browser Down", shortcut: splitBrowserDownMenuShortcut) { + performBrowserSplitFromMenu(direction: .down) + } + Divider() // Cmd+1 through Cmd+9 for workspace selection (9 = last workspace) @@ -545,6 +555,20 @@ struct cmuxApp: App { decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut) } + private var splitBrowserRightMenuShortcut: StoredShortcut { + decodeShortcut( + from: splitBrowserRightShortcutData, + fallback: KeyboardShortcutSettings.Action.splitBrowserRight.defaultShortcut + ) + } + + private var splitBrowserDownMenuShortcut: StoredShortcut { + decodeShortcut( + from: splitBrowserDownShortcutData, + fallback: KeyboardShortcutSettings.Action.splitBrowserDown.defaultShortcut + ) + } + private var notificationMenuSnapshot: NotificationMenuSnapshot { NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications) } @@ -577,6 +601,13 @@ struct cmuxApp: App { tabManager.createSplit(direction: direction) } + private func performBrowserSplitFromMenu(direction: SplitDirection) { + if AppDelegate.shared?.performBrowserSplitShortcut(direction: direction) == true { + return + } + _ = tabManager.createBrowserSplit(direction: direction) + } + @ViewBuilder private func splitCommandButton(title: String, shortcut: StoredShortcut, action: @escaping () -> Void) -> some View { if let key = keyEquivalent(for: shortcut) { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 0060171f..98f6914a 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -225,6 +225,74 @@ final class ShortcutHintDebugSettingsTests: XCTestCase { } } +final class KeyboardShortcutSettingsTests: XCTestCase { + func testBrowserSplitShortcutDefaults() { + let keys = [ + KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey, + KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey + ] + let defaults = UserDefaults.standard + let previousValues = keys.map { key in (key, defaults.data(forKey: key)) } + defer { + for (key, value) in previousValues { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + keys.forEach { defaults.removeObject(forKey: $0) } + + XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .splitBrowserRight).displayString, "⌥⌘D") + XCTAssertEqual(KeyboardShortcutSettings.shortcut(for: .splitBrowserDown).displayString, "⌥⇧⌘D") + } + + @MainActor + func testWorkspaceConfiguresSplitButtonTooltipsWithEffectiveShortcuts() throws { + let keys = [ + KeyboardShortcutSettings.Action.newSurface.defaultsKey, + KeyboardShortcutSettings.Action.openBrowser.defaultsKey, + KeyboardShortcutSettings.Action.splitRight.defaultsKey, + KeyboardShortcutSettings.Action.splitDown.defaultsKey + ] + let defaults = UserDefaults.standard + let previousValues = keys.map { key in (key, defaults.data(forKey: key)) } + defer { + for (key, value) in previousValues { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + + let customPairs: [(KeyboardShortcutSettings.Action, StoredShortcut)] = [ + (.newSurface, StoredShortcut(key: "1", command: true, shift: false, option: false, control: false)), + (.openBrowser, StoredShortcut(key: "2", command: true, shift: false, option: false, control: false)), + (.splitRight, StoredShortcut(key: "3", command: true, shift: false, option: false, control: false)), + (.splitDown, StoredShortcut(key: "4", command: true, shift: false, option: false, control: false)), + ] + + for (action, shortcut) in customPairs { + guard let data = try? JSONEncoder().encode(shortcut) else { + XCTFail("Failed to encode shortcut for \(action.rawValue)") + return + } + defaults.set(data, forKey: action.defaultsKey) + } + + let workspace = Workspace(title: "Tooltip Test") + let tooltips = workspace.bonsplitController.configuration.appearance.splitButtonTooltips + + XCTAssertEqual(tooltips.newTerminal, "New Terminal (⌘1)") + XCTAssertEqual(tooltips.newBrowser, "New Browser (⌘2)") + XCTAssertEqual(tooltips.splitRight, "Split Right (⌘3)") + XCTAssertEqual(tooltips.splitDown, "Split Down (⌘4)") + } +} + final class ShortcutHintLanePlannerTests: XCTestCase { func testAssignLanesKeepsSeparatedIntervalsOnSingleLane() { let intervals: [ClosedRange] = [0...20, 28...40, 48...64] diff --git a/vendor/bonsplit b/vendor/bonsplit index ae234a22..6ac667d3 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit ae234a227cb77cc4f34e28098a565e987ca23d87 +Subproject commit 6ac667d3a9c359b84f920eac4a2ffb027e3bf745 diff --git a/web/app/keyboard-shortcuts.tsx b/web/app/keyboard-shortcuts.tsx index e70d174a..f4c483c0 100644 --- a/web/app/keyboard-shortcuts.tsx +++ b/web/app/keyboard-shortcuts.tsx @@ -80,6 +80,16 @@ const CATEGORIES: ShortcutCategory[] = [ combos: [["⌥", "⌘", "←/→/↑/↓"]], description: "Focus pane directionally", }, + { + id: "sp-browser-right", + combos: [["⌥", "⌘", "D"]], + description: "Split browser right", + }, + { + id: "sp-browser-down", + combos: [["⌥", "⌘", "⇧", "D"]], + description: "Split browser down", + }, ], }, { @@ -88,8 +98,8 @@ const CATEGORIES: ShortcutCategory[] = [ shortcuts: [ { id: "br-open", - combos: [["⌘", "⇧", "B"]], - description: "Open browser in split", + combos: [["⌘", "⇧", "L"]], + description: "Open browser surface", }, { id: "br-addr", combos: [["⌘", "L"]], description: "Focus address bar" }, { id: "br-forward", combos: [["⌘", "]"]], description: "Forward" },