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() {
| Mode |
Description |
+ How to enable |
@@ -85,24 +97,31 @@ export default function ApiPage() {
Off
Socket disabled |
+ Settings 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" },