Merge origin/main into issue-180-http-nonsecure-hosts

This commit is contained in:
Lawrence Chen 2026-02-20 15:27:59 -08:00
commit c9cafc0806
19 changed files with 887 additions and 162 deletions

View file

@ -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 {

View file

@ -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
{

View file

@ -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

View file

@ -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<ObjectIdentifier> = []
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

View file

@ -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 {
@ -411,6 +419,7 @@ struct ContentView: View {
@State private var workspaceHandoffGeneration: UInt64 = 0
@State private var workspaceHandoffFallbackTask: Task<Void, Never>?
@State private var titlebarThemeGeneration: UInt64 = 0
@State private var sidebarDraggedTabId: UUID?
private var sidebarView: some View {
VerticalTabsSidebar(
@ -523,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
@ -651,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
@ -663,7 +679,7 @@ struct ContentView: View {
if sidebarState.isVisible {
sidebarView
}
terminalContent
terminalContentWithSidebarDropOverlay
}
}
}
@ -772,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
}
@ -1031,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?
@ -1114,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))
}
}
@ -1161,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
@ -1725,7 +1988,7 @@ private struct TabItemView: View {
Divider()
Button("Close Tabs") {
Button("Close Workspaces") {
closeTabs(targetIds, allowPinned: true)
}
.disabled(targetIds.isEmpty)
@ -1735,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)
@ -2386,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
}
@ -2394,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)
}
@ -2406,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,
@ -2415,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]
@ -2453,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.

View file

@ -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)
}

View file

@ -26,6 +26,8 @@ enum KeyboardShortcutSettings {
case focusDown
case splitRight
case splitDown
case splitBrowserRight
case splitBrowserDown
// Panels
case openBrowser
@ -35,7 +37,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"
@ -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) }

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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,11 +465,19 @@ 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)
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)
@ -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) {

View file

@ -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<CGFloat>] = [0...20, 28...40, 48...64]
@ -390,75 +458,26 @@ final class AppearanceSettingsTests: XCTestCase {
}
}
// Compatibility shim for update-channel tests while feed selection is sourced from Info.plist.
private enum UpdateChannelSettings {
static let includeNightlyBuildsKey = "includeNightlyBuilds"
static let defaultIncludeNightlyBuilds = false
static let stableFeedURL = "https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml"
static let nightlyFeedURL = "https://github.com/manaflow-ai/cmux/releases/download/nightly/appcast.xml"
static func resolvedFeedURLString(infoFeedURL: String?, defaults: UserDefaults) -> (url: String, isNightly: Bool, usedFallback: Bool) {
let includeNightlyBuilds = defaults.object(forKey: includeNightlyBuildsKey) as? Bool ?? defaultIncludeNightlyBuilds
if includeNightlyBuilds {
return (nightlyFeedURL, true, false)
}
if let infoFeedURL, !infoFeedURL.isEmpty {
return (infoFeedURL, false, false)
}
return (stableFeedURL, false, true)
}
}
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)
}
@ -802,6 +821,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)

View file

@ -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

View file

@ -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 ""

View file

@ -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:

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit ae234a227cb77cc4f34e28098a565e987ca23d87
Subproject commit 6ac667d3a9c359b84f920eac4a2ffb027e3bf745

View file

@ -61,15 +61,26 @@ export default function ApiPage() {
<code>/tmp/cmux-debug.sock</code>
</td>
</tr>
<tr>
<td>Tagged debug build</td>
<td>
<code>/tmp/cmux-debug-&lt;tag&gt;.sock</code>
</td>
</tr>
</tbody>
</table>
<p>
Override with the <code>CMUX_SOCKET_PATH</code> environment variable.
Commands are newline-terminated JSON:
Send one newline-terminated JSON request per call:
</p>
<CodeBlock lang="json">{`{"command": "command-name", "arg1": "value1"}
<CodeBlock lang="json">{`{"id":"req-1","method":"workspace.list","params":{}}
// Response:
{"success": true, "data": {...}}`}</CodeBlock>
{"id":"req-1","ok":true,"result":{"workspaces":[...]}}`}</CodeBlock>
<Callout>
JSON socket requests must use <code>method</code> and{" "}
<code>params</code>. Legacy v1 JSON payloads such as{" "}
<code>{`{"command":"..."}`}</code> are not supported.
</Callout>
<h2>Access modes</h2>
<table>
@ -77,6 +88,7 @@ export default function ApiPage() {
<tr>
<th>Mode</th>
<th>Description</th>
<th>How to enable</th>
</tr>
</thead>
<tbody>
@ -85,24 +97,31 @@ export default function ApiPage() {
<strong>Off</strong>
</td>
<td>Socket disabled</td>
<td>Settings UI or <code>CMUX_SOCKET_MODE=off</code></td>
</tr>
<tr>
<td>
<strong>Notifications only</strong>
<strong>cmux processes only</strong>
</td>
<td>Only notification commands allowed</td>
<td>
Only processes spawned inside cmux terminals can connect.
</td>
<td>Default mode in Settings UI</td>
</tr>
<tr>
<td>
<strong>Full control</strong>
<strong>allowAll</strong>
</td>
<td>Allow any local process to connect (no ancestry check).</td>
<td>
Environment override only: <code>CMUX_SOCKET_MODE=allowAll</code>
</td>
<td>All commands enabled</td>
</tr>
</tbody>
</table>
<Callout type="warn">
On shared machines, use &ldquo;Notifications only&rdquo; mode to prevent
other users from controlling your terminals.
On shared machines, use <strong>Off</strong> or{" "}
<strong>cmux processes only</strong>.
</Callout>
<h2>CLI options</h2>
@ -126,6 +145,12 @@ export default function ApiPage() {
</td>
<td>Output in JSON format</td>
</tr>
<tr>
<td>
<code>--window ID</code>
</td>
<td>Target a specific window</td>
</tr>
<tr>
<td>
<code>--workspace ID</code>
@ -138,6 +163,12 @@ export default function ApiPage() {
</td>
<td>Target a specific surface</td>
</tr>
<tr>
<td>
<code>--id-format refs|uuids|both</code>
</td>
<td>Control identifier format in JSON output</td>
</tr>
</tbody>
</table>
@ -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":{}}`}
/>
<Cmd
name="new-workspace"
desc="Create a new workspace."
cli={`cmux new-workspace`}
socket={`{"command": "new-workspace"}`}
socket={`{"id":"ws-new","method":"workspace.create","params":{}}`}
/>
<Cmd
name="select-workspace"
desc="Switch to a specific workspace."
cli={`cmux select-workspace --workspace <id>`}
socket={`{"command": "select-workspace", "id": "<id>"}`}
socket={`{"id":"ws-select","method":"workspace.select","params":{"workspace_id":"<id>"}}`}
/>
<Cmd
name="current-workspace"
desc="Get the currently active workspace."
cli={`cmux current-workspace
cmux current-workspace --json`}
socket={`{"command": "current-workspace"}`}
socket={`{"id":"ws-current","method":"workspace.current","params":{}}`}
/>
<Cmd
name="close-workspace"
desc="Close a workspace."
cli={`cmux close-workspace --workspace <id>`}
socket={`{"command": "close-workspace", "id": "<id>"}`}
socket={`{"id":"ws-close","method":"workspace.close","params":{"workspace_id":"<id>"}}`}
/>
<h2>Split commands</h2>
@ -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"}}`}
/>
<Cmd
name="list-surfaces"
desc="List all surfaces in the current workspace."
cli={`cmux list-surfaces
cmux list-surfaces --json`}
socket={`{"command": "list-surfaces"}`}
socket={`{"id":"surface-list","method":"surface.list","params":{}}`}
/>
<Cmd
name="focus-surface"
desc="Focus a specific surface."
cli={`cmux focus-surface --surface <id>`}
socket={`{"command": "focus-surface", "id": "<id>"}`}
socket={`{"id":"surface-focus","method":"surface.focus","params":{"surface_id":"<id>"}}`}
/>
<h2>Input commands</h2>
@ -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"}}`}
/>
<Cmd
name="send-key"
desc="Send a key press. Keys: enter, tab, escape, backspace, delete, up, down, left, right."
cli={`cmux send-key enter`}
socket={`{"command": "send-key", "key": "enter"}`}
socket={`{"id":"send-key","method":"surface.send_key","params":{"key":"enter"}}`}
/>
<Cmd
name="send-surface"
desc="Send text to a specific surface."
cli={`cmux send-surface --surface <id> "command"`}
socket={`{"command": "send-surface", "id": "<id>", "text": "command"}`}
socket={`{"id":"send-surface","method":"surface.send_text","params":{"surface_id":"<id>","text":"command"}}`}
/>
<Cmd
name="send-key-surface"
desc="Send a key press to a specific surface."
cli={`cmux send-key-surface --surface <id> enter`}
socket={`{"command": "send-key-surface", "id": "<id>", "key": "enter"}`}
socket={`{"id":"send-key-surface","method":"surface.send_key","params":{"surface_id":"<id>","key":"enter"}}`}
/>
<h2>Notification commands</h2>
@ -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"}}`}
/>
<Cmd
name="list-notifications"
desc="List all notifications."
cli={`cmux list-notifications
cmux list-notifications --json`}
socket={`{"command": "list-notifications"}`}
socket={`{"id":"notif-list","method":"notification.list","params":{}}`}
/>
<Cmd
name="clear-notifications"
desc="Clear all notifications."
cli={`cmux clear-notifications`}
socket={`{"command": "clear-notifications"}`}
socket={`{"id":"notif-clear","method":"notification.clear","params":{}}`}
/>
<h2>Utility commands</h2>
@ -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}}`}
/>
<Cmd
name="capabilities"
desc="List available socket methods and current access mode."
cli={`cmux capabilities
cmux capabilities --json`}
socket={`{"id":"caps","method":"system.capabilities","params":{}}`}
/>
<Cmd
name="identify"
desc="Show focused window/workspace/pane/surface context."
cli={`cmux identify
cmux identify --json`}
socket={`{"id":"identify","method":"system.identify","params":{}}`}
/>
<h2>Environment variables</h2>
@ -274,14 +318,16 @@ cmux list-notifications --json`}
<td>
<code>CMUX_SOCKET_PATH</code>
</td>
<td>Override the default socket path</td>
<td>Override the socket path used by CLI and integrations</td>
</tr>
<tr>
<td>
<code>CMUX_SOCKET_ENABLE</code>
</td>
<td>
Enable/disable socket (<code>1</code>/<code>0</code>)
Force-enable/disable socket (<code>1</code>/<code>0</code>,{" "}
<code>true</code>/<code>false</code>, <code>on</code>/
<code>off</code>)
</td>
</tr>
<tr>
@ -289,8 +335,10 @@ cmux list-notifications --json`}
<code>CMUX_SOCKET_MODE</code>
</td>
<td>
Override access mode (<code>full</code>,{" "}
<code>notifications</code>, <code>off</code>)
Override access mode (<code>cmuxOnly</code>,{" "}
<code>allowAll</code>, <code>off</code>). Also accepts{" "}
<code>cmux-only</code>/<code>cmux_only</code> and{" "}
<code>allow-all</code>/<code>allow_all</code>
</td>
</tr>
<tr>
@ -324,51 +372,60 @@ cmux list-notifications --json`}
</tbody>
</table>
<Callout>
Environment variables override app settings. Use the socket check to
distinguish cmux from regular Ghostty.
Legacy <code>CMUX_SOCKET_MODE</code> values <code>full</code> and{" "}
<code>notifications</code> are still accepted for compatibility.
</Callout>
<h2>Detecting cmux</h2>
<CodeBlock title="bash" lang="bash">{`# Check for the socket
[ -S /tmp/cmux.sock ] && echo "In cmux"
<CodeBlock title="bash" lang="bash">{`# 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"`}</CodeBlock>
[ "$TERM_PROGRAM" = "ghostty" ] && [ -n "\${CMUX_WORKSPACE_ID:-}" ] && echo "In cmux"`}</CodeBlock>
<h2>Examples</h2>
<h3>Python client</h3>
<CodeBlock title="python" lang="python">{`import socket, json
<CodeBlock title="python" lang="python">{`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!"
})`}</CodeBlock>
print(rpc(
"notification.create",
{"title": "Hello", "body": "From Python!"},
req_id="notify"
))`}</CodeBlock>
<h3>Shell script</h3>
<CodeBlock title="bash" lang="bash">{`#!/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"}'`}</CodeBlock>
cmux_cmd '{"id":"ws","method":"workspace.list","params":{}}'
cmux_cmd '{"id":"notify","method":"notification.create","params":{"title":"Done","body":"Task complete"}}'`}</CodeBlock>
<h3>Build script with notification</h3>
<CodeBlock title="bash" lang="bash">{`#!/bin/bash

View file

@ -95,15 +95,17 @@ working-directory = ~/Projects`}</CodeBlock>
<strong>Off</strong> no socket control (most secure)
</li>
<li>
<strong>Notifications only</strong> only allow notification commands
<strong>cmux processes only</strong> only allow processes started
inside cmux terminals to connect
</li>
<li>
<strong>Full control</strong> allow all socket commands
<strong>allowAll</strong> allow any local process to connect (
<code>CMUX_SOCKET_MODE=allowAll</code>, env override only)
</li>
</ul>
<Callout type="warn">
On shared machines, consider using &ldquo;Notifications only&rdquo; mode
to prevent other processes from controlling your terminals.
On shared machines, consider using &ldquo;Off&rdquo; or
&ldquo;cmux processes only&rdquo; mode.
</Callout>
<h2>Example config</h2>

View file

@ -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" },