From 1f0b3e04264f9f92b2b5c84e13430e3a3a2e7a76 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 22:01:22 -0700 Subject: [PATCH 01/46] Add terminal web sidekick drawer --- Resources/Localizable.xcstrings | 6 + Sources/AppDelegate.swift | 27 +++ Sources/KeyboardShortcutContext.swift | 2 +- Sources/KeyboardShortcutSettings.swift | 4 + Sources/Panels/TerminalPanel.swift | 160 +++++++++++++ Sources/Panels/TerminalPanelView.swift | 218 ++++++++++++++++++ Sources/SessionPersistence.swift | 21 ++ Sources/Workspace.swift | 4 +- .../AppDelegateShortcutRoutingTests.swift | 34 ++- .../TabManagerSessionSnapshotTests.swift | 32 +++ cmuxTests/WorkspaceUnitTests.swift | 16 ++ web/data/cmux-shortcuts.ts | 6 + 12 files changed, 526 insertions(+), 4 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index f51105c327ad..55e4ea393d56 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -7,6 +7,12 @@ "menu.view.toggleRightSidebar": { "localizations": {"ar":{"stringUnit":{"state":"translated","value":"تبديل الشريط الجانبي الأيمن"}},"bs":{"stringUnit":{"state":"translated","value":"Prebaci desnu bočnu traku"}},"da":{"stringUnit":{"state":"translated","value":"Slå højre sidebjælke til/fra"}},"de":{"stringUnit":{"state":"translated","value":"Rechte Seitenleiste ein-/ausblenden"}},"en":{"stringUnit":{"state":"translated","value":"Toggle Right Sidebar"}},"es":{"stringUnit":{"state":"translated","value":"Alternar barra lateral derecha"}},"fr":{"stringUnit":{"state":"translated","value":"Afficher/masquer la barre latérale droite"}},"it":{"stringUnit":{"state":"translated","value":"Attiva/Disattiva barra laterale destra"}},"ja":{"stringUnit":{"state":"translated","value":"右サイドバーを切り替え"}},"ko":{"stringUnit":{"state":"translated","value":"오른쪽 사이드바 전환"}},"nb":{"stringUnit":{"state":"translated","value":"Slå høyre sidepanel av/på"}},"pl":{"stringUnit":{"state":"translated","value":"Przełącz prawy pasek boczny"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Alternar barra lateral direita"}},"ru":{"stringUnit":{"state":"translated","value":"Переключить правую боковую панель"}},"th":{"stringUnit":{"state":"translated","value":"สลับแถบด้านข้างขวา"}},"tr":{"stringUnit":{"state":"translated","value":"Sağ Kenar Çubuğunu Aç/Kapat"}},"uk":{"stringUnit":{"state":"translated","value":"Перемкнути праву бічну панель"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"切换右侧边栏"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"切換右側邊欄"}}} }, "shortcut.toggleLeftSidebar.label": { "localizations": {"ar":{"stringUnit":{"state":"translated","value":"تبديل الشريط الجانبي الأيسر"}},"bs":{"stringUnit":{"state":"translated","value":"Prebaci lijevu bočnu traku"}},"da":{"stringUnit":{"state":"translated","value":"Slå venstre sidebjælke til/fra"}},"de":{"stringUnit":{"state":"translated","value":"Linke Seitenleiste ein-/ausblenden"}},"en":{"stringUnit":{"state":"translated","value":"Toggle Left Sidebar"}},"es":{"stringUnit":{"state":"translated","value":"Alternar barra lateral izquierda"}},"fr":{"stringUnit":{"state":"translated","value":"Afficher/masquer la barre latérale gauche"}},"it":{"stringUnit":{"state":"translated","value":"Attiva/Disattiva barra laterale sinistra"}},"ja":{"stringUnit":{"state":"translated","value":"左サイドバーを切り替え"}},"ko":{"stringUnit":{"state":"translated","value":"왼쪽 사이드바 전환"}},"nb":{"stringUnit":{"state":"translated","value":"Slå venstre sidepanel av/på"}},"pl":{"stringUnit":{"state":"translated","value":"Przełącz lewy pasek boczny"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Alternar barra lateral esquerda"}},"ru":{"stringUnit":{"state":"translated","value":"Переключить левую боковую панель"}},"th":{"stringUnit":{"state":"translated","value":"สลับแถบด้านข้างซ้าย"}},"tr":{"stringUnit":{"state":"translated","value":"Sol Kenar Çubuğunu Aç/Kapat"}},"uk":{"stringUnit":{"state":"translated","value":"Перемкнути ліву бічну панель"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"切换左侧边栏"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"切換左側邊欄"}}} }, "shortcut.toggleRightSidebar.label": { "localizations": {"ar":{"stringUnit":{"state":"translated","value":"تبديل الشريط الجانبي الأيمن"}},"bs":{"stringUnit":{"state":"translated","value":"Prebaci desnu bočnu traku"}},"da":{"stringUnit":{"state":"translated","value":"Slå højre sidebjælke til/fra"}},"de":{"stringUnit":{"state":"translated","value":"Rechte Seitenleiste ein-/ausblenden"}},"en":{"stringUnit":{"state":"translated","value":"Toggle Right Sidebar"}},"es":{"stringUnit":{"state":"translated","value":"Alternar barra lateral derecha"}},"fr":{"stringUnit":{"state":"translated","value":"Afficher/masquer la barre latérale droite"}},"it":{"stringUnit":{"state":"translated","value":"Attiva/Disattiva barra laterale destra"}},"ja":{"stringUnit":{"state":"translated","value":"右サイドバーを切り替え"}},"ko":{"stringUnit":{"state":"translated","value":"오른쪽 사이드바 전환"}},"nb":{"stringUnit":{"state":"translated","value":"Slå høyre sidepanel av/på"}},"pl":{"stringUnit":{"state":"translated","value":"Przełącz prawy pasek boczny"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Alternar barra lateral direita"}},"ru":{"stringUnit":{"state":"translated","value":"Переключить правую боковую панель"}},"th":{"stringUnit":{"state":"translated","value":"สลับแถบด้านข้างขวา"}},"tr":{"stringUnit":{"state":"translated","value":"Sağ Kenar Çubuğunu Aç/Kapat"}},"uk":{"stringUnit":{"state":"translated","value":"Перемкнути праву бічну панель"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"切换右侧边栏"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"切換右側邊欄"}}} }, + "shortcut.toggleTerminalSidekick.label": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Toggle Terminal Sidekick" } }, "ja": { "stringUnit": { "state": "translated", "value": "ターミナルサイドキックを切り替え" } } } }, + "terminalSidekick.accessibilityLabel": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Terminal sidekick browser" } }, "ja": { "stringUnit": { "state": "translated", "value": "ターミナルサイドキックブラウザ" } } } }, + "terminalSidekick.addressPlaceholder": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Enter URL or search" } }, "ja": { "stringUnit": { "state": "translated", "value": "URLまたは検索語を入力" } } } }, + "terminalSidekick.addressLabel": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Sidekick address" } }, "ja": { "stringUnit": { "state": "translated", "value": "サイドキックアドレス" } } } }, + "terminalSidekick.open": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Open in Sidekick" } }, "ja": { "stringUnit": { "state": "translated", "value": "サイドキックで開く" } } } }, + "terminalSidekick.collapse": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Collapse Sidekick" } }, "ja": { "stringUnit": { "state": "translated", "value": "サイドキックを折りたたむ" } } } }, "shortcut.commandPaletteNext.label": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "ja": { "stringUnit": { "state": "translated", "value": "コマンドパレット: 次へ" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "ko": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "de": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "es": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "fr": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "it": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "da": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "pl": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "ru": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "bs": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "ar": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "nb": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "th": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "tr": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "uk": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } } } }, "shortcut.commandPalettePrevious.label": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "ja": { "stringUnit": { "state": "translated", "value": "コマンドパレット: 前へ" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "ko": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "de": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "es": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "fr": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "it": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "da": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "pl": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "ru": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "bs": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "ar": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "nb": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "th": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "tr": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "uk": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } } } }, "settings.shortcuts.managedByFile": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "ja": { "stringUnit": { "state": "translated", "value": "cmux.json で管理" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "ko": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "de": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "es": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "fr": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "it": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "da": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "pl": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "ru": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "bs": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "ar": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "nb": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "th": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "tr": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "uk": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } } } }, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index df9d93cfa3c3..8b8a6e585a9c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5472,6 +5472,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + @discardableResult + func toggleTerminalSidekickInActiveMainWindow(preferredWindow: NSWindow? = nil) -> Bool { + guard let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow), + let terminalPanel = context.tabManager.selectedWorkspace?.focusedTerminalPanel else { + return false + } + + let window = context.window ?? windowForMainWindowId(context.windowId) + if let window { + setActiveMainWindow(window) + } + + guard BrowserAvailabilitySettings.isEnabled() else { + NSSound.beep() + return true + } + + return terminalPanel.toggleSidekick() + } + @discardableResult func closeRightSidebarInActiveMainWindow(preferredWindow: NSWindow? = nil) -> Bool { guard let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow) else { @@ -11144,6 +11164,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + if matchConfiguredShortcut(event: event, action: .toggleTerminalSidekick) { + let preferredWindow = mainWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + if toggleTerminalSidekickInActiveMainWindow(preferredWindow: preferredWindow) { + return true + } + } + if matchConfiguredShortcut(event: event, action: .toggleRightSidebar) { // Escape AppKit's performKeyEquivalent animation context. Without // deferring the toggle, NSAnimationContext implicitly animates the diff --git a/Sources/KeyboardShortcutContext.swift b/Sources/KeyboardShortcutContext.swift index 43e9d3f48482..f4bdb267ceff 100644 --- a/Sources/KeyboardShortcutContext.swift +++ b/Sources/KeyboardShortcutContext.swift @@ -51,7 +51,7 @@ extension KeyboardShortcutSettings.Action { switch self { case .switchRightSidebarToFiles, .switchRightSidebarToFind, .switchRightSidebarToSessions, .switchRightSidebarToFeed, .switchRightSidebarToDock: return .rightSidebarFocus - case .renameTab, .renameWorkspace: + case .renameTab, .renameWorkspace, .toggleTerminalSidekick: return .nonBrowserPanel case .browserBack, .browserForward, .browserReload, .toggleBrowserDeveloperTools, .showBrowserJavaScriptConsole, .browserZoomIn, .browserZoomOut, .browserZoomReset: diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index f34776d4fe18..57275336fcdc 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -120,6 +120,7 @@ enum KeyboardShortcutSettings { // Panels case saveFilePreview case openBrowser + case toggleTerminalSidekick case focusBrowserAddressBar case browserBack case browserForward @@ -194,6 +195,7 @@ enum KeyboardShortcutSettings { case .toggleRightSidebar: return String(localized: "shortcut.toggleRightSidebar.label", defaultValue: "Toggle Right Sidebar") case .saveFilePreview: return String(localized: "shortcut.saveFilePreview.label", defaultValue: "Save File Preview") case .openBrowser: return String(localized: "shortcut.openBrowser.label", defaultValue: "Open Browser") + case .toggleTerminalSidekick: return String(localized: "shortcut.toggleTerminalSidekick.label", defaultValue: "Toggle Terminal Sidekick") case .focusBrowserAddressBar: return String(localized: "command.browserFocusAddressBar.title", defaultValue: "Focus Address Bar") case .browserBack: return String(localized: "menu.view.back", defaultValue: "Back") case .browserForward: return String(localized: "menu.view.forward", defaultValue: "Forward") @@ -337,6 +339,8 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "s", command: true, shift: false, option: false, control: false) case .openBrowser: return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false) + case .toggleTerminalSidekick: + return StoredShortcut(key: "b", command: true, shift: false, option: true, control: false) case .focusBrowserAddressBar: return StoredShortcut(key: "l", command: true, shift: false, option: false, control: false) case .browserBack: diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 7ca080d0f53d..9d19e4dc7167 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -3,6 +3,48 @@ import Combine import AppKit import Bonsplit +struct TerminalSidekickState: Codable, Equatable, Sendable { + static let defaultSplitRatio = 0.4 + private static let minimumSplitRatio = 0.25 + private static let maximumSplitRatio = 0.7 + + var urlString: String? + var isOpen: Bool + var splitRatio: Double + + init( + urlString: String? = nil, + isOpen: Bool = false, + splitRatio: Double = Self.defaultSplitRatio + ) { + self.urlString = Self.normalizedURLString(urlString) + self.isOpen = isOpen + self.splitRatio = Self.clampedSplitRatio(splitRatio) + } + + static func clampedSplitRatio(_ value: Double) -> Double { + min(max(value, minimumSplitRatio), maximumSplitRatio) + } + + static func normalizedURLString(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } + + var url: URL? { + urlString.flatMap(URL.init(string:)) + } + + var hasPersistableValue: Bool { + isOpen || + urlString != nil || + abs(splitRatio - Self.defaultSplitRatio) > 0.0001 + } +} + /// TerminalPanel wraps an existing TerminalSurface and conforms to the Panel protocol. /// This allows TerminalSurface to be used within the bonsplit-based layout system. @MainActor @@ -38,6 +80,9 @@ final class TerminalPanel: Panel, ObservableObject { /// (hostedView.window == nil) until the user switches workspaces. @Published var viewReattachToken: UInt64 = 0 + @Published private(set) var sidekickState = TerminalSidekickState() + @Published private(set) var sidekickBrowserPanel: BrowserPanel? + var onRequestWorkspacePaneFlash: ((WorkspaceAttentionFlashReason) -> Void)? private var cancellables = Set() @@ -133,6 +178,119 @@ final class TerminalPanel: Panel, ObservableObject { func updateWorkspaceId(_ newWorkspaceId: UUID) { workspaceId = newWorkspaceId surface.updateWorkspaceId(newWorkspaceId) + sidekickBrowserPanel?.updateWorkspaceId(newWorkspaceId) + } + + @discardableResult + func toggleSidekick() -> Bool { + guard BrowserAvailabilitySettings.isEnabled() else { return false } + if sidekickState.isOpen { + closeSidekick() + } else { + openSidekick() + } + return true + } + + @discardableResult + func openSidekick(url: URL? = nil) -> Bool { + guard BrowserAvailabilitySettings.isEnabled() else { return false } + var next = sidekickState + next.isOpen = true + if let url { + next.urlString = url.absoluteString + } + sidekickState = next + + guard let browserPanel = ensureSidekickBrowserPanel(renderInitialNavigation: url == nil) else { return false } + if let url { + browserPanel.navigate(to: url, recordTypedNavigation: true) + } + return true + } + + func closeSidekick() { + var next = sidekickState + next.urlString = currentSidekickURLString() + next.isOpen = false + sidekickState = next + } + + func navigateSidekick(input: String) { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard openSidekick() else { return } + sidekickBrowserPanel?.navigateSmart(trimmed) + } + + func recordSidekickCurrentURL(_ url: URL?) { + let nextURLString = TerminalSidekickState.normalizedURLString(url?.absoluteString) + guard sidekickState.urlString != nextURLString else { return } + var next = sidekickState + next.urlString = nextURLString + sidekickState = next + } + + func setSidekickSplitRatio(_ splitRatio: Double) { + let clamped = TerminalSidekickState.clampedSplitRatio(splitRatio) + guard sidekickState.splitRatio != clamped else { return } + var next = sidekickState + next.splitRatio = clamped + sidekickState = next + } + + func restoreSidekick(_ snapshot: SessionTerminalSidekickSnapshot?) { + guard let snapshot else { + sidekickState = TerminalSidekickState() + sidekickBrowserPanel?.close() + sidekickBrowserPanel = nil + return + } + + sidekickState = TerminalSidekickState( + urlString: snapshot.urlString, + isOpen: snapshot.isOpen, + splitRatio: snapshot.splitRatio + ) + if sidekickState.isOpen { + _ = ensureSidekickBrowserPanel() + } + } + + func sidekickSessionSnapshot() -> SessionTerminalSidekickSnapshot? { + let urlString = currentSidekickURLString() + let snapshotState = TerminalSidekickState( + urlString: urlString, + isOpen: sidekickState.isOpen, + splitRatio: sidekickState.splitRatio + ) + guard snapshotState.hasPersistableValue else { return nil } + return SessionTerminalSidekickSnapshot( + urlString: snapshotState.urlString, + isOpen: snapshotState.isOpen, + splitRatio: snapshotState.splitRatio + ) + } + + private func currentSidekickURLString() -> String? { + TerminalSidekickState.normalizedURLString( + sidekickBrowserPanel?.preferredURLStringForOmnibar() ?? sidekickState.urlString + ) + } + + @discardableResult + private func ensureSidekickBrowserPanel(renderInitialNavigation: Bool = true) -> BrowserPanel? { + if let sidekickBrowserPanel { + return sidekickBrowserPanel + } + + let browserPanel = BrowserPanel( + workspaceId: workspaceId, + initialURL: sidekickState.url, + renderInitialNavigation: renderInitialNavigation && sidekickState.url != nil + ) + sidekickBrowserPanel = browserPanel + return browserPanel } func updateTmuxLayoutReport(_ report: TmuxPaneLayoutReport?) { @@ -168,6 +326,8 @@ final class TerminalPanel: Panel, ObservableObject { } func close() { + sidekickBrowserPanel?.close() + sidekickBrowserPanel = nil // The surface will be cleaned up by its deinit // Detach from the window portal on real close so stale hosted views // cannot remain above browser panes after split close. diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 250bbb6de459..4e18eb586022 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -2,6 +2,7 @@ import SwiftUI import Foundation import AppKit import Bonsplit +import WebKit /// View for rendering a terminal panel struct TerminalPanelView: View { @@ -19,6 +20,41 @@ struct TerminalPanelView: View { let onTriggerFlash: () -> Void var body: some View { + Group { + if panel.sidekickState.isOpen, let sidekickPanel = panel.sidekickBrowserPanel { + GeometryReader { proxy in + HStack(spacing: 0) { + terminalSurface + .frame( + width: TerminalSidekickLayout.terminalWidth( + totalWidth: proxy.size.width, + splitRatio: panel.sidekickState.splitRatio + ) + ) + Rectangle() + .fill(appearance.dividerColor.opacity(0.9)) + .frame(width: TerminalSidekickLayout.dividerWidth) + TerminalSidekickView( + terminalPanel: panel, + browserPanel: sidekickPanel + ) + .frame( + width: TerminalSidekickLayout.sidekickWidth( + totalWidth: proxy.size.width, + splitRatio: panel.sidekickState.splitRatio + ) + ) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + } else { + terminalSurface + } + } + .animation(.easeInOut(duration: 0.16), value: panel.sidekickState.isOpen) + } + + private var terminalSurface: some View { // Layering contract: terminal find UI is mounted in GhosttySurfaceScrollView (AppKit portal layer) // via `searchState`. Rendering `SurfaceSearchOverlay` in this SwiftUI container can hide it. GhosttyTerminalView( @@ -43,6 +79,188 @@ struct TerminalPanelView: View { } } +private enum TerminalSidekickLayout { + static let dividerWidth: CGFloat = 1 + private static let minimumTerminalWidth: CGFloat = 280 + private static let minimumSidekickWidth: CGFloat = 260 + + static func sidekickWidth(totalWidth: CGFloat, splitRatio: Double) -> CGFloat { + guard totalWidth > dividerWidth else { return 0 } + let availableWidth = max(0, totalWidth - dividerWidth) + let targetWidth = availableWidth * CGFloat(TerminalSidekickState.clampedSplitRatio(splitRatio)) + guard availableWidth >= minimumTerminalWidth + minimumSidekickWidth else { + return max(0, min(availableWidth, targetWidth)) + } + let maximumSidekickWidth = availableWidth - minimumTerminalWidth + return min(maximumSidekickWidth, max(minimumSidekickWidth, targetWidth)) + } + + static func terminalWidth(totalWidth: CGFloat, splitRatio: Double) -> CGFloat { + max(0, totalWidth - dividerWidth - sidekickWidth(totalWidth: totalWidth, splitRatio: splitRatio)) + } +} + +private struct TerminalSidekickView: View { + @ObservedObject var terminalPanel: TerminalPanel + @ObservedObject var browserPanel: BrowserPanel + @State private var addressText = "" + @FocusState private var addressFocused: Bool + + private let toolbarButtonSize: CGFloat = 26 + + var body: some View { + VStack(spacing: 0) { + toolbar + Divider() + content + } + .background(Color(nsColor: .windowBackgroundColor)) + .accessibilityIdentifier("TerminalSidekickDrawer") + .accessibilityLabel(String(localized: "terminalSidekick.accessibilityLabel", defaultValue: "Terminal sidekick browser")) + .onAppear { + syncAddressFromPanel() + } + .onChange(of: browserPanel.currentURL) { _, url in + terminalPanel.recordSidekickCurrentURL(url) + guard !addressFocused else { return } + syncAddressFromPanel() + } + .onChange(of: addressFocused) { _, focused in + if focused { + addressText = browserPanel.preferredURLStringForOmnibar() ?? addressText + } else { + syncAddressFromPanel() + } + } + } + + private var toolbar: some View { + HStack(spacing: 4) { + toolbarButton( + systemName: "chevron.left", + help: String(localized: "browser.goBack", defaultValue: "Go Back"), + action: { browserPanel.goBack() } + ) + .disabled(!browserPanel.canGoBack) + .opacity(browserPanel.canGoBack ? 1 : 0.4) + + toolbarButton( + systemName: "chevron.right", + help: String(localized: "browser.goForward", defaultValue: "Go Forward"), + action: { browserPanel.goForward() } + ) + .disabled(!browserPanel.canGoForward) + .opacity(browserPanel.canGoForward ? 1 : 0.4) + + toolbarButton( + systemName: browserPanel.isLoading ? "xmark" : "arrow.clockwise", + help: browserPanel.isLoading + ? String(localized: "browser.stop", defaultValue: "Stop") + : String(localized: "browser.reload", defaultValue: "Reload"), + action: { + if browserPanel.isLoading { + browserPanel.stopLoading() + } else { + browserPanel.reload() + } + } + ) + + TextField( + String(localized: "terminalSidekick.addressPlaceholder", defaultValue: "Enter URL or search"), + text: $addressText + ) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .focused($addressFocused) + .onSubmit(commitAddress) + .padding(.horizontal, 8) + .frame(height: 24) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(nsColor: .controlBackgroundColor)) + ) + .accessibilityIdentifier("TerminalSidekickAddressField") + .accessibilityLabel(String(localized: "terminalSidekick.addressLabel", defaultValue: "Sidekick address")) + + toolbarButton( + systemName: "arrow.right.circle.fill", + help: String(localized: "terminalSidekick.open", defaultValue: "Open in Sidekick"), + action: commitAddress + ) + .disabled(addressText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .opacity(addressText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? 0.4 : 1) + + toolbarButton( + systemName: "sidebar.trailing", + help: String(localized: "terminalSidekick.collapse", defaultValue: "Collapse Sidekick"), + action: { terminalPanel.closeSidekick() } + ) + } + .padding(.horizontal, 6) + .padding(.vertical, 5) + } + + @ViewBuilder + private var content: some View { + if browserPanel.shouldRenderWebView { + TerminalSidekickWebViewRepresentable(browserPanel: browserPanel) + .id(browserPanel.webViewInstanceID) + .accessibilityIdentifier("TerminalSidekickWebView") + } else { + Color(nsColor: .textBackgroundColor) + .accessibilityIdentifier("TerminalSidekickBlankWebView") + } + } + + private func toolbarButton( + systemName: String, + help: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 12, weight: .medium)) + .frame(width: toolbarButtonSize, height: toolbarButtonSize) + .contentShape(Rectangle()) + } + .buttonStyle(TerminalSidekickToolbarButtonStyle()) + .safeHelp(help) + } + + private func commitAddress() { + terminalPanel.navigateSidekick(input: addressText) + addressFocused = false + } + + private func syncAddressFromPanel() { + addressText = browserPanel.preferredURLStringForOmnibar() ?? "" + } +} + +private struct TerminalSidekickToolbarButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .foregroundStyle(Color.primary.opacity(configuration.isPressed ? 0.65 : 0.9)) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(configuration.isPressed ? Color.primary.opacity(0.12) : Color.clear) + ) + } +} + +private struct TerminalSidekickWebViewRepresentable: NSViewRepresentable { + let browserPanel: BrowserPanel + + func makeNSView(context: Context) -> WKWebView { + browserPanel.webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) { + nsView.allowsBackForwardNavigationGestures = true + } +} + /// Shared appearance settings for panels struct PanelAppearance { let backgroundColor: NSColor diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 66d23de82cc1..18310d7ae632 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -221,11 +221,32 @@ struct SessionGitBranchSnapshot: Codable, Sendable { var isDirty: Bool } +struct SessionTerminalSidekickSnapshot: Codable, Equatable, Sendable { + var urlString: String? + var isOpen: Bool + var splitRatio: Double +} + struct SessionTerminalPanelSnapshot: Codable, Sendable { var workingDirectory: String? var scrollback: String? var agent: SessionRestorableAgentSnapshot? var tmuxStartCommand: String? + var sidekick: SessionTerminalSidekickSnapshot? + + init( + workingDirectory: String?, + scrollback: String?, + agent: SessionRestorableAgentSnapshot?, + tmuxStartCommand: String?, + sidekick: SessionTerminalSidekickSnapshot? = nil + ) { + self.workingDirectory = workingDirectory + self.scrollback = scrollback + self.agent = agent + self.tmuxStartCommand = tmuxStartCommand + self.sidekick = sidekick + } } struct SessionBrowserPanelSnapshot: Codable, Sendable { var urlString: String? diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 18950839e669..30508538ee9d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -459,7 +459,8 @@ extension Workspace { workingDirectory: directory, scrollback: resolvedScrollback, agent: effectiveRestorableAgent, - tmuxStartCommand: restorableTmuxStartCommand + tmuxStartCommand: restorableTmuxStartCommand, + sidekick: terminalPanel.sidekickSessionSnapshot() ) browserSnapshot = nil markdownSnapshot = nil @@ -790,6 +791,7 @@ extension Workspace { } else { restoredTerminalScrollbackByPanelId.removeValue(forKey: terminalPanel.id) } + terminalPanel.restoreSidekick(snapshot.terminal?.sidekick) if let restorableAgent { restoredAgentSnapshotsByPanelId[terminalPanel.id] = restorableAgent if restoredAgentResumeInput != nil { diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index e708ff7f4561..2201bb2f3192 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -912,8 +912,11 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { let defaults = UserDefaults.standard let previousRightSidebarVisibility = defaults.object(forKey: "fileExplorer.isVisible") + let wasBrowserDisabled = BrowserAvailabilitySettings.isDisabled() + BrowserAvailabilitySettings.setDisabled(false) defer { restoreDefaultsValue(previousRightSidebarVisibility, forKey: "fileExplorer.isVisible", defaults: defaults) + BrowserAvailabilitySettings.setDisabled(wasBrowserDisabled) } let windowId = UUID() @@ -968,8 +971,22 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertFalse(sidebarState.isVisible, "Cmd+B should toggle the Welcome window left sidebar") XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: rightSidebarEvent)) - RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) - XCTAssertTrue(fileExplorerState.isVisible, "Cmd+Option+B should toggle the Welcome window right sidebar") + XCTAssertTrue( + tabManager.selectedWorkspace?.focusedTerminalPanel?.sidekickState.isOpen == true, + "Cmd+Option+B should toggle the focused terminal sidekick before falling back to the right sidebar" + ) + XCTAssertFalse(fileExplorerState.isVisible) + + if let workspace = tabManager.selectedWorkspace, + let paneId = workspace.bonsplitController.allPaneIds.first, + let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) { + workspace.focusPanel(browserPanel.id) + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: rightSidebarEvent)) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertTrue(fileExplorerState.isVisible, "Cmd+Option+B should toggle the right sidebar outside focused terminals") + } else { + XCTFail("Expected to create a browser panel for right-sidebar shortcut routing") + } #else XCTFail("debugHandleCustomShortcut is only available in DEBUG") #endif @@ -4830,6 +4847,19 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return } + let wasBrowserDisabled = BrowserAvailabilitySettings.isDisabled() + BrowserAvailabilitySettings.setDisabled(false) + defer { BrowserAvailabilitySettings.setDisabled(wasBrowserDisabled) } + + guard let context = appDelegate.contextForMainTerminalWindow(window), + let workspace = context.tabManager.selectedWorkspace, + let paneId = workspace.bonsplitController.allPaneIds.first, + let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else { + XCTFail("Expected a browser panel for non-terminal shortcut routing") + return + } + workspace.focusPanel(browserPanel.id) + let cases: [(action: KeyboardShortcutSettings.Action, modifiers: NSEvent.ModifierFlags, key: String, keyCode: UInt16)] = [ ( .toggleRightSidebar, diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 31535fc23060..f1e6dea611cb 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -34,6 +34,38 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(restored.tabs[1].customTitle, "Second") } + func testTerminalSidekickStatePersistsAndRestoresWithTerminalSnapshot() throws { + let wasBrowserDisabled = BrowserAvailabilitySettings.isDisabled() + BrowserAvailabilitySettings.setDisabled(false) + defer { BrowserAvailabilitySettings.setDisabled(wasBrowserDisabled) } + + let manager = TabManager() + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let terminalPanel = try XCTUnwrap(workspace.focusedTerminalPanel) + let sidekickURL = try XCTUnwrap(URL(string: "data:text/html,Sidekick")) + + XCTAssertTrue(terminalPanel.openSidekick(url: sidekickURL)) + terminalPanel.setSidekickSplitRatio(0.45) + + let snapshot = manager.sessionSnapshot(includeScrollback: false) + let panelSnapshot = try XCTUnwrap( + snapshot.workspaces.first?.panels.first { $0.id == terminalPanel.id } + ) + let sidekickSnapshot = try XCTUnwrap(panelSnapshot.terminal?.sidekick) + XCTAssertEqual(sidekickSnapshot.urlString, sidekickURL.absoluteString) + XCTAssertTrue(sidekickSnapshot.isOpen) + XCTAssertEqual(sidekickSnapshot.splitRatio, 0.45, accuracy: 0.001) + + let restored = TabManager() + restored.restoreSessionSnapshot(snapshot) + + let restoredTerminal = try XCTUnwrap(restored.selectedWorkspace?.focusedTerminalPanel) + XCTAssertEqual(restoredTerminal.sidekickState.urlString, sidekickURL.absoluteString) + XCTAssertTrue(restoredTerminal.sidekickState.isOpen) + XCTAssertEqual(restoredTerminal.sidekickState.splitRatio, 0.45, accuracy: 0.001) + XCTAssertNotNil(restoredTerminal.sidekickBrowserPanel) + } + func testRestoreSessionSnapshotWithNoWorkspacesKeepsSingleFallbackWorkspace() { let manager = TabManager() let emptySnapshot = SessionTabManagerSnapshot( diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 10f502246683..566b7dbe0db0 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -330,6 +330,22 @@ final class WorkspaceRenameShortcutDefaultsTests: XCTestCase { XCTAssertFalse(shortcut.control) } + func testToggleTerminalSidekickShortcutDefaultsAndMetadata() { + XCTAssertEqual(KeyboardShortcutSettings.Action.toggleTerminalSidekick.label, "Toggle Terminal Sidekick") + XCTAssertEqual( + KeyboardShortcutSettings.Action.toggleTerminalSidekick.defaultsKey, + "shortcut.toggleTerminalSidekick" + ) + + let shortcut = KeyboardShortcutSettings.Action.toggleTerminalSidekick.defaultShortcut + XCTAssertEqual(shortcut.key, "b") + XCTAssertTrue(shortcut.command) + XCTAssertFalse(shortcut.shift) + XCTAssertTrue(shortcut.option) + XCTAssertFalse(shortcut.control) + XCTAssertTrue(KeyboardShortcutSettings.settingsVisibleActions.contains(.toggleTerminalSidekick)) + } + func testRightSidebarAndFindShortcutDefaultsMatchSettingsSurface() { XCTAssertEqual( KeyboardShortcutSettings.Action.focusRightSidebar.label, diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 7b915dc20916..c9a021dfd9e8 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -103,6 +103,12 @@ export const shortcutCategories: ShortcutCategory[] = [ { id: "closeOtherTabsInPane", combos: [["⌥", "⌘", "T"]], description: { en: "Close other tabs in pane", ja: "ペイン内の他のタブを閉じる" } }, { id: "reopenClosedBrowserPanel", combos: [["⌘", "⇧", "T"]], description: { en: "Reopen closed browser panel", ja: "閉じたブラウザパネルを再度開く" } }, { id: "toggleTerminalCopyMode", combos: [["⌘", "⇧", "M"]], description: { en: "Toggle terminal copy mode", ja: "ターミナルコピーモードを切り替え" } }, + { + id: "toggleTerminalSidekick", + combos: [["⌥", "⌘", "B"]], + description: { en: "Toggle terminal sidekick browser", ja: "ターミナルサイドキックブラウザを切り替え" }, + note: { en: "focused terminal", ja: "フォーカス中のターミナル" }, + }, { id: "saveFilePreview", combos: [["⌘", "S"]], From 1c57bb9e705530a2b10a3ba2d0b3242064374b72 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 22:08:30 -0700 Subject: [PATCH 02/46] Fix terminal sidekick shortcut fallback --- Sources/AppDelegate.swift | 5 +--- Sources/KeyboardShortcutSettings.swift | 8 ++++++ .../AppDelegateShortcutRoutingTests.swift | 11 ++++++++ cmuxTests/KeyboardShortcutContextTests.swift | 28 +++++++++++++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 8b8a6e585a9c..5ac7ae2b5347 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5484,10 +5484,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent setActiveMainWindow(window) } - guard BrowserAvailabilitySettings.isEnabled() else { - NSSound.beep() - return true - } + guard BrowserAvailabilitySettings.isEnabled() else { return false } return terminalPanel.toggleSidekick() } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 57275336fcdc..b322a9e41206 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -406,6 +406,9 @@ enum KeyboardShortcutSettings { proposedAction: Action, configuredShortcut: StoredShortcut ) -> Bool { + if Self.allowsSharedShortcut(self, proposedAction) { + return false + } guard shortcutContext.overlaps(proposedAction.shortcutContext) else { return false } @@ -417,6 +420,11 @@ enum KeyboardShortcutSettings { ) } + private static func allowsSharedShortcut(_ lhs: Action, _ rhs: Action) -> Bool { + (lhs == .toggleTerminalSidekick && rhs == .toggleRightSidebar) || + (lhs == .toggleRightSidebar && rhs == .toggleTerminalSidekick) + } + func normalizedRecordedShortcutResult(_ shortcut: StoredShortcut) -> RecordedShortcutResolution { if shortcut.isUnbound { return .accepted(.unbound) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 2201bb2f3192..af6c82108025 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -970,6 +970,17 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: leftSidebarEvent)) XCTAssertFalse(sidebarState.isVisible, "Cmd+B should toggle the Welcome window left sidebar") + BrowserAvailabilitySettings.setDisabled(true) + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: rightSidebarEvent)) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertFalse( + tabManager.selectedWorkspace?.focusedTerminalPanel?.sidekickState.isOpen == true, + "Cmd+Option+B should not open a terminal sidekick when browser surfaces are disabled" + ) + XCTAssertTrue(fileExplorerState.isVisible, "Cmd+Option+B should fall through to the right sidebar when browser surfaces are disabled") + fileExplorerState.setVisible(false) + BrowserAvailabilitySettings.setDisabled(false) + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: rightSidebarEvent)) XCTAssertTrue( tabManager.selectedWorkspace?.focusedTerminalPanel?.sidekickState.isOpen == true, diff --git a/cmuxTests/KeyboardShortcutContextTests.swift b/cmuxTests/KeyboardShortcutContextTests.swift index 6c3d29e2deb8..d3a5b50ecfdc 100644 --- a/cmuxTests/KeyboardShortcutContextTests.swift +++ b/cmuxTests/KeyboardShortcutContextTests.swift @@ -36,6 +36,34 @@ final class KeyboardShortcutContextTests: XCTestCase { ) } + func testTerminalSidekickAndRightSidebarCanShareFallbackShortcut() { + let shortcut = KeyboardShortcutSettings.Action.toggleTerminalSidekick.defaultShortcut + + XCTAssertEqual(shortcut, KeyboardShortcutSettings.Action.toggleRightSidebar.defaultShortcut) + XCTAssertFalse( + KeyboardShortcutSettings.Action.toggleTerminalSidekick.conflicts( + with: shortcut, + proposedAction: .toggleRightSidebar, + configuredShortcut: shortcut + ) + ) + XCTAssertFalse( + KeyboardShortcutSettings.Action.toggleRightSidebar.conflicts( + with: shortcut, + proposedAction: .toggleTerminalSidekick, + configuredShortcut: shortcut + ) + ) + XCTAssertEqual( + KeyboardShortcutSettings.Action.toggleTerminalSidekick.normalizedRecordedShortcutResult(shortcut), + .accepted(shortcut) + ) + XCTAssertEqual( + KeyboardShortcutSettings.Action.toggleRightSidebar.normalizedRecordedShortcutResult(shortcut), + .accepted(shortcut) + ) + } + func testRenameTabCanReassignCommandRAfterUnbindingWithoutBrowserReloadConflict() throws { let originalSettingsFileStore = KeyboardShortcutSettings.settingsFileStore let directoryURL = try makeTemporaryDirectory() From 9421884340cfb581f0d0d0fe789baaf54d7fb20a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 22:42:31 -0700 Subject: [PATCH 03/46] Tighten terminal sidekick restore state --- Sources/Panels/TerminalPanel.swift | 32 +++++++- Sources/Panels/TerminalPanelView.swift | 52 +++++++------ .../TabManagerSessionSnapshotTests.swift | 74 +++++++++++++++++++ 3 files changed, 130 insertions(+), 28 deletions(-) diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 9d19e4dc7167..fdfe11d30a8d 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -195,6 +195,8 @@ final class TerminalPanel: Panel, ObservableObject { @discardableResult func openSidekick(url: URL? = nil) -> Bool { guard BrowserAvailabilitySettings.isEnabled() else { return false } + guard let browserPanel = ensureSidekickBrowserPanel(renderInitialNavigation: url == nil) else { return false } + var next = sidekickState next.isOpen = true if let url { @@ -202,7 +204,6 @@ final class TerminalPanel: Panel, ObservableObject { } sidekickState = next - guard let browserPanel = ensureSidekickBrowserPanel(renderInitialNavigation: url == nil) else { return false } if let url { browserPanel.navigate(to: url, recordTypedNavigation: true) } @@ -219,8 +220,12 @@ final class TerminalPanel: Panel, ObservableObject { func navigateSidekick(input: String) { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } - guard openSidekick() else { return } - sidekickBrowserPanel?.navigateSmart(trimmed) + guard BrowserAvailabilitySettings.isEnabled() else { return } + guard let browserPanel = ensureSidekickBrowserPanel(renderInitialNavigation: false) else { return } + var next = sidekickState + next.isOpen = true + sidekickState = next + browserPanel.navigateSmart(trimmed) } func recordSidekickCurrentURL(_ url: URL?) { @@ -252,8 +257,27 @@ final class TerminalPanel: Panel, ObservableObject { isOpen: snapshot.isOpen, splitRatio: snapshot.splitRatio ) + + guard BrowserAvailabilitySettings.isEnabled() else { + var next = sidekickState + next.isOpen = false + sidekickState = next + sidekickBrowserPanel?.close() + sidekickBrowserPanel = nil + return + } + if sidekickState.isOpen { - _ = ensureSidekickBrowserPanel() + let hadBrowserPanel = sidekickBrowserPanel != nil + guard let browserPanel = ensureSidekickBrowserPanel() else { + var next = sidekickState + next.isOpen = false + sidekickState = next + return + } + if hadBrowserPanel, let url = sidekickState.url { + browserPanel.navigate(to: url, recordTypedNavigation: false) + } } } diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 4e18eb586022..d9bc8577a1b5 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -20,35 +20,37 @@ struct TerminalPanelView: View { let onTriggerFlash: () -> Void var body: some View { - Group { + GeometryReader { proxy in if panel.sidekickState.isOpen, let sidekickPanel = panel.sidekickBrowserPanel { - GeometryReader { proxy in - HStack(spacing: 0) { - terminalSurface - .frame( - width: TerminalSidekickLayout.terminalWidth( - totalWidth: proxy.size.width, - splitRatio: panel.sidekickState.splitRatio - ) - ) - Rectangle() - .fill(appearance.dividerColor.opacity(0.9)) - .frame(width: TerminalSidekickLayout.dividerWidth) - TerminalSidekickView( - terminalPanel: panel, - browserPanel: sidekickPanel - ) + HStack(spacing: 0) { + terminalSurface .frame( - width: TerminalSidekickLayout.sidekickWidth( + width: TerminalSidekickLayout.terminalWidth( totalWidth: proxy.size.width, splitRatio: panel.sidekickState.splitRatio ) ) - .transition(.move(edge: .trailing).combined(with: .opacity)) - } + Rectangle() + .fill(appearance.dividerColor.opacity(0.9)) + .frame(width: TerminalSidekickLayout.dividerWidth) + TerminalSidekickView( + browserPanel: sidekickPanel, + onNavigate: { panel.navigateSidekick(input: $0) }, + onRecordCurrentURL: { panel.recordSidekickCurrentURL($0) }, + onClose: { panel.closeSidekick() } + ) + .frame( + width: TerminalSidekickLayout.sidekickWidth( + totalWidth: proxy.size.width, + splitRatio: panel.sidekickState.splitRatio + ) + ) + .transition(.move(edge: .trailing).combined(with: .opacity)) } + .frame(width: proxy.size.width, height: proxy.size.height) } else { terminalSurface + .frame(width: proxy.size.width, height: proxy.size.height) } } .animation(.easeInOut(duration: 0.16), value: panel.sidekickState.isOpen) @@ -101,8 +103,10 @@ private enum TerminalSidekickLayout { } private struct TerminalSidekickView: View { - @ObservedObject var terminalPanel: TerminalPanel @ObservedObject var browserPanel: BrowserPanel + let onNavigate: (String) -> Void + let onRecordCurrentURL: (URL?) -> Void + let onClose: () -> Void @State private var addressText = "" @FocusState private var addressFocused: Bool @@ -121,7 +125,7 @@ private struct TerminalSidekickView: View { syncAddressFromPanel() } .onChange(of: browserPanel.currentURL) { _, url in - terminalPanel.recordSidekickCurrentURL(url) + onRecordCurrentURL(url) guard !addressFocused else { return } syncAddressFromPanel() } @@ -194,7 +198,7 @@ private struct TerminalSidekickView: View { toolbarButton( systemName: "sidebar.trailing", help: String(localized: "terminalSidekick.collapse", defaultValue: "Collapse Sidekick"), - action: { terminalPanel.closeSidekick() } + action: onClose ) } .padding(.horizontal, 6) @@ -229,7 +233,7 @@ private struct TerminalSidekickView: View { } private func commitAddress() { - terminalPanel.navigateSidekick(input: addressText) + onNavigate(addressText) addressFocused = false } diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index f1e6dea611cb..baf9139bddac 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -66,6 +66,80 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertNotNil(restoredTerminal.sidekickBrowserPanel) } + func testTerminalSidekickRestoreStaysClosedWhenBrowserIsDisabled() throws { + let wasBrowserDisabled = BrowserAvailabilitySettings.isDisabled() + BrowserAvailabilitySettings.setDisabled(true) + defer { BrowserAvailabilitySettings.setDisabled(wasBrowserDisabled) } + + let manager = TabManager() + let terminalPanel = try XCTUnwrap(manager.selectedWorkspace?.focusedTerminalPanel) + + terminalPanel.restoreSidekick( + SessionTerminalSidekickSnapshot( + urlString: "https://sidekick.example/restored", + isOpen: true, + splitRatio: 0.4 + ) + ) + + XCTAssertFalse(terminalPanel.sidekickState.isOpen) + XCTAssertEqual(terminalPanel.sidekickState.urlString, "https://sidekick.example/restored") + XCTAssertNil(terminalPanel.sidekickBrowserPanel) + } + + func testTerminalSidekickManualNavigationDoesNotPreloadStoredURL() throws { + let wasBrowserDisabled = BrowserAvailabilitySettings.isDisabled() + BrowserAvailabilitySettings.setDisabled(false) + defer { BrowserAvailabilitySettings.setDisabled(wasBrowserDisabled) } + + let manager = TabManager() + let terminalPanel = try XCTUnwrap(manager.selectedWorkspace?.focusedTerminalPanel) + + terminalPanel.restoreSidekick( + SessionTerminalSidekickSnapshot( + urlString: "https://sidekick.example/stale", + isOpen: false, + splitRatio: 0.4 + ) + ) + terminalPanel.navigateSidekick(input: "https://sidekick.example/current") + + let browserPanel = try XCTUnwrap(terminalPanel.sidekickBrowserPanel) + XCTAssertTrue(terminalPanel.sidekickState.isOpen) + XCTAssertEqual(browserPanel.currentURL?.absoluteString, "https://sidekick.example/current") + } + + func testTerminalSidekickRestoreNavigatesExistingPanelToSnapshotURL() throws { + let wasBrowserDisabled = BrowserAvailabilitySettings.isDisabled() + BrowserAvailabilitySettings.setDisabled(false) + defer { BrowserAvailabilitySettings.setDisabled(wasBrowserDisabled) } + + let manager = TabManager() + let terminalPanel = try XCTUnwrap(manager.selectedWorkspace?.focusedTerminalPanel) + + terminalPanel.restoreSidekick( + SessionTerminalSidekickSnapshot( + urlString: "https://sidekick.example/first", + isOpen: true, + splitRatio: 0.4 + ) + ) + let browserPanel = try XCTUnwrap(terminalPanel.sidekickBrowserPanel) + XCTAssertEqual(browserPanel.currentURL?.absoluteString, "https://sidekick.example/first") + + terminalPanel.restoreSidekick( + SessionTerminalSidekickSnapshot( + urlString: "https://sidekick.example/second", + isOpen: true, + splitRatio: 0.4 + ) + ) + + let restoredBrowserPanel = try XCTUnwrap(terminalPanel.sidekickBrowserPanel) + XCTAssertTrue(browserPanel === restoredBrowserPanel) + XCTAssertEqual(restoredBrowserPanel.currentURL?.absoluteString, "https://sidekick.example/second") + } + func testRestoreSessionSnapshotWithNoWorkspacesKeepsSingleFallbackWorkspace() { let manager = TabManager() let emptySnapshot = SessionTabManagerSnapshot( From 8edd80bff87bfe181522b3b9d7b3492d8ae0aa99 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 01:20:20 -0700 Subject: [PATCH 04/46] fix: preserve sidekick shortcut fallback --- Sources/AppDelegate.swift | 4 ++-- Sources/Panels/TerminalPanel.swift | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 5ac7ae2b5347..b57c12bc2211 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5479,13 +5479,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } + guard BrowserAvailabilitySettings.isEnabled() else { return false } + let window = context.window ?? windowForMainWindowId(context.windowId) if let window { setActiveMainWindow(window) } - guard BrowserAvailabilitySettings.isEnabled() else { return false } - return terminalPanel.toggleSidekick() } diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index fdfe11d30a8d..775a3dc6158c 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -186,10 +186,9 @@ final class TerminalPanel: Panel, ObservableObject { guard BrowserAvailabilitySettings.isEnabled() else { return false } if sidekickState.isOpen { closeSidekick() - } else { - openSidekick() + return true } - return true + return openSidekick() } @discardableResult From 2fae07bdd027f6c65f31182b62d83b36e4c50f20 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 02:24:47 -0700 Subject: [PATCH 05/46] fix: address terminal sidekick review feedback --- Sources/Panels/TerminalPanel.swift | 2 ++ Sources/Panels/TerminalPanelView.swift | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 775a3dc6158c..fd456fdb5318 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -214,6 +214,8 @@ final class TerminalPanel: Panel, ObservableObject { next.urlString = currentSidekickURLString() next.isOpen = false sidekickState = next + sidekickBrowserPanel?.close() + sidekickBrowserPanel = nil } func navigateSidekick(input: String) { diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index d9bc8577a1b5..9b5a69cc5bb2 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -233,7 +233,9 @@ private struct TerminalSidekickView: View { } private func commitAddress() { - onNavigate(addressText) + let trimmed = addressText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + onNavigate(trimmed) addressFocused = false } From 0768df66e12690a44a1350c9ed5315d1a5a1164b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 02:32:43 -0700 Subject: [PATCH 06/46] fix: satisfy sidekick state layout checks --- Sources/Panels/TerminalPanel.swift | 17 ++- Sources/Panels/TerminalPanelView.swift | 155 ++++++++++++++++--------- Sources/SessionPersistence.swift | 2 +- 3 files changed, 116 insertions(+), 58 deletions(-) diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index fd456fdb5318..f507ef829cd2 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -3,7 +3,7 @@ import Combine import AppKit import Bonsplit -struct TerminalSidekickState: Codable, Equatable, Sendable { +nonisolated struct TerminalSidekickState: Codable, Equatable, Sendable { static let defaultSplitRatio = 0.4 private static let minimumSplitRatio = 0.25 private static let maximumSplitRatio = 0.7 @@ -81,11 +81,16 @@ final class TerminalPanel: Panel, ObservableObject { @Published var viewReattachToken: UInt64 = 0 @Published private(set) var sidekickState = TerminalSidekickState() - @Published private(set) var sidekickBrowserPanel: BrowserPanel? + @Published private(set) var sidekickBrowserPanel: BrowserPanel? { + didSet { + observeSidekickBrowserPanelChanges() + } + } var onRequestWorkspacePaneFlash: ((WorkspaceAttentionFlashReason) -> Void)? private var cancellables = Set() + private var sidekickBrowserPanelCancellable: AnyCancellable? var displayTitle: String { title.isEmpty ? "Terminal" : title @@ -303,6 +308,14 @@ final class TerminalPanel: Panel, ObservableObject { ) } + private func observeSidekickBrowserPanelChanges() { + sidekickBrowserPanelCancellable = sidekickBrowserPanel?.objectWillChange.sink { [weak self] _ in + MainActor.assumeIsolated { + self?.objectWillChange.send() + } + } + } + @discardableResult private func ensureSidekickBrowserPanel(renderInitialNavigation: Bool = true) -> BrowserPanel? { if let sidekickBrowserPanel { diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 9b5a69cc5bb2..a04141fff433 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -18,42 +18,58 @@ struct TerminalPanelView: View { let hasUnreadNotification: Bool let onFocus: () -> Void let onTriggerFlash: () -> Void + @State private var containerSize: CGSize = .zero var body: some View { - GeometryReader { proxy in - if panel.sidekickState.isOpen, let sidekickPanel = panel.sidekickBrowserPanel { - HStack(spacing: 0) { - terminalSurface - .frame( - width: TerminalSidekickLayout.terminalWidth( - totalWidth: proxy.size.width, - splitRatio: panel.sidekickState.splitRatio - ) - ) - Rectangle() - .fill(appearance.dividerColor.opacity(0.9)) - .frame(width: TerminalSidekickLayout.dividerWidth) - TerminalSidekickView( - browserPanel: sidekickPanel, - onNavigate: { panel.navigateSidekick(input: $0) }, - onRecordCurrentURL: { panel.recordSidekickCurrentURL($0) }, - onClose: { panel.closeSidekick() } - ) - .frame( - width: TerminalSidekickLayout.sidekickWidth( - totalWidth: proxy.size.width, - splitRatio: panel.sidekickState.splitRatio - ) - ) - .transition(.move(edge: .trailing).combined(with: .opacity)) - } - .frame(width: proxy.size.width, height: proxy.size.height) - } else { + ZStack(alignment: .leading) { + content + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { newSize in + containerSize = newSize + } + .animation(.easeInOut(duration: 0.16), value: panel.sidekickState.isOpen) + } + + @ViewBuilder + private var content: some View { + if panel.sidekickState.isOpen, let sidekickPanel = panel.sidekickBrowserPanel { + let terminalWidth = TerminalSidekickLayout.terminalWidth( + totalWidth: containerSize.width, + splitRatio: panel.sidekickState.splitRatio + ) + let sidekickWidth = TerminalSidekickLayout.sidekickWidth( + totalWidth: containerSize.width, + splitRatio: panel.sidekickState.splitRatio + ) + + HStack(spacing: 0) { terminalSurface - .frame(width: proxy.size.width, height: proxy.size.height) + .frame(width: terminalWidth, maxHeight: .infinity) + Rectangle() + .fill(appearance.dividerColor.opacity(0.9)) + .frame(width: TerminalSidekickLayout.dividerWidth) + TerminalSidekickView( + snapshot: TerminalSidekickBrowserSnapshot(browserPanel: sidekickPanel), + webView: sidekickPanel.webView, + onGoBack: { sidekickPanel.goBack() }, + onGoForward: { sidekickPanel.goForward() }, + onReload: { sidekickPanel.reload() }, + onStopLoading: { sidekickPanel.stopLoading() }, + onNavigate: { panel.navigateSidekick(input: $0) }, + onRecordCurrentURL: { panel.recordSidekickCurrentURL($0) }, + onClose: { panel.closeSidekick() } + ) + .frame(width: sidekickWidth, maxHeight: .infinity) + .transition(.move(edge: .trailing).combined(with: .opacity)) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + terminalSurface + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .animation(.easeInOut(duration: 0.16), value: panel.sidekickState.isOpen) } private var terminalSurface: some View { @@ -102,8 +118,33 @@ private enum TerminalSidekickLayout { } } +private struct TerminalSidekickBrowserSnapshot: Equatable { + let canGoBack: Bool + let canGoForward: Bool + let isLoading: Bool + let shouldRenderWebView: Bool + let currentURL: URL? + let preferredAddressText: String? + let webViewInstanceID: UUID + + init(browserPanel: BrowserPanel) { + self.canGoBack = browserPanel.canGoBack + self.canGoForward = browserPanel.canGoForward + self.isLoading = browserPanel.isLoading + self.shouldRenderWebView = browserPanel.shouldRenderWebView + self.currentURL = browserPanel.currentURL + self.preferredAddressText = browserPanel.preferredURLStringForOmnibar() + self.webViewInstanceID = browserPanel.webViewInstanceID + } +} + private struct TerminalSidekickView: View { - @ObservedObject var browserPanel: BrowserPanel + let snapshot: TerminalSidekickBrowserSnapshot + let webView: WKWebView + let onGoBack: () -> Void + let onGoForward: () -> Void + let onReload: () -> Void + let onStopLoading: () -> Void let onNavigate: (String) -> Void let onRecordCurrentURL: (URL?) -> Void let onClose: () -> Void @@ -122,18 +163,22 @@ private struct TerminalSidekickView: View { .accessibilityIdentifier("TerminalSidekickDrawer") .accessibilityLabel(String(localized: "terminalSidekick.accessibilityLabel", defaultValue: "Terminal sidekick browser")) .onAppear { - syncAddressFromPanel() + syncAddressFromSnapshot() } - .onChange(of: browserPanel.currentURL) { _, url in + .onChange(of: snapshot.currentURL) { _, url in onRecordCurrentURL(url) guard !addressFocused else { return } - syncAddressFromPanel() + syncAddressFromSnapshot() + } + .onChange(of: snapshot.preferredAddressText) { _, _ in + guard !addressFocused else { return } + syncAddressFromSnapshot() } .onChange(of: addressFocused) { _, focused in if focused { - addressText = browserPanel.preferredURLStringForOmnibar() ?? addressText + addressText = snapshot.preferredAddressText ?? addressText } else { - syncAddressFromPanel() + syncAddressFromSnapshot() } } } @@ -143,29 +188,29 @@ private struct TerminalSidekickView: View { toolbarButton( systemName: "chevron.left", help: String(localized: "browser.goBack", defaultValue: "Go Back"), - action: { browserPanel.goBack() } + action: onGoBack ) - .disabled(!browserPanel.canGoBack) - .opacity(browserPanel.canGoBack ? 1 : 0.4) + .disabled(!snapshot.canGoBack) + .opacity(snapshot.canGoBack ? 1 : 0.4) toolbarButton( systemName: "chevron.right", help: String(localized: "browser.goForward", defaultValue: "Go Forward"), - action: { browserPanel.goForward() } + action: onGoForward ) - .disabled(!browserPanel.canGoForward) - .opacity(browserPanel.canGoForward ? 1 : 0.4) + .disabled(!snapshot.canGoForward) + .opacity(snapshot.canGoForward ? 1 : 0.4) toolbarButton( - systemName: browserPanel.isLoading ? "xmark" : "arrow.clockwise", - help: browserPanel.isLoading + systemName: snapshot.isLoading ? "xmark" : "arrow.clockwise", + help: snapshot.isLoading ? String(localized: "browser.stop", defaultValue: "Stop") : String(localized: "browser.reload", defaultValue: "Reload"), action: { - if browserPanel.isLoading { - browserPanel.stopLoading() + if snapshot.isLoading { + onStopLoading() } else { - browserPanel.reload() + onReload() } } ) @@ -207,9 +252,9 @@ private struct TerminalSidekickView: View { @ViewBuilder private var content: some View { - if browserPanel.shouldRenderWebView { - TerminalSidekickWebViewRepresentable(browserPanel: browserPanel) - .id(browserPanel.webViewInstanceID) + if snapshot.shouldRenderWebView { + TerminalSidekickWebViewRepresentable(webView: webView) + .id(snapshot.webViewInstanceID) .accessibilityIdentifier("TerminalSidekickWebView") } else { Color(nsColor: .textBackgroundColor) @@ -239,8 +284,8 @@ private struct TerminalSidekickView: View { addressFocused = false } - private func syncAddressFromPanel() { - addressText = browserPanel.preferredURLStringForOmnibar() ?? "" + private func syncAddressFromSnapshot() { + addressText = snapshot.preferredAddressText ?? "" } } @@ -256,10 +301,10 @@ private struct TerminalSidekickToolbarButtonStyle: ButtonStyle { } private struct TerminalSidekickWebViewRepresentable: NSViewRepresentable { - let browserPanel: BrowserPanel + let webView: WKWebView func makeNSView(context: Context) -> WKWebView { - browserPanel.webView + webView } func updateNSView(_ nsView: WKWebView, context: Context) { diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 18310d7ae632..d89c231188c0 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -221,7 +221,7 @@ struct SessionGitBranchSnapshot: Codable, Sendable { var isDirty: Bool } -struct SessionTerminalSidekickSnapshot: Codable, Equatable, Sendable { +nonisolated struct SessionTerminalSidekickSnapshot: Codable, Equatable, Sendable { var urlString: String? var isOpen: Bool var splitRatio: Double From 5db90947d702bc84c66641c8434ea809236408a3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 02:36:43 -0700 Subject: [PATCH 07/46] fix: compile sidekick snapshot layout --- Sources/Panels/TerminalPanelView.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index a04141fff433..e13714ed2ef8 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -47,7 +47,8 @@ struct TerminalPanelView: View { HStack(spacing: 0) { terminalSurface - .frame(width: terminalWidth, maxHeight: .infinity) + .frame(width: terminalWidth) + .frame(maxHeight: .infinity) Rectangle() .fill(appearance.dividerColor.opacity(0.9)) .frame(width: TerminalSidekickLayout.dividerWidth) @@ -62,7 +63,8 @@ struct TerminalPanelView: View { onRecordCurrentURL: { panel.recordSidekickCurrentURL($0) }, onClose: { panel.closeSidekick() } ) - .frame(width: sidekickWidth, maxHeight: .infinity) + .frame(width: sidekickWidth) + .frame(maxHeight: .infinity) .transition(.move(edge: .trailing).combined(with: .opacity)) } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -127,6 +129,7 @@ private struct TerminalSidekickBrowserSnapshot: Equatable { let preferredAddressText: String? let webViewInstanceID: UUID + @MainActor init(browserPanel: BrowserPanel) { self.canGoBack = browserPanel.canGoBack self.canGoForward = browserPanel.canGoForward From 7b05a1b00e41d12e480d3dc113c435f4e6d90a0d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 13:11:03 -0700 Subject: [PATCH 08/46] feat: add draggable terminal sidekick divider --- Resources/Localizable.xcstrings | 1 + Sources/Panels/TerminalPanelView.swift | 64 +++++++++++++++++-- .../TabManagerSessionSnapshotTests.swift | 18 ++++++ 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 162153736361..31286b6af804 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -13,6 +13,7 @@ "terminalSidekick.addressLabel": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Sidekick address" } }, "ja": { "stringUnit": { "state": "translated", "value": "サイドキックアドレス" } } } }, "terminalSidekick.open": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Open in Sidekick" } }, "ja": { "stringUnit": { "state": "translated", "value": "サイドキックで開く" } } } }, "terminalSidekick.collapse": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Collapse Sidekick" } }, "ja": { "stringUnit": { "state": "translated", "value": "サイドキックを折りたたむ" } } } }, + "terminalSidekick.resizeDivider": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Resize Sidekick" } }, "ja": { "stringUnit": { "state": "translated", "value": "サイドキックのサイズを変更" } } } }, "shortcut.commandPaletteNext.label": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "ja": { "stringUnit": { "state": "translated", "value": "コマンドパレット: 次へ" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "ko": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "de": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "es": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "fr": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "it": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "da": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "pl": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "ru": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "bs": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "ar": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "nb": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "th": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "tr": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "uk": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } } } }, "shortcut.commandPalettePrevious.label": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "ja": { "stringUnit": { "state": "translated", "value": "コマンドパレット: 前へ" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "ko": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "de": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "es": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "fr": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "it": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "da": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "pl": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "ru": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "bs": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "ar": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "nb": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "th": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "tr": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "uk": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } } } }, "settings.shortcuts.managedByFile": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "ja": { "stringUnit": { "state": "translated", "value": "cmux.json で管理" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "ko": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "de": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "es": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "fr": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "it": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "da": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "pl": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "ru": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "bs": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "ar": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "nb": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "th": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "tr": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "uk": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } } } }, diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index e13714ed2ef8..57dcb790b910 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -19,6 +19,7 @@ struct TerminalPanelView: View { let onFocus: () -> Void let onTriggerFlash: () -> Void @State private var containerSize: CGSize = .zero + @State private var sidekickResizeStartRatio: Double? var body: some View { ZStack(alignment: .leading) { @@ -49,9 +50,11 @@ struct TerminalPanelView: View { terminalSurface .frame(width: terminalWidth) .frame(maxHeight: .infinity) - Rectangle() - .fill(appearance.dividerColor.opacity(0.9)) - .frame(width: TerminalSidekickLayout.dividerWidth) + TerminalSidekickDivider( + color: appearance.dividerColor, + onDrag: resizeSidekick, + onEndDrag: finishSidekickResize + ) TerminalSidekickView( snapshot: TerminalSidekickBrowserSnapshot(browserPanel: sidekickPanel), webView: sidekickPanel.webView, @@ -74,6 +77,24 @@ struct TerminalPanelView: View { } } + private func resizeSidekick(translationWidth: CGFloat) { + let availableWidth = max(0, containerSize.width - TerminalSidekickLayout.dividerWidth) + guard availableWidth > 0 else { return } + + let startRatio = sidekickResizeStartRatio ?? panel.sidekickState.splitRatio + if sidekickResizeStartRatio == nil { + sidekickResizeStartRatio = startRatio + } + + let startWidth = availableWidth * CGFloat(startRatio) + let resizedWidth = startWidth - translationWidth + panel.setSidekickSplitRatio(Double(resizedWidth / availableWidth)) + } + + private func finishSidekickResize() { + sidekickResizeStartRatio = nil + } + private var terminalSurface: some View { // Layering contract: terminal find UI is mounted in GhosttySurfaceScrollView (AppKit portal layer) // via `searchState`. Rendering `SurfaceSearchOverlay` in this SwiftUI container can hide it. @@ -99,8 +120,13 @@ struct TerminalPanelView: View { } } +private enum TerminalSidekickDividerMetrics { + static let hitWidth: CGFloat = 8 + static let hairlineWidth: CGFloat = 1 +} + private enum TerminalSidekickLayout { - static let dividerWidth: CGFloat = 1 + static let dividerWidth = TerminalSidekickDividerMetrics.hitWidth private static let minimumTerminalWidth: CGFloat = 280 private static let minimumSidekickWidth: CGFloat = 260 @@ -120,6 +146,36 @@ private enum TerminalSidekickLayout { } } +private struct TerminalSidekickDivider: View { + let color: Color + let onDrag: (CGFloat) -> Void + let onEndDrag: () -> Void + + var body: some View { + ZStack { + Color.clear + Rectangle() + .fill(color.opacity(0.9)) + .frame(width: TerminalSidekickDividerMetrics.hairlineWidth) + } + .frame(width: TerminalSidekickDividerMetrics.hitWidth) + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + .backport.pointerStyle(.resizeLeftRight) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + onDrag(value.translation.width) + } + .onEnded { _ in + onEndDrag() + } + ) + .accessibilityIdentifier("TerminalSidekickResizeDivider") + .accessibilityLabel(String(localized: "terminalSidekick.resizeDivider", defaultValue: "Resize Sidekick")) + } +} + private struct TerminalSidekickBrowserSnapshot: Equatable { let canGoBack: Bool let canGoForward: Bool diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index baf9139bddac..364d23b060ac 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -66,6 +66,24 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertNotNil(restoredTerminal.sidekickBrowserPanel) } + func testTerminalSidekickSplitRatioClampsWhenSetAndRestored() throws { + let manager = TabManager() + let terminalPanel = try XCTUnwrap(manager.selectedWorkspace?.focusedTerminalPanel) + + terminalPanel.setSidekickSplitRatio(0.1) + XCTAssertEqual(terminalPanel.sidekickState.splitRatio, 0.25, accuracy: 0.001) + + terminalPanel.restoreSidekick( + SessionTerminalSidekickSnapshot( + urlString: nil, + isOpen: false, + splitRatio: 0.9 + ) + ) + + XCTAssertEqual(terminalPanel.sidekickState.splitRatio, 0.7, accuracy: 0.001) + } + func testTerminalSidekickRestoreStaysClosedWhenBrowserIsDisabled() throws { let wasBrowserDisabled = BrowserAvailabilitySettings.isDisabled() BrowserAvailabilitySettings.setDisabled(true) From 4fc782b3f4e4069377e6e307164d68ff6e5de2cc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 13:17:50 -0700 Subject: [PATCH 09/46] fix: keep sidekick resize preview local --- Sources/Panels/TerminalPanelView.swift | 38 ++++++++++++++++++-------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 57dcb790b910..62de6749427f 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -20,6 +20,7 @@ struct TerminalPanelView: View { let onTriggerFlash: () -> Void @State private var containerSize: CGSize = .zero @State private var sidekickResizeStartRatio: Double? + @State private var sidekickResizePreviewRatio: Double? var body: some View { ZStack(alignment: .leading) { @@ -37,13 +38,14 @@ struct TerminalPanelView: View { @ViewBuilder private var content: some View { if panel.sidekickState.isOpen, let sidekickPanel = panel.sidekickBrowserPanel { + let splitRatio = sidekickResizePreviewRatio ?? panel.sidekickState.splitRatio let terminalWidth = TerminalSidekickLayout.terminalWidth( totalWidth: containerSize.width, - splitRatio: panel.sidekickState.splitRatio + splitRatio: splitRatio ) let sidekickWidth = TerminalSidekickLayout.sidekickWidth( totalWidth: containerSize.width, - splitRatio: panel.sidekickState.splitRatio + splitRatio: splitRatio ) HStack(spacing: 0) { @@ -78,8 +80,26 @@ struct TerminalPanelView: View { } private func resizeSidekick(translationWidth: CGFloat) { + guard let resizedSplitRatio = resizedSidekickSplitRatio(translationWidth: translationWidth) else { + return + } + sidekickResizePreviewRatio = resizedSplitRatio + } + + private func finishSidekickResize(translationWidth: CGFloat) { + if sidekickResizePreviewRatio == nil { + resizeSidekick(translationWidth: translationWidth) + } + if let sidekickResizePreviewRatio { + panel.setSidekickSplitRatio(sidekickResizePreviewRatio) + } + sidekickResizeStartRatio = nil + sidekickResizePreviewRatio = nil + } + + private func resizedSidekickSplitRatio(translationWidth: CGFloat) -> Double? { let availableWidth = max(0, containerSize.width - TerminalSidekickLayout.dividerWidth) - guard availableWidth > 0 else { return } + guard availableWidth > 0 else { return nil } let startRatio = sidekickResizeStartRatio ?? panel.sidekickState.splitRatio if sidekickResizeStartRatio == nil { @@ -88,11 +108,7 @@ struct TerminalPanelView: View { let startWidth = availableWidth * CGFloat(startRatio) let resizedWidth = startWidth - translationWidth - panel.setSidekickSplitRatio(Double(resizedWidth / availableWidth)) - } - - private func finishSidekickResize() { - sidekickResizeStartRatio = nil + return TerminalSidekickState.clampedSplitRatio(Double(resizedWidth / availableWidth)) } private var terminalSurface: some View { @@ -149,7 +165,7 @@ private enum TerminalSidekickLayout { private struct TerminalSidekickDivider: View { let color: Color let onDrag: (CGFloat) -> Void - let onEndDrag: () -> Void + let onEndDrag: (CGFloat) -> Void var body: some View { ZStack { @@ -167,8 +183,8 @@ private struct TerminalSidekickDivider: View { .onChanged { value in onDrag(value.translation.width) } - .onEnded { _ in - onEndDrag() + .onEnded { value in + onEndDrag(value.translation.width) } ) .accessibilityIdentifier("TerminalSidekickResizeDivider") From 3c5d4982e705a2f5cdcbf73fbc91f88050476bb4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 17:13:12 -0700 Subject: [PATCH 10/46] refactor: isolate terminal sidekick coordination --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + Sources/Panels/TerminalPanel.swift | 195 ++------------- .../Panels/TerminalSidekickCoordinator.swift | 235 ++++++++++++++++++ 3 files changed, 262 insertions(+), 172 deletions(-) create mode 100644 Sources/Panels/TerminalSidekickCoordinator.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index d11940b9ec11..da5304ecadb7 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -235,6 +235,7 @@ F1A0C0DE0000000000000002 /* FindTextFieldSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0C0DE0000000000000001 /* FindTextFieldSupport.swift */; }; A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; }; A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; }; + C3866A000000000000000001 /* TerminalSidekickCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3866A000000000000000002 /* TerminalSidekickCoordinator.swift */; }; A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; B3770BA00000000000000001 /* BrowserAutomation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3770BA00000000000000002 /* BrowserAutomation.swift */; }; D7AB00000000000000000007 /* BrowserPanel+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000008 /* BrowserPanel+MoveTabToNewWorkspace.swift */; }; @@ -736,6 +737,7 @@ F1A0C0DE0000000000000001 /* FindTextFieldSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/FindTextFieldSupport.swift; sourceTree = ""; }; A5001410 /* Panel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/Panel.swift; sourceTree = ""; }; A5001411 /* TerminalPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanel.swift; sourceTree = ""; }; + C3866A000000000000000002 /* TerminalSidekickCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalSidekickCoordinator.swift; sourceTree = ""; }; A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = ""; }; B3770BA00000000000000002 /* BrowserAutomation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserAutomation.swift; sourceTree = ""; }; D7AB00000000000000000008 /* BrowserPanel+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; @@ -1244,6 +1246,7 @@ A5008372 /* BrowserFindJavaScript.swift */, A5001410 /* Panel.swift */, A5001411 /* TerminalPanel.swift */, + C3866A000000000000000002 /* TerminalSidekickCoordinator.swift */, A5001412 /* BrowserPanel.swift */, B3770BA00000000000000002 /* BrowserAutomation.swift */, D7AB00000000000000000008 /* BrowserPanel+MoveTabToNewWorkspace.swift */, @@ -1883,6 +1886,7 @@ A5008373 /* BrowserFindJavaScript.swift in Sources */, A5001400 /* Panel.swift in Sources */, A5001401 /* TerminalPanel.swift in Sources */, + C3866A000000000000000001 /* TerminalSidekickCoordinator.swift in Sources */, A5001402 /* BrowserPanel.swift in Sources */, B3770BA00000000000000001 /* BrowserAutomation.swift in Sources */, D7AB00000000000000000007 /* BrowserPanel+MoveTabToNewWorkspace.swift in Sources */, diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index f507ef829cd2..3d64263d05e4 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -3,48 +3,6 @@ import Combine import AppKit import Bonsplit -nonisolated struct TerminalSidekickState: Codable, Equatable, Sendable { - static let defaultSplitRatio = 0.4 - private static let minimumSplitRatio = 0.25 - private static let maximumSplitRatio = 0.7 - - var urlString: String? - var isOpen: Bool - var splitRatio: Double - - init( - urlString: String? = nil, - isOpen: Bool = false, - splitRatio: Double = Self.defaultSplitRatio - ) { - self.urlString = Self.normalizedURLString(urlString) - self.isOpen = isOpen - self.splitRatio = Self.clampedSplitRatio(splitRatio) - } - - static func clampedSplitRatio(_ value: Double) -> Double { - min(max(value, minimumSplitRatio), maximumSplitRatio) - } - - static func normalizedURLString(_ value: String?) -> String? { - guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), - !trimmed.isEmpty else { - return nil - } - return trimmed - } - - var url: URL? { - urlString.flatMap(URL.init(string:)) - } - - var hasPersistableValue: Bool { - isOpen || - urlString != nil || - abs(splitRatio - Self.defaultSplitRatio) > 0.0001 - } -} - /// TerminalPanel wraps an existing TerminalSurface and conforms to the Panel protocol. /// This allows TerminalSurface to be used within the bonsplit-based layout system. @MainActor @@ -80,17 +38,14 @@ final class TerminalPanel: Panel, ObservableObject { /// (hostedView.window == nil) until the user switches workspaces. @Published var viewReattachToken: UInt64 = 0 - @Published private(set) var sidekickState = TerminalSidekickState() - @Published private(set) var sidekickBrowserPanel: BrowserPanel? { - didSet { - observeSidekickBrowserPanelChanges() - } - } - var onRequestWorkspacePaneFlash: ((WorkspaceAttentionFlashReason) -> Void)? private var cancellables = Set() - private var sidekickBrowserPanelCancellable: AnyCancellable? + private lazy var sidekickCoordinator = TerminalSidekickCoordinator( + workspaceId: workspaceId + ) { [weak self] in + self?.objectWillChange.send() + } var displayTitle: String { title.isEmpty ? "Terminal" : title @@ -121,6 +76,14 @@ final class TerminalPanel: Panel, ObservableObject { surface.requestedWorkingDirectory } + var sidekickState: TerminalSidekickState { + sidekickCoordinator.state + } + + var sidekickBrowserPanel: BrowserPanel? { + sidekickCoordinator.browserPanel + } + init(workspaceId: UUID, surface: TerminalSurface) { self.id = surface.id self.workspaceId = workspaceId @@ -183,152 +146,41 @@ final class TerminalPanel: Panel, ObservableObject { func updateWorkspaceId(_ newWorkspaceId: UUID) { workspaceId = newWorkspaceId surface.updateWorkspaceId(newWorkspaceId) - sidekickBrowserPanel?.updateWorkspaceId(newWorkspaceId) + sidekickCoordinator.updateWorkspaceId(newWorkspaceId) } @discardableResult func toggleSidekick() -> Bool { - guard BrowserAvailabilitySettings.isEnabled() else { return false } - if sidekickState.isOpen { - closeSidekick() - return true - } - return openSidekick() + sidekickCoordinator.toggleSidekick() } @discardableResult func openSidekick(url: URL? = nil) -> Bool { - guard BrowserAvailabilitySettings.isEnabled() else { return false } - guard let browserPanel = ensureSidekickBrowserPanel(renderInitialNavigation: url == nil) else { return false } - - var next = sidekickState - next.isOpen = true - if let url { - next.urlString = url.absoluteString - } - sidekickState = next - - if let url { - browserPanel.navigate(to: url, recordTypedNavigation: true) - } - return true + sidekickCoordinator.openSidekick(url: url) } func closeSidekick() { - var next = sidekickState - next.urlString = currentSidekickURLString() - next.isOpen = false - sidekickState = next - sidekickBrowserPanel?.close() - sidekickBrowserPanel = nil + sidekickCoordinator.closeSidekick() } func navigateSidekick(input: String) { - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - guard BrowserAvailabilitySettings.isEnabled() else { return } - guard let browserPanel = ensureSidekickBrowserPanel(renderInitialNavigation: false) else { return } - var next = sidekickState - next.isOpen = true - sidekickState = next - browserPanel.navigateSmart(trimmed) + sidekickCoordinator.navigateSidekick(input: input) } func recordSidekickCurrentURL(_ url: URL?) { - let nextURLString = TerminalSidekickState.normalizedURLString(url?.absoluteString) - guard sidekickState.urlString != nextURLString else { return } - var next = sidekickState - next.urlString = nextURLString - sidekickState = next + sidekickCoordinator.recordSidekickCurrentURL(url) } func setSidekickSplitRatio(_ splitRatio: Double) { - let clamped = TerminalSidekickState.clampedSplitRatio(splitRatio) - guard sidekickState.splitRatio != clamped else { return } - var next = sidekickState - next.splitRatio = clamped - sidekickState = next + sidekickCoordinator.setSidekickSplitRatio(splitRatio) } func restoreSidekick(_ snapshot: SessionTerminalSidekickSnapshot?) { - guard let snapshot else { - sidekickState = TerminalSidekickState() - sidekickBrowserPanel?.close() - sidekickBrowserPanel = nil - return - } - - sidekickState = TerminalSidekickState( - urlString: snapshot.urlString, - isOpen: snapshot.isOpen, - splitRatio: snapshot.splitRatio - ) - - guard BrowserAvailabilitySettings.isEnabled() else { - var next = sidekickState - next.isOpen = false - sidekickState = next - sidekickBrowserPanel?.close() - sidekickBrowserPanel = nil - return - } - - if sidekickState.isOpen { - let hadBrowserPanel = sidekickBrowserPanel != nil - guard let browserPanel = ensureSidekickBrowserPanel() else { - var next = sidekickState - next.isOpen = false - sidekickState = next - return - } - if hadBrowserPanel, let url = sidekickState.url { - browserPanel.navigate(to: url, recordTypedNavigation: false) - } - } + sidekickCoordinator.restoreSidekick(snapshot) } func sidekickSessionSnapshot() -> SessionTerminalSidekickSnapshot? { - let urlString = currentSidekickURLString() - let snapshotState = TerminalSidekickState( - urlString: urlString, - isOpen: sidekickState.isOpen, - splitRatio: sidekickState.splitRatio - ) - guard snapshotState.hasPersistableValue else { return nil } - return SessionTerminalSidekickSnapshot( - urlString: snapshotState.urlString, - isOpen: snapshotState.isOpen, - splitRatio: snapshotState.splitRatio - ) - } - - private func currentSidekickURLString() -> String? { - TerminalSidekickState.normalizedURLString( - sidekickBrowserPanel?.preferredURLStringForOmnibar() ?? sidekickState.urlString - ) - } - - private func observeSidekickBrowserPanelChanges() { - sidekickBrowserPanelCancellable = sidekickBrowserPanel?.objectWillChange.sink { [weak self] _ in - MainActor.assumeIsolated { - self?.objectWillChange.send() - } - } - } - - @discardableResult - private func ensureSidekickBrowserPanel(renderInitialNavigation: Bool = true) -> BrowserPanel? { - if let sidekickBrowserPanel { - return sidekickBrowserPanel - } - - let browserPanel = BrowserPanel( - workspaceId: workspaceId, - initialURL: sidekickState.url, - renderInitialNavigation: renderInitialNavigation && sidekickState.url != nil - ) - sidekickBrowserPanel = browserPanel - return browserPanel + sidekickCoordinator.sessionSnapshot() } func updateTmuxLayoutReport(_ report: TmuxPaneLayoutReport?) { @@ -364,8 +216,7 @@ final class TerminalPanel: Panel, ObservableObject { } func close() { - sidekickBrowserPanel?.close() - sidekickBrowserPanel = nil + sidekickCoordinator.closeBrowserPanel() // The surface will be cleaned up by its deinit // Detach from the window portal on real close so stale hosted views // cannot remain above browser panes after split close. diff --git a/Sources/Panels/TerminalSidekickCoordinator.swift b/Sources/Panels/TerminalSidekickCoordinator.swift new file mode 100644 index 000000000000..e5b827d71e45 --- /dev/null +++ b/Sources/Panels/TerminalSidekickCoordinator.swift @@ -0,0 +1,235 @@ +import Foundation +import Combine + +nonisolated struct TerminalSidekickState: Codable, Equatable, Sendable { + static let defaultSplitRatio = 0.4 + private static let minimumSplitRatio = 0.25 + private static let maximumSplitRatio = 0.7 + + var urlString: String? + var isOpen: Bool + var splitRatio: Double + + init( + urlString: String? = nil, + isOpen: Bool = false, + splitRatio: Double = Self.defaultSplitRatio + ) { + self.urlString = Self.normalizedURLString(urlString) + self.isOpen = isOpen + self.splitRatio = Self.clampedSplitRatio(splitRatio) + } + + static func clampedSplitRatio(_ value: Double) -> Double { + min(max(value, minimumSplitRatio), maximumSplitRatio) + } + + static func normalizedURLString(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } + + var url: URL? { + urlString.flatMap(URL.init(string:)) + } + + var hasPersistableValue: Bool { + isOpen || + urlString != nil || + abs(splitRatio - Self.defaultSplitRatio) > 0.0001 + } +} + +@MainActor +final class TerminalSidekickCoordinator { + private(set) var state = TerminalSidekickState() + private(set) var browserPanel: BrowserPanel? + + private var workspaceId: UUID + private let notifyChanged: @MainActor () -> Void + private var browserPanelCancellable: AnyCancellable? + + init( + workspaceId: UUID, + notifyChanged: @MainActor @escaping () -> Void + ) { + self.workspaceId = workspaceId + self.notifyChanged = notifyChanged + } + + func updateWorkspaceId(_ newWorkspaceId: UUID) { + workspaceId = newWorkspaceId + browserPanel?.updateWorkspaceId(newWorkspaceId) + } + + @discardableResult + func toggleSidekick() -> Bool { + guard BrowserAvailabilitySettings.isEnabled() else { return false } + if state.isOpen { + closeSidekick() + return true + } + return openSidekick() + } + + @discardableResult + func openSidekick(url: URL? = nil) -> Bool { + guard BrowserAvailabilitySettings.isEnabled() else { return false } + guard let browserPanel = ensureBrowserPanel(renderInitialNavigation: url == nil) else { return false } + + var next = state + next.isOpen = true + if let url { + next.urlString = url.absoluteString + } + replaceState(next) + + if let url { + browserPanel.navigate(to: url, recordTypedNavigation: true) + } + return true + } + + func closeSidekick() { + var next = state + next.urlString = currentURLString() + next.isOpen = false + replaceState(next) + closeBrowserPanel() + } + + func navigateSidekick(input: String) { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard BrowserAvailabilitySettings.isEnabled() else { return } + guard let browserPanel = ensureBrowserPanel(renderInitialNavigation: false) else { return } + var next = state + next.isOpen = true + replaceState(next) + browserPanel.navigateSmart(trimmed) + } + + func recordSidekickCurrentURL(_ url: URL?) { + let nextURLString = TerminalSidekickState.normalizedURLString(url?.absoluteString) + guard state.urlString != nextURLString else { return } + var next = state + next.urlString = nextURLString + replaceState(next) + } + + func setSidekickSplitRatio(_ splitRatio: Double) { + let clamped = TerminalSidekickState.clampedSplitRatio(splitRatio) + guard state.splitRatio != clamped else { return } + var next = state + next.splitRatio = clamped + replaceState(next) + } + + func restoreSidekick(_ snapshot: SessionTerminalSidekickSnapshot?) { + guard let snapshot else { + replaceState(TerminalSidekickState()) + closeBrowserPanel() + return + } + + replaceState( + TerminalSidekickState( + urlString: snapshot.urlString, + isOpen: snapshot.isOpen, + splitRatio: snapshot.splitRatio + ) + ) + + guard BrowserAvailabilitySettings.isEnabled() else { + var next = state + next.isOpen = false + replaceState(next) + closeBrowserPanel() + return + } + + if state.isOpen { + let hadBrowserPanel = browserPanel != nil + guard let browserPanel = ensureBrowserPanel() else { + var next = state + next.isOpen = false + replaceState(next) + return + } + if hadBrowserPanel, let url = state.url { + browserPanel.navigate(to: url, recordTypedNavigation: false) + } + } + } + + func sessionSnapshot() -> SessionTerminalSidekickSnapshot? { + let urlString = currentURLString() + let snapshotState = TerminalSidekickState( + urlString: urlString, + isOpen: state.isOpen, + splitRatio: state.splitRatio + ) + guard snapshotState.hasPersistableValue else { return nil } + return SessionTerminalSidekickSnapshot( + urlString: snapshotState.urlString, + isOpen: snapshotState.isOpen, + splitRatio: snapshotState.splitRatio + ) + } + + func closeBrowserPanel() { + browserPanel?.close() + replaceBrowserPanel(nil) + } + + private func currentURLString() -> String? { + TerminalSidekickState.normalizedURLString( + browserPanel?.preferredURLStringForOmnibar() ?? state.urlString + ) + } + + private func observeBrowserPanelChanges() { + browserPanelCancellable = browserPanel?.objectWillChange.sink { [weak self] _ in + MainActor.assumeIsolated { + self?.notifyChanged() + } + } + } + + @discardableResult + private func ensureBrowserPanel(renderInitialNavigation: Bool = true) -> BrowserPanel? { + if let browserPanel { + return browserPanel + } + + let browserPanel = BrowserPanel( + workspaceId: workspaceId, + initialURL: state.url, + renderInitialNavigation: renderInitialNavigation && state.url != nil + ) + replaceBrowserPanel(browserPanel) + return browserPanel + } + + private func replaceState(_ next: TerminalSidekickState) { + guard state != next else { return } + notifyChanged() + state = next + } + + private func replaceBrowserPanel(_ next: BrowserPanel?) { + if browserPanel == nil, next == nil { + return + } + if let browserPanel, let next, browserPanel === next { + return + } + + notifyChanged() + browserPanel = next + observeBrowserPanelChanges() + } +} From 9025859b0311b7805462a9283bdc115a8f533a32 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 17:51:35 -0700 Subject: [PATCH 11/46] fix: align sidekick resize ratio with layout bounds --- README.md | 3 +- Sources/Panels/TerminalPanelView.swift | 42 +++++++++++++---- .../TabManagerSessionSnapshotTests.swift | 45 +++++++++++++++++++ web/data/cmux-shortcuts.ts | 7 ++- 4 files changed, 86 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index a583b7f11c07..c83d95c5b0b3 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ For more info on how to configure cmux, [head over to our docs](https://cmux.com | ⌘ ⇧ R | Rename workspace | | ⌥ ⌘ E | Edit workspace description | | ⌘ B | Toggle sidebar | -| ⌥ ⌘ B | Toggle right sidebar | +| ⌥ ⌘ B | Toggle right sidebar outside focused terminals | | ⌘ ⇧ E | Toggle right sidebar focus | ### Surfaces @@ -172,6 +172,7 @@ For more info on how to configure cmux, [head over to our docs](https://cmux.com | ⌃ 1–8 | Jump to surface 1–8 | | ⌃ 9 | Jump to last surface | | ⌘ W | Close surface | +| ⌥ ⌘ B | Toggle terminal sidekick in a focused terminal | ### Split Panes diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index 62de6749427f..de212371b8d2 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -98,17 +98,16 @@ struct TerminalPanelView: View { } private func resizedSidekickSplitRatio(translationWidth: CGFloat) -> Double? { - let availableWidth = max(0, containerSize.width - TerminalSidekickLayout.dividerWidth) - guard availableWidth > 0 else { return nil } - let startRatio = sidekickResizeStartRatio ?? panel.sidekickState.splitRatio if sidekickResizeStartRatio == nil { sidekickResizeStartRatio = startRatio } - let startWidth = availableWidth * CGFloat(startRatio) - let resizedWidth = startWidth - translationWidth - return TerminalSidekickState.clampedSplitRatio(Double(resizedWidth / availableWidth)) + return TerminalSidekickLayout.splitRatio( + totalWidth: containerSize.width, + startRatio: startRatio, + translationWidth: translationWidth + ) } private var terminalSurface: some View { @@ -137,12 +136,12 @@ struct TerminalPanelView: View { } private enum TerminalSidekickDividerMetrics { - static let hitWidth: CGFloat = 8 + static let hitWidth = TerminalSidekickLayout.dividerWidth static let hairlineWidth: CGFloat = 1 } -private enum TerminalSidekickLayout { - static let dividerWidth = TerminalSidekickDividerMetrics.hitWidth +enum TerminalSidekickLayout { + static let dividerWidth: CGFloat = 8 private static let minimumTerminalWidth: CGFloat = 280 private static let minimumSidekickWidth: CGFloat = 260 @@ -150,6 +149,31 @@ private enum TerminalSidekickLayout { guard totalWidth > dividerWidth else { return 0 } let availableWidth = max(0, totalWidth - dividerWidth) let targetWidth = availableWidth * CGFloat(TerminalSidekickState.clampedSplitRatio(splitRatio)) + return constrainedSidekickWidth(availableWidth: availableWidth, targetWidth: targetWidth) + } + + static func splitRatio( + totalWidth: CGFloat, + startRatio: Double, + translationWidth: CGFloat + ) -> Double? { + guard totalWidth > dividerWidth else { return nil } + let availableWidth = max(0, totalWidth - dividerWidth) + guard availableWidth > 0 else { return nil } + + let startWidth = sidekickWidth(totalWidth: totalWidth, splitRatio: startRatio) + let resizedWidth = startWidth - translationWidth + let boundedWidth = constrainedSidekickWidth( + availableWidth: availableWidth, + targetWidth: resizedWidth + ) + return TerminalSidekickState.clampedSplitRatio(Double(boundedWidth / availableWidth)) + } + + private static func constrainedSidekickWidth( + availableWidth: CGFloat, + targetWidth: CGFloat + ) -> CGFloat { guard availableWidth >= minimumTerminalWidth + minimumSidekickWidth else { return max(0, min(availableWidth, targetWidth)) } diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 364d23b060ac..2d42492d44d9 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1,3 +1,4 @@ +import CoreGraphics import XCTest #if canImport(cmux_DEV) @@ -84,6 +85,50 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(terminalPanel.sidekickState.splitRatio, 0.7, accuracy: 0.001) } + func testTerminalSidekickResizeRatioMatchesDisplayedMinimumSidekickWidth() throws { + let totalWidth: CGFloat = 1000 + let ratio = try XCTUnwrap( + TerminalSidekickLayout.splitRatio( + totalWidth: totalWidth, + startRatio: 0.25, + translationWidth: 0 + ) + ) + + let renderedSidekickWidth = TerminalSidekickLayout.sidekickWidth( + totalWidth: totalWidth, + splitRatio: ratio + ) + let availableWidth = totalWidth - TerminalSidekickLayout.dividerWidth + + XCTAssertEqual(renderedSidekickWidth, 260, accuracy: 0.001) + XCTAssertEqual(ratio, Double(renderedSidekickWidth / availableWidth), accuracy: 0.001) + } + + func testTerminalSidekickResizeRatioPreservesMinimumTerminalWidth() throws { + let totalWidth: CGFloat = 720 + let ratio = try XCTUnwrap( + TerminalSidekickLayout.splitRatio( + totalWidth: totalWidth, + startRatio: 0.7, + translationWidth: -400 + ) + ) + + let renderedSidekickWidth = TerminalSidekickLayout.sidekickWidth( + totalWidth: totalWidth, + splitRatio: ratio + ) + let renderedTerminalWidth = totalWidth - TerminalSidekickLayout.dividerWidth - renderedSidekickWidth + + XCTAssertEqual(renderedTerminalWidth, 280, accuracy: 0.001) + XCTAssertEqual( + ratio, + Double(renderedSidekickWidth / (totalWidth - TerminalSidekickLayout.dividerWidth)), + accuracy: 0.001 + ) + } + func testTerminalSidekickRestoreStaysClosedWhenBrowserIsDisabled() throws { let wasBrowserDisabled = BrowserAvailabilitySettings.isDisabled() BrowserAvailabilitySettings.setDisabled(true) diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index c9a021dfd9e8..5dce20f051de 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -62,7 +62,12 @@ export const shortcutCategories: ShortcutCategory[] = [ blurbKey: "workspacesBlurb", shortcuts: [ { id: "toggleSidebar", combos: [["⌘", "B"]], description: { en: "Toggle left sidebar", ja: "左サイドバーを切り替え" } }, - { id: "toggleFileExplorer", combos: [["⌘", "⌥", "B"]], description: { en: "Toggle right sidebar", ja: "右サイドバーを切り替え" } }, + { + id: "toggleFileExplorer", + combos: [["⌘", "⌥", "B"]], + description: { en: "Toggle right sidebar", ja: "右サイドバーを切り替え" }, + note: { en: "outside focused terminals", ja: "フォーカス中のターミナル以外" }, + }, { id: "newTab", combos: [["⌘", "N"]], description: { en: "New workspace", ja: "新規ワークスペース" } }, { id: "openFolder", combos: [["⌘", "O"]], description: { en: "Open folder", ja: "フォルダを開く" } }, { From fbb1b39b010d03a391809c71b7a5d2ca38e156a4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 13:41:28 -0700 Subject: [PATCH 12/46] fix: defer terminal sidekick shortcut toggle --- Sources/AppDelegate.swift | 33 +++++++++++++++---- .../AppDelegateShortcutRoutingTests.swift | 1 + 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index be85a0eafa69..64eba503879e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5572,21 +5572,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return preferredRegisteredMainWindowContext() } - @discardableResult - func toggleTerminalSidekickInActiveMainWindow(preferredWindow: NSWindow? = nil) -> Bool { + private func terminalSidekickShortcutTarget( + preferredWindow: NSWindow? = nil + ) -> (context: MainWindowContext, terminalPanel: TerminalPanel)? { guard let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow), let terminalPanel = context.tabManager.selectedWorkspace?.focusedTerminalPanel else { - return false + return nil } - guard BrowserAvailabilitySettings.isEnabled() else { return false } + guard BrowserAvailabilitySettings.isEnabled() else { return nil } - let window = context.window ?? windowForMainWindowId(context.windowId) + return (context, terminalPanel) + } + + func canToggleTerminalSidekickInActiveMainWindow(preferredWindow: NSWindow? = nil) -> Bool { + terminalSidekickShortcutTarget(preferredWindow: preferredWindow) != nil + } + + @discardableResult + func toggleTerminalSidekickInActiveMainWindow(preferredWindow: NSWindow? = nil) -> Bool { + guard let target = terminalSidekickShortcutTarget(preferredWindow: preferredWindow) else { + return false + } + + let window = target.context.window ?? windowForMainWindowId(target.context.windowId) if let window { setActiveMainWindow(window) } - return terminalPanel.toggleSidekick() + return target.terminalPanel.toggleSidekick() } @discardableResult @@ -11263,7 +11277,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if matchConfiguredShortcut(event: event, action: .toggleTerminalSidekick) { let preferredWindow = mainWindowForShortcutEvent(event) ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow - if toggleTerminalSidekickInActiveMainWindow(preferredWindow: preferredWindow) { + if canToggleTerminalSidekickInActiveMainWindow(preferredWindow: preferredWindow) { + // Escape AppKit's performKeyEquivalent animation context before resizing + // the terminal portal and sidekick WKWebView host. + Task { @MainActor [weak self, weak preferredWindow] in + _ = self?.toggleTerminalSidekickInActiveMainWindow(preferredWindow: preferredWindow) + } return true } } diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index af6c82108025..9d88579bbbb3 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -982,6 +982,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { BrowserAvailabilitySettings.setDisabled(false) XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: rightSidebarEvent)) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) XCTAssertTrue( tabManager.selectedWorkspace?.focusedTerminalPanel?.sidekickState.isOpen == true, "Cmd+Option+B should toggle the focused terminal sidekick before falling back to the right sidebar" From e98f35ff281918adf044d23dfd43e1a4df7c7b93 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 14 May 2026 16:54:39 -0700 Subject: [PATCH 13/46] fix: make file preview save test deterministic --- Sources/Panels/FilePreviewPanel.swift | 15 +++++-- cmuxTests/WindowAndDragTests.swift | 57 +++++++++++++++++++++------ 2 files changed, 57 insertions(+), 15 deletions(-) diff --git a/Sources/Panels/FilePreviewPanel.swift b/Sources/Panels/FilePreviewPanel.swift index 1c61f5af4cb7..28acb2850dbf 100644 --- a/Sources/Panels/FilePreviewPanel.swift +++ b/Sources/Panels/FilePreviewPanel.swift @@ -813,6 +813,8 @@ enum FilePreviewTextSaver { case failed(fileExists: Bool) } + typealias Operation = @Sendable (String, URL, String.Encoding) async -> Result + static func save(content: String, to url: URL, encoding: String.Encoding) async -> Result { await Task.detached(priority: .userInitiated) { guard let data = content.data(using: encoding) else { @@ -852,15 +854,21 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan private var activeSaveGeneration: Int? private weak var textView: NSTextView? private let focusCoordinator: FilePreviewFocusCoordinator + private let textSaver: FilePreviewTextSaver.Operation var fileURL: URL { URL(fileURLWithPath: filePath) } - init(workspaceId: UUID, filePath: String) { + init( + workspaceId: UUID, + filePath: String, + textSaver: @escaping FilePreviewTextSaver.Operation = FilePreviewTextSaver.save + ) { self.id = UUID() self.workspaceId = workspaceId self.filePath = filePath + self.textSaver = textSaver self.displayTitle = URL(fileURLWithPath: filePath).lastPathComponent let fileURL = URL(fileURLWithPath: filePath) let initialPreviewMode = FilePreviewKindResolver.initialMode(for: fileURL) @@ -1082,8 +1090,9 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan activeSaveGeneration = generation let fileURL = fileURL let encoding = textEncoding - return Task { [weak self, currentContent, fileURL, encoding, generation] in - let result = await FilePreviewTextSaver.save(content: currentContent, to: fileURL, encoding: encoding) + let textSaver = textSaver + return Task { [weak self, currentContent, fileURL, encoding, generation, textSaver] in + let result = await textSaver(currentContent, fileURL, encoding) guard let self, self.activeSaveGeneration == generation else { return } self.activeSaveGeneration = nil self.isSaving = false diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index 1ce14e3316b0..9d05b65f1a64 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -2183,6 +2183,41 @@ final class FilePreviewDragPasteboardWriterTests: XCTestCase { } +private actor ControlledFilePreviewTextSaver { + private var capturedContent: String? + private var startContinuation: CheckedContinuation? + private var finishContinuation: CheckedContinuation? + + func save( + content: String, + to url: URL, + encoding: String.Encoding + ) async -> FilePreviewTextSaver.Result { + _ = url + _ = encoding + capturedContent = content + startContinuation?.resume(returning: content) + startContinuation = nil + return await withCheckedContinuation { continuation in + finishContinuation = continuation + } + } + + func waitForSaveStart() async -> String { + if let capturedContent { + return capturedContent + } + return await withCheckedContinuation { continuation in + startContinuation = continuation + } + } + + func finishSave(with result: FilePreviewTextSaver.Result = .saved) { + finishContinuation?.resume(returning: result) + finishContinuation = nil + } +} + @MainActor final class FilePreviewPanelTextSavingTests: XCTestCase { func testSaveTextContentWritesLiveTextViewContent() async throws { @@ -2209,27 +2244,25 @@ final class FilePreviewPanelTextSavingTests: XCTestCase { let url = try temporaryTextFile(contents: "original", encoding: .utf8) defer { try? FileManager.default.removeItem(at: url) } - let panel = FilePreviewPanel(workspaceId: UUID(), filePath: url.path) + let saveProbe = ControlledFilePreviewTextSaver() + let panel = FilePreviewPanel( + workspaceId: UUID(), + filePath: url.path, + textSaver: { content, url, encoding in + await saveProbe.save(content: content, to: url, encoding: encoding) + } + ) await panel.loadTextContent().value panel.updateTextContent("first save") - try FileManager.default.removeItem(at: url) - XCTAssertEqual(mkfifo(url.path, 0o600), 0) - let firstSave = try XCTUnwrap(panel.saveTextContent()) XCTAssertTrue(panel.isSaving) + XCTAssertEqual(await saveProbe.waitForSaveStart(), "first save") panel.updateTextContent("second save") XCTAssertNil(panel.saveTextContent()) - let pipeRead = Task.detached { () throws -> String in - let handle = try FileHandle(forReadingFrom: url) - defer { try? handle.close() } - return String(data: handle.availableData, encoding: .utf8) ?? "" - } - - let savedContent = try await pipeRead.value - XCTAssertEqual(savedContent, "first save") + await saveProbe.finishSave() await firstSave.value XCTAssertEqual(panel.textContent, "second save") From a326c8f0ad5a23bb5df5b4cc784a2ddca1eb8d5e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 17 May 2026 23:16:42 -0700 Subject: [PATCH 14/46] fix: reuse browser panel view for sidekick --- Sources/KeyboardShortcutContext.swift | 30 ++++ Sources/Panels/BrowserPanelView.swift | 34 ++++ Sources/Panels/TerminalPanelView.swift | 217 ++----------------------- cmuxTests/BrowserConfigTests.swift | 4 + 4 files changed, 79 insertions(+), 206 deletions(-) diff --git a/Sources/KeyboardShortcutContext.swift b/Sources/KeyboardShortcutContext.swift index f4bdb267ceff..ad39d9425e5a 100644 --- a/Sources/KeyboardShortcutContext.swift +++ b/Sources/KeyboardShortcutContext.swift @@ -169,6 +169,9 @@ extension AppDelegate { if let panel = workspace.browserPanel(for: panelId) { return panel } + if let sidekickPanel = shortcutSidekickBrowserPanel(in: workspace, panelId: panelId) { + return sidekickPanel + } } } return nil @@ -184,7 +187,34 @@ extension AppDelegate { } return browserPanel } + if let sidekickPanel = shortcutSidekickBrowserPanel(in: workspace, webView: webView) { + return sidekickPanel + } + } + } + return nil + } + + private func shortcutSidekickBrowserPanel(in workspace: Workspace, panelId: UUID) -> BrowserPanel? { + for panel in workspace.panels.values { + guard let terminalPanel = panel as? TerminalPanel, + let sidekickPanel = terminalPanel.sidekickBrowserPanel, + sidekickPanel.id == panelId else { + continue + } + return sidekickPanel + } + return nil + } + + private func shortcutSidekickBrowserPanel(in workspace: Workspace, webView: WKWebView) -> BrowserPanel? { + for panel in workspace.panels.values { + guard let terminalPanel = panel as? TerminalPanel, + let sidekickPanel = terminalPanel.sidekickBrowserPanel, + sidekickPanel.webView === webView else { + continue } + return sidekickPanel } return nil } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 970da7f58200..62fe2cf9bbd8 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -389,6 +389,8 @@ struct BrowserPanelView: View { let isVisibleInUI: Bool let portalPriority: Int let onRequestPanelFocus: () -> Void + let usesProvidedPaneContext: Bool = false + let embeddedCloseAction: (() -> Void)? = nil @Environment(\.colorScheme) private var colorScheme @Environment(\.openWindow) private var openWindow @Environment(\.paneDropZone) private var paneDropZone @@ -543,6 +545,9 @@ struct BrowserPanelView: View { } private var isCurrentPaneOwner: Bool { + if usesProvidedPaneContext { + return true + } guard let currentPaneId = owningWorkspace?.paneId(forPanelId: panel.id) else { return false } @@ -856,6 +861,7 @@ struct BrowserPanelView: View { browserProfileButton browserThemeModeButton developerToolsButton + embeddedCloseButton } } .padding(.horizontal, 8) @@ -979,6 +985,24 @@ struct BrowserPanelView: View { .accessibilityIdentifier("BrowserToggleDevToolsButton") } + @ViewBuilder + private var embeddedCloseButton: some View { + if let embeddedCloseAction { + Button(action: embeddedCloseAction) { + Image(systemName: "sidebar.trailing") + .symbolRenderingMode(.monochrome) + .cmuxFlatSymbolColorRendering() + .font(.system(size: devToolsButtonIconSize, weight: .medium)) + .foregroundStyle(devToolsColorOption.color) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + } + .buttonStyle(OmnibarAddressButtonStyle()) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + .safeHelp(String(localized: "terminalSidekick.collapse", defaultValue: "Collapse Sidekick")) + .accessibilityIdentifier("TerminalSidekickCollapseButton") + } + } + private var browserProfileButton: some View { Button(action: { isBrowserProfileMenuPresented.toggle() @@ -1267,6 +1291,7 @@ struct BrowserPanelView: View { WebViewRepresentable( panel: panel, paneId: paneId, + usesProvidedPaneContext: usesProvidedPaneContext, shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner && !useLocalInlineDeveloperToolsHosting, useLocalInlineHosting: useLocalInlineDeveloperToolsHosting, shouldFocusWebView: isFocused && !addressBarFocused, @@ -4457,6 +4482,7 @@ private struct OmnibarSuggestionsView: View { struct WebViewRepresentable: NSViewRepresentable { let panel: BrowserPanel let paneId: PaneID + let usesProvidedPaneContext: Bool let shouldAttachWebView: Bool let useLocalInlineHosting: Bool let shouldFocusWebView: Bool @@ -6847,6 +6873,14 @@ struct WebViewRepresentable: NSViewRepresentable { } private func currentPaneDropContext() -> BrowserPaneDropContext? { + if usesProvidedPaneContext { + return BrowserPaneDropContext( + workspaceId: panel.workspaceId, + panelId: panel.id, + paneId: paneId + ) + } + guard let app = AppDelegate.shared, let manager = app.tabManagerFor(tabId: panel.workspaceId), let workspace = manager.tabs.first(where: { $0.id == panel.workspaceId }), diff --git a/Sources/Panels/TerminalPanelView.swift b/Sources/Panels/TerminalPanelView.swift index d8254c0bab00..961238e7caca 100644 --- a/Sources/Panels/TerminalPanelView.swift +++ b/Sources/Panels/TerminalPanelView.swift @@ -2,7 +2,6 @@ import SwiftUI import Foundation import AppKit import Bonsplit -import WebKit /// View for rendering a terminal panel struct TerminalPanelView: View { @@ -57,17 +56,18 @@ struct TerminalPanelView: View { onDrag: resizeSidekick, onEndDrag: finishSidekickResize ) - TerminalSidekickView( - snapshot: TerminalSidekickBrowserSnapshot(browserPanel: sidekickPanel), - webView: sidekickPanel.webView, - onGoBack: { sidekickPanel.goBack() }, - onGoForward: { sidekickPanel.goForward() }, - onReload: { sidekickPanel.reload() }, - onStopLoading: { sidekickPanel.stopLoading() }, - onNavigate: { panel.navigateSidekick(input: $0) }, - onRecordCurrentURL: { panel.recordSidekickCurrentURL($0) }, - onClose: { panel.closeSidekick() } + BrowserPanelView( + panel: sidekickPanel, + paneId: paneId, + isFocused: false, + isVisibleInUI: isVisibleInUI, + portalPriority: portalPriority + 1, + onRequestPanelFocus: onFocus, + usesProvidedPaneContext: true, + embeddedCloseAction: { panel.closeSidekick() } ) + .environment(\.paneDropZone, nil) + .accessibilityIdentifier("TerminalSidekickDrawer") .frame(width: sidekickWidth) .frame(maxHeight: .infinity) .transition(.move(edge: .trailing).combined(with: .opacity)) @@ -216,201 +216,6 @@ private struct TerminalSidekickDivider: View { } } -private struct TerminalSidekickBrowserSnapshot: Equatable { - let canGoBack: Bool - let canGoForward: Bool - let isLoading: Bool - let shouldRenderWebView: Bool - let currentURL: URL? - let preferredAddressText: String? - let webViewInstanceID: UUID - - @MainActor - init(browserPanel: BrowserPanel) { - self.canGoBack = browserPanel.canGoBack - self.canGoForward = browserPanel.canGoForward - self.isLoading = browserPanel.isLoading - self.shouldRenderWebView = browserPanel.shouldRenderWebView - self.currentURL = browserPanel.currentURL - self.preferredAddressText = browserPanel.preferredURLStringForOmnibar() - self.webViewInstanceID = browserPanel.webViewInstanceID - } -} - -private struct TerminalSidekickView: View { - let snapshot: TerminalSidekickBrowserSnapshot - let webView: WKWebView - let onGoBack: () -> Void - let onGoForward: () -> Void - let onReload: () -> Void - let onStopLoading: () -> Void - let onNavigate: (String) -> Void - let onRecordCurrentURL: (URL?) -> Void - let onClose: () -> Void - @State private var addressText = "" - @FocusState private var addressFocused: Bool - - private let toolbarButtonSize: CGFloat = 26 - - var body: some View { - VStack(spacing: 0) { - toolbar - Divider() - content - } - .background(Color(nsColor: .windowBackgroundColor)) - .accessibilityIdentifier("TerminalSidekickDrawer") - .accessibilityLabel(String(localized: "terminalSidekick.accessibilityLabel", defaultValue: "Terminal sidekick browser")) - .onAppear { - syncAddressFromSnapshot() - } - .onChange(of: snapshot.currentURL) { _, url in - onRecordCurrentURL(url) - guard !addressFocused else { return } - syncAddressFromSnapshot() - } - .onChange(of: snapshot.preferredAddressText) { _, _ in - guard !addressFocused else { return } - syncAddressFromSnapshot() - } - .onChange(of: addressFocused) { _, focused in - if focused { - addressText = snapshot.preferredAddressText ?? addressText - } else { - syncAddressFromSnapshot() - } - } - } - - private var toolbar: some View { - HStack(spacing: 4) { - toolbarButton( - systemName: "chevron.left", - help: String(localized: "browser.goBack", defaultValue: "Go Back"), - action: onGoBack - ) - .disabled(!snapshot.canGoBack) - .opacity(snapshot.canGoBack ? 1 : 0.4) - - toolbarButton( - systemName: "chevron.right", - help: String(localized: "browser.goForward", defaultValue: "Go Forward"), - action: onGoForward - ) - .disabled(!snapshot.canGoForward) - .opacity(snapshot.canGoForward ? 1 : 0.4) - - toolbarButton( - systemName: snapshot.isLoading ? "xmark" : "arrow.clockwise", - help: snapshot.isLoading - ? String(localized: "browser.stop", defaultValue: "Stop") - : String(localized: "browser.reload", defaultValue: "Reload"), - action: { - if snapshot.isLoading { - onStopLoading() - } else { - onReload() - } - } - ) - - TextField( - String(localized: "terminalSidekick.addressPlaceholder", defaultValue: "Enter URL or search"), - text: $addressText - ) - .textFieldStyle(.plain) - .font(.system(size: 12)) - .focused($addressFocused) - .onSubmit(commitAddress) - .padding(.horizontal, 8) - .frame(height: 24) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor)) - ) - .accessibilityIdentifier("TerminalSidekickAddressField") - .accessibilityLabel(String(localized: "terminalSidekick.addressLabel", defaultValue: "Sidekick address")) - - toolbarButton( - systemName: "arrow.right.circle.fill", - help: String(localized: "terminalSidekick.open", defaultValue: "Open in Sidekick"), - action: commitAddress - ) - .disabled(addressText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - .opacity(addressText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? 0.4 : 1) - - toolbarButton( - systemName: "sidebar.trailing", - help: String(localized: "terminalSidekick.collapse", defaultValue: "Collapse Sidekick"), - action: onClose - ) - } - .padding(.horizontal, 6) - .padding(.vertical, 5) - } - - @ViewBuilder - private var content: some View { - if snapshot.shouldRenderWebView { - TerminalSidekickWebViewRepresentable(webView: webView) - .id(snapshot.webViewInstanceID) - .accessibilityIdentifier("TerminalSidekickWebView") - } else { - Color(nsColor: .textBackgroundColor) - .accessibilityIdentifier("TerminalSidekickBlankWebView") - } - } - - private func toolbarButton( - systemName: String, - help: String, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - Image(systemName: systemName) - .font(.system(size: 12, weight: .medium)) - .frame(width: toolbarButtonSize, height: toolbarButtonSize) - .contentShape(Rectangle()) - } - .buttonStyle(TerminalSidekickToolbarButtonStyle()) - .safeHelp(help) - } - - private func commitAddress() { - let trimmed = addressText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } - onNavigate(trimmed) - addressFocused = false - } - - private func syncAddressFromSnapshot() { - addressText = snapshot.preferredAddressText ?? "" - } -} - -private struct TerminalSidekickToolbarButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .foregroundStyle(Color.primary.opacity(configuration.isPressed ? 0.65 : 0.9)) - .background( - RoundedRectangle(cornerRadius: 5, style: .continuous) - .fill(configuration.isPressed ? Color.primary.opacity(0.12) : Color.clear) - ) - } -} - -private struct TerminalSidekickWebViewRepresentable: NSViewRepresentable { - let webView: WKWebView - - func makeNSView(context: Context) -> WKWebView { - webView - } - - func updateNSView(_ nsView: WKWebView, context: Context) { - nsView.allowsBackForwardNavigationGestures = true - } -} - /// Shared appearance settings for panels struct PanelAppearance { let backgroundColor: NSColor diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index 9adc8cc8a279..842ed54f0327 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -2537,6 +2537,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { let representable = WebViewRepresentable( panel: panel, paneId: paneId, + usesProvidedPaneContext: false, shouldAttachWebView: true, useLocalInlineHosting: false, shouldFocusWebView: false, @@ -2579,6 +2580,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { let representable = WebViewRepresentable( panel: panel, paneId: paneId, + usesProvidedPaneContext: false, shouldAttachWebView: true, useLocalInlineHosting: false, shouldFocusWebView: false, @@ -2640,6 +2642,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { let representable = WebViewRepresentable( panel: panel, paneId: paneId, + usesProvidedPaneContext: false, shouldAttachWebView: false, useLocalInlineHosting: true, shouldFocusWebView: false, @@ -2723,6 +2726,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { let representable = WebViewRepresentable( panel: panel, paneId: paneId, + usesProvidedPaneContext: false, shouldAttachWebView: false, useLocalInlineHosting: true, shouldFocusWebView: false, From 17b98bb5fa29d16043b6f2238442b9fc63bcb9e0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 17 May 2026 23:28:57 -0700 Subject: [PATCH 15/46] fix: expose sidekick browser view initializer --- Sources/Panels/BrowserPanelView.swift | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 62fe2cf9bbd8..5433bc864218 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -389,8 +389,8 @@ struct BrowserPanelView: View { let isVisibleInUI: Bool let portalPriority: Int let onRequestPanelFocus: () -> Void - let usesProvidedPaneContext: Bool = false - let embeddedCloseAction: (() -> Void)? = nil + let usesProvidedPaneContext: Bool + let embeddedCloseAction: (() -> Void)? @Environment(\.colorScheme) private var colorScheme @Environment(\.openWindow) private var openWindow @Environment(\.paneDropZone) private var paneDropZone @@ -442,6 +442,26 @@ struct BrowserPanelView: View { private let addressBarVerticalPadding: CGFloat = 4 private let devToolsButtonIconSize: CGFloat = 11 + init( + panel: BrowserPanel, + paneId: PaneID, + isFocused: Bool, + isVisibleInUI: Bool, + portalPriority: Int, + onRequestPanelFocus: @escaping () -> Void, + usesProvidedPaneContext: Bool = false, + embeddedCloseAction: (() -> Void)? = nil + ) { + self.panel = panel + self.paneId = paneId + self.isFocused = isFocused + self.isVisibleInUI = isVisibleInUI + self.portalPriority = portalPriority + self.onRequestPanelFocus = onRequestPanelFocus + self.usesProvidedPaneContext = usesProvidedPaneContext + self.embeddedCloseAction = embeddedCloseAction + } + private var searchEngine: BrowserSearchEngine { BrowserSearchEngine(rawValue: searchEngineRaw) ?? BrowserSearchSettings.defaultSearchEngine } From a7c0a27d86bf62bc545e1c76b68460db4692f260 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 00:09:06 -0700 Subject: [PATCH 16/46] fix: preserve codex hook trust ownership --- CLI/cmux.swift | 286 +++++++++++++++++++++++++++++---- tests/test_codex_feed_hooks.py | 111 +++++++++++++ 2 files changed, 369 insertions(+), 28 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 9ba1a6fd33de..8fc961b088a2 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -19996,6 +19996,11 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { hooksFilePath: filePath, def: def ) + let codexHookTrustEscapedKeyPrefixes = Self.codexHookTrustEscapedKeyPrefixes( + hooksFilePath: filePath, + def: def + ) + let codexLegacyHookTrustHashes = Self.codexLegacyHookTrustHashes(def: def) let newData = try JSONSerialization.data(withJSONObject: existing, options: [.prettyPrinted, .sortedKeys]) let newString = String(data: newData, encoding: .utf8) ?? "{}" @@ -20044,10 +20049,18 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } else { existingContent = "" } - let featureContent = Self.codexConfigTomlInstallingHooksFeature(in: existingContent) + let trustClean = Self.codexConfigTomlRemovingHookTrust( + in: existingContent, + entries: codexHookTrustEntries, + removingEscapedKeyPrefixes: codexHookTrustEscapedKeyPrefixes, + removingTrustedHashes: codexLegacyHookTrustHashes + ) + let featureContent = Self.codexConfigTomlInstallingHooksFeature(in: trustClean) let trustInstall = Self.codexConfigTomlInstallingHookTrust( in: featureContent, - entries: codexHookTrustEntries + entries: codexHookTrustEntries, + removingEscapedKeyPrefixes: codexHookTrustEscapedKeyPrefixes, + removingTrustedHashes: codexLegacyHookTrustHashes ) let newContent = trustInstall.content if newContent != existingContent { @@ -20108,6 +20121,21 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } var hooks = json["hooks"] as? [String: Any] ?? [:] + let codexHookTrustEntriesToRemove = Self.codexHookTrustEntries( + hooks: hooks, + hooksFilePath: filePath, + def: def, + includeLegacyOwnedCommands: true + ) + let codexStaleHookTrustHashesToRemove = Set(Self.codexHookTrustEntries( + hooks: buildHooksDict(for: def), + hooksFilePath: filePath, + def: def + ).map(\.trustedHash)).union(Self.codexLegacyHookTrustHashes(def: def)) + let codexHookTrustEscapedKeyPrefixesToRemove = Self.codexHookTrustEscapedKeyPrefixes( + hooksFilePath: filePath, + def: def + ) var removed = 0 let isCmuxOwnedCommand: (String) -> Bool = { cmd in @@ -20159,7 +20187,12 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } catch { throw CLIError(message: "\(configPath) exists but could not be read. Fix permissions or remove it before uninstalling \(def.displayName) hooks. \(String(describing: error))") } - let newContent = Self.codexConfigTomlUninstallingHooksFeature(from: content) + let newContent = Self.codexConfigTomlUninstallingHooksFeature( + from: content, + removingHookTrustEntries: codexHookTrustEntriesToRemove, + removingEscapedKeyPrefixes: codexHookTrustEscapedKeyPrefixesToRemove, + removingTrustedHashes: codexStaleHookTrustHashesToRemove + ) if newContent != content { try newContent.write(toFile: configPath, atomically: true, encoding: .utf8) print("Removed Codex hooks feature from \(configPath)") @@ -20200,6 +20233,9 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { "# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 begin" private static let cmuxCodexHookTrustEnd = "# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 end" + private static let codexHookTrustTableHeaderRegex = try! NSRegularExpression( + pattern: #"^\s*\[\s*hooks\s*\.\s*state\s*\.\s*"((?:[^"\\\n]|\\.)*)"\s*\]\s*(#.*)?$"# + ) struct CodexHookTrustEntry: Equatable { let key: String @@ -20220,9 +20256,6 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { static func codexConfigTomlInstallingHooksFeature(in existingContent: String) -> String { var lines = tomlLines(from: existingContent) removeCmuxCodexHooksFeatureBlock(from: &lines) - if removeCmuxCodexHookTrustBlock(from: &lines) == .malformed { - stripMalformedCmuxCodexHookTrustMarker(from: &lines) - } lines.removeAll { tomlLineDefinesKey("codex_hooks", line: $0) } lines.removeAll { tomlLineDefinesDottedFeaturesKey("codex_hooks", line: $0) } @@ -20284,34 +20317,75 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { return lines } - static func codexConfigTomlUninstallingHooksFeature(from existingContent: String) -> String { + static func codexConfigTomlUninstallingHooksFeature( + from existingContent: String, + removingHookTrustEntries entries: [CodexHookTrustEntry] = [], + removingEscapedKeyPrefixes escapedKeyPrefixes: Set = [], + removingTrustedHashes additionalTrustedHashes: Set = [] + ) -> String { var lines = tomlLines(from: existingContent) + let escapedKeys = Set(entries.map { tomlBasicStringContent($0.key) }) + let trustedHashes = Set(entries.map(\.trustedHash)).union(additionalTrustedHashes) removeCmuxCodexHooksFeatureBlock(from: &lines) - if removeCmuxCodexHookTrustBlock(from: &lines) == .malformed { + if removeCmuxCodexHookTrustBlock( + from: &lines, + removingEscapedKeys: escapedKeys, + removingEscapedKeyPrefixes: escapedKeyPrefixes, + removingTrustedHashes: trustedHashes + ) == .malformed { stripMalformedCmuxCodexHookTrustMarker(from: &lines) } + removeCodexHookTrustTables(withEscapedKeys: escapedKeys, from: &lines) lines.removeAll { tomlLineDefinesKey("codex_hooks", line: $0) } lines.removeAll { tomlLineDefinesDottedFeaturesKey("codex_hooks", line: $0) } removeEmptyFeaturesTable(from: &lines) return tomlContent(from: lines) } + private static func codexConfigTomlRemovingHookTrust( + in existingContent: String, + entries: [CodexHookTrustEntry], + removingEscapedKeyPrefixes escapedKeyPrefixes: Set, + removingTrustedHashes additionalTrustedHashes: Set = [] + ) -> String { + var lines = tomlLines(from: existingContent) + let escapedKeys = Set(entries.map { tomlBasicStringContent($0.key) }) + let trustedHashes = Set(entries.map(\.trustedHash)).union(additionalTrustedHashes) + let removalResult = removeCmuxCodexHookTrustBlock( + from: &lines, + removingEscapedKeys: escapedKeys, + removingEscapedKeyPrefixes: escapedKeyPrefixes, + removingTrustedHashes: trustedHashes + ) + if removalResult == .malformed { + stripMalformedCmuxCodexHookTrustMarker(from: &lines) + } + removeCodexHookTrustTables(withEscapedKeys: escapedKeys, from: &lines) + return tomlContent(from: lines) + } + private static func codexConfigTomlInstallingHookTrust( in existingContent: String, - entries: [CodexHookTrustEntry] + entries: [CodexHookTrustEntry], + removingEscapedKeyPrefixes escapedKeyPrefixes: Set = [], + removingTrustedHashes additionalTrustedHashes: Set = [] ) -> CodexHookTrustInstallResult { var lines = tomlLines(from: existingContent) - let removalResult = removeCmuxCodexHookTrustBlock(from: &lines) + let escapedKeys = Set(entries.map { tomlBasicStringContent($0.key) }) + let trustedHashes = Set(entries.map(\.trustedHash)).union(additionalTrustedHashes) + let removalResult = removeCmuxCodexHookTrustBlock( + from: &lines, + removingEscapedKeys: escapedKeys, + removingEscapedKeyPrefixes: escapedKeyPrefixes, + removingTrustedHashes: trustedHashes + ) if removalResult == .malformed { stripMalformedCmuxCodexHookTrustMarker(from: &lines) } guard !entries.isEmpty else { return CodexHookTrustInstallResult(content: tomlContent(from: lines), installedTrust: false) } - removeCodexHookTrustTables( - withEscapedKeys: Set(entries.map { tomlBasicStringContent($0.key) }), - from: &lines - ) + removeCodexHookTrustTables(withEscapedKeys: escapedKeys, from: &lines) if !lines.isEmpty, lines.last?.isEmpty == false { lines.append("") @@ -20328,11 +20402,12 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { static func codexHookTrustEntries( hooks: [String: Any], hooksFilePath: String, - def: AgentHookDef + def: AgentHookDef, + includeLegacyOwnedCommands: Bool = false ) -> [CodexHookTrustEntry] { guard def.name == "codex" else { return [] } let isOwnedCommand: (String) -> Bool = { command in - isCmuxOwnedHookCommand(command, for: def, includeLegacy: false) + isCmuxOwnedHookCommand(command, for: def, includeLegacy: includeLegacyOwnedCommands) } var entries: [CodexHookTrustEntry] = [] let keySource = codexNormalizedHookSourcePath(hooksFilePath) @@ -20368,6 +20443,62 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { return entries } + private static func codexHookTrustEscapedKeyPrefixes( + hooksFilePath: String, + def: AgentHookDef + ) -> Set { + guard def.name == "codex" else { return [] } + return [tomlBasicStringContent("\(codexNormalizedHookSourcePath(hooksFilePath)):")] + } + + private static func codexLegacyHookTrustHashes(def: AgentHookDef) -> Set { + guard def.name == "codex" else { return [] } + let hookTimeoutMs: Int + if case .nested(let timeoutMs) = def.format { + hookTimeoutMs = timeoutMs + } else { + hookTimeoutMs = 600 + } + + var hashes = Set() + func insertHashes(eventLabel: String, command: String, timeouts: [Int]) { + let commands = [ + command, + "[ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && command -v cmux >/dev/null 2>&1 && \(command) || echo '{}'", + ] + for command in commands { + for timeout in timeouts { + hashes.insert(codexCommandHookHash( + eventLabel: eventLabel, + matcher: nil, + command: command, + timeoutMs: timeout, + statusMessage: nil + )) + } + } + } + + for event in def.events { + guard let eventLabel = codexHookEventLabel(event.agentEvent) else { continue } + insertHashes( + eventLabel: eventLabel, + command: "cmux codex-hook \(event.cmuxSubcommand)", + timeouts: [hookTimeoutMs, 600] + ) + } + + for agentEvent in def.feedHookEvents { + guard let eventLabel = codexHookEventLabel(agentEvent) else { continue } + insertHashes( + eventLabel: eventLabel, + command: "cmux feed-hook --source \(def.name) --event \(agentEvent)", + timeouts: [120_000, 600] + ) + } + return hashes + } + private static func codexNormalizedHookSourcePath(_ path: String) -> String { let url = URL(fileURLWithPath: path).standardizedFileURL if let resolved = realPath(url.path) { @@ -20617,8 +20748,13 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { } @discardableResult - private static func removeCmuxCodexHookTrustBlock(from lines: inout [String]) -> CodexHookTrustBlockRemovalResult { - var ranges: [ClosedRange] = [] + private static func removeCmuxCodexHookTrustBlock( + from lines: inout [String], + removingEscapedKeys escapedKeys: Set = [], + removingEscapedKeyPrefixes escapedKeyPrefixes: Set = [], + removingTrustedHashes trustedHashes: Set = [] + ) -> CodexHookTrustBlockRemovalResult { + var replacements: [(range: ClosedRange, lines: [String])] = [] var index = 0 while index < lines.count { guard lines[index] == cmuxCodexHookTrustBegin else { @@ -20629,14 +20765,94 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { guard let endIndex = lines[index...].firstIndex(of: cmuxCodexHookTrustEnd) else { return .malformed } - ranges.append(index...endIndex) + let preservedLines = codexHookTrustBlockUnownedLines( + from: lines[(index + 1).., + removingEscapedKeys escapedKeys: Set, + removingEscapedKeyPrefixes escapedKeyPrefixes: Set, + removingTrustedHashes trustedHashes: Set + ) -> [String] { + var preserved: [String] = [] + var index = lines.startIndex + while index < lines.endIndex { + if let escapedKey = codexHookTrustTableEscapedKey(from: lines[index]) { + let tableStart = index + index += 1 + while index < lines.endIndex, !tomlLineIsCodexHookTrustBlockTableBoundary(lines[index]) { + index += 1 + } + if !codexHookTrustEscapedKeyIsRemoved( + escapedKey, + trustedHash: codexHookTrustTrustedHash(from: lines[tableStart.., + removingEscapedKeyPrefixes escapedKeyPrefixes: Set, + removingTrustedHashes trustedHashes: Set + ) -> Bool { + if escapedKeys.contains(escapedKey) { + return true + } + guard let trustedHash, trustedHashes.contains(trustedHash) else { + return false + } + return escapedKeyPrefixes.contains { escapedKey.hasPrefix($0) } + } + + private static func codexHookTrustTrustedHash(from lines: ArraySlice) -> String? { + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard let equalsIndex = trimmed.firstIndex(of: "=") else { continue } + let key = String(trimmed[..= 2 else { continue } + return String(value.dropFirst().dropLast()) + } + return nil } private static func stripMalformedCmuxCodexHookTrustMarker(from lines: inout [String]) { @@ -20652,19 +20868,33 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { index += 1 continue } - let endIndex = tomlTableEndIndex(in: lines, after: index) + let endIndex = codexHookTrustTableEndIndex(in: lines, after: index) lines.removeSubrange(index.. Int { + var index = tableStart + 1 + while index < lines.count { + if tomlLineIsCodexHookTrustBlockTableBoundary(lines[index]) { + return index + } + index += 1 + } + return lines.count + } + private static func codexHookTrustTableEscapedKey(from line: String) -> String? { - let trimmed = line.trimmingCharacters(in: .whitespaces) - let prefix = "[hooks.state.\"" - let suffix = "\"]" - guard trimmed.hasPrefix(prefix), trimmed.hasSuffix(suffix) else { + let range = NSRange(line.startIndex.. Bool { + codexHookTrustTableEscapedKey(from: line) != nil || tomlLineIsAnyTableHeader(line) } private static func tomlLineIsCodexHooksFeatureBegin(_ line: String) -> Bool { diff --git a/tests/test_codex_feed_hooks.py b/tests/test_codex_feed_hooks.py index 43aeaaaad14a..5786ec967049 100644 --- a/tests/test_codex_feed_hooks.py +++ b/tests/test_codex_feed_hooks.py @@ -1373,6 +1373,115 @@ def test_install_recovers_hook_trust_when_cmux_marker_is_unclosed( ) +def test_install_preserves_third_party_hook_trust_inside_cmux_marker( + cli_path: str, root: Path +) -> None: + codex_home = root / "codex-home-stale-trust-with-third-party" + codex_home.mkdir() + hooks_path = codex_home / "hooks.json" + stale_key = f"{hooks_path.resolve()}:pre_tool_use:0:0" + third_party_key = "/tmp/third-party/hooks.json:pre_tool_use:0:0" + config_path = codex_home / "config.toml" + config_path.write_text( + "[features]\n" + "hooks = true\n" + "# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 begin\n" + f'[ hooks . state . "{stale_key}" ] # stale cmux trust\n' + 'trusted_hash = "sha256:stale"\n' + f'[hooks.state."{third_party_key}"]\n' + 'trusted_hash = "sha256:third-party"\n' + "# cmux-codex-hook-trust-f5cc24da-7a09-4b20-a756-89e7786f6738 end\n", + encoding="utf-8", + ) + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + + result = subprocess.run( + [cli_path, "hooks", "codex", "install", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode != 0: + raise AssertionError( + f"hooks codex install failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + config_toml = config_path.read_text(encoding="utf-8") + if 'trusted_hash = "sha256:third-party"' not in config_toml: + raise AssertionError(f"third-party hook trust was removed: {config_toml!r}") + if 'trusted_hash = "sha256:stale"' in config_toml: + raise AssertionError(f"stale cmux hook trust was preserved: {config_toml!r}") + hooks = json.loads(hooks_path.read_text(encoding="utf-8")) + state = codex_hook_trust_state(config_toml) + expected_trust = expected_cmux_codex_hook_trust(hooks, hooks_path) + for key, trusted_hash in expected_trust.items(): + if state.get(key, {}).get("trusted_hash") != trusted_hash: + raise AssertionError( + f"missing fresh trusted hash for {key}: expected {trusted_hash!r}, got state {state!r}" + ) + + +def test_uninstall_removes_standalone_cmux_hook_trust_tables( + cli_path: str, root: Path +) -> None: + codex_home = root / "codex-home-uninstall-standalone-trust" + codex_home.mkdir() + env = os.environ.copy() + env["CODEX_HOME"] = str(codex_home) + + result = subprocess.run( + [cli_path, "hooks", "codex", "install", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode != 0: + raise AssertionError( + f"hooks codex install failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + hooks_path = codex_home / "hooks.json" + hooks = json.loads(hooks_path.read_text(encoding="utf-8")) + expected_trust = expected_cmux_codex_hook_trust(hooks, hooks_path) + if not expected_trust: + raise AssertionError(f"expected cmux Codex trust entries, got {expected_trust!r}") + + cmux_key, cmux_hash = next(iter(expected_trust.items())) + third_party_key = "/tmp/third-party/hooks.json:pre_tool_use:0:0" + config_path = codex_home / "config.toml" + with config_path.open("a", encoding="utf-8") as handle: + handle.write( + f'[hooks.state."{cmux_key}"]\n' + f'trusted_hash = "{cmux_hash}"\n' + f'[hooks.state."{third_party_key}"]\n' + 'trusted_hash = "sha256:third-party"\n' + ) + + result = subprocess.run( + [cli_path, "hooks", "codex", "uninstall", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if result.returncode != 0: + raise AssertionError( + f"hooks codex uninstall failed exit={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + config_toml = config_path.read_text(encoding="utf-8") + if cmux_key in config_toml or cmux_hash in config_toml: + raise AssertionError(f"standalone cmux hook trust was preserved: {config_toml!r}") + if 'trusted_hash = "sha256:third-party"' not in config_toml: + raise AssertionError(f"third-party hook trust was removed: {config_toml!r}") + + def test_uninstall_codex_hooks_removes_legacy_managed_block(cli_path: str, root: Path) -> None: codex_home = root / "codex-home-legacy-uninstall" codex_home.mkdir() @@ -1629,6 +1738,8 @@ def main() -> int: test_uninstall_removes_cmux_owned_codex_hooks_feature(cli_path, root) test_uninstall_preserves_unowned_hook_trust_when_cmux_marker_is_unclosed(cli_path, root) test_install_recovers_hook_trust_when_cmux_marker_is_unclosed(cli_path, root) + test_install_preserves_third_party_hook_trust_inside_cmux_marker(cli_path, root) + test_uninstall_removes_standalone_cmux_hook_trust_tables(cli_path, root) test_uninstall_codex_hooks_removes_legacy_managed_block(cli_path, root) test_install_surfaces_invalid_codex_config_encoding(cli_path, root) test_uninstall_surfaces_invalid_codex_config_encoding(cli_path, root) From 151a125009dc3e5754037864cd02e70ba28eb4b9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 00:24:43 -0700 Subject: [PATCH 17/46] test: cover review feedback regressions --- .../AgentLaunchSanitizerTests.swift | 29 ++++ cmuxTests/NotificationAndMenuBarTests.swift | 127 +++++++++++++++++- 2 files changed, 154 insertions(+), 2 deletions(-) diff --git a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift index e744f5f3dfb0..1510e0635661 100644 --- a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift +++ b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift @@ -69,6 +69,35 @@ struct AgentLaunchSanitizerTests { ) } + @Test("Stops Codex variadic add-dir before resume subcommand") + func stopsCodexVariadicAddDirBeforeResumeSubcommand() { + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + [ + "codex", + "--add-dir", + "/tmp/shared", + "resume", + "019dad34-d218-7943-b81a-eddac5c87951", + "--model", + "gpt-5.4", + "--sandbox", + "danger-full-access", + ], + launcher: "codex", + fallbackKind: "codex" + ) == [ + "codex", + "--add-dir", + "/tmp/shared", + "--model", + "gpt-5.4", + "--sandbox", + "danger-full-access", + ] + ) + } + @Test("Drops Pi session selectors and prompt while preserving configuration") func dropsPiSessionSelectorsAndPrompt() { #expect( diff --git a/cmuxTests/NotificationAndMenuBarTests.swift b/cmuxTests/NotificationAndMenuBarTests.swift index 2e2f716752c8..36f694bbfd3c 100644 --- a/cmuxTests/NotificationAndMenuBarTests.swift +++ b/cmuxTests/NotificationAndMenuBarTests.swift @@ -422,10 +422,94 @@ final class GhosttyCrashBreadcrumbTests: XCTestCase { defaults: defaults ) - XCTAssertEqual(pending?.fileURL, crashURL) + XCTAssertEqual(pending?.fileURL.resolvingSymlinksInPath(), crashURL.resolvingSymlinksInPath()) XCTAssertEqual(pending?.modifiedAt, crashDate) } + func testPendingCrashDetectedFromMatchingEnvelopeWhenNewerThanCleanExit() throws { + let cleanExit = Date(timeIntervalSince1970: 100) + let crashDate = Date(timeIntervalSince1970: 200) + defaults.set(cleanExit, forKey: GhosttyCrashBreadcrumb.lastCleanExitDefaultsKey) + let currentExecutablePath = try XCTUnwrap(Bundle.main.executableURL?.path) + let crashURL = try writeCrashEnvelope( + named: "matching-newer.ghosttycrash", + executablePath: currentExecutablePath, + modifiedAt: crashDate + ) + + let pending = GhosttyCrashBreadcrumb.pendingCrash( + in: crashDirectoryURL, + defaults: defaults + ) + + XCTAssertEqual(pending?.fileURL.resolvingSymlinksInPath(), crashURL.resolvingSymlinksInPath()) + XCTAssertEqual(pending?.modifiedAt, crashDate) + } + + func testPendingCrashIgnoresNewerCrashFromDifferentExecutable() throws { + let currentCrashDate = Date(timeIntervalSince1970: 200) + let foreignCrashDate = Date(timeIntervalSince1970: 300) + let currentExecutablePath = try XCTUnwrap(Bundle.main.executableURL?.path) + let currentCrashURL = try writeCrashEnvelope( + named: "current.ghosttycrash", + executablePath: currentExecutablePath, + modifiedAt: currentCrashDate + ) + _ = try writeCrashEnvelope( + named: "foreign.ghosttycrash", + executablePath: "/private/tmp/cmux-tbinput-unit/Build/Products/Debug/cmux DEV.app/Contents/MacOS/cmux DEV", + modifiedAt: foreignCrashDate + ) + + let pending = GhosttyCrashBreadcrumb.pendingCrash( + in: crashDirectoryURL, + defaults: defaults + ) + + XCTAssertEqual(pending?.fileURL.resolvingSymlinksInPath(), currentCrashURL.resolvingSymlinksInPath()) + XCTAssertEqual(pending?.modifiedAt, currentCrashDate) + } + + func testPendingCrashIgnoresForeignCrashWhenEventIsNotFirstEnvelopeItem() throws { + let currentCrashDate = Date(timeIntervalSince1970: 200) + let foreignCrashDate = Date(timeIntervalSince1970: 300) + let currentExecutablePath = try XCTUnwrap(Bundle.main.executableURL?.path) + let currentCrashURL = try writeCrashEnvelope( + named: "current-before-foreign-leading-item.ghosttycrash", + executablePath: currentExecutablePath, + modifiedAt: currentCrashDate + ) + _ = try writeCrashEnvelope( + named: "foreign-leading-item.ghosttycrash", + executablePath: "/private/tmp/cmux-tbinput-unit/Build/Products/Debug/cmux DEV.app/Contents/MacOS/cmux DEV", + modifiedAt: foreignCrashDate, + leadingItems: [ + (type: "attachment", payload: Data(#"{"filename":"metadata.txt"}"#.utf8)), + ] + ) + + let pending = GhosttyCrashBreadcrumb.pendingCrash( + in: crashDirectoryURL, + defaults: defaults + ) + + XCTAssertEqual(pending?.fileURL.resolvingSymlinksInPath(), currentCrashURL.resolvingSymlinksInPath()) + XCTAssertEqual(pending?.modifiedAt, currentCrashDate) + } + + func testPendingCrashReturnsNilForOnlyDifferentExecutableCrash() throws { + _ = try writeCrashEnvelope( + named: "foreign-only.ghosttycrash", + executablePath: "/private/tmp/cmux-tbinput-unit/Build/Products/Debug/cmux DEV.app/Contents/MacOS/cmux DEV", + modifiedAt: Date(timeIntervalSince1970: 300) + ) + + XCTAssertNil(GhosttyCrashBreadcrumb.pendingCrash( + in: crashDirectoryURL, + defaults: defaults + )) + } + func testDefaultCrashDirectoryUsesCmuxStatePath() throws { XCTAssertTrue( GhosttyCrashBreadcrumb.defaultCrashDirectoryURL.path.hasSuffix("/.local/state/cmux/crash"), @@ -440,7 +524,7 @@ final class GhosttyCrashBreadcrumbTests: XCTestCase { in: crashDirectoryURL, defaults: defaults )) - XCTAssertEqual(pending.fileURL, crashURL) + XCTAssertEqual(pending.fileURL.resolvingSymlinksInPath(), crashURL.resolvingSymlinksInPath()) GhosttyCrashBreadcrumb.markShown(pending, defaults: defaults) @@ -459,6 +543,45 @@ final class GhosttyCrashBreadcrumbTests: XCTestCase { ) return url } + + private func writeCrashEnvelope( + named name: String, + executablePath: String, + modifiedAt: Date, + leadingItems: [(type: String, payload: Data)] = [] + ) throws -> URL { + let url = crashDirectoryURL.appendingPathComponent(name) + let event = [ + "debug_meta": [ + "images": [ + [ + "code_file": executablePath, + ], + ], + ], + ] + let eventData = try JSONSerialization.data(withJSONObject: event) + let eventHeader = #"{"type":"event","length":\#(eventData.count)}"# + var envelope = Data(#"{"event_id":"00000000-0000-0000-0000-000000000000"}"#.utf8) + envelope.append(0x0A) + for item in leadingItems { + let itemHeader = #"{"type":"\#(item.type)","length":\#(item.payload.count)}"# + envelope.append(Data(itemHeader.utf8)) + envelope.append(0x0A) + envelope.append(item.payload) + envelope.append(0x0A) + } + envelope.append(Data(eventHeader.utf8)) + envelope.append(0x0A) + envelope.append(eventData) + envelope.append(0x0A) + try envelope.write(to: url) + try FileManager.default.setAttributes( + [.modificationDate: modifiedAt], + ofItemAtPath: url.path + ) + return url + } } @MainActor From d2a07b7976017bf4010dd9800bd7b613046650b2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 00:26:35 -0700 Subject: [PATCH 18/46] fix: address review feedback regressions --- .../AgentLaunchSanitizer.swift | 4 + Sources/GhosttyCrashBreadcrumb.swift | 106 +++++++++++++++++- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift index 07edf583ddea..8c4425d1d059 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift @@ -218,6 +218,10 @@ public enum AgentLaunchSanitizer { if policy.variadicOptions.contains(arg) { var end = index + 1 while end < args.count, !args[end].hasPrefix("-") { + if let resumeSubcommand = policy.resumeSubcommand, + args[end] == resumeSubcommand { + break + } end += 1 } return max(1, end - index) diff --git a/Sources/GhosttyCrashBreadcrumb.swift b/Sources/GhosttyCrashBreadcrumb.swift index 67b6e2858fbf..cf4ffb14ca85 100644 --- a/Sources/GhosttyCrashBreadcrumb.swift +++ b/Sources/GhosttyCrashBreadcrumb.swift @@ -25,9 +25,14 @@ nonisolated enum GhosttyCrashBreadcrumb { static func pendingCrash( in crashDirectoryURL: URL = defaultCrashDirectoryURL, defaults: UserDefaults = .standard, - fileManager: FileManager = .default + fileManager: FileManager = .default, + currentExecutableURL: URL? = Bundle.main.executableURL ) -> PendingCrash? { - guard let latest = latestCrashFile(in: crashDirectoryURL, fileManager: fileManager) else { + guard let latest = latestCrashFile( + in: crashDirectoryURL, + fileManager: fileManager, + currentExecutableURL: currentExecutableURL + ) else { return nil } @@ -49,7 +54,8 @@ nonisolated enum GhosttyCrashBreadcrumb { private static func latestCrashFile( in crashDirectoryURL: URL, - fileManager: FileManager + fileManager: FileManager, + currentExecutableURL: URL? ) -> PendingCrash? { guard let urls = try? fileManager.contentsOfDirectory( at: crashDirectoryURL, @@ -61,6 +67,7 @@ nonisolated enum GhosttyCrashBreadcrumb { return urls .filter { $0.pathExtension == "ghosttycrash" } + .filter { crashReportMatchesCurrentExecutable($0, currentExecutableURL: currentExecutableURL) } .compactMap { url -> PendingCrash? in guard let values = try? url.resourceValues(forKeys: [.contentModificationDateKey]), let modifiedAt = values.contentModificationDate else { @@ -70,4 +77,97 @@ nonisolated enum GhosttyCrashBreadcrumb { } .max { lhs, rhs in lhs.modifiedAt < rhs.modifiedAt } } + + private static func crashReportMatchesCurrentExecutable(_ url: URL, currentExecutableURL: URL?) -> Bool { + guard let currentExecutableURL else { return true } + guard let reportedExecutablePaths = GhosttyCrashReportMetadata.reportedExecutablePaths(in: url) else { return true } + let currentExecutablePath = GhosttyCrashReportMetadata.normalizedPath(currentExecutableURL.path) + return reportedExecutablePaths.contains(currentExecutablePath) + } +} + +private enum GhosttyCrashReportMetadata { + static func reportedExecutablePaths(in url: URL) -> Set? { + guard let data = try? Data(contentsOf: url, options: .mappedIfSafe), + let event = sentryEvent(from: data), + let debugMeta = event["debug_meta"] as? [String: Any], + let images = debugMeta["images"] as? [[String: Any]] + else { + return nil + } + + let paths = images.compactMap { image -> String? in + guard let codeFile = image["code_file"] as? String else { return nil } + let trimmedPath = codeFile.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPath.isEmpty else { return nil } + return normalizedPath(trimmedPath) + } + return paths.isEmpty ? nil : Set(paths) + } + + static func normalizedPath(_ path: String) -> String { + URL(fileURLWithPath: path) + .standardizedFileURL + .resolvingSymlinksInPath() + .path + } + + private static func sentryEvent(from data: Data) -> [String: Any]? { + guard let envelopeHeaderRange = lineRange(after: data.startIndex, in: data) else { + return nil + } + + var itemHeaderStart = data.index(after: envelopeHeaderRange.upperBound) + while itemHeaderStart < data.endIndex { + guard let itemHeaderRange = lineRange(after: itemHeaderStart, in: data), + let itemHeader = jsonObject(in: itemHeaderRange, from: data) + else { + return nil + } + + let payloadStart = data.index(after: itemHeaderRange.upperBound) + let payloadRange: Range + if let length = itemHeader["length"] as? Int { + guard length >= 0, + let payloadEnd = data.index(payloadStart, offsetBy: length, limitedBy: data.endIndex) + else { + return nil + } + payloadRange = payloadStart.. Range? { + guard startIndex < data.endIndex, + let newlineIndex = data[startIndex...].firstIndex(of: 0x0A) + else { + return nil + } + return startIndex.., from data: Data) -> [String: Any]? { + guard range.lowerBound <= range.upperBound else { return nil } + guard let object = try? JSONSerialization.jsonObject(with: data.subdata(in: range)) else { + return nil + } + return object as? [String: Any] + } } From 8c54a504cbed21388028a5380ddd71f8d3db465f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 02:11:14 -0700 Subject: [PATCH 19/46] fix: satisfy CI concurrency checks --- Sources/Panels/FilePreviewPanel.swift | 6 +++++- cmuxTests/WindowAndDragTests.swift | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/Panels/FilePreviewPanel.swift b/Sources/Panels/FilePreviewPanel.swift index 4b293a7e9813..42c0ee81f576 100644 --- a/Sources/Panels/FilePreviewPanel.swift +++ b/Sources/Panels/FilePreviewPanel.swift @@ -882,6 +882,10 @@ enum FilePreviewTextSaver { typealias Operation = @Sendable (String, URL, String.Encoding) async -> Result + static let live: Operation = { content, url, encoding in + await save(content: content, to: url, encoding: encoding) + } + static func save(content: String, to url: URL, encoding: String.Encoding) async -> Result { await Task.detached(priority: .userInitiated) { guard let data = content.data(using: encoding) else { @@ -930,7 +934,7 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan init( workspaceId: UUID, filePath: String, - textSaver: @escaping FilePreviewTextSaver.Operation = FilePreviewTextSaver.save + textSaver: @escaping FilePreviewTextSaver.Operation = FilePreviewTextSaver.live ) { self.id = UUID() self.workspaceId = workspaceId diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index d9891c5a8274..3658e2e0165e 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -2263,7 +2263,8 @@ final class FilePreviewPanelTextSavingTests: XCTestCase { let firstSave = try XCTUnwrap(panel.saveTextContent()) XCTAssertTrue(panel.isSaving) - XCTAssertEqual(await saveProbe.waitForSaveStart(), "first save") + let savedContent = await saveProbe.waitForSaveStart() + XCTAssertEqual(savedContent, "first save") panel.updateTextContent("second save") XCTAssertNil(panel.saveTextContent()) From 6a4f61f9ee6d10e1c1fb1a24199c4e380b1fa250 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 19:19:01 -0700 Subject: [PATCH 20/46] fix: restore browser omnibar portal after merge --- Sources/BrowserWindowPortal.swift | 165 +++++++++++++++++++++++++- Sources/Panels/BrowserPanelView.swift | 11 +- 2 files changed, 173 insertions(+), 3 deletions(-) diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 0868608d78e7..a6056fdab02d 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -1279,6 +1279,57 @@ struct BrowserPortalSearchOverlayConfiguration { let onFieldDidFocus: () -> Void } +struct BrowserPortalOmnibarSuggestionsConfiguration { + let panelId: UUID + let popupFrame: CGRect + let colorScheme: ColorScheme + let engineName: String + let items: [OmnibarSuggestion] + let selectedIndex: Int + let isLoadingRemoteSuggestions: Bool + let searchSuggestionsEnabled: Bool + let onCommit: (OmnibarSuggestion) -> Void + let onHighlight: (Int) -> Void +} + +private struct BrowserPortalOmnibarSuggestionsOverlay: View { + let configuration: BrowserPortalOmnibarSuggestionsConfiguration + + var body: some View { + Color.clear + .overlay(alignment: .topLeading) { + OmnibarSuggestionsView( + engineName: configuration.engineName, + items: configuration.items, + selectedIndex: configuration.selectedIndex, + isLoadingRemoteSuggestions: configuration.isLoadingRemoteSuggestions, + searchSuggestionsEnabled: configuration.searchSuggestionsEnabled, + onCommit: configuration.onCommit, + onHighlight: configuration.onHighlight + ) + .frame(width: configuration.popupFrame.width) + .offset(x: configuration.popupFrame.minX, y: configuration.popupFrame.minY) + .environment(\.colorScheme, configuration.colorScheme) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +private final class BrowserPortalOmnibarSuggestionsHostingView: NSHostingView { + var popupFrameInTopLeftCoordinates: CGRect = .zero + + override func hitTest(_ point: NSPoint) -> NSView? { + let topLeftPoint: NSPoint + if isFlipped { + topLeftPoint = point + } else { + topLeftPoint = NSPoint(x: point.x, y: bounds.height - point.y) + } + guard popupFrameInTopLeftCoordinates.contains(topLeftPoint) else { return nil } + return super.hitTest(point) + } +} + struct BrowserPaneDropContext: Equatable { let workspaceId: UUID let panelId: UUID @@ -1426,6 +1477,7 @@ final class WindowBrowserSlotView: NSView { private let paneDropTargetView = BrowserPaneDropTargetView(frame: .zero) private let dropZoneOverlayView = BrowserDropZoneOverlayView(frame: .zero) private var searchOverlayHostingView: NSHostingView? + private var omnibarSuggestionsHostingView: BrowserPortalOmnibarSuggestionsHostingView? private weak var hostedWebView: WKWebView? private var hostedWebViewConstraints: [NSLayoutConstraint] = [] private var forwardedDropZone: DropZone? @@ -1615,6 +1667,7 @@ final class WindowBrowserSlotView: NSView { overlay.trailingAnchor.constraint(equalTo: trailingAnchor), ]) } + bringInteractionLayersToFrontIfNeeded() return } @@ -1635,6 +1688,64 @@ final class WindowBrowserSlotView: NSView { ]) searchOverlayHostingView = overlay logSearchOverlayEvent("create", panelId: configuration.panelId) + bringInteractionLayersToFrontIfNeeded() + } + + private func logOmnibarSuggestionsEvent(_ action: String, configuration: BrowserPortalOmnibarSuggestionsConfiguration?) { +#if DEBUG + cmuxDebugLog( + "browser.omnibar.portal action=\(action) " + + "panel=\(configuration?.panelId.uuidString.prefix(5) ?? "nil") " + + "window=\(window?.windowNumber ?? -1) " + + "items=\(configuration?.items.count ?? 0) " + + "frame=\(browserPortalDebugFrame(configuration?.popupFrame ?? .zero)) " + + "hasOverlay=\(omnibarSuggestionsHostingView != nil ? 1 : 0)" + ) +#endif + } + + func setOmnibarSuggestions(_ configuration: BrowserPortalOmnibarSuggestionsConfiguration?) { + guard let configuration else { + logOmnibarSuggestionsEvent("remove", configuration: nil) + omnibarSuggestionsHostingView?.removeFromSuperview() + omnibarSuggestionsHostingView = nil + return + } + + logOmnibarSuggestionsEvent("set", configuration: configuration) + let rootView = BrowserPortalOmnibarSuggestionsOverlay(configuration: configuration) + + if let overlay = omnibarSuggestionsHostingView { + logOmnibarSuggestionsEvent("updateExisting", configuration: configuration) + overlay.rootView = rootView + overlay.popupFrameInTopLeftCoordinates = configuration.popupFrame + if overlay.superview !== self { + overlay.removeFromSuperview() + addSubview(overlay) + NSLayoutConstraint.activate([ + overlay.topAnchor.constraint(equalTo: topAnchor), + overlay.bottomAnchor.constraint(equalTo: bottomAnchor), + overlay.leadingAnchor.constraint(equalTo: leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + } + bringInteractionLayersToFrontIfNeeded() + return + } + + let overlay = BrowserPortalOmnibarSuggestionsHostingView(rootView: rootView) + overlay.translatesAutoresizingMaskIntoConstraints = false + overlay.popupFrameInTopLeftCoordinates = configuration.popupFrame + addSubview(overlay) + NSLayoutConstraint.activate([ + overlay.topAnchor.constraint(equalTo: topAnchor), + overlay.bottomAnchor.constraint(equalTo: bottomAnchor), + overlay.leadingAnchor.constraint(equalTo: leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + omnibarSuggestionsHostingView = overlay + logOmnibarSuggestionsEvent("create", configuration: configuration) + bringInteractionLayersToFrontIfNeeded() } private func searchOverlayOwnsFieldEditor(_ fieldEditor: NSTextView, in root: NSView) -> Bool { @@ -1861,7 +1972,9 @@ final class WindowBrowserSlotView: NSView { } private func interactionLayerPriority(of view: NSView) -> Int { - if view === paneDropTargetView { return 1 } + if view === paneDropTargetView { return 3 } + if view === omnibarSuggestionsHostingView { return 2 } + if view === searchOverlayHostingView { return 1 } return 0 } @@ -1948,6 +2061,7 @@ final class WindowBrowserPortal: NSObject { var dropZone: DropZone? var paneDropContext: BrowserPaneDropContext? var searchOverlay: BrowserPortalSearchOverlayConfiguration? + var omnibarSuggestions: BrowserPortalOmnibarSuggestionsConfiguration? var paneTopChromeHeight: CGFloat var transientRecoveryReason: String? var transientRecoveryRetriesRemaining: Int @@ -2270,6 +2384,27 @@ final class WindowBrowserPortal: NSObject { } } + private static func omnibarSuggestionsConfigurationsEquivalent( + _ lhs: BrowserPortalOmnibarSuggestionsConfiguration?, + _ rhs: BrowserPortalOmnibarSuggestionsConfiguration? + ) -> Bool { + switch (lhs, rhs) { + case (nil, nil): + return true + case let (lhs?, rhs?): + return lhs.panelId == rhs.panelId && + rectApproximatelyEqual(lhs.popupFrame, rhs.popupFrame, epsilon: 0.5) && + lhs.colorScheme == rhs.colorScheme && + lhs.engineName == rhs.engineName && + lhs.items == rhs.items && + lhs.selectedIndex == rhs.selectedIndex && + lhs.isLoadingRemoteSuggestions == rhs.isLoadingRemoteSuggestions && + lhs.searchSuggestionsEnabled == rhs.searchSuggestionsEnabled + default: + return false + } + } + /// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping. /// SwiftUI/AppKit hosting layers can briefly report an anchor bounds rect larger than the /// visible split pane during rearrangement; intersecting through ancestor bounds keeps the @@ -2516,12 +2651,14 @@ final class WindowBrowserPortal: NSObject { if let existing = entry.containerView { existing.setPaneDropContext(entry.paneDropContext) existing.setSearchOverlay(entry.searchOverlay) + existing.setOmnibarSuggestions(entry.omnibarSuggestions) existing.setPaneTopChromeHeight(entry.paneTopChromeHeight) return existing } let created = WindowBrowserSlotView(frame: .zero) created.setPaneDropContext(entry.paneDropContext) created.setSearchOverlay(entry.searchOverlay) + created.setOmnibarSuggestions(entry.omnibarSuggestions) created.setPaneTopChromeHeight(entry.paneTopChromeHeight) #if DEBUG cmuxDebugLog( @@ -2888,6 +3025,17 @@ final class WindowBrowserPortal: NSObject { entry.containerView?.setSearchOverlay(configuration) } + func updateOmnibarSuggestions( + forWebViewId webViewId: ObjectIdentifier, + configuration: BrowserPortalOmnibarSuggestionsConfiguration? + ) { + guard var entry = entriesByWebViewId[webViewId] else { return } + guard !Self.omnibarSuggestionsConfigurationsEquivalent(entry.omnibarSuggestions, configuration) else { return } + entry.omnibarSuggestions = configuration + entriesByWebViewId[webViewId] = entry + entry.containerView?.setOmnibarSuggestions(configuration) + } + func searchOverlayPanelId(for responder: NSResponder) -> UUID? { for entry in entriesByWebViewId.values { if let panelId = entry.containerView?.searchOverlayPanelId(for: responder) { @@ -2958,6 +3106,7 @@ final class WindowBrowserPortal: NSObject { dropZone: nil, paneDropContext: nil, searchOverlay: nil, + omnibarSuggestions: nil, paneTopChromeHeight: 0, transientRecoveryReason: nil, transientRecoveryRetriesRemaining: 0 @@ -2994,6 +3143,7 @@ final class WindowBrowserPortal: NSObject { dropZone: previousEntry?.dropZone, paneDropContext: previousEntry?.paneDropContext, searchOverlay: previousEntry?.searchOverlay, + omnibarSuggestions: previousEntry?.omnibarSuggestions, paneTopChromeHeight: previousEntry?.paneTopChromeHeight ?? 0, transientRecoveryReason: previousEntry?.transientRecoveryReason, transientRecoveryRetriesRemaining: previousEntry?.transientRecoveryRetriesRemaining ?? 0 @@ -3189,6 +3339,7 @@ final class WindowBrowserPortal: NSObject { cancelPendingHostedWebViewRefreshes(for: webViewId) containerView.setPaneTopChromeHeight(0) containerView.setSearchOverlay(nil) + containerView.setOmnibarSuggestions(nil) containerView.setPaneDropContext(nil) containerView.setPortalDragDropZone(nil) containerView.setDropZoneOverlay(zone: nil) @@ -3406,6 +3557,7 @@ final class WindowBrowserPortal: NSObject { scheduleDeferredFullSynchronizeAll() } containerView.setPaneTopChromeHeight(0) + containerView.setOmnibarSuggestions(nil) return } let oldFrame = containerView.frame @@ -3607,6 +3759,7 @@ final class WindowBrowserPortal: NSObject { } containerView.setPaneTopChromeHeight(shouldHide ? 0 : entry.paneTopChromeHeight) containerView.setSearchOverlay(shouldHide ? nil : entry.searchOverlay) + containerView.setOmnibarSuggestions(shouldHide ? nil : entry.omnibarSuggestions) containerView.setPaneDropContext(containerView.isHidden ? nil : entry.paneDropContext) containerView.setDropZoneOverlay(zone: containerView.isHidden ? nil : entry.dropZone) if revealedForDisplay { @@ -3977,6 +4130,16 @@ enum BrowserWindowPortalRegistry { portal.updateSearchOverlay(forWebViewId: webViewId, configuration: configuration) } + static func updateOmnibarSuggestions( + for webView: WKWebView, + configuration: BrowserPortalOmnibarSuggestionsConfiguration? + ) { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return } + portal.updateOmnibarSuggestions(forWebViewId: webViewId, configuration: configuration) + } + static func searchOverlayPanelId(for responder: NSResponder, in window: NSWindow) -> UUID? { let windowId = ObjectIdentifier(window) guard let portal = portalsByWindowId[windowId] else { return nil } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 8c5440e3652e..8d452e99990d 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -736,7 +736,7 @@ struct BrowserPanelView: View { .allowsHitTesting(false) } .overlay(alignment: .topLeading) { - if addressBarFocused, !omnibarState.suggestions.isEmpty, omnibarPillFrame.width > 0 { + if shouldRenderOmnibarSuggestionsInSwiftUI { OmnibarSuggestionsView( engineName: searchEngine.displayName, items: omnibarState.suggestions, @@ -1455,6 +1455,7 @@ struct BrowserPanelView: View { onFieldDidFocus: { panel.noteFindFieldFocused() } ) }, + omnibarSuggestions: portalOmnibarSuggestions, paneTopChromeHeight: addressBarHeight ) .accessibilityIdentifier("BrowserWebViewSurface") @@ -4360,7 +4361,7 @@ private func browserOmnibarField(for responder: NSResponder?) -> OmnibarNativeTe return nil } -private struct OmnibarSuggestionsView: View { +struct OmnibarSuggestionsView: View { let engineName: String let items: [OmnibarSuggestion] let selectedIndex: Int @@ -4670,6 +4671,7 @@ struct WebViewRepresentable: NSViewRepresentable { let portalZPriority: Int let paneDropZone: DropZone? let searchOverlay: BrowserPortalSearchOverlayConfiguration? + let omnibarSuggestions: BrowserPortalOmnibarSuggestionsConfiguration? let paneTopChromeHeight: CGFloat final class Coordinator { @@ -6787,6 +6789,7 @@ struct WebViewRepresentable: NSViewRepresentable { if host.window != nil, portalHostAccepted { Self.installPortalAnchorView(portalAnchorView, in: host) } + let activeOmnibarSuggestions = coordinator.desiredPortalVisibleInUI ? omnibarSuggestions : nil host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator, weak portalAnchorView, weak browserPanel = panel] in guard let host, let webView, let coordinator, let portalAnchorView, let browserPanel else { return } @@ -6817,6 +6820,7 @@ struct WebViewRepresentable: NSViewRepresentable { ) BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: activePaneDropContext) BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) + BrowserWindowPortalRegistry.updateOmnibarSuggestions(for: webView, configuration: activeOmnibarSuggestions) coordinator.lastPortalHostId = ObjectIdentifier(host) coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision } @@ -6852,6 +6856,7 @@ struct WebViewRepresentable: NSViewRepresentable { ) BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: activePaneDropContext) BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) + BrowserWindowPortalRegistry.updateOmnibarSuggestions(for: webView, configuration: activeOmnibarSuggestions) coordinator.lastPortalHostId = hostId } BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) @@ -6899,6 +6904,7 @@ struct WebViewRepresentable: NSViewRepresentable { height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0 ) BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) + BrowserWindowPortalRegistry.updateOmnibarSuggestions(for: webView, configuration: activeOmnibarSuggestions) if !shouldBindNow, coordinator.lastSynchronizedHostGeometryRevision != geometryRevision { BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) @@ -6929,6 +6935,7 @@ struct WebViewRepresentable: NSViewRepresentable { context: activePaneDropContext ) BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) + BrowserWindowPortalRegistry.updateOmnibarSuggestions(for: webView, configuration: activeOmnibarSuggestions) } panel.restoreDeveloperToolsAfterAttachIfNeeded() From 4f2a416baeed821a3b27c221d1055ce55223eaff Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 19:20:05 -0700 Subject: [PATCH 21/46] fix: retire stale codex monitor leases --- CLI/cmux.swift | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 816106d0c2f0..ddf757b1726a 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -18327,7 +18327,12 @@ struct CMUXCLI { } } - private func retireCodexMonitorLeases(sessionId: String, turnId: String?, env: [String: String]) { + private func retireCodexMonitorLeases( + sessionId: String, + turnId: String?, + preservingLeasePath: String? = nil, + env: [String: String] + ) { let normalizedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedSessionId.isEmpty else { return } @@ -18335,6 +18340,9 @@ struct CMUXCLI { let now = Date().timeIntervalSince1970 let normalizedTurnId = turnId?.trimmingCharacters(in: .whitespacesAndNewlines) let shouldMatchTurn = normalizedTurnId?.isEmpty == false + let preservingPath = preservingLeasePath.map { + URL(fileURLWithPath: $0, isDirectory: false).standardizedFileURL.path + } let directory = codexMonitorLeaseDirectory(env: env) let targetPaths = ((try? fileManager.contentsOfDirectory( at: directory, @@ -18343,6 +18351,10 @@ struct CMUXCLI { )) ?? []).map(\.path) for path in targetPaths { + let standardizedPath = URL(fileURLWithPath: path, isDirectory: false).standardizedFileURL.path + guard preservingPath == nil || standardizedPath != preservingPath else { + continue + } guard var record = readCodexMonitorLease(path: path), record.sessionId == normalizedSessionId, !shouldMatchTurn || record.turnId == normalizedTurnId, @@ -20914,6 +20926,13 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { "codex-hook.monitor.lease-unavailable", data: ["has_turn_id": normalizedHookValue(input.turnId) != nil] ) + } else { + retireCodexMonitorLeases( + sessionId: sessionId, + turnId: nil, + preservingLeasePath: leasePath, + env: env + ) } startCodexTranscriptMonitor( sessionId: sessionId, From 38b0941bc9abe6cca945f7aa886e591166edf16b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 19:25:16 -0700 Subject: [PATCH 22/46] fix: restore task manager process attribution --- Sources/CmuxTopSnapshot.swift | 58 +++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index 08b0357c93a3..c6f7514d6c71 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -63,6 +63,7 @@ nonisolated struct CmuxTopProcessInfo: Sendable { let ttyDevice: Int64? let cmuxWorkspaceID: UUID? let cmuxSurfaceID: UUID? + let cmuxAttributionReason: String? let processGroupID: Int? let terminalProcessGroupID: Int? var cpuPercent: Double @@ -116,6 +117,13 @@ nonisolated struct CmuxTopProcessInfo: Sendable { nonisolated struct CmuxTopProcessScope: Sendable { let workspaceID: UUID? let surfaceID: UUID? + let attributionReason: String + + init(workspaceID: UUID?, surfaceID: UUID?, attributionReason: String) { + self.workspaceID = workspaceID + self.surfaceID = surfaceID + self.attributionReason = attributionReason + } } nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { @@ -135,7 +143,7 @@ nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { ) } - private init( + init( processes: [CmuxTopProcessInfo], sampledAt: Date, includesProcessDetails: Bool @@ -314,7 +322,14 @@ nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { } var visited: Set = [] - return roots.compactMap { processTreeNode(pid: $0, allowedPIDs: allowedPIDs, visited: &visited) } + return roots.compactMap { + processTreeNode( + pid: $0, + allowedPIDs: allowedPIDs, + rootPIDs: explicitRootPIDs, + visited: &visited + ) + } } func topLevelPIDs(for pids: Set) -> Set { @@ -477,6 +492,7 @@ nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { private func processTreeNode( pid: Int, allowedPIDs: Set, + rootPIDs: Set, visited: inout Set ) -> [String: Any]? { guard visited.insert(pid).inserted, @@ -487,7 +503,14 @@ nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { let childNodes = (childrenByParentPID[pid] ?? []) .filter { allowedPIDs.contains($0) } .sorted { processSortKey($0) < processSortKey($1) } - .compactMap { processTreeNode(pid: $0, allowedPIDs: allowedPIDs, visited: &visited) } + .compactMap { + processTreeNode( + pid: $0, + allowedPIDs: allowedPIDs, + rootPIDs: rootPIDs, + visited: &visited + ) + } var payload: [String: Any] = [ "kind": "process", @@ -495,6 +518,7 @@ nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { "ppid": process.parentPID, "name": process.name, "path": process.path ?? NSNull(), + "attribution_reason": attributionReason(for: process, allowedPIDs: allowedPIDs, rootPIDs: rootPIDs), "thread_count": process.threadCount, "memory_source": process.memorySource.rawValue, "resident_memory_source": process.residentMemorySource.rawValue, @@ -529,6 +553,33 @@ nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { return payload } + private func attributionReason( + for process: CmuxTopProcessInfo, + allowedPIDs: Set, + rootPIDs: Set + ) -> String { + if let reason = process.cmuxAttributionReason { + return reason + } + if rootPIDs.contains(process.pid), isWebKitWebContentProcess(process) { + return "webview-root-pid" + } + if rootPIDs.contains(process.pid) { + return "explicit-root-pid" + } + if allowedPIDs.contains(process.parentPID) { + return "child-process" + } + return "included-process" + } + + private func isWebKitWebContentProcess(_ process: CmuxTopProcessInfo) -> Bool { + if process.name.localizedCaseInsensitiveContains("WebContent") { + return true + } + return process.path?.localizedCaseInsensitiveContains("com.apple.WebKit.WebContent") == true + } + private func processSortKey(_ pid: Int) -> String { let process = processesByPID[pid] return "\(process?.name ?? ""):\(pid)" @@ -665,6 +716,7 @@ nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { ttyDevice: ttyDevice, cmuxWorkspaceID: cmuxScope?.workspaceID, cmuxSurfaceID: cmuxScope?.surfaceID, + cmuxAttributionReason: cmuxScope?.attributionReason, processGroupID: processGroupID, terminalProcessGroupID: terminalProcessGroupID, cpuPercent: 0, From a8a19687d81011c23d237de7512b8192b12b20c9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 19:34:25 -0700 Subject: [PATCH 23/46] fix: restore merge-dropped app support APIs --- Sources/App/ShortcutRoutingSupport.swift | 18 ++++++++++++++++ Sources/CmuxTopSnapshot.swift | 2 +- Sources/TerminalNotificationStore.swift | 4 ++++ Sources/Workspace.swift | 26 ++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/Sources/App/ShortcutRoutingSupport.swift b/Sources/App/ShortcutRoutingSupport.swift index 595ca4c138da..a54b4665a9d4 100644 --- a/Sources/App/ShortcutRoutingSupport.swift +++ b/Sources/App/ShortcutRoutingSupport.swift @@ -45,6 +45,10 @@ func browserOmnibarNormalizedModifierFlags(_ flags: NSEvent.ModifierFlags) -> NS .subtracting([.numericPad, .function, .capsLock]) } +func browserOmnibarShouldContinueControlNavigationRepeat(flags: NSEvent.ModifierFlags) -> Bool { + browserOmnibarNormalizedModifierFlags(flags) == [.control] +} + func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) return normalizedFlags == [] || normalizedFlags == [.shift] @@ -101,6 +105,20 @@ func shouldDispatchBrowserArrowViaFirstResponderKeyDown( return normalizedFlags.isEmpty } +func shouldDispatchBrowserOmnibarArrowViaFirstResponderKeyDown( + keyCode: UInt16, + firstResponderIsBrowserOmnibar: Bool, + firstResponderHasMarkedText: Bool = false, + flags: NSEvent.ModifierFlags +) -> Bool { + guard firstResponderIsBrowserOmnibar else { return false } + guard !firstResponderHasMarkedText else { return false } + guard (123...126).contains(keyCode) else { return false } + + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + return normalizedFlags.isEmpty +} + func shouldDispatchCommandPaletteHorizontalArrowViaFirstResponderKeyDown( keyCode: UInt16, firstResponderIsCommandPaletteFieldEditor: Bool, diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index c6f7514d6c71..392024a7d420 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -119,7 +119,7 @@ nonisolated struct CmuxTopProcessScope: Sendable { let surfaceID: UUID? let attributionReason: String - init(workspaceID: UUID?, surfaceID: UUID?, attributionReason: String) { + init(workspaceID: UUID?, surfaceID: UUID?, attributionReason: String = "cmux-environment") { self.workspaceID = workspaceID self.surfaceID = surfaceID self.attributionReason = attributionReason diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index ac99f54bc950..f06cdb0e274d 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -836,6 +836,10 @@ final class TerminalNotificationStore: ObservableObject { indexes.unreadCount } + var workspaceUnreadIndicatorIds: Set { + manualUnreadWorkspaceIds.union(restoredUnreadWorkspaceIds) + } + private func logAuthorization(_ message: String) { #if DEBUG cmuxDebugLog("notification.auth \(message)") diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 46436b7b15cd..07085ae25ea6 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -8529,6 +8529,23 @@ final class Workspace: Identifiable, ObservableObject { syncUnreadBadgeStateForPanel(panelId) } + func preferredUnreadPanelIdForJump() -> UUID? { + let latestManualPanelId = manualUnreadMarkedAt + .filter { manualUnreadPanelIds.contains($0.key) && panels[$0.key] != nil } + .max { $0.value < $1.value }? + .key + if let latestManualPanelId { + return latestManualPanelId + } + if let manualPanelId = manualUnreadPanelIds.first(where: { panels[$0] != nil }) { + return manualPanelId + } + if let restoredPanelId = restoredUnreadPanelIds.first(where: { panels[$0] != nil }) { + return restoredPanelId + } + return representativePanelIdForWorkspaceManualUnread() + } + func markPanelRead(_ panelId: UUID) { guard panels[panelId] != nil else { return } AppDelegate.shared?.notificationStore?.markRead(forTabId: id, surfaceId: panelId) @@ -8537,6 +8554,15 @@ final class Workspace: Identifiable, ObservableObject { syncUnreadBadgeStateForPanel(panelId) } + func clearUnreadAfterJump(panelId: UUID?) { + if let panelId, + manualUnreadPanelIds.contains(panelId) || restoredUnreadPanelIds.contains(panelId) { + markPanelRead(panelId) + return + } + AppDelegate.shared?.notificationStore?.markRead(forTabId: id) + } + func clearManualUnread(panelId: UUID) { let didRemoveManual = clearManualUnreadState(panelId: panelId) let didRemoveRestored = clearRestoredUnreadIndicatorState(panelId: panelId) From 12e24c6657dd205de6d7fe6a20e75286c4e62770 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 19:39:15 -0700 Subject: [PATCH 24/46] fix: expose omnibar portal bridge helpers --- Sources/Panels/BrowserPanelView.swift | 45 +++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 8d452e99990d..05c57b19932e 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -4334,6 +4334,29 @@ func browserOmnibarPanelId(for responder: NSResponder?) -> UUID? { browserOmnibarField(for: responder)?.panelId } +@MainActor +func browserOmnibarField(panelId: UUID?, in window: NSWindow?) -> OmnibarNativeTextField? { + guard let panelId else { return nil } + + if let focusedField = browserOmnibarField(for: window?.firstResponder), + focusedField.panelId == panelId { + return focusedField + } + + guard let root = window?.contentView?.superview ?? window?.contentView else { + return nil + } + + var stack: [NSView] = [root] + while let view = stack.popLast() { + if let field = view as? OmnibarNativeTextField, field.panelId == panelId { + return field + } + stack.append(contentsOf: view.subviews) + } + return nil +} + @discardableResult func browserPrepareOmnibarForProgrammaticBlur(panelId: UUID, responder: NSResponder?) -> Bool { guard let field = browserOmnibarField(for: responder), @@ -4407,8 +4430,24 @@ struct OmnibarSuggestionsView: View { return (value * scale).rounded(.toNearestOrAwayFromZero) / scale } - private var popupHeight: CGFloat { - snapToDevicePixels(min(max(contentHeight, minimumPopupHeight), maxPopupHeight)) + private var resolvedPopupHeight: CGFloat { + Self.popupHeight(for: items) + } + + static func popupHeight(for items: [OmnibarSuggestion]) -> CGFloat { + let singleLineRowHeight: CGFloat = 24 + let rowSpacing: CGFloat = 1 + let topInset: CGFloat = 3 + let bottomInset: CGFloat = 3 + let maxPopupHeight: CGFloat = 560 + let totalRowCount = max(1, items.count) + let rowsHeight = items.isEmpty ? singleLineRowHeight : CGFloat(items.count) * singleLineRowHeight + let gaps = CGFloat(max(0, totalRowCount - 1)) + let contentHeight = rowsHeight + (gaps * rowSpacing) + topInset + bottomInset + let minimumPopupHeight = singleLineRowHeight + topInset + bottomInset + let height = min(max(contentHeight, minimumPopupHeight), maxPopupHeight) + let scale = NSScreen.main?.backingScaleFactor ?? 2 + return (height * scale).rounded(.toNearestOrAwayFromZero) / scale } private var isPointerDrivenSelectionEvent: Bool { @@ -4613,7 +4652,7 @@ struct OmnibarSuggestionsView: View { rowsView } } - .frame(height: popupHeight, alignment: .top) + .frame(height: resolvedPopupHeight, alignment: .top) .overlay(alignment: .topTrailing) { if searchSuggestionsEnabled, isLoadingRemoteSuggestions { ProgressView() From 0e6476dc86bfbc914bcac72c262875a0980ce1d0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 19:54:57 -0700 Subject: [PATCH 25/46] fix: address sidekick review regressions --- CLI/cmux.swift | 7 ++- Sources/ContentView.swift | 32 +++++------ Sources/Feed/FeedCoordinator.swift | 8 ++- Sources/GhosttyTerminalView.swift | 77 +++++++++++++++++++++------ Sources/Panels/BrowserPanel.swift | 15 ++++-- Sources/Panels/BrowserPanelView.swift | 6 +-- Sources/TerminalController.swift | 5 ++ Sources/Workspace.swift | 18 ++++++- cmuxTests/BrowserConfigTests.swift | 18 ++++++- 9 files changed, 139 insertions(+), 47 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index ddf757b1726a..d0d597a211f7 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -12314,7 +12314,12 @@ struct CMUXCLI { let pid = topInt(process["pid"]).map(String.init) ?? "?" let name = topLabelText(process["name"] as? String) let label = name.isEmpty ? "process" : name - return "process \(pid) \(label)" + var parts = ["process", pid, label] + let attributionReason = topLabelText(process["attribution_reason"] as? String) + if !attributionReason.isEmpty { + parts.append("reason=\(attributionReason)") + } + return parts.joined(separator: " ") } private func topResourceColumns(node: [String: Any]) -> String { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 3546efcfcc53..e59431f95222 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -5072,7 +5072,18 @@ struct ContentView: View { includeSurfaces: includeSurfaces, commandsContext: commandsContext ) - commandPaletteSearchCommandsByID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) }) +#if DEBUG + let duplicateCommandIDs = Dictionary(grouping: entries, by: \.id) + .compactMap { id, entries in entries.count > 1 ? id : nil } + .sorted() + if !duplicateCommandIDs.isEmpty { + cmuxDebugLog("commandPalette.duplicateCommandIDs ids=\(duplicateCommandIDs.joined(separator: ","))") + } +#endif + commandPaletteSearchCommandsByID = Dictionary( + entries.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) let searchCorpus = entries.map { entry in CommandPaletteSearchCorpusEntry( payload: entry.id, @@ -5082,7 +5093,10 @@ struct ContentView: View { ) } commandPaletteSearchCorpus = searchCorpus - commandPaletteSearchCorpusByID = Dictionary(uniqueKeysWithValues: searchCorpus.map { ($0.payload, $0) }) + commandPaletteSearchCorpusByID = Dictionary( + searchCorpus.map { ($0.payload, $0) }, + uniquingKeysWith: { first, _ in first } + ) cachedCommandPaletteScope = scope cachedCommandPaletteFingerprint = fingerprint } @@ -5203,20 +5217,6 @@ struct ContentView: View { return [] } - if scope == .commands { - let matches = commandPaletteResolvedSearchMatches( - searchCorpus: searchCorpus, - query: query, - usageHistory: usageHistory, - queryIsEmpty: queryIsEmpty, - historyTimestamp: historyTimestamp - ) - guard matches.count > resultLimit else { - return matches - } - return Array(matches.prefix(resultLimit)) - } - guard !candidateCommandIDs.isEmpty else { return [] } diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index e4ef9cafe281..f79b8a09342f 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -625,11 +625,15 @@ private func deliverFeedNotification( } } if !granted { - runFallbackEffects() + DispatchQueue.main.async { + runFallbackEffects() + } } } default: - runFallbackEffects() + DispatchQueue.main.async { + runFallbackEffects() + } } } } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index d865a6cfa7f7..7b72a252bb3e 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5702,10 +5702,10 @@ final class TerminalSurface: Identifiable, ObservableObject { desiredFocusState = focused } - func setFocus(_ focused: Bool) { + func setFocus(_ focused: Bool, force: Bool = false) { // Only send focus events when the state changes to avoid redundant // prompt redraws with zsh themes like Powerlevel10k. - guard focused != desiredFocusState else { return } + guard force || focused != desiredFocusState else { return } desiredFocusState = focused // Track desired state even before the C surface exists (e.g. during // layout restoration). createSurface syncs the state once created. @@ -5766,35 +5766,59 @@ final class TerminalSurface: Identifiable, ObservableObject { /// events so the shell processes them, while regular text is sent via the /// normal key-text path. Mirrors `TerminalController.sendSocketText`. func sendInput(_ text: String) { - guard let surface = surface else { return } + let activeSurface = surface var bufferedText = "" var previousWasCR = false + var queuedPendingInput = false + + func flushBufferedText() { + if let activeSurface { + flushText(&bufferedText, surface: activeSurface) + } else { + queuedPendingInput = enqueuePendingText(&bufferedText) || queuedPendingInput + } + } + + func sendOrQueueKey(keycode: UInt32, label: String) { + if let activeSurface { + sendKeyEvent(surface: activeSurface, keycode: keycode) + } else { + enqueuePendingSocketInput( + .key(PendingKeyEvent(keycode: keycode, mods: GHOSTTY_MODS_NONE, label: label)) + ) + queuedPendingInput = true + } + } + for scalar in text.unicodeScalars { switch scalar.value { case 0x0A: // \n — skip if preceded by \r (already sent Return) if !previousWasCR { - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x24) // kVK_Return + flushBufferedText() + sendOrQueueKey(keycode: 0x24, label: "enter") // kVK_Return } previousWasCR = false case 0x0D: - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x24) // kVK_Return + flushBufferedText() + sendOrQueueKey(keycode: 0x24, label: "enter") // kVK_Return previousWasCR = true case 0x09: - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x30) // kVK_Tab + flushBufferedText() + sendOrQueueKey(keycode: 0x30, label: "tab") // kVK_Tab previousWasCR = false case 0x1B: - flushText(&bufferedText, surface: surface) - sendKeyEvent(surface: surface, keycode: 0x35) // kVK_Escape + flushBufferedText() + sendOrQueueKey(keycode: 0x35, label: "escape") // kVK_Escape previousWasCR = false default: bufferedText.unicodeScalars.append(scalar) previousWasCR = false } } - flushText(&bufferedText, surface: surface) + flushBufferedText() + if activeSurface == nil, queuedPendingInput { + requestBackgroundSurfaceStartIfNeeded() + } } private func flushText(_ buffer: inout String, surface: ghostty_surface_t) { @@ -5813,6 +5837,14 @@ final class TerminalSurface: Identifiable, ObservableObject { buffer.removeAll(keepingCapacity: true) } + @discardableResult + private func enqueuePendingText(_ buffer: inout String) -> Bool { + guard let data = buffer.data(using: .utf8), !data.isEmpty else { return false } + enqueuePendingSocketInput(.text(data)) + buffer.removeAll(keepingCapacity: true) + return true + } + private func sendKeyEvent( surface: ghostty_surface_t, keycode: UInt32, @@ -6013,10 +6045,21 @@ final class TerminalSurface: Identifiable, ObservableObject { private func enqueuePendingSocketInput(_ input: PendingSocketInput) { let incomingBytes = input.estimatedBytes - while !pendingSocketInputQueue.isEmpty, - pendingSocketInputBytes + incomingBytes > maxPendingSocketInputBytes { - let dropped = pendingSocketInputQueue.removeFirst() - pendingSocketInputBytes -= dropped.estimatedBytes + guard incomingBytes <= maxPendingSocketInputBytes else { +#if DEBUG + cmuxDebugLog( + "surface.socket_input.drop surface=\(id.uuidString.prefix(8)) reason=oversized bytes=\(incomingBytes)" + ) +#endif + return + } + guard pendingSocketInputBytes + incomingBytes <= maxPendingSocketInputBytes else { +#if DEBUG + cmuxDebugLog( + "surface.socket_input.drop surface=\(id.uuidString.prefix(8)) reason=full bytes=\(incomingBytes) queuedBytes=\(pendingSocketInputBytes)" + ) +#endif + return } pendingSocketInputQueue.append(input) @@ -12043,7 +12086,7 @@ final class GhosttySurfaceScrollView: NSView { #if DEBUG cmuxDebugLog("focus.surface.reassert surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)") #endif - terminalSurface.setFocus(true) + terminalSurface.setFocus(true, force: true) refreshSurfaceAfterFocusIfNeeded(reason: reason) } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 39db97ad6cdc..cd18c458b1ef 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -7070,13 +7070,14 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { navigationAction.targetFrame?.isMainFrame != false, browserShouldOpenURLExternally(url) { let opened = NSWorkspace.shared.open(url) + let redactedURL = browserNavigationDebugURL(url) if !opened { - NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) + NSLog("BrowserPanel external navigation failed to open URL: %@", redactedURL) } #if DEBUG - cmuxDebugLog("browser.navigation.external source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") + cmuxDebugLog("browser.navigation.external source=navDelegate opened=\(opened ? 1 : 0) url=\(redactedURL)") #endif - decisionHandler(.cancel) + decisionHandler(opened ? .cancel : .allow) return } @@ -7258,12 +7259,16 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { if let url = navigationAction.request.url, browserShouldOpenURLExternally(url) { let opened = NSWorkspace.shared.open(url) + let redactedURL = browserNavigationDebugURL(url) if !opened { - NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) + NSLog("BrowserPanel external navigation failed to open URL: %@", redactedURL) } #if DEBUG - cmuxDebugLog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(browserNavigationDebugURL(url))") + cmuxDebugLog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(redactedURL)") #endif + if !opened { + requestNavigation?(navigationAction.request, .currentTab) + } return nil } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 05c57b19932e..c70612695211 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -4077,17 +4077,17 @@ struct OmnibarTextFieldRepresentable: NSViewRepresentable { // charactersIgnoringModifiers returns non-ASCII characters. Normalize // via KeyboardLayout so Cmd/Ctrl+N/P navigation works across input sources. let lowered = KeyboardLayout.normalizedCharacters(for: event) - let hasCommandOrControl = modifiers.contains(.command) || modifiers.contains(.control) + let repeatsSelectionShortcut = modifiers == [.command] || modifiers == [.control] // Cmd/Ctrl+N and Cmd/Ctrl+P should repeat while held. - if hasCommandOrControl, lowered == "n" { + if repeatsSelectionShortcut, lowered == "n" { parent.onMoveSelection(+1) #if DEBUG handled = true #endif return true } - if hasCommandOrControl, lowered == "p" { + if repeatsSelectionShortcut, lowered == "p" { parent.onMoveSelection(-1) #if DEBUG handled = true diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index f2f0c85c2d5c..f5372da2ec49 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -15270,13 +15270,18 @@ class TerminalController { _ = v2AwaitCallback(timeout: timeout) { finish in var readyObserver: NSObjectProtocol? var hostedViewObserver: NSObjectProtocol? + var didFinish = false let finishOnce: () -> Void = { + guard !didFinish else { return } + didFinish = true if let readyObserver { NotificationCenter.default.removeObserver(readyObserver) } if let hostedViewObserver { NotificationCenter.default.removeObserver(hostedViewObserver) } + readyObserver = nil + hostedViewObserver = nil finish(()) } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 07085ae25ea6..186072c0ffd1 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -13123,19 +13123,30 @@ final class Workspace: Identifiable, ObservableObject { workingDirectory: String?, initialInput: String? ) -> TerminalPanel? { - let inheritedConfig = inheritedTerminalConfig(inPane: paneId) + var inheritedConfig = inheritedTerminalConfig(inPane: paneId) + let remoteTerminalStartupCommand = remoteTerminalStartupCommand() + let localWorkingDirectory = remoteTerminalStartupCommand == nil ? workingDirectory : nil + if remoteTerminalStartupCommand != nil { + var template = inheritedConfig ?? CmuxSurfaceConfigTemplate() + template.waitAfterCommand = true + inheritedConfig = template + } let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, configTemplate: inheritedConfig, - workingDirectory: workingDirectory, + workingDirectory: localWorkingDirectory, portOrdinal: portOrdinal, + initialCommand: remoteTerminalStartupCommand, initialInput: initialInput ) configureTerminalPanel(newPanel) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + if remoteTerminalStartupCommand != nil { + trackRemoteTerminalSurface(newPanel.id) + } seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) let newTab = Bonsplit.Tab( @@ -13153,6 +13164,9 @@ final class Workspace: Identifiable, ObservableObject { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) + if remoteTerminalStartupCommand != nil { + untrackRemoteTerminalSurface(newPanel.id) + } terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index 97569bca2dde..c387c286dcdf 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -3678,7 +3678,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { } -final class BrowserOmnibarCommandNavigationTests: XCTestCase { +final class BrowserOmnibarCommandOrControlNavigationTests: XCTestCase { func testArrowNavigationDeltaRequiresFocusedAddressBarAndNoModifierFlags() { XCTAssertNil( browserOmnibarSelectionDeltaForArrowNavigation( @@ -3766,6 +3766,22 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase { ) ) + XCTAssertNil( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.command, .control], + chars: "n" + ) + ) + + XCTAssertNil( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.command, .control], + chars: "p" + ) + ) + XCTAssertEqual( browserOmnibarSelectionDeltaForCommandNavigation( hasFocusedAddressBar: true, From 9cf6958481d0372b6993e171151059e8f0f2e2fb Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 21:51:47 -0700 Subject: [PATCH 26/46] fix: align omnibar shortcut tests with responder lookup --- cmuxTests/AppDelegateShortcutRoutingTests.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 3af28f4f4288..334f6c780cd4 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -3200,9 +3200,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { field.panelId = browserPanelId field.stringValue = "example" contentView.addSubview(field) - BrowserOmnibarNativeFieldRegistry.shared.register(field, panelId: browserPanelId) defer { - BrowserOmnibarNativeFieldRegistry.shared.unregister(field, panelId: browserPanelId) field.removeFromSuperview() } @@ -3271,10 +3269,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { field.panelId = browserPanelId field.stringValue = "ㄉㄚˋ" contentView.addSubview(field) - BrowserOmnibarNativeFieldRegistry.shared.register(field, panelId: browserPanelId) defer { - BrowserOmnibarNativeFieldRegistry.shared.unregister(field, panelId: browserPanelId) field.removeFromSuperview() } @@ -3348,10 +3344,8 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { field.panelId = browserPanelId field.stringValue = "example" contentView.addSubview(field) - BrowserOmnibarNativeFieldRegistry.shared.register(field, panelId: browserPanelId) defer { NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: browserPanelId) - BrowserOmnibarNativeFieldRegistry.shared.unregister(field, panelId: browserPanelId) field.removeFromSuperview() } From 0bc74d2291ea2f7e892b99e4ae407150e6869587 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 21:59:01 -0700 Subject: [PATCH 27/46] fix: use shared codex resume sanitizer --- CLI/cmux.swift | 4 ++-- .../CMUXAgentLaunch/AgentLaunchSanitizer.swift | 12 +++++++----- .../AgentLaunchSanitizerPrimaryPolicies.swift | 6 ++++-- Sources/RestorableAgentSession.swift | 4 ++-- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index bd51e89da35b..57525a41e33d 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -19456,7 +19456,7 @@ struct CMUXCLI { let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "cmux") var tail = original.tail if tail.first == "codex-teams" { tail.removeFirst() } - argv = AgentLaunchSanitizer.preservedCodexForkArguments(args: tail).map { + argv = AgentLaunchSanitizer.preservedArguments(kind: "codex", args: tail).map { [original.executable, "codex-teams", "resume", normalizedSessionId] + $0 } case "omx", "omc": @@ -19482,7 +19482,7 @@ struct CMUXCLI { return agentSurfaceResumeWithOption(kind: kind, launchCommand: launchCommand, fallbackExecutable: "claude", option: "--resume", sessionId: sessionId) case "codex": let original = agentSurfaceResumeCommandParts(launchCommand: launchCommand, fallbackExecutable: "codex") - return AgentLaunchSanitizer.preservedCodexForkArguments(args: original.tail).map { + return AgentLaunchSanitizer.preservedArguments(kind: "codex", args: original.tail).map { [original.executable, "resume", sessionId] + $0 } case "pi": diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift index 8c4425d1d059..17664e50304f 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift @@ -10,6 +10,7 @@ public enum AgentLaunchSanitizer { var droppedOptionPrefixes: [String] = [] var rejectOptions: Set = [] var resumeSubcommand: String? + var sessionSubcommands: Set = [] var preserveFirstPositional: Bool = false var skipClaudeHookSettings: Bool = false } @@ -133,7 +134,7 @@ public enum AgentLaunchSanitizer { var result: [String] = [] var index = 0 var consumedFirstPositional = false - var skippingResumePositionals = false + var skippingSessionPositionals = false while index < args.count { let arg = args[index] @@ -142,13 +143,14 @@ public enum AgentLaunchSanitizer { } if !arg.hasPrefix("-") || arg == "-" { - if let resumeSubcommand = policy.resumeSubcommand, arg == resumeSubcommand { - skippingResumePositionals = true + let isSessionSubcommand = policy.resumeSubcommand == arg || + policy.sessionSubcommands.contains(arg) + if isSessionSubcommand { + skippingSessionPositionals = true index += 1 continue } - if skippingResumePositionals { - skippingResumePositionals = false + if skippingSessionPositionals { index += 1 continue } diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift index 6aaa1040e09e..bbcf3175f598 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift @@ -146,7 +146,6 @@ extension AgentLaunchSanitizer { "debug", "apply", "a", - "fork", "cloud", "exec-server", "features", @@ -164,7 +163,10 @@ extension AgentLaunchSanitizer { "--remote=", "--remote-auth-token-env=" ], - resumeSubcommand: "resume" + resumeSubcommand: "resume", + sessionSubcommands: [ + "fork" + ] ) static let piPolicy = Policy( diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index b29414286373..c6044caf91e3 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -111,7 +111,7 @@ enum AgentResumeCommandBuilder { if args.first == "codex-teams" { args.removeFirst() } - guard let preserved = AgentLaunchSanitizer.preservedCodexForkArguments(args: args) else { return nil } + guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "codex", args: args) else { return nil } return [original.executable, "codex-teams", "resume", sessionId] + preserved case "omo": let original = commandParts( @@ -152,7 +152,7 @@ enum AgentResumeCommandBuilder { ) case .codex: let original = commandParts(launchCommand: launchCommand, fallbackExecutable: "codex") - guard let preserved = AgentLaunchSanitizer.preservedCodexForkArguments(args: original.tail) else { return nil } + guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "codex", args: original.tail) else { return nil } return [original.executable, "resume", sessionId] + preserved case .pi: return resumeWithOption( From 0f65d6f5c41e190839e7f0f81b98811a49f159d6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 22:05:47 -0700 Subject: [PATCH 28/46] fix: restore opencode process detection helpers --- Sources/VaultAgentProcessScanner.swift | 466 +++++++++++++++++++++++++ 1 file changed, 466 insertions(+) diff --git a/Sources/VaultAgentProcessScanner.swift b/Sources/VaultAgentProcessScanner.swift index 61fc7b3a0a87..0697cdfc2892 100644 --- a/Sources/VaultAgentProcessScanner.swift +++ b/Sources/VaultAgentProcessScanner.swift @@ -1,4 +1,32 @@ import Foundation +import CMUXAgentLaunch +import SQLite3 + +extension AgentLaunchCommandSnapshot { + init( + processDetectedLauncher launcher: String, + executablePath: String?, + arguments: [String], + workingDirectory: String?, + environment: [String: String] + ) { + var selectedEnvironment = AgentLaunchEnvironmentPolicy.selectedEnvironment(from: environment) + if launcher == "opencode", + let path = environment["PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !path.isEmpty { + selectedEnvironment["PATH"] = path + } + self.init( + launcher: launcher, + executablePath: executablePath, + arguments: arguments, + workingDirectory: workingDirectory, + environment: selectedEnvironment.isEmpty ? nil : selectedEnvironment, + capturedAt: nil, + source: "process" + ) + } +} extension RestorableAgentSessionIndex { static func processDetectedSnapshots( @@ -90,6 +118,430 @@ extension RestorableAgentSessionIndex { return resolved } + static func processLooksLikeOpenCode( + processName: String, + processPath: String?, + arguments: [String] + ) -> Bool { + VaultObservedAgentProcess( + processName: processName, + processPath: processPath, + arguments: arguments, + environment: [:] + ).isOpenCodeProcess + } + + static func openCodeExecutablePathForProcess( + arguments: [String], + environment: [String: String] + ) -> String { + let observed = VaultObservedAgentProcess( + processName: "", + processPath: nil, + arguments: arguments, + environment: environment + ) + return openCodeExecutablePath(observed: observed, environment: environment) + } + + static func openCodeLaunchArgumentsForProcess( + arguments: [String], + environment: [String: String] + ) -> [String]? { + let observed = VaultObservedAgentProcess( + processName: "", + processPath: nil, + arguments: arguments, + environment: environment + ) + let executablePath = openCodeExecutablePath(observed: observed, environment: environment) + return openCodeLaunchArguments(observed: observed, executablePath: executablePath) + } + + static func openCodeWorkingDirectoryForProcess( + arguments: [String], + environment: [String: String] + ) -> String? { + let observed = VaultObservedAgentProcess( + processName: "", + processPath: nil, + arguments: arguments, + environment: environment + ) + return openCodeWorkingDirectory(observed: observed) + } + + static func openCodeFallbackSessionIdForProcess( + arguments: [String], + latestSessionIdForSolePanel: String?, + sameWorkingDirectoryPanelCount: Int + ) -> String? { + if arguments.hasOpenCodeForkFlag { + let explicitSessionId = arguments.value(afterOption: "--session") ?? arguments.value(afterOption: "-s") + let assignedForkParentSessionId = arguments.openCodeForkParentSessionId + if let explicitSessionId, + let assignedForkParentSessionId, + explicitSessionId != assignedForkParentSessionId { + return explicitSessionId + } + guard sameWorkingDirectoryPanelCount == 1 else { return nil } + guard let latestSessionIdForSolePanel else { return nil } + let forkParentSessionId = assignedForkParentSessionId ?? explicitSessionId + guard let forkParentSessionId else { return nil } + guard forkParentSessionId != latestSessionIdForSolePanel else { return nil } + return latestSessionIdForSolePanel + } + if let explicitSessionId = arguments.value(afterOption: "--session") ?? arguments.value(afterOption: "-s") { + return explicitSessionId + } + return nil + } + + private static func processDetectedOpenCodeSnapshots( + processSnapshot: CmuxTopProcessSnapshot, + capturedAt: TimeInterval, + fileManager: FileManager + ) -> [PanelKey: (snapshot: SessionRestorableAgentSnapshot, updatedAt: TimeInterval)] { + var resolved: [PanelKey: (snapshot: SessionRestorableAgentSnapshot, updatedAt: TimeInterval)] = [:] + var sessionByWorkingDirectoryAndParent: [String: String] = [:] + var sessionMissesByWorkingDirectoryAndParent = Set() + var openCodeProcesses: [ + ( + panelKey: PanelKey, + observed: VaultObservedAgentProcess, + environment: [String: String], + workingDirectory: String?, + workingDirectoryKey: String + ) + ] = [] + var panelKeysByWorkingDirectory: [String: Set] = [:] + + for process in processSnapshot.cmuxScopedProcesses() { + guard let workspaceId = process.cmuxWorkspaceID, + let panelId = process.cmuxSurfaceID, + let processArguments = CmuxTopProcessSnapshot.processArgumentsAndEnvironment(for: process.pid) else { + continue + } + let observed = VaultObservedAgentProcess( + processName: process.name, + processPath: process.path, + arguments: processArguments.arguments, + environment: processArguments.environment + ) + guard observed.isOpenCodeProcess else { continue } + + let cwd = openCodeWorkingDirectory(observed: observed) + let cwdKey = cwd.map { ($0 as NSString).standardizingPath } ?? "" + let panelKey = PanelKey(workspaceId: workspaceId, panelId: panelId) + openCodeProcesses.append(( + panelKey: panelKey, + observed: observed, + environment: processArguments.environment, + workingDirectory: cwd, + workingDirectoryKey: cwdKey + )) + panelKeysByWorkingDirectory[cwdKey, default: []].insert(panelKey) + } + + for process in openCodeProcesses { + let sameWorkingDirectoryPanelCount = panelKeysByWorkingDirectory[process.workingDirectoryKey]?.count ?? 0 + let hasForkFlag = process.observed.arguments.hasOpenCodeForkFlag + let forkParentSessionId = process.observed.arguments.openCodeForkParentSessionId + ?? (hasForkFlag ? process.observed.arguments.value(afterOption: "--session") : nil) + let latestSessionId: String? + let sessionCacheKey = process.workingDirectoryKey + "\u{1f}" + (forkParentSessionId ?? "") + if !hasForkFlag || forkParentSessionId == nil || sameWorkingDirectoryPanelCount != 1 || process.workingDirectory == nil { + latestSessionId = nil + } else if let cached = sessionByWorkingDirectoryAndParent[sessionCacheKey] { + latestSessionId = cached + } else if sessionMissesByWorkingDirectoryAndParent.contains(sessionCacheKey) { + latestSessionId = nil + } else { + latestSessionId = latestOpenCodeSessionId( + workingDirectory: process.workingDirectory, + parentSessionId: forkParentSessionId, + fileManager: fileManager + ) + if let latestSessionId { + sessionByWorkingDirectoryAndParent[sessionCacheKey] = latestSessionId + } else { + sessionMissesByWorkingDirectoryAndParent.insert(sessionCacheKey) + } + } + guard let sessionId = openCodeFallbackSessionIdForProcess( + arguments: process.observed.arguments, + latestSessionIdForSolePanel: latestSessionId, + sameWorkingDirectoryPanelCount: sameWorkingDirectoryPanelCount + ) else { continue } + + let executablePath = openCodeExecutablePath( + observed: process.observed, + environment: process.environment + ) + guard let launchArguments = openCodeLaunchArguments( + observed: process.observed, + executablePath: executablePath + ) else { continue } + let snapshot = SessionRestorableAgentSnapshot( + kind: .opencode, + sessionId: sessionId, + workingDirectory: process.workingDirectory, + launchCommand: AgentLaunchCommandSnapshot( + processDetectedLauncher: "opencode", + executablePath: executablePath, + arguments: launchArguments, + workingDirectory: process.workingDirectory, + environment: process.observed.environment + ) + ) + resolved[process.panelKey] = ( + snapshot: snapshot, + updatedAt: capturedAt + ) + } + + return resolved + } + + private static func openCodeExecutablePath( + observed: VaultObservedAgentProcess, + environment: [String: String] + ) -> String { + let argumentExecutable = observed.openCodeExecutableArgument + if let argumentExecutable, + argumentExecutable.contains("/") { + return argumentExecutable + } + if let argumentExecutable, + let resolved = executablePath(named: argumentExecutable, environment: environment) { + return resolved + } + if let processPath = observed.processPath, + processPath.contains("/"), + VaultObservedAgentProcess.argumentLooksLikeOpenCode(processPath) { + return processPath + } + if let resolved = executablePath(named: "opencode", environment: environment) { + return resolved + } + return argumentExecutable ?? "opencode" + } + + private static func openCodeLaunchArguments( + observed: VaultObservedAgentProcess, + executablePath: String + ) -> [String]? { + let tail = openCodeLaunchTail(observed: observed) + guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "opencode", args: tail) else { + return nil + } + return [executablePath] + preserved + } + + private static func openCodeLaunchTail(observed: VaultObservedAgentProcess) -> [String] { + let arguments = observed.arguments + guard !arguments.isEmpty else { return [] } + if let executableIndex = observed.openCodeExecutableArgumentIndex { + return Array(arguments.dropFirst(executableIndex + 1)) + } + let processIdentityLooksLikeOpenCode = observed.executableBasenames.contains { basename in + VaultObservedAgentProcess.argumentLooksLikeOpenCode(basename) + } + guard processIdentityLooksLikeOpenCode else { return [] } + if arguments[0].hasPrefix("-") { + return arguments + } + return Array(arguments.dropFirst()) + } + + private static func openCodeWorkingDirectory(observed: VaultObservedAgentProcess) -> String? { + let fallbackWorkingDirectory = normalized( + observed.environment["CMUX_AGENT_LAUNCH_CWD"] ?? observed.environment["PWD"] + ) + return openCodeProjectWorkingDirectory( + observed: observed, + fallbackWorkingDirectory: fallbackWorkingDirectory + ) ?? fallbackWorkingDirectory + } + + private static func openCodeProjectWorkingDirectory( + observed: VaultObservedAgentProcess, + fallbackWorkingDirectory: String? + ) -> String? { + guard let project = openCodeProjectArgument(in: openCodeLaunchTail(observed: observed)) else { + return nil + } + return resolvedOpenCodeProjectPath(project, fallbackWorkingDirectory: fallbackWorkingDirectory) + } + + private static func openCodeProjectArgument(in arguments: [String]) -> String? { + let commandNames: Set = [ + "completion", + "acp", + "mcp", + "attach", + "run", + "debug", + "providers", + "auth", + "agent", + "upgrade", + "uninstall", + "serve", + "web", + "models", + "stats", + "export", + "import", + "github", + "pr", + "session", + "plugin", + "plug", + "db" + ] + var index = 0 + while index < arguments.count { + let argument = arguments[index] + if argument == "--" { + let nextIndex = index + 1 + return nextIndex < arguments.count ? arguments[nextIndex] : nil + } + if argument.hasPrefix("-") { + index += openCodeOptionWidth(arguments, index: index) + continue + } + return commandNames.contains(argument) ? nil : argument + } + return nil + } + + private static func openCodeOptionWidth(_ arguments: [String], index: Int) -> Int { + guard index < arguments.count else { return 1 } + let argument = arguments[index] + if argument.contains("=") { + return 1 + } + let valueOptions: Set = [ + "--log-level", + "--port", + "--hostname", + "--mdns-domain", + "--cors", + "--model", + "-m", + "--session", + "-s", + "--prompt", + "--agent" + ] + guard valueOptions.contains(argument), + index + 1 < arguments.count else { + return 1 + } + if argument == "--cors" { + var end = index + 1 + while end < arguments.count, !arguments[end].hasPrefix("-") { + end += 1 + } + return max(1, end - index) + } + return 2 + } + + private static func resolvedOpenCodeProjectPath( + _ rawValue: String, + fallbackWorkingDirectory: String? + ) -> String? { + guard let project = normalized(rawValue) else { return nil } + let expandedProject = (project as NSString).expandingTildeInPath + if expandedProject.hasPrefix("/") { + return (expandedProject as NSString).standardizingPath + } + guard let fallbackWorkingDirectory = normalized(fallbackWorkingDirectory) else { + return (expandedProject as NSString).standardizingPath + } + return URL(fileURLWithPath: fallbackWorkingDirectory, isDirectory: true) + .appendingPathComponent(expandedProject) + .standardizedFileURL + .path + } + + private static func executablePath( + named name: String, + environment: [String: String] + ) -> String? { + let executableName = (name as NSString).lastPathComponent + guard !executableName.isEmpty else { return nil } + for path in (environment["PATH"] ?? "").split(separator: ":") { + let candidate = URL(fileURLWithPath: String(path), isDirectory: true) + .appendingPathComponent(executableName, isDirectory: false) + .path + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } + + private static func latestOpenCodeSessionId( + workingDirectory: String?, + parentSessionId: String?, + fileManager: FileManager + ) -> String? { + let snapshot: OpenCodeDatabaseSnapshot.Snapshot + do { + guard let madeSnapshot = try OpenCodeDatabaseSnapshot.make(prefix: "cmux-opencode-process") else { + return nil + } + snapshot = madeSnapshot + } catch { + return nil + } + defer { snapshot.remove() } + + var db: OpaquePointer? + guard sqlite3_open_v2(snapshot.databaseURL.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, let db else { + sqlite3_close(db) + return nil + } + defer { sqlite3_close(db) } + + guard let parentId = normalized(parentSessionId) else { + return nil + } + guard let cwd = normalized(workingDirectory).map({ ($0 as NSString).standardizingPath }) else { + return nil + } + let sql = """ + SELECT id FROM session + WHERE directory = ? + AND parent_id = ? + ORDER BY time_updated DESC + LIMIT 1 + """ + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { + sqlite3_finalize(stmt) + return nil + } + defer { sqlite3_finalize(stmt) } + + let SQLITE_TRANSIENT_FN = unsafeBitCast(OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self) + var bindIndex: Int32 = 1 + sqlite3_bind_text(stmt, bindIndex, cwd, -1, SQLITE_TRANSIENT_FN) + bindIndex += 1 + sqlite3_bind_text(stmt, bindIndex, parentId, -1, SQLITE_TRANSIENT_FN) + + guard sqlite3_step(stmt) == SQLITE_ROW, + let sessionId = SessionIndexStore.sqliteText(stmt, 0), + !sessionId.isEmpty else { + return nil + } + return sessionId + } + private static func normalized(_ rawValue: String?) -> String? { guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), !rawValue.isEmpty else { @@ -344,6 +796,20 @@ private extension CmuxVaultAgentSessionIDSource { } private extension Array where Element == String { + var hasOpenCodeForkFlag: Bool { + contains { $0 == "--fork" || $0.hasPrefix("--fork=") } + } + + var openCodeForkParentSessionId: String? { + for argument in self { + let prefix = "--fork=" + guard argument.hasPrefix(prefix) else { continue } + let value = String(argument.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + return nil + } + func value(afterOption option: String) -> String? { for index in indices { let argument = self[index] From 9f21fab7d132cd1cfb4c743f8fdceb4f60cd2959 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 22:31:00 -0700 Subject: [PATCH 29/46] test: cover codex fork after variadic option --- .../AgentLaunchSanitizerTests.swift | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift index 0642b012497e..0b156ffe5a17 100644 --- a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift +++ b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift @@ -399,6 +399,35 @@ struct AgentLaunchSanitizerTests { ) } + @Test("Stops Codex variadic add-dir before fork session subcommand") + func stopsCodexVariadicAddDirBeforeForkSessionSubcommand() { + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + [ + "codex", + "--add-dir", + "/tmp/shared", + "fork", + "019dad34-d218-7943-b81a-eddac5c87951", + "--model", + "gpt-5.4", + "--sandbox", + "danger-full-access", + ], + launcher: "codex", + fallbackKind: "codex" + ) == [ + "codex", + "--add-dir", + "/tmp/shared", + "--model", + "gpt-5.4", + "--sandbox", + "danger-full-access", + ] + ) + } + @Test("Drops Pi session selectors and prompt while preserving configuration") func dropsPiSessionSelectorsAndPrompt() { #expect( From fc8f5b9dd384c9c978dea45946d7f83b71f456aa Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 22:31:27 -0700 Subject: [PATCH 30/46] fix: stop codex variadic scan at fork sessions --- .../AgentLaunchSanitizer.swift | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift index 17664e50304f..51ed3c4a924f 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift @@ -143,9 +143,7 @@ public enum AgentLaunchSanitizer { } if !arg.hasPrefix("-") || arg == "-" { - let isSessionSubcommand = policy.resumeSubcommand == arg || - policy.sessionSubcommands.contains(arg) - if isSessionSubcommand { + if isSessionSubcommandStart(args, index: index, policy: policy) { skippingSessionPositionals = true index += 1 continue @@ -220,8 +218,7 @@ public enum AgentLaunchSanitizer { if policy.variadicOptions.contains(arg) { var end = index + 1 while end < args.count, !args[end].hasPrefix("-") { - if let resumeSubcommand = policy.resumeSubcommand, - args[end] == resumeSubcommand { + if isSessionSubcommandStart(args, index: end, policy: policy) { break } end += 1 @@ -231,6 +228,21 @@ public enum AgentLaunchSanitizer { return 2 } + private static func isSessionSubcommandStart( + _ args: [String], + index: Int, + policy: Policy + ) -> Bool { + let arg = args[index] + if let resumeSubcommand = policy.resumeSubcommand, arg == resumeSubcommand { + return true + } + guard policy.sessionSubcommands.contains(arg) else { + return false + } + return index + 1 < args.count && !args[index + 1].hasPrefix("-") + } + private static func looksLikeOptionalValue(_ value: String, following: String?) -> Bool { guard !value.isEmpty, !value.hasPrefix("-"), From 7ad3ee27d8209d7ca9be1c0bc25611ef38eadfd0 Mon Sep 17 00:00:00 2001 From: austinywang Date: Tue, 19 May 2026 06:59:25 -0700 Subject: [PATCH 31/46] fix: address sidekick review feedback --- Sources/AppDelegate.swift | 46 ++++++++-- Sources/ContentView.swift | 22 ++--- Sources/Feed/FeedCoordinator.swift | 87 +++++++++++++++---- Sources/GhosttyTerminalView.swift | 28 +++--- Sources/Panels/BrowserPanelView.swift | 21 ++++- .../Panels/TerminalSidekickCoordinator.swift | 1 + Sources/SessionPersistence.swift | 34 ++++++++ Sources/TerminalController.swift | 56 ++++-------- Sources/TerminalNotificationStore.swift | 16 +++- Sources/VaultAgentProcessScanner.swift | 10 ++- Sources/Workspace.swift | 45 ++++++---- .../AppDelegateShortcutRoutingTests.swift | 10 +++ cmuxTests/FeedCoordinatorTests.swift | 69 +++++++++++++++ .../TabManagerSessionSnapshotTests.swift | 24 +++++ cmuxTests/WorkspaceManualUnreadTests.swift | 28 ++++++ 15 files changed, 387 insertions(+), 110 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 66f7f5f5835b..16003bcd3af2 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -697,6 +697,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var browserOmnibarRepeatTickWorkItem: DispatchWorkItem? private var browserOmnibarRepeatKeyCode: UInt16? private var browserOmnibarRepeatDelta: Int = 0 + private var browserOmnibarRepeatModifierFlags: NSEvent.ModifierFlags = [] private var browserAddressBarFocusObserver: NSObjectProtocol? private var browserAddressBarBlurObserver: NSObjectProtocol? private var browserWebViewFirstResponderObserver: NSObjectProtocol? @@ -11689,7 +11690,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent chars: chars ) { dispatchBrowserOmnibarSelectionMove(delta: delta) - startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: event.keyCode, delta: delta) + startBrowserOmnibarSelectionRepeatIfNeeded( + keyCode: event.keyCode, + delta: delta, + flags: flags + ) return true } @@ -12685,7 +12690,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) } - private func startBrowserOmnibarSelectionRepeatIfNeeded(keyCode: UInt16, delta: Int) { + private func startBrowserOmnibarSelectionRepeatIfNeeded( + keyCode: UInt16, + delta: Int, + flags: NSEvent.ModifierFlags + ) { guard delta != 0 else { return } guard browserAddressBarFocusedPanelId != nil else { #if DEBUG @@ -12697,7 +12706,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return } - if browserOmnibarRepeatKeyCode == keyCode, browserOmnibarRepeatDelta == delta { + let repeatModifierFlags = browserOmnibarNormalizedModifierFlags(flags) + if browserOmnibarRepeatKeyCode == keyCode, + browserOmnibarRepeatDelta == delta, + browserOmnibarRepeatModifierFlags == repeatModifierFlags { #if DEBUG let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" cmuxDebugLog( @@ -12711,6 +12723,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent stopBrowserOmnibarSelectionRepeat() browserOmnibarRepeatKeyCode = keyCode browserOmnibarRepeatDelta = delta + browserOmnibarRepeatModifierFlags = repeatModifierFlags #if DEBUG let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" cmuxDebugLog( @@ -12764,6 +12777,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent browserOmnibarRepeatTickWorkItem = nil browserOmnibarRepeatKeyCode = nil browserOmnibarRepeatDelta = 0 + browserOmnibarRepeatModifierFlags = [] #if DEBUG if previousKeyCode != nil || previousDelta != 0 { cmuxDebugLog( @@ -12790,7 +12804,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } case .flagsChanged: let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - if !flags.contains(.command) { + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + let commandWasReleased = browserOmnibarRepeatModifierFlags.contains(.command) && + !normalizedFlags.contains(.command) + let controlWasReleased = browserOmnibarRepeatModifierFlags.contains(.control) && + !normalizedFlags.contains(.control) + if commandWasReleased || controlWasReleased { #if DEBUG cmuxDebugLog( "browser.focus.omnibar.repeat.lifecycle event=flagsChanged " + @@ -14014,16 +14033,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let panel = self.browserPanelOwning(webView) else { return } if let trackedPanelId = self.browserAddressBarFocusedPanelId, - trackedPanelId != panel.id, - let trackedPanel = self.browserPanel(for: trackedPanelId), - !self.shouldPreserveBrowserAddressBarTracking(for: trackedPanel) { - trackedPanel.endSuppressWebViewFocusForAddressBar() + trackedPanelId != panel.id { + let trackedPanel = self.browserPanel(for: trackedPanelId) + let shouldPreserveTrackedPanel = trackedPanel.map { + self.shouldPreserveBrowserAddressBarTracking(for: $0) + } ?? false + if !shouldPreserveTrackedPanel { + trackedPanel?.endSuppressWebViewFocusForAddressBar() + } self.browserAddressBarFocusedPanelId = nil self.stopBrowserOmnibarSelectionRepeat() #if DEBUG + let reason = trackedPanel == nil + ? "stale_missing_panel_webViewFirstResponder" + : (shouldPreserveTrackedPanel + ? "stale_other_panel_preserved_webViewFirstResponder" + : "stale_other_panel_webViewFirstResponder") cmuxDebugLog( "addressBar CLEAR panelId=\(trackedPanelId.uuidString.prefix(8)) " + - "reason=stale_other_panel_webViewFirstResponder" + "reason=\(reason)" ) #endif } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e59431f95222..6687f1d0a884 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -5067,23 +5067,28 @@ struct ContentView: View { return } - let entries = commandPaletteEntries( + let rawEntries = commandPaletteEntries( for: scope, includeSurfaces: includeSurfaces, commandsContext: commandsContext ) #if DEBUG - let duplicateCommandIDs = Dictionary(grouping: entries, by: \.id) + let duplicateCommandIDs = Dictionary(grouping: rawEntries, by: \.id) .compactMap { id, entries in entries.count > 1 ? id : nil } .sorted() if !duplicateCommandIDs.isEmpty { cmuxDebugLog("commandPalette.duplicateCommandIDs ids=\(duplicateCommandIDs.joined(separator: ","))") } #endif - commandPaletteSearchCommandsByID = Dictionary( - entries.map { ($0.id, $0) }, - uniquingKeysWith: { first, _ in first } - ) + var seenCommandIDs: Set = [] + let entries = rawEntries + .reversed() + .compactMap { entry in + guard seenCommandIDs.insert(entry.id).inserted else { return nil } + return entry + } + .reversed() + commandPaletteSearchCommandsByID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) }) let searchCorpus = entries.map { entry in CommandPaletteSearchCorpusEntry( payload: entry.id, @@ -5093,10 +5098,7 @@ struct ContentView: View { ) } commandPaletteSearchCorpus = searchCorpus - commandPaletteSearchCorpusByID = Dictionary( - searchCorpus.map { ($0.payload, $0) }, - uniquingKeysWith: { first, _ in first } - ) + commandPaletteSearchCorpusByID = Dictionary(uniqueKeysWithValues: searchCorpus.map { ($0.payload, $0) }) cachedCommandPaletteScope = scope cachedCommandPaletteFingerprint = fingerprint } diff --git a/Sources/Feed/FeedCoordinator.swift b/Sources/Feed/FeedCoordinator.swift index 6d05faa5d5cc..f810c00d19b0 100644 --- a/Sources/Feed/FeedCoordinator.swift +++ b/Sources/Feed/FeedCoordinator.swift @@ -69,7 +69,7 @@ final class FeedCoordinator: @unchecked Sendable { src.setEventHandler { [weak self] in Task { @MainActor in guard let self else { return } - self.store?.expireItems(forPpid: ppid) + self.expirePendingItemsAndWaiters(forPpid: ppid) self.pidWatchers[ppid]?.cancel() self.pidWatchers.removeValue(forKey: ppid) } @@ -179,32 +179,85 @@ final class FeedCoordinator: @unchecked Sendable { cancelNotification(requestId: requestId) } + @MainActor + private func expirePendingItemsAndWaiters(forPpid ppid: Int) { + guard let store else { return } + let requestIds = Self.pendingRequestIds(forPpid: ppid, in: store.items) + store.expireItems(forPpid: ppid) + signalExpiredWaiters(requestIds: requestIds) + } + +#if DEBUG + @MainActor + func debugExpirePendingItemsAndWaiters(forPpid ppid: Int) { + expirePendingItemsAndWaiters(forPpid: ppid) + } +#endif + + private func signalExpiredWaiters(requestIds: Set) { + guard !requestIds.isEmpty else { return } + waiterLock.lock() + let expiredWaiters = requestIds.compactMap { waiters.removeValue(forKey: $0) } + waiterLock.unlock() + + for waiter in expiredWaiters { + waiter.semaphore.signal() + } + } + + @MainActor fileprivate func isAwaitingDecision(requestId: String) -> Bool { waiterLock.lock() - defer { waiterLock.unlock() } - guard let waiter = waiters[requestId] else { return false } - return waiter.decision == nil + let waiter = waiters[requestId] + waiterLock.unlock() + guard let waiter, waiter.decision == nil else { return false } + guard let store, + let item = Self.findItem(for: requestId, in: store.items) else { + return false + } + return item.status.isPending } - private static func findItemId( + private static func requestId(for item: WorkstreamItem) -> String? { + switch item.payload { + case .permissionRequest(let requestId, _, _, _): + return requestId + case .exitPlan(let requestId, _, _): + return requestId + case .question(let requestId, _): + return requestId + default: + return nil + } + } + + private static func pendingRequestIds( + forPpid ppid: Int, + in items: [WorkstreamItem] + ) -> Set { + Set(items.compactMap { item in + guard item.status.isPending, item.ppid == ppid else { return nil } + return requestId(for: item) + }) + } + + private static func findItem( for requestId: String, in items: [WorkstreamItem] - ) -> UUID? { - for item in items.reversed() { - switch item.payload { - case .permissionRequest(let rid, _, _, _) where rid == requestId: - return item.id - case .exitPlan(let rid, _, _) where rid == requestId: - return item.id - case .question(let rid, _) where rid == requestId: - return item.id - default: - continue - } + ) -> WorkstreamItem? { + for item in items.reversed() where Self.requestId(for: item) == requestId { + return item } return nil } + private static func findItemId( + for requestId: String, + in items: [WorkstreamItem] + ) -> UUID? { + findItem(for: requestId, in: items)?.id + } + private func expireTimedOutItem(_ itemId: UUID?) { guard let itemId else { return } let expire: @Sendable () -> Void = { [itemId] in diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 41a9ed9f954f..3c9d95af44dc 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5664,6 +5664,7 @@ final class TerminalSurface: Identifiable, ObservableObject { /// Force a full size recalculation and surface redraw. func forceRefresh(reason: String = "unspecified") { + #if DEBUG let hasSurface = surface != nil let viewState: String if let view = attachedView { @@ -5674,7 +5675,6 @@ final class TerminalSurface: Identifiable, ObservableObject { } else { viewState = "NO_ATTACHED_VIEW hasSurface=\(hasSurface)" } - #if DEBUG cmuxDebugLog("forceRefresh: \(id) reason=\(reason) \(viewState)") #endif guard let view = attachedView, @@ -5895,14 +5895,6 @@ final class TerminalSurface: Identifiable, ObservableObject { self.backgroundSurfaceStartQueued = false guard self.allowsRuntimeSurfaceCreation() else { return } guard self.surface == nil, let view = self.attachedView else { return } - guard view.window != nil else { - #if DEBUG - cmuxDebugLog( - "surface.background_start.defer surface=\(self.id.uuidString.prefix(8)) reason=noWindow" - ) - #endif - return - } #if DEBUG let startedAt = ProcessInfo.processInfo.systemUptime #endif @@ -9959,6 +9951,7 @@ final class GhosttySurfaceScrollView: NSView { private let notificationRingLayer: CAShapeLayer private let flashOverlayView: GhosttyFlashOverlayView private let flashLayer: CAShapeLayer + private var reparentFocusSuppressionCount = 0 var isRightSidebarDockSurface: Bool { surfaceView.terminalSurface?.focusPlacement == .rightSidebarDock } @@ -11998,12 +11991,23 @@ final class GhosttySurfaceScrollView: NSView { } /// Suppress the surface view's onFocus callback and ghostty_surface_set_focus during - /// SwiftUI reparenting (programmatic splits). Call clearSuppressReparentFocus() after layout settles. - func suppressReparentFocus() { + /// SwiftUI reparenting (programmatic splits). + func beginSuppressingReparentFocus() { + reparentFocusSuppressionCount += 1 surfaceView.suppressingReparentFocus = true } - func clearSuppressReparentFocus() { + func endSuppressingReparentFocus() { + guard reparentFocusSuppressionCount > 0 else { + clearSuppressReparentFocus() + return + } + reparentFocusSuppressionCount -= 1 + guard reparentFocusSuppressionCount == 0 else { return } + clearSuppressReparentFocus() + } + + private func clearSuppressReparentFocus() { surfaceView.suppressingReparentFocus = false let hasUsablePortalGeometry: Bool = { let size = bounds.size diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index fb925e447991..45a3c01ee2fe 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -1142,6 +1142,7 @@ struct BrowserPanelView: View { .buttonStyle(OmnibarAddressButtonStyle()) .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) .safeHelp(String(localized: "terminalSidekick.collapse", defaultValue: "Collapse Sidekick")) + .accessibilityLabel(String(localized: "terminalSidekick.collapse", defaultValue: "Collapse Sidekick")) .accessibilityIdentifier("TerminalSidekickCollapseButton") } } @@ -1749,7 +1750,7 @@ struct BrowserPanelView: View { // Re-run focus behavior (select-all/refresh suggestions) when focus is // explicitly requested again while already focused. let urlString = panel.preferredURLStringForOmnibar() ?? "" - let effects = omnibarReduce(state: &omnibarState, event: .focusGained(currentURLString: urlString)) + let effects = omnibarReduce(state: &omnibarState, event: .reassertFocus(currentURLString: urlString)) applyOmnibarEffects(effects) refreshInlineCompletion() #if DEBUG @@ -3156,6 +3157,7 @@ struct OmnibarState: Equatable { enum OmnibarEvent: Equatable { case focusGained(currentURLString: String) + case reassertFocus(currentURLString: String) case focusLostRevertBuffer(currentURLString: String) case focusLostPreserveBuffer(currentURLString: String) case panelURLChanged(currentURLString: String) @@ -3187,6 +3189,23 @@ func omnibarReduce(state: inout OmnibarState, event: OmnibarEvent) -> OmnibarEff state.selectedSuggestionID = nil effects.shouldSelectAll = true + case .reassertFocus(let url): + if state.isFocused { + state.currentURLString = url + state.selectedSuggestionIndex = 0 + state.selectedSuggestionID = nil + effects.shouldSelectAll = true + } else { + state.isFocused = true + state.currentURLString = url + state.buffer = url + state.isUserEditing = false + state.suggestions = [] + state.selectedSuggestionIndex = 0 + state.selectedSuggestionID = nil + effects.shouldSelectAll = true + } + case .focusLostRevertBuffer(let url): state.isFocused = false state.currentURLString = url diff --git a/Sources/Panels/TerminalSidekickCoordinator.swift b/Sources/Panels/TerminalSidekickCoordinator.swift index e5b827d71e45..00037393dcff 100644 --- a/Sources/Panels/TerminalSidekickCoordinator.swift +++ b/Sources/Panels/TerminalSidekickCoordinator.swift @@ -21,6 +21,7 @@ nonisolated struct TerminalSidekickState: Codable, Equatable, Sendable { } static func clampedSplitRatio(_ value: Double) -> Double { + guard value.isFinite else { return defaultSplitRatio } min(max(value, minimumSplitRatio), maximumSplitRatio) } diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 15887fd1353d..28c1c7ef261d 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -231,6 +231,40 @@ nonisolated struct SessionTerminalSidekickSnapshot: Codable, Equatable, Sendable var urlString: String? var isOpen: Bool var splitRatio: Double + + init( + urlString: String? = nil, + isOpen: Bool, + splitRatio: Double = TerminalSidekickState.defaultSplitRatio + ) { + self.urlString = Self.sanitizedURLString(urlString) + self.isOpen = isOpen + self.splitRatio = Self.sanitizedSplitRatio(splitRatio) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + urlString: try container.decodeIfPresent(String.self, forKey: .urlString), + isOpen: try container.decodeIfPresent(Bool.self, forKey: .isOpen) ?? false, + splitRatio: try container.decodeIfPresent(Double.self, forKey: .splitRatio) + ?? TerminalSidekickState.defaultSplitRatio + ) + } + + private static func sanitizedURLString(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty, + URL(string: trimmed) != nil else { + return nil + } + return trimmed + } + + private static func sanitizedSplitRatio(_ value: Double) -> Double { + guard value.isFinite else { return TerminalSidekickState.defaultSplitRatio } + return TerminalSidekickState.clampedSplitRatio(value) + } } enum SurfaceResumeApprovalPolicy: String, Codable, CaseIterable, Sendable { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 14b620a7f943..08654bc66abe 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -16223,12 +16223,6 @@ class TerminalController { return terminalPanel.surface.surface } - private func resolveSurface(from arg: String, tabManager: TabManager) -> ghostty_surface_t? { - // Backwards compatibility: resolve a terminal surface by panel UUID or a stable index. - // Use a slightly longer wait to reduce flakiness during bonsplit/layout restructures. - return resolveTerminalSurface(from: arg, tabManager: tabManager, waitUpTo: 2.0) - } - private func resolveSurfaceId(from arg: String, tab: Workspace) -> UUID? { if let uuid = UUID(uuidString: arg), tab.panels[uuid] != nil { return uuid @@ -16406,15 +16400,6 @@ class TerminalController { return } - guard let surface = resolveTerminalSurface( - from: terminalPanel.id.uuidString, - tabManager: tabManager, - waitUpTo: 2.0 - ) else { - error = "ERROR: Surface not ready" - return - } - // Unescape common escape sequences // Note: \n is converted to \r for terminal (Enter key sends \r) let unescaped = text @@ -16422,14 +16407,7 @@ class TerminalController { .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - for char in unescaped { - if char.unicodeScalars.count == 1, - let scalar = char.unicodeScalars.first, - handleControlScalar(scalar, surface: surface) { - continue - } - sendTextEvent(surface: surface, text: String(char)) - } + sendSocketInput(unescaped, to: terminalPanel) success = true } if let error { return error } @@ -16459,6 +16437,15 @@ class TerminalController { #endif } + private func sendSocketInput(_ text: String, to terminalPanel: TerminalPanel) { + if let surface = terminalPanel.surface.surface { + sendSocketText(text, surface: surface) + } else { + terminalPanel.sendInput(text) + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + } + } + private func sendInputToWorkspace(_ args: String) -> String { guard let tabManager else { return "ERROR: TabManager not available" } let parts = args.split(separator: " ", maxSplits: 1).map(String.init) @@ -16498,12 +16485,7 @@ class TerminalController { // payload does not hold the control-socket response open in CI. TerminalMutationBus.shared.enqueueMainActorMutation { [weak self] in guard let self else { return } - if let surface = terminalPanel.surface.surface { - self.sendSocketText(unescaped, surface: surface) - } else { - terminalPanel.sendText(unescaped) - terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() - } + self.sendSocketInput(unescaped, to: terminalPanel) } success = true } @@ -16559,25 +16541,23 @@ class TerminalController { let text = parts[1] var success = false + var error: String? v2MainSync { - guard let surface = resolveSurface(from: target, tabManager: tabManager) else { return } + guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else { + error = "ERROR: Surface not found" + return + } let unescaped = text .replacingOccurrences(of: "\\n", with: "\r") .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") - for char in unescaped { - if char.unicodeScalars.count == 1, - let scalar = char.unicodeScalars.first, - handleControlScalar(scalar, surface: surface) { - continue - } - sendTextEvent(surface: surface, text: String(char)) - } + sendSocketInput(unescaped, to: terminalPanel) success = true } + if let error { return error } return success ? "OK" : "ERROR: Failed to send input" } diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index 039f95cb3817..e14962c3e457 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -970,6 +970,13 @@ final class TerminalNotificationStore: ObservableObject { restoredUnreadWorkspaceIds = [] } + @discardableResult + private func clearWorkspaceUnreadState(forTabId tabId: UUID) -> Bool { + let clearedManual = setWorkspaceManualUnread(false, forTabId: tabId) + let clearedRestored = setWorkspaceRestoredUnread(false, forTabId: tabId) + return clearedManual || clearedRestored + } + func hasManualUnread(forTabId tabId: UUID) -> Bool { manualUnreadWorkspaceIds.contains(tabId) } @@ -1475,7 +1482,7 @@ final class TerminalNotificationStore: ObservableObject { } } if surfaceId == nil { - setWorkspaceRestoredUnread(false, forTabId: tabId) + clearWorkspaceUnreadState(forTabId: tabId) } if !idsToClear.isEmpty { notifications = updated @@ -1593,7 +1600,8 @@ final class TerminalNotificationStore: ObservableObject { ) { if discardQueuedNotifications { TerminalMutationBus.shared.discardPendingNotifications(forTabId: tabId, surfaceId: surfaceId) } let hadFocusedReadIndicator = focusedReadIndicatorByTabId[tabId].map { $0 == surfaceId } ?? false - let hadRestoredWorkspaceUnread = surfaceId == nil && restoredUnreadWorkspaceIds.contains(tabId) + let hadWorkspaceUnread = surfaceId == nil && + (manualUnreadWorkspaceIds.contains(tabId) || restoredUnreadWorkspaceIds.contains(tabId)) var updated: [TerminalNotification] = [] updated.reserveCapacity(notifications.count) var idsToClear: [String] = [] @@ -1604,12 +1612,12 @@ final class TerminalNotificationStore: ObservableObject { updated.append(notification) } } - guard !idsToClear.isEmpty || hadFocusedReadIndicator || hadRestoredWorkspaceUnread else { return } + guard !idsToClear.isEmpty || hadFocusedReadIndicator || hadWorkspaceUnread else { return } if !idsToClear.isEmpty { replaceNotificationsForClear(updated) } if surfaceId == nil { - setWorkspaceRestoredUnread(false, forTabId: tabId) + clearWorkspaceUnreadState(forTabId: tabId) } clearFocusedReadIndicator(forTabId: tabId, surfaceId: surfaceId) if !idsToClear.isEmpty { diff --git a/Sources/VaultAgentProcessScanner.swift b/Sources/VaultAgentProcessScanner.swift index 0697cdfc2892..c154bc6d6221 100644 --- a/Sources/VaultAgentProcessScanner.swift +++ b/Sources/VaultAgentProcessScanner.swift @@ -8,7 +8,9 @@ extension AgentLaunchCommandSnapshot { executablePath: String?, arguments: [String], workingDirectory: String?, - environment: [String: String] + environment: [String: String], + capturedAt: TimeInterval? = nil, + source: String = "process" ) { var selectedEnvironment = AgentLaunchEnvironmentPolicy.selectedEnvironment(from: environment) if launcher == "opencode", @@ -22,8 +24,8 @@ extension AgentLaunchCommandSnapshot { arguments: arguments, workingDirectory: workingDirectory, environment: selectedEnvironment.isEmpty ? nil : selectedEnvironment, - capturedAt: nil, - source: "process" + capturedAt: capturedAt, + source: source ) } } @@ -102,7 +104,7 @@ extension RestorableAgentSessionIndex { sessionId: sessionId, workingDirectory: registration.cwd == .ignore ? nil : cwd, launchCommand: AgentLaunchCommandSnapshot( - launcher: registration.id, + processDetectedLauncher: registration.id, executablePath: executablePath, arguments: arguments, workingDirectory: cwd, diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 3ba3db8b72d2..74893c8d2f5c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -8221,6 +8221,7 @@ final class Workspace: Identifiable, ObservableObject { private var layoutFollowUpAttemptScheduled = false private var layoutFollowUpAttemptVersion: Int = 0 private var layoutFollowUpStalledAttemptCount = 0 + private var layoutFollowUpReparentSuppressedViews: [GhosttySurfaceScrollView] = [] private var portalRenderingEnabled = true private var isAttemptingLayoutFollowUp = false private var isNormalizingPinnedTabOrder = false @@ -10481,15 +10482,9 @@ final class Workspace: Identifiable, ObservableObject { ) #endif - // Suppress the old view's becomeFirstResponder side-effects during SwiftUI reparenting. - // Without this, reparenting triggers onFocus + ghostty_surface_set_focus on the old view, - // stealing focus from the new panel and creating model/surface divergence. if focus { - previousHostedView?.suppressReparentFocus() + suppressReparentFocusUntilLayoutSettles(previousHostedView, reason: "terminal_split") focusPanel(newPanel.id, previousHostedView: previousHostedView) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } } else { preserveFocusAfterNonFocusSplit( preferredPanelId: previousFocusedPanelId, @@ -10700,11 +10695,8 @@ final class Workspace: Identifiable, ObservableObject { // See newTerminalSplit: suppress old view's becomeFirstResponder during reparenting. let previousHostedView = focusedTerminalPanel?.hostedView if focus { - previousHostedView?.suppressReparentFocus() + suppressReparentFocusUntilLayoutSettles(previousHostedView, reason: "browser_split") focusPanel(browserPanel.id) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } } else { preserveFocusAfterNonFocusSplit( preferredPanelId: previousFocusedPanelId, @@ -10889,11 +10881,8 @@ final class Workspace: Identifiable, ObservableObject { let previousHostedView = focusedTerminalPanel?.hostedView if focus { - previousHostedView?.suppressReparentFocus() + suppressReparentFocusUntilLayoutSettles(previousHostedView, reason: "markdown_split") focusPanel(markdownPanel.id) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - previousHostedView?.clearSuppressReparentFocus() - } } else { preserveFocusAfterNonFocusSplit( preferredPanelId: previousFocusedPanelId, @@ -12539,6 +12528,31 @@ final class Workspace: Identifiable, ObservableObject { } } + private func suppressReparentFocusUntilLayoutSettles( + _ hostedView: GhosttySurfaceScrollView?, + reason: String + ) { + guard let hostedView else { return } + hostedView.beginSuppressingReparentFocus() + guard portalRenderingEnabled else { + DispatchQueue.main.async { + hostedView.endSuppressingReparentFocus() + } + return + } + layoutFollowUpReparentSuppressedViews.append(hostedView) + beginEventDrivenLayoutFollowUp(reason: "reparent_focus.\(reason)", includeGeometry: true) + } + + private func clearLayoutFollowUpReparentSuppressions() { + guard !layoutFollowUpReparentSuppressedViews.isEmpty else { return } + let suppressedViews = layoutFollowUpReparentSuppressedViews + layoutFollowUpReparentSuppressedViews.removeAll() + for hostedView in suppressedViews { + hostedView.endSuppressingReparentFocus() + } + } + private func beginEventDrivenLayoutFollowUp( reason: String, browserPanelId: UUID? = nil, @@ -12654,6 +12668,7 @@ final class Workspace: Identifiable, ObservableObject { } private func clearLayoutFollowUp() { + clearLayoutFollowUpReparentSuppressions() layoutFollowUpTimeoutWorkItem?.cancel() layoutFollowUpTimeoutWorkItem = nil layoutFollowUpObservers.forEach { NotificationCenter.default.removeObserver($0) } diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 334f6c780cd4..351fa1f7bb90 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -989,12 +989,19 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ) XCTAssertFalse(fileExplorerState.isVisible) + let previousTerminalPanel = tabManager.selectedWorkspace?.focusedTerminalPanel + let previousSidekickState = previousTerminalPanel?.sidekickState if let workspace = tabManager.selectedWorkspace, let paneId = workspace.bonsplitController.allPaneIds.first, let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) { workspace.focusPanel(browserPanel.id) XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: rightSidebarEvent)) RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + XCTAssertEqual( + previousTerminalPanel?.sidekickState, + previousSidekickState, + "Cmd+Option+B should not mutate the previous terminal sidekick when a browser panel is focused" + ) XCTAssertTrue(fileExplorerState.isVisible, "Cmd+Option+B should toggle the right sidebar outside focused terminals") } else { XCTFail("Expected to create a browser panel for right-sidebar shortcut routing") @@ -5108,6 +5115,9 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { ] for testCase in cases { + workspace.focusPanel(browserPanel.id) + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01)) + var observedActions: [KeyboardShortcutSettings.Action] = [] #if DEBUG KeyboardShortcutSettings.shortcutLookupObserver = { action in diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index ab725ce7e314..36beb3363f81 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -135,6 +135,75 @@ final class FeedCoordinatorTests: XCTestCase { ) } + func testBlockingIngestExpiresWaiterWhenAgentProcessExitsBeforeNotificationDisplay() async { +#if DEBUG + let requestId = "agent-exit-request" + let ppid = 424_242 + let notifications = NotificationRequestRecorder() + + addTeardownBlock { + Self.resetFeedCoordinatorTestHooks() + } + + await MainActor.run { + let store = WorkstreamStore(ringCapacity: 10) + FeedCoordinator.shared.install(store: store) + FeedCoordinatorTestHooks.afterBlockingEventIngested = { _, ingestedRequestId in + guard ingestedRequestId == requestId else { return } + FeedCoordinator.shared.debugExpirePendingItemsAndWaiters(forPpid: ppid) + } + FeedCoordinatorTestHooks.isAppActiveOverride = { false } + FeedCoordinatorTestHooks.notificationPostObserver = { _, postedRequestId in + notifications.record(postedRequestId) + } + } + + let event = WorkstreamEvent( + sessionId: "claude-agent-exit-test", + hookEventName: .permissionRequest, + source: "claude", + cwd: "/tmp", + toolName: "Bash", + toolInputJSON: #"{"command":"true"}"#, + requestId: requestId, + ppid: ppid + ) + + let done = expectation(description: "blocking ingest expired after agent exit") + let resultBox = IngestResultBox() + + DispatchQueue.global(qos: .userInitiated).async { + resultBox.value = FeedCoordinator.shared.ingestBlocking( + event: event, + waitTimeout: 5 + ) + done.fulfill() + } + + await fulfillment(of: [done], timeout: 2) + await MainActor.run {} + + guard case .timedOut = resultBox.value else { + XCTFail("expected agent-exit hook to unblock as timed out") + return + } + + let status = await MainActor.run { + FeedCoordinator.shared.store.items.first?.status + } + guard case .expired = status else { + XCTFail("agent-exit hook item should be expired") + return + } + XCTAssertTrue( + notifications.requestIds.isEmpty, + "expired hook requests should not post native notifications" + ) +#else + XCTFail("debug feed test hooks are only available in DEBUG") +#endif + } + private static func resetFeedCoordinatorTestHooks() { let reset: @Sendable () -> Void = { MainActor.assumeIsolated { diff --git a/cmuxTests/TabManagerSessionSnapshotTests.swift b/cmuxTests/TabManagerSessionSnapshotTests.swift index 2d42492d44d9..85648c406bd5 100644 --- a/cmuxTests/TabManagerSessionSnapshotTests.swift +++ b/cmuxTests/TabManagerSessionSnapshotTests.swift @@ -1,4 +1,5 @@ import CoreGraphics +import Foundation import XCTest #if canImport(cmux_DEV) @@ -85,6 +86,29 @@ final class TabManagerSessionSnapshotTests: XCTestCase { XCTAssertEqual(terminalPanel.sidekickState.splitRatio, 0.7, accuracy: 0.001) } + func testTerminalSidekickSnapshotSanitizesPersistedValues() throws { + let directSnapshot = SessionTerminalSidekickSnapshot( + urlString: " ", + isOpen: true, + splitRatio: .nan + ) + + XCTAssertNil(directSnapshot.urlString) + XCTAssertEqual(directSnapshot.splitRatio, TerminalSidekickState.defaultSplitRatio, accuracy: 0.001) + + let decoded = try JSONDecoder().decode( + SessionTerminalSidekickSnapshot.self, + from: Data( + #"{"urlString":" https://sidekick.example/restored ","isOpen":true,"splitRatio":9.0}"# + .utf8 + ) + ) + + XCTAssertEqual(decoded.urlString, "https://sidekick.example/restored") + XCTAssertTrue(decoded.isOpen) + XCTAssertEqual(decoded.splitRatio, 0.7, accuracy: 0.001) + } + func testTerminalSidekickResizeRatioMatchesDisplayedMinimumSidekickWidth() throws { let totalWidth: CGFloat = 1000 let ratio = try XCTUnwrap( diff --git a/cmuxTests/WorkspaceManualUnreadTests.swift b/cmuxTests/WorkspaceManualUnreadTests.swift index 7664a1b89245..d27fe054e26c 100644 --- a/cmuxTests/WorkspaceManualUnreadTests.swift +++ b/cmuxTests/WorkspaceManualUnreadTests.swift @@ -168,6 +168,34 @@ final class WorkspaceManualUnreadTests: XCTestCase { } } + func testManualWorkspaceUnreadClearsFromWorkspaceReadAndClearFlows() { + let store = TerminalNotificationStore.shared + + func assertManualUnreadClears(_ action: (UUID) -> Void, line: UInt = #line) { + let workspaceId = UUID() + store.replaceNotificationsForTesting([]) + store.markUnread(forTabId: workspaceId) + + XCTAssertTrue(store.hasManualUnread(forTabId: workspaceId), line: line) + XCTAssertEqual(store.unreadCount(forTabId: workspaceId), 1, line: line) + + action(workspaceId) + + XCTAssertFalse(store.hasManualUnread(forTabId: workspaceId), line: line) + XCTAssertEqual(store.unreadCount(forTabId: workspaceId), 0, line: line) + } + + assertManualUnreadClears { workspaceId in + store.markRead(forTabId: workspaceId, surfaceId: nil) + } + assertManualUnreadClears { workspaceId in + store.clearNotifications(forTabId: workspaceId, surfaceId: nil, discardQueuedNotifications: false) + } + assertManualUnreadClears { workspaceId in + store.clearNotifications(forTabId: workspaceId, discardQueuedNotifications: false) + } + } + func testManualWorkspaceUnreadSurvivesNonTerminalDirectInteraction() { let appDelegate = AppDelegate.shared ?? AppDelegate() let manager = TabManager() From 0cce64c359b5922bb5c5fef2aef9acac51208d60 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 11:30:49 -0700 Subject: [PATCH 32/46] fix: resolve sidekick build and sanitizer review --- .../AgentLaunchSanitizer.swift | 2 +- .../AgentLaunchSanitizerTests.swift | 27 +++++++++++++++++++ .../Panels/TerminalSidekickCoordinator.swift | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift index 85914b9f07c7..d1a417d96fa4 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift @@ -239,7 +239,7 @@ public enum AgentLaunchSanitizer { ) -> Bool { let arg = args[index] if let resumeSubcommand = policy.resumeSubcommand, arg == resumeSubcommand { - return true + return index + 1 < args.count && !args[index + 1].hasPrefix("-") } guard policy.sessionSubcommands.contains(arg) else { return false diff --git a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift index 45a8c35aa856..cedbde479797 100644 --- a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift +++ b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift @@ -417,6 +417,33 @@ struct AgentLaunchSanitizerTests { ) } + @Test("Preserves Codex variadic add-dir literal resume value") + func preservesCodexVariadicAddDirLiteralResumeValue() { + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + [ + "codex", + "--add-dir", + "resume", + "--model", + "gpt-5.4", + "--sandbox", + "danger-full-access", + ], + launcher: "codex", + fallbackKind: "codex" + ) == [ + "codex", + "--add-dir", + "resume", + "--model", + "gpt-5.4", + "--sandbox", + "danger-full-access", + ] + ) + } + @Test("Stops Codex variadic add-dir before fork session subcommand") func stopsCodexVariadicAddDirBeforeForkSessionSubcommand() { #expect( diff --git a/Sources/Panels/TerminalSidekickCoordinator.swift b/Sources/Panels/TerminalSidekickCoordinator.swift index 00037393dcff..575fc8964a31 100644 --- a/Sources/Panels/TerminalSidekickCoordinator.swift +++ b/Sources/Panels/TerminalSidekickCoordinator.swift @@ -22,7 +22,7 @@ nonisolated struct TerminalSidekickState: Codable, Equatable, Sendable { static func clampedSplitRatio(_ value: Double) -> Double { guard value.isFinite else { return defaultSplitRatio } - min(max(value, minimumSplitRatio), maximumSplitRatio) + return min(max(value, minimumSplitRatio), maximumSplitRatio) } static func normalizedURLString(_ value: String?) -> String? { From b36a507dcbdfe530a233f01d2f3b4fd853af615b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 11:47:22 -0700 Subject: [PATCH 33/46] fix: address sidekick review feedback --- Resources/Localizable.xcstrings | 68 +++---------------- Sources/App/ShortcutRoutingSupport.swift | 3 +- Sources/AppDelegate.swift | 13 ++++ Sources/GhosttyTerminalView.swift | 18 +++-- .../Panels/TerminalSidekickCoordinator.swift | 26 +++++-- Sources/TerminalController.swift | 32 +++++---- Sources/Workspace.swift | 32 +++++++-- cmuxTests/BrowserConfigTests.swift | 4 +- cmuxTests/FeedCoordinatorTests.swift | 4 +- 9 files changed, 107 insertions(+), 93 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index bae7270836c0..c0248ced75b6 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -29,13 +29,13 @@ "menu.view.toggleRightSidebar": { "localizations": {"ar":{"stringUnit":{"state":"translated","value":"تبديل الشريط الجانبي الأيمن"}},"bs":{"stringUnit":{"state":"translated","value":"Prebaci desnu bočnu traku"}},"da":{"stringUnit":{"state":"translated","value":"Slå højre sidebjælke til/fra"}},"de":{"stringUnit":{"state":"translated","value":"Rechte Seitenleiste ein-/ausblenden"}},"en":{"stringUnit":{"state":"translated","value":"Toggle Right Sidebar"}},"es":{"stringUnit":{"state":"translated","value":"Alternar barra lateral derecha"}},"fr":{"stringUnit":{"state":"translated","value":"Afficher/masquer la barre latérale droite"}},"it":{"stringUnit":{"state":"translated","value":"Attiva/Disattiva barra laterale destra"}},"ja":{"stringUnit":{"state":"translated","value":"右サイドバーを切り替え"}},"ko":{"stringUnit":{"state":"translated","value":"오른쪽 사이드바 전환"}},"nb":{"stringUnit":{"state":"translated","value":"Slå høyre sidepanel av/på"}},"pl":{"stringUnit":{"state":"translated","value":"Przełącz prawy pasek boczny"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Alternar barra lateral direita"}},"ru":{"stringUnit":{"state":"translated","value":"Переключить правую боковую панель"}},"th":{"stringUnit":{"state":"translated","value":"สลับแถบด้านข้างขวา"}},"tr":{"stringUnit":{"state":"translated","value":"Sağ Kenar Çubuğunu Aç/Kapat"}},"uk":{"stringUnit":{"state":"translated","value":"Перемкнути праву бічну панель"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"切换右侧边栏"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"切換右側邊欄"}}} }, "shortcut.toggleLeftSidebar.label": { "localizations": {"ar":{"stringUnit":{"state":"translated","value":"تبديل الشريط الجانبي الأيسر"}},"bs":{"stringUnit":{"state":"translated","value":"Prebaci lijevu bočnu traku"}},"da":{"stringUnit":{"state":"translated","value":"Slå venstre sidebjælke til/fra"}},"de":{"stringUnit":{"state":"translated","value":"Linke Seitenleiste ein-/ausblenden"}},"en":{"stringUnit":{"state":"translated","value":"Toggle Left Sidebar"}},"es":{"stringUnit":{"state":"translated","value":"Alternar barra lateral izquierda"}},"fr":{"stringUnit":{"state":"translated","value":"Afficher/masquer la barre latérale gauche"}},"it":{"stringUnit":{"state":"translated","value":"Attiva/Disattiva barra laterale sinistra"}},"ja":{"stringUnit":{"state":"translated","value":"左サイドバーを切り替え"}},"ko":{"stringUnit":{"state":"translated","value":"왼쪽 사이드바 전환"}},"nb":{"stringUnit":{"state":"translated","value":"Slå venstre sidepanel av/på"}},"pl":{"stringUnit":{"state":"translated","value":"Przełącz lewy pasek boczny"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Alternar barra lateral esquerda"}},"ru":{"stringUnit":{"state":"translated","value":"Переключить левую боковую панель"}},"th":{"stringUnit":{"state":"translated","value":"สลับแถบด้านข้างซ้าย"}},"tr":{"stringUnit":{"state":"translated","value":"Sol Kenar Çubuğunu Aç/Kapat"}},"uk":{"stringUnit":{"state":"translated","value":"Перемкнути ліву бічну панель"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"切换左侧边栏"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"切換左側邊欄"}}} }, "shortcut.toggleRightSidebar.label": { "localizations": {"ar":{"stringUnit":{"state":"translated","value":"تبديل الشريط الجانبي الأيمن"}},"bs":{"stringUnit":{"state":"translated","value":"Prebaci desnu bočnu traku"}},"da":{"stringUnit":{"state":"translated","value":"Slå højre sidebjælke til/fra"}},"de":{"stringUnit":{"state":"translated","value":"Rechte Seitenleiste ein-/ausblenden"}},"en":{"stringUnit":{"state":"translated","value":"Toggle Right Sidebar"}},"es":{"stringUnit":{"state":"translated","value":"Alternar barra lateral derecha"}},"fr":{"stringUnit":{"state":"translated","value":"Afficher/masquer la barre latérale droite"}},"it":{"stringUnit":{"state":"translated","value":"Attiva/Disattiva barra laterale destra"}},"ja":{"stringUnit":{"state":"translated","value":"右サイドバーを切り替え"}},"ko":{"stringUnit":{"state":"translated","value":"오른쪽 사이드바 전환"}},"nb":{"stringUnit":{"state":"translated","value":"Slå høyre sidepanel av/på"}},"pl":{"stringUnit":{"state":"translated","value":"Przełącz prawy pasek boczny"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Alternar barra lateral direita"}},"ru":{"stringUnit":{"state":"translated","value":"Переключить правую боковую панель"}},"th":{"stringUnit":{"state":"translated","value":"สลับแถบด้านข้างขวา"}},"tr":{"stringUnit":{"state":"translated","value":"Sağ Kenar Çubuğunu Aç/Kapat"}},"uk":{"stringUnit":{"state":"translated","value":"Перемкнути праву бічну панель"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"切换右侧边栏"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"切換右側邊欄"}}} }, - "shortcut.toggleTerminalSidekick.label": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Toggle Terminal Sidekick" } }, "ja": { "stringUnit": { "state": "translated", "value": "ターミナルサイドキックを切り替え" } } } }, - "terminalSidekick.accessibilityLabel": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Terminal sidekick browser" } }, "ja": { "stringUnit": { "state": "translated", "value": "ターミナルサイドキックブラウザ" } } } }, - "terminalSidekick.addressPlaceholder": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Enter URL or search" } }, "ja": { "stringUnit": { "state": "translated", "value": "URLまたは検索語を入力" } } } }, - "terminalSidekick.addressLabel": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Sidekick address" } }, "ja": { "stringUnit": { "state": "translated", "value": "サイドキックアドレス" } } } }, - "terminalSidekick.open": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Open in Sidekick" } }, "ja": { "stringUnit": { "state": "translated", "value": "サイドキックで開く" } } } }, - "terminalSidekick.collapse": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Collapse Sidekick" } }, "ja": { "stringUnit": { "state": "translated", "value": "サイドキックを折りたたむ" } } } }, - "terminalSidekick.resizeDivider": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Resize Sidekick" } }, "ja": { "stringUnit": { "state": "translated", "value": "サイドキックのサイズを変更" } } } }, + "shortcut.toggleTerminalSidekick.label": {"localizations":{"ar":{"stringUnit":{"state":"translated","value":"Toggle Terminal Sidekick"}},"bs":{"stringUnit":{"state":"translated","value":"Toggle Terminal Sidekick"}},"da":{"stringUnit":{"state":"translated","value":"Toggle Terminal Sidekick"}},"de":{"stringUnit":{"state":"translated","value":"Terminal-Sidekick ein-/ausblenden"}},"en":{"stringUnit":{"state":"translated","value":"Toggle Terminal Sidekick"}},"es":{"stringUnit":{"state":"translated","value":"Alternar Sidekick del terminal"}},"fr":{"stringUnit":{"state":"translated","value":"Afficher/masquer le sidekick du terminal"}},"it":{"stringUnit":{"state":"translated","value":"Attiva/Disattiva Sidekick del terminale"}},"ja":{"stringUnit":{"state":"translated","value":"ターミナルサイドキックを切り替え"}},"ko":{"stringUnit":{"state":"translated","value":"터미널 사이드킥 전환"}},"nb":{"stringUnit":{"state":"translated","value":"Slå terminal-sidekick av/på"}},"pl":{"stringUnit":{"state":"translated","value":"Toggle Terminal Sidekick"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Alternar Sidekick do terminal"}},"ru":{"stringUnit":{"state":"translated","value":"Переключить помощник терминала"}},"th":{"stringUnit":{"state":"translated","value":"Toggle Terminal Sidekick"}},"tr":{"stringUnit":{"state":"translated","value":"Toggle Terminal Sidekick"}},"uk":{"stringUnit":{"state":"translated","value":"Перемкнути помічник термінала"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"切换终端 Sidekick"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"切換終端 Sidekick"}}}}, + "terminalSidekick.accessibilityLabel": {"localizations":{"ar":{"stringUnit":{"state":"translated","value":"Terminal sidekick browser"}},"bs":{"stringUnit":{"state":"translated","value":"Terminal sidekick browser"}},"da":{"stringUnit":{"state":"translated","value":"Terminal sidekick browser"}},"de":{"stringUnit":{"state":"translated","value":"Terminal-Sidekick-Browser"}},"en":{"stringUnit":{"state":"translated","value":"Terminal sidekick browser"}},"es":{"stringUnit":{"state":"translated","value":"Navegador auxiliar del terminal"}},"fr":{"stringUnit":{"state":"translated","value":"Navigateur sidekick du terminal"}},"it":{"stringUnit":{"state":"translated","value":"Browser Sidekick del terminale"}},"ja":{"stringUnit":{"state":"translated","value":"ターミナルサイドキックブラウザ"}},"ko":{"stringUnit":{"state":"translated","value":"터미널 사이드킥 브라우저"}},"nb":{"stringUnit":{"state":"translated","value":"Terminal-sidekick-nettleser"}},"pl":{"stringUnit":{"state":"translated","value":"Terminal sidekick browser"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Navegador Sidekick do terminal"}},"ru":{"stringUnit":{"state":"translated","value":"Браузер-помощник терминала"}},"th":{"stringUnit":{"state":"translated","value":"Terminal sidekick browser"}},"tr":{"stringUnit":{"state":"translated","value":"Terminal sidekick browser"}},"uk":{"stringUnit":{"state":"translated","value":"Браузер-помічник термінала"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"终端 Sidekick 浏览器"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"終端 Sidekick 瀏覽器"}}}}, + "terminalSidekick.addressPlaceholder": {"localizations":{"ar":{"stringUnit":{"state":"translated","value":"Enter URL or search"}},"bs":{"stringUnit":{"state":"translated","value":"Enter URL or search"}},"da":{"stringUnit":{"state":"translated","value":"Enter URL or search"}},"de":{"stringUnit":{"state":"translated","value":"URL oder Suche eingeben"}},"en":{"stringUnit":{"state":"translated","value":"Enter URL or search"}},"es":{"stringUnit":{"state":"translated","value":"Introduce una URL o búsqueda"}},"fr":{"stringUnit":{"state":"translated","value":"Saisissez une URL ou une recherche"}},"it":{"stringUnit":{"state":"translated","value":"Inserisci URL o ricerca"}},"ja":{"stringUnit":{"state":"translated","value":"URLまたは検索語を入力"}},"ko":{"stringUnit":{"state":"translated","value":"URL 또는 검색어 입력"}},"nb":{"stringUnit":{"state":"translated","value":"Skriv inn URL eller søk"}},"pl":{"stringUnit":{"state":"translated","value":"Enter URL or search"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Digite uma URL ou pesquisa"}},"ru":{"stringUnit":{"state":"translated","value":"Введите URL или поисковый запрос"}},"th":{"stringUnit":{"state":"translated","value":"Enter URL or search"}},"tr":{"stringUnit":{"state":"translated","value":"Enter URL or search"}},"uk":{"stringUnit":{"state":"translated","value":"Введіть URL або пошуковий запит"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"输入 URL 或搜索内容"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"輸入 URL 或搜尋內容"}}}}, + "terminalSidekick.addressLabel": {"localizations":{"ar":{"stringUnit":{"state":"translated","value":"Sidekick address"}},"bs":{"stringUnit":{"state":"translated","value":"Sidekick address"}},"da":{"stringUnit":{"state":"translated","value":"Sidekick address"}},"de":{"stringUnit":{"state":"translated","value":"Sidekick-Adresse"}},"en":{"stringUnit":{"state":"translated","value":"Sidekick address"}},"es":{"stringUnit":{"state":"translated","value":"Dirección de Sidekick"}},"fr":{"stringUnit":{"state":"translated","value":"Adresse du sidekick"}},"it":{"stringUnit":{"state":"translated","value":"Indirizzo Sidekick"}},"ja":{"stringUnit":{"state":"translated","value":"サイドキックアドレス"}},"ko":{"stringUnit":{"state":"translated","value":"사이드킥 주소"}},"nb":{"stringUnit":{"state":"translated","value":"Sidekick-adresse"}},"pl":{"stringUnit":{"state":"translated","value":"Sidekick address"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Endereço do Sidekick"}},"ru":{"stringUnit":{"state":"translated","value":"Адрес помощника"}},"th":{"stringUnit":{"state":"translated","value":"Sidekick address"}},"tr":{"stringUnit":{"state":"translated","value":"Sidekick address"}},"uk":{"stringUnit":{"state":"translated","value":"Адреса помічника"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"Sidekick 地址"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"Sidekick 位址"}}}}, + "terminalSidekick.open": {"localizations":{"ar":{"stringUnit":{"state":"translated","value":"Open in Sidekick"}},"bs":{"stringUnit":{"state":"translated","value":"Open in Sidekick"}},"da":{"stringUnit":{"state":"translated","value":"Open in Sidekick"}},"de":{"stringUnit":{"state":"translated","value":"In Sidekick öffnen"}},"en":{"stringUnit":{"state":"translated","value":"Open in Sidekick"}},"es":{"stringUnit":{"state":"translated","value":"Abrir en Sidekick"}},"fr":{"stringUnit":{"state":"translated","value":"Ouvrir dans Sidekick"}},"it":{"stringUnit":{"state":"translated","value":"Apri in Sidekick"}},"ja":{"stringUnit":{"state":"translated","value":"サイドキックで開く"}},"ko":{"stringUnit":{"state":"translated","value":"사이드킥에서 열기"}},"nb":{"stringUnit":{"state":"translated","value":"Åpne i Sidekick"}},"pl":{"stringUnit":{"state":"translated","value":"Open in Sidekick"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Abrir no Sidekick"}},"ru":{"stringUnit":{"state":"translated","value":"Открыть в помощнике"}},"th":{"stringUnit":{"state":"translated","value":"Open in Sidekick"}},"tr":{"stringUnit":{"state":"translated","value":"Open in Sidekick"}},"uk":{"stringUnit":{"state":"translated","value":"Відкрити в помічнику"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"在 Sidekick 中打开"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"在 Sidekick 中開啟"}}}}, + "terminalSidekick.collapse": {"localizations":{"ar":{"stringUnit":{"state":"translated","value":"Collapse Sidekick"}},"bs":{"stringUnit":{"state":"translated","value":"Collapse Sidekick"}},"da":{"stringUnit":{"state":"translated","value":"Collapse Sidekick"}},"de":{"stringUnit":{"state":"translated","value":"Sidekick einklappen"}},"en":{"stringUnit":{"state":"translated","value":"Collapse Sidekick"}},"es":{"stringUnit":{"state":"translated","value":"Contraer Sidekick"}},"fr":{"stringUnit":{"state":"translated","value":"Réduire Sidekick"}},"it":{"stringUnit":{"state":"translated","value":"Comprimi Sidekick"}},"ja":{"stringUnit":{"state":"translated","value":"サイドキックを折りたたむ"}},"ko":{"stringUnit":{"state":"translated","value":"사이드킥 접기"}},"nb":{"stringUnit":{"state":"translated","value":"Skjul Sidekick"}},"pl":{"stringUnit":{"state":"translated","value":"Collapse Sidekick"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Recolher Sidekick"}},"ru":{"stringUnit":{"state":"translated","value":"Свернуть помощник"}},"th":{"stringUnit":{"state":"translated","value":"Collapse Sidekick"}},"tr":{"stringUnit":{"state":"translated","value":"Collapse Sidekick"}},"uk":{"stringUnit":{"state":"translated","value":"Згорнути помічника"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"折叠 Sidekick"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"收合 Sidekick"}}}}, + "terminalSidekick.resizeDivider": {"localizations":{"ar":{"stringUnit":{"state":"translated","value":"Resize Sidekick"}},"bs":{"stringUnit":{"state":"translated","value":"Resize Sidekick"}},"da":{"stringUnit":{"state":"translated","value":"Resize Sidekick"}},"de":{"stringUnit":{"state":"translated","value":"Sidekick-Größe ändern"}},"en":{"stringUnit":{"state":"translated","value":"Resize Sidekick"}},"es":{"stringUnit":{"state":"translated","value":"Cambiar tamaño de Sidekick"}},"fr":{"stringUnit":{"state":"translated","value":"Redimensionner Sidekick"}},"it":{"stringUnit":{"state":"translated","value":"Ridimensiona Sidekick"}},"ja":{"stringUnit":{"state":"translated","value":"サイドキックのサイズを変更"}},"ko":{"stringUnit":{"state":"translated","value":"사이드킥 크기 조절"}},"nb":{"stringUnit":{"state":"translated","value":"Endre størrelse på Sidekick"}},"pl":{"stringUnit":{"state":"translated","value":"Resize Sidekick"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Redimensionar Sidekick"}},"ru":{"stringUnit":{"state":"translated","value":"Изменить размер помощника"}},"th":{"stringUnit":{"state":"translated","value":"Resize Sidekick"}},"tr":{"stringUnit":{"state":"translated","value":"Resize Sidekick"}},"uk":{"stringUnit":{"state":"translated","value":"Змінити розмір помічника"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"调整 Sidekick 大小"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"調整 Sidekick 大小"}}}}, "shortcut.commandPaletteNext.label": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "ja": { "stringUnit": { "state": "translated", "value": "コマンドパレット: 次へ" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "ko": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "de": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "es": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "fr": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "it": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "da": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "pl": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "ru": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "bs": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "ar": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "nb": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "th": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "tr": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } }, "uk": { "stringUnit": { "state": "translated", "value": "Command Palette: Next" } } } }, "shortcut.commandPalettePrevious.label": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "ja": { "stringUnit": { "state": "translated", "value": "コマンドパレット: 前へ" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "ko": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "de": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "es": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "fr": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "it": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "da": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "pl": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "ru": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "bs": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "ar": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "nb": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "th": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "tr": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } }, "uk": { "stringUnit": { "state": "translated", "value": "Command Palette: Previous" } } } }, "settings.shortcuts.managedByFile": { "localizations": { "en": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "ja": { "stringUnit": { "state": "translated", "value": "cmux.json で管理" } }, "zh-Hans": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "zh-Hant": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "ko": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "de": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "es": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "fr": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "it": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "da": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "pl": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "ru": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "bs": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "ar": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "nb": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "pt-BR": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "th": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "tr": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } }, "uk": { "stringUnit": { "state": "translated", "value": "Managed in cmux.json" } } } }, @@ -21699,23 +21699,7 @@ } } }, - "command.supportedFileRouting.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "File Preview" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ファイルプレビュー" - } - } - } - }, + "command.supportedFileRouting.subtitle": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"File Preview"}},"bs":{"stringUnit":{"state":"translated","value":"File Preview"}},"da":{"stringUnit":{"state":"translated","value":"File Preview"}},"de":{"stringUnit":{"state":"translated","value":"Dateivorschau"}},"en":{"stringUnit":{"state":"translated","value":"File Preview"}},"es":{"stringUnit":{"state":"translated","value":"Vista previa de archivo"}},"fr":{"stringUnit":{"state":"translated","value":"Aperçu du fichier"}},"it":{"stringUnit":{"state":"translated","value":"Anteprima file"}},"ja":{"stringUnit":{"state":"translated","value":"ファイルプレビュー"}},"ko":{"stringUnit":{"state":"translated","value":"파일 미리보기"}},"nb":{"stringUnit":{"state":"translated","value":"Filforhåndsvisning"}},"pl":{"stringUnit":{"state":"translated","value":"File Preview"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Prévia do arquivo"}},"ru":{"stringUnit":{"state":"translated","value":"Предпросмотр файла"}},"th":{"stringUnit":{"state":"translated","value":"File Preview"}},"tr":{"stringUnit":{"state":"translated","value":"File Preview"}},"uk":{"stringUnit":{"state":"translated","value":"Попередній перегляд файлу"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"文件预览"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"檔案預覽"}}}}, "command.disableBrowser.title": { "extractionState": "manual", "localizations": { @@ -21733,23 +21717,7 @@ } } }, - "command.disableSupportedFileRouting.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Disable Cmd-click File Previews" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Cmd+クリックのファイルプレビューを無効にする" - } - } - } - }, + "command.disableSupportedFileRouting.title": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Disable Cmd-click File Previews"}},"bs":{"stringUnit":{"state":"translated","value":"Disable Cmd-click File Previews"}},"da":{"stringUnit":{"state":"translated","value":"Disable Cmd-click File Previews"}},"de":{"stringUnit":{"state":"translated","value":"Cmd-Klick-Dateivorschau deaktivieren"}},"en":{"stringUnit":{"state":"translated","value":"Disable Cmd-click File Previews"}},"es":{"stringUnit":{"state":"translated","value":"Desactivar vistas previas con Cmd-clic"}},"fr":{"stringUnit":{"state":"translated","value":"Désactiver les aperçus de fichiers Cmd-clic"}},"it":{"stringUnit":{"state":"translated","value":"Disattiva anteprime file con Cmd-clic"}},"ja":{"stringUnit":{"state":"translated","value":"Cmd+クリックのファイルプレビューを無効にする"}},"ko":{"stringUnit":{"state":"translated","value":"Cmd-클릭 파일 미리보기 비활성화"}},"nb":{"stringUnit":{"state":"translated","value":"Slå av filforhåndsvisning med Cmd-klikk"}},"pl":{"stringUnit":{"state":"translated","value":"Disable Cmd-click File Previews"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Desativar prévias de arquivo com Cmd-clique"}},"ru":{"stringUnit":{"state":"translated","value":"Отключить предпросмотр файлов по Cmd-клику"}},"th":{"stringUnit":{"state":"translated","value":"Disable Cmd-click File Previews"}},"tr":{"stringUnit":{"state":"translated","value":"Disable Cmd-click File Previews"}},"uk":{"stringUnit":{"state":"translated","value":"Вимкнути попередній перегляд файлів за Cmd-кліком"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"停用 Cmd 点按文件预览"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"停用 Cmd 點按檔案預覽"}}}}, "command.disableMinimalMode.title": { "extractionState": "manual", "localizations": { @@ -21796,23 +21764,7 @@ } } }, - "command.enableSupportedFileRouting.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Enable Cmd-click File Previews" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Cmd+クリックのファイルプレビューを有効にする" - } - } - } - }, + "command.enableSupportedFileRouting.title": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"Enable Cmd-click File Previews"}},"bs":{"stringUnit":{"state":"translated","value":"Enable Cmd-click File Previews"}},"da":{"stringUnit":{"state":"translated","value":"Enable Cmd-click File Previews"}},"de":{"stringUnit":{"state":"translated","value":"Cmd-Klick-Dateivorschau aktivieren"}},"en":{"stringUnit":{"state":"translated","value":"Enable Cmd-click File Previews"}},"es":{"stringUnit":{"state":"translated","value":"Activar vistas previas con Cmd-clic"}},"fr":{"stringUnit":{"state":"translated","value":"Activer les aperçus de fichiers Cmd-clic"}},"it":{"stringUnit":{"state":"translated","value":"Attiva anteprime file con Cmd-clic"}},"ja":{"stringUnit":{"state":"translated","value":"Cmd+クリックのファイルプレビューを有効にする"}},"ko":{"stringUnit":{"state":"translated","value":"Cmd-클릭 파일 미리보기 활성화"}},"nb":{"stringUnit":{"state":"translated","value":"Slå på filforhåndsvisning med Cmd-klikk"}},"pl":{"stringUnit":{"state":"translated","value":"Enable Cmd-click File Previews"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Ativar prévias de arquivo com Cmd-clique"}},"ru":{"stringUnit":{"state":"translated","value":"Включить предпросмотр файлов по Cmd-клику"}},"th":{"stringUnit":{"state":"translated","value":"Enable Cmd-click File Previews"}},"tr":{"stringUnit":{"state":"translated","value":"Enable Cmd-click File Previews"}},"uk":{"stringUnit":{"state":"translated","value":"Увімкнути попередній перегляд файлів за Cmd-кліком"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"启用 Cmd 点按文件预览"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"啟用 Cmd 點按檔案預覽"}}}}, "command.installCLI.subtitle": { "extractionState": "manual", "localizations": { diff --git a/Sources/App/ShortcutRoutingSupport.swift b/Sources/App/ShortcutRoutingSupport.swift index a54b4665a9d4..e4e956438bc2 100644 --- a/Sources/App/ShortcutRoutingSupport.swift +++ b/Sources/App/ShortcutRoutingSupport.swift @@ -46,7 +46,8 @@ func browserOmnibarNormalizedModifierFlags(_ flags: NSEvent.ModifierFlags) -> NS } func browserOmnibarShouldContinueControlNavigationRepeat(flags: NSEvent.ModifierFlags) -> Bool { - browserOmnibarNormalizedModifierFlags(flags) == [.control] + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + return normalizedFlags == [.control] || normalizedFlags == [.command] } func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 16003bcd3af2..a303efb9b990 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -12592,6 +12592,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return nil } + guard browserOmnibarPanelId(for: shortcutResponder) == panelId else { +#if DEBUG + let responderPanel = browserOmnibarPanelId(for: shortcutResponder) + .map { String($0.uuidString.prefix(5)) } ?? "nil" + cmuxDebugLog( + "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + + "accepted=0 reason=responder_panel_mismatch responderPanel=\(responderPanel) " + + "workspace=\(workspace.id.uuidString.prefix(5)) event=\(NSWindow.keyDescription(event))" + ) +#endif + return nil + } + #if DEBUG cmuxDebugLog( "browser.focus.addressBar.shortcutContext panel=\(panelId.uuidString.prefix(5)) " + diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 3c9d95af44dc..2958648681ed 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4558,13 +4558,16 @@ final class TerminalSurface: Identifiable, ObservableObject { } private enum PendingSocketInput { - case text(Data) + case pasteText(Data) + case keyText(String) case key(PendingKeyEvent) var estimatedBytes: Int { switch self { - case .text(let data): + case .pasteText(let data): return data.count + case .keyText(let text): + return text.utf8.count case .key(let event): return max(event.label.utf8.count, 1) } @@ -5759,7 +5762,7 @@ final class TerminalSurface: Identifiable, ObservableObject { func sendText(_ text: String) { guard let data = text.data(using: .utf8), !data.isEmpty else { return } guard let surface = surface else { - enqueuePendingSocketInput(.text(data)) + enqueuePendingSocketInput(.pasteText(data)) requestBackgroundSurfaceStartIfNeeded() return } @@ -5855,8 +5858,8 @@ final class TerminalSurface: Identifiable, ObservableObject { @discardableResult private func enqueuePendingText(_ buffer: inout String) -> Bool { - guard let data = buffer.data(using: .utf8), !data.isEmpty else { return false } - enqueuePendingSocketInput(.text(data)) + guard !buffer.isEmpty else { return false } + enqueuePendingSocketInput(.keyText(buffer)) buffer.removeAll(keepingCapacity: true) return true } @@ -6095,8 +6098,11 @@ final class TerminalSurface: Identifiable, ObservableObject { var queuedKeys = 0 for item in queued { switch item { - case .text(let chunk): + case .pasteText(let chunk): writeTextData(chunk, to: surface) + case .keyText(let chunk): + var bufferedText = chunk + flushText(&bufferedText, surface: surface) case .key(let event): queuedKeys += 1 sendKeyEvent(surface: surface, keycode: event.keycode, mods: event.mods) diff --git a/Sources/Panels/TerminalSidekickCoordinator.swift b/Sources/Panels/TerminalSidekickCoordinator.swift index 575fc8964a31..a2a66f945214 100644 --- a/Sources/Panels/TerminalSidekickCoordinator.swift +++ b/Sources/Panels/TerminalSidekickCoordinator.swift @@ -152,17 +152,31 @@ final class TerminalSidekickCoordinator { return } - if state.isOpen { - let hadBrowserPanel = browserPanel != nil - guard let browserPanel = ensureBrowserPanel() else { + guard state.isOpen else { + closeBrowserPanel() + return + } + + guard let url = state.url else { + closeBrowserPanel() + guard ensureBrowserPanel(renderInitialNavigation: false) != nil else { var next = state next.isOpen = false replaceState(next) return } - if hadBrowserPanel, let url = state.url { - browserPanel.navigate(to: url, recordTypedNavigation: false) - } + return + } + + let hadBrowserPanel = browserPanel != nil + guard let browserPanel = ensureBrowserPanel() else { + var next = state + next.isOpen = false + replaceState(next) + return + } + if hadBrowserPanel { + browserPanel.navigate(to: url, recordTypedNavigation: false) } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 08654bc66abe..c587efff766e 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -16178,21 +16178,24 @@ class TerminalController { let terminalSurface = terminalPanel.surface terminalSurface.requestBackgroundSurfaceStartIfNeeded() - _ = v2AwaitCallback(timeout: timeout) { finish in - var readyObserver: NSObjectProtocol? - var hostedViewObserver: NSObjectProtocol? - var didFinish = false + var readyObserver: NSObjectProtocol? + var hostedViewObserver: NSObjectProtocol? + var didFinish = false + let cleanupObservers: () -> Void = { + if let readyObserver { + NotificationCenter.default.removeObserver(readyObserver) + } + if let hostedViewObserver { + NotificationCenter.default.removeObserver(hostedViewObserver) + } + readyObserver = nil + hostedViewObserver = nil + } + let waitResult: Void? = v2AwaitCallback(timeout: timeout) { finish in let finishOnce: () -> Void = { guard !didFinish else { return } didFinish = true - if let readyObserver { - NotificationCenter.default.removeObserver(readyObserver) - } - if let hostedViewObserver { - NotificationCenter.default.removeObserver(hostedViewObserver) - } - readyObserver = nil - hostedViewObserver = nil + cleanupObservers() finish(()) } @@ -16219,6 +16222,10 @@ class TerminalController { finishOnce() } } + if waitResult == nil, !didFinish { + didFinish = true + cleanupObservers() + } return terminalPanel.surface.surface } @@ -16440,6 +16447,7 @@ class TerminalController { private func sendSocketInput(_ text: String, to terminalPanel: TerminalPanel) { if let surface = terminalPanel.surface.surface { sendSocketText(text, surface: surface) + terminalPanel.surface.forceRefresh(reason: "terminalController.sendSocketInput") } else { terminalPanel.sendInput(text) terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 74893c8d2f5c..c1cc2b21fb94 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7438,8 +7438,12 @@ final class Workspace: Identifiable, ObservableObject { @Published var panelTitles: [UUID: String] = [:] @Published var panelCustomTitles: [UUID: String] = [:] @Published var pinnedPanelIds: Set = [] - @Published var manualUnreadPanelIds: Set = [] - @Published private(set) var restoredUnreadPanelIds: Set = [] + @Published var manualUnreadPanelIds: Set = [] { + didSet { syncPanelDerivedWorkspaceUnreadState() } + } + @Published private(set) var restoredUnreadPanelIds: Set = [] { + didSet { syncPanelDerivedWorkspaceUnreadState() } + } @Published private(set) var tmuxLayoutSnapshot: LayoutSnapshot? @Published private(set) var tmuxWorkspaceFlashPanelId: UUID? @Published private(set) var tmuxWorkspaceFlashReason: WorkspaceAttentionFlashReason? @@ -8553,6 +8557,20 @@ final class Workspace: Identifiable, ObservableObject { } } + private func syncPanelDerivedWorkspaceUnreadState() { + let hasPanelUnreadIndicator = + manualUnreadPanelIds.contains { panels[$0] != nil } || + restoredUnreadPanelIds.contains { panels[$0] != nil } + if let notificationStore = AppDelegate.shared?.notificationStore { + if hasPanelUnreadIndicator { + notificationStore.restoreUnreadIndicator(forTabId: id) + } else { + notificationStore.clearRestoredUnreadIndicator(forTabId: id) + } + } + syncUnreadBadgeStateForAllPanels() + } + private func normalizePinnedTabs(in paneId: PaneID) { guard !isNormalizingPinnedTabOrder else { return } isNormalizingPinnedTabOrder = true @@ -8719,7 +8737,7 @@ final class Workspace: Identifiable, ObservableObject { let didInsertManual = manualUnreadPanelIds.insert(panelId).inserted guard didInsertManual || didClearRestored else { return } manualUnreadMarkedAt[panelId] = Date() - syncUnreadBadgeStateForPanel(panelId) + syncPanelDerivedWorkspaceUnreadState() } func preferredUnreadPanelIdForJump() -> UUID? { @@ -8744,7 +8762,7 @@ final class Workspace: Identifiable, ObservableObject { AppDelegate.shared?.notificationStore?.markRead(forTabId: id, surfaceId: panelId) _ = clearManualUnreadState(panelId: panelId) _ = clearRestoredUnreadIndicatorState(panelId: panelId) - syncUnreadBadgeStateForPanel(panelId) + syncPanelDerivedWorkspaceUnreadState() } func clearUnreadAfterJump(panelId: UUID?) { @@ -8760,7 +8778,7 @@ final class Workspace: Identifiable, ObservableObject { let didRemoveManual = clearManualUnreadState(panelId: panelId) let didRemoveRestored = clearRestoredUnreadIndicatorState(panelId: panelId) guard didRemoveManual || didRemoveRestored else { return } - syncUnreadBadgeStateForPanel(panelId) + syncPanelDerivedWorkspaceUnreadState() } private func clearManualUnreadState(panelId: UUID) -> Bool { @@ -8772,13 +8790,13 @@ final class Workspace: Identifiable, ObservableObject { func restorePanelUnreadIndicator(_ panelId: UUID) { guard panels[panelId] != nil else { return } guard restoredUnreadPanelIds.insert(panelId).inserted else { return } - syncUnreadBadgeStateForPanel(panelId) + syncPanelDerivedWorkspaceUnreadState() } func clearRestoredUnreadIndicator(panelId: UUID) { let didRemoveUnread = clearRestoredUnreadIndicatorState(panelId: panelId) guard didRemoveUnread else { return } - syncUnreadBadgeStateForPanel(panelId) + syncPanelDerivedWorkspaceUnreadState() } func hasRestoredUnreadIndicator(panelId: UUID) -> Bool { diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index 3cd1516329e8..28fba826d873 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -3894,9 +3894,11 @@ final class BrowserOmnibarCommandOrControlNavigationTests: XCTestCase { ) } - func testControlNavigationRepeatLifecycleRequiresControlOnly() { + func testCommandNavigationRepeatLifecycleRequiresCommandOrControlOnly() { XCTAssertTrue(browserOmnibarShouldContinueControlNavigationRepeat(flags: [.control])) XCTAssertTrue(browserOmnibarShouldContinueControlNavigationRepeat(flags: [.control, .capsLock])) + XCTAssertTrue(browserOmnibarShouldContinueControlNavigationRepeat(flags: [.command])) + XCTAssertTrue(browserOmnibarShouldContinueControlNavigationRepeat(flags: [.command, .capsLock])) XCTAssertFalse(browserOmnibarShouldContinueControlNavigationRepeat(flags: [.control, .command])) XCTAssertFalse(browserOmnibarShouldContinueControlNavigationRepeat(flags: [.control, .option])) XCTAssertFalse(browserOmnibarShouldContinueControlNavigationRepeat(flags: [.control, .shift])) diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index 36beb3363f81..2a1cd96e2671 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -22,7 +22,7 @@ final class FeedCoordinatorTests: XCTestCase { XCTAssertFalse(FeedPermissionActionPolicy.supportsBypassPermissions(source: .hermesAgent)) } - func testBlockingIngestExpiresItemWhenHookTimesOut() async { + func testBlockingIngestExpiresItemWhenHookTimesOut() async throws { await MainActor.run { let store = WorkstreamStore(ringCapacity: 10) FeedCoordinator.shared.install(store: store) @@ -200,7 +200,7 @@ final class FeedCoordinatorTests: XCTestCase { "expired hook requests should not post native notifications" ) #else - XCTFail("debug feed test hooks are only available in DEBUG") + throw XCTSkip("debug feed test hooks are only available in DEBUG") #endif } From 61d0478d87f4f7dbd0df1c32ec8002f9b8f1c2d3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 11:55:40 -0700 Subject: [PATCH 34/46] fix: address remaining sidekick review items --- Sources/AppDelegate.swift | 7 +- Sources/BrowserPaneDropTargetView.swift | 1 + Sources/BrowserWindowPortal.swift | 1 + Sources/ContentView+ViewCommandPalette.swift | 291 +++++++++++++++++++ Sources/ContentView.swift | 291 ------------------- Sources/Panels/BrowserPanelView.swift | 33 ++- Sources/RestorableAgentSession.swift | 9 + Sources/TerminalNotificationStore.swift | 1 + cmuxTests/SessionPersistenceTests.swift | 34 +++ 9 files changed, 370 insertions(+), 298 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a303efb9b990..505846e9dd3f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14053,17 +14053,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } ?? false if !shouldPreserveTrackedPanel { trackedPanel?.endSuppressWebViewFocusForAddressBar() + self.browserAddressBarFocusedPanelId = nil + self.stopBrowserOmnibarSelectionRepeat() } - self.browserAddressBarFocusedPanelId = nil - self.stopBrowserOmnibarSelectionRepeat() #if DEBUG let reason = trackedPanel == nil ? "stale_missing_panel_webViewFirstResponder" : (shouldPreserveTrackedPanel ? "stale_other_panel_preserved_webViewFirstResponder" : "stale_other_panel_webViewFirstResponder") + let action = shouldPreserveTrackedPanel ? "PRESERVE" : "CLEAR" cmuxDebugLog( - "addressBar CLEAR panelId=\(trackedPanelId.uuidString.prefix(8)) " + + "addressBar \(action) panelId=\(trackedPanelId.uuidString.prefix(8)) " + "reason=\(reason)" ) #endif diff --git a/Sources/BrowserPaneDropTargetView.swift b/Sources/BrowserPaneDropTargetView.swift index dad8031b172f..438379684de7 100644 --- a/Sources/BrowserPaneDropTargetView.swift +++ b/Sources/BrowserPaneDropTargetView.swift @@ -370,6 +370,7 @@ final class BrowserPaneDropTargetView: NSView { } private func focusBrowserPanelAfterSuccessfulFileDrop(context: BrowserPaneDropContext) { + guard context.allowsPanelFocusAfterFileDrop else { return } guard let workspace = AppDelegate.shared?.workspaceFor(tabId: context.workspaceId) else { return } FileDropTextDropController.focusPanelAfterSuccessfulTextDrop( workspace: workspace, diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index a6056fdab02d..9b5be99593b7 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -1334,6 +1334,7 @@ struct BrowserPaneDropContext: Equatable { let workspaceId: UUID let panelId: UUID let paneId: PaneID + let allowsPanelFocusAfterFileDrop: Bool = true } struct BrowserPaneDragTransfer: Equatable { diff --git a/Sources/ContentView+ViewCommandPalette.swift b/Sources/ContentView+ViewCommandPalette.swift index 6a434867acbf..8061f1eab6ba 100644 --- a/Sources/ContentView+ViewCommandPalette.swift +++ b/Sources/ContentView+ViewCommandPalette.swift @@ -1,5 +1,296 @@ import Foundation +enum CommandPaletteMode { + case commands + case renameInput(CommandPaletteRenameTarget) + case renameConfirm(CommandPaletteRenameTarget, proposedName: String) + case workspaceDescriptionInput(CommandPaletteWorkspaceDescriptionTarget) +} + +enum CommandPaletteListScope: String { + case commands + case switcher +} + +enum CommandPalettePendingActivation: Equatable { + case selected(requestID: UInt64, fallbackSelectedIndex: Int, preferredCommandID: String?) + case command(requestID: UInt64, commandID: String) +} + +enum CommandPaletteResolvedActivation: Equatable { + case selected(index: Int) + case command(commandID: String) +} + +struct CommandPaletteRenameTarget: Equatable { + enum Kind: Equatable { + case workspace(workspaceId: UUID) + case tab(workspaceId: UUID, panelId: UUID) + } + + let kind: Kind + let currentName: String + + var title: String { + switch kind { + case .workspace: + return String(localized: "commandPalette.rename.workspaceTitle", defaultValue: "Rename Workspace") + case .tab: + return String(localized: "commandPalette.rename.tabTitle", defaultValue: "Rename Tab") + } + } + + var description: String { + switch kind { + case .workspace: + return String(localized: "commandPalette.rename.workspaceDescription", defaultValue: "Choose a custom workspace name.") + case .tab: + return String(localized: "commandPalette.rename.tabDescription", defaultValue: "Choose a custom tab name.") + } + } + + var placeholder: String { + switch kind { + case .workspace: + return String(localized: "commandPalette.rename.workspacePlaceholder", defaultValue: "Workspace name") + case .tab: + return String(localized: "commandPalette.rename.tabPlaceholder", defaultValue: "Tab name") + } + } +} + +struct CommandPaletteWorkspaceDescriptionTarget: Equatable { + let workspaceId: UUID + let currentDescription: String + + var placeholder: String { + String( + localized: "commandPalette.description.workspacePlaceholder", + defaultValue: "Workspace description" + ) + } + + var inputHint: String { + String( + localized: "commandPalette.description.workspaceInputHint", + defaultValue: "Press Enter to save. Press Shift-Enter for a new line, or Escape to cancel." + ) + } +} + +struct CommandPaletteRestoreFocusTarget { + let workspaceId: UUID + let panelId: UUID + let intent: PanelFocusIntent +} + +enum CommandPaletteInputFocusTarget { + case search + case rename +} + +enum CommandPaletteTextSelectionBehavior { + case caretAtEnd + case selectAll +} + +enum CommandPaletteTrailingLabelStyle { + case shortcut + case kind +} + +struct CommandPaletteTrailingLabel { + let text: String + let style: CommandPaletteTrailingLabelStyle +} + +struct CommandPaletteInputFocusPolicy { + let focusTarget: CommandPaletteInputFocusTarget + let selectionBehavior: CommandPaletteTextSelectionBehavior + + static let search = CommandPaletteInputFocusPolicy( + focusTarget: .search, + selectionBehavior: .caretAtEnd + ) +} + +struct CommandPaletteCommand: Identifiable { + let id: String + let rank: Int + let title: String + let subtitle: String + let shortcutHint: String? + let kindLabel: String? + let keywords: [String] + let dismissOnRun: Bool + let action: () -> Void + + var searchableTexts: [String] { + [title, subtitle] + keywords + } +} + +struct CommandPaletteUsageEntry: Codable, Sendable { + var useCount: Int + var lastUsedAt: TimeInterval +} + +struct CommandPaletteContextSnapshot { + private var boolValues: [String: Bool] = [:] + private var stringValues: [String: String] = [:] + + init() {} + + mutating func setBool(_ key: String, _ value: Bool) { + boolValues[key] = value + } + + mutating func setString(_ key: String, _ value: String?) { + guard let value, !value.isEmpty else { + stringValues.removeValue(forKey: key) + return + } + stringValues[key] = value + } + + func bool(_ key: String) -> Bool { + boolValues[key] ?? false + } + + func string(_ key: String) -> String? { + stringValues[key] + } + + func fingerprint() -> Int { + ContentView.commandPaletteContextFingerprint( + boolValues: boolValues, + stringValues: stringValues + ) + } +} + +struct CommandPaletteCommandsContext { + let snapshot: CommandPaletteContextSnapshot +} + +enum CommandPaletteContextKeys { + static let hasWorkspace = "workspace.hasSelection" + static let workspaceName = "workspace.name" + static let workspaceHasCustomName = "workspace.hasCustomName" + static let workspaceHasCustomDescription = "workspace.hasCustomDescription" + static let workspaceMinimalModeEnabled = "workspace.minimalModeEnabled" + static let workspaceShouldPin = "workspace.shouldPin" + static let workspaceHasPullRequests = "workspace.hasPullRequests" + static let workspaceHasSplits = "workspace.hasSplits" + static let workspaceHasPeers = "workspace.hasPeers" + static let workspaceHasAbove = "workspace.hasAbove" + static let workspaceHasBelow = "workspace.hasBelow" + static let workspaceCanMarkRead = "workspace.canMarkRead" + static let workspaceCanMarkUnread = "workspace.canMarkUnread" + static let sidebarMatchTerminalBackground = "sidebar.matchTerminalBackground" + static let hasFocusedPanel = "panel.hasFocus" + static let panelName = "panel.name" + static let panelIsBrowser = "panel.isBrowser" + static let panelIsTerminal = "panel.isTerminal" + static let panelHasPane = "panel.hasPane" + static let panelHasCustomName = "panel.hasCustomName" + static let panelShouldPin = "panel.shouldPin" + static let panelHasUnread = "panel.hasUnread" + static let panelCanMoveToNewWorkspace = "panel.canMoveToNewWorkspace" + static let updateHasAvailable = "update.hasAvailable" + static let cliInstalledInPATH = "cli.installedInPATH" + static let browserDisabled = "browser.disabled" + static let supportedFileRoutingDisabled = "filePreview.supportedFileRoutingDisabled" + static func terminalOpenTargetAvailable(_ target: TerminalDirectoryOpenTarget) -> String { + "terminal.openTarget.\(target.rawValue).available" + } +} + +struct CommandPaletteCommandContribution { + let commandId: String + let title: (CommandPaletteContextSnapshot) -> String + let subtitle: (CommandPaletteContextSnapshot) -> String + let shortcutHint: String? + let keywords: [String] + let dismissOnRun: Bool + let when: (CommandPaletteContextSnapshot) -> Bool + let enablement: (CommandPaletteContextSnapshot) -> Bool + + init( + commandId: String, + title: @escaping (CommandPaletteContextSnapshot) -> String, + subtitle: @escaping (CommandPaletteContextSnapshot) -> String, + shortcutHint: String? = nil, + keywords: [String] = [], + dismissOnRun: Bool = true, + when: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true }, + enablement: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true } + ) { + self.commandId = commandId + self.title = title + self.subtitle = subtitle + self.shortcutHint = shortcutHint + self.keywords = keywords + self.dismissOnRun = dismissOnRun + self.when = when + self.enablement = enablement + } +} + +struct CommandPaletteHandlerRegistry { + private var handlers: [String: () -> Void] = [:] + + mutating func register(commandId: String, handler: @escaping () -> Void) { + handlers[commandId] = handler + } + + func handler(for commandId: String) -> (() -> Void)? { + handlers[commandId] + } +} + +struct CommandPaletteSearchResult: Identifiable { + let command: CommandPaletteCommand + let score: Int + let titleMatchIndices: Set + + var id: String { command.id } +} + +struct CommandPaletteResolvedSearchMatch: Sendable { + let commandID: String + let score: Int + let titleMatchIndices: Set +} + +struct CommandPaletteSwitcherWindowContext { + let windowId: UUID + let tabManager: TabManager + let selectedWorkspaceId: UUID? + let windowLabel: String? +} + +struct CommandPaletteSwitcherFingerprintWorkspace: Sendable { + let id: UUID + let displayName: String + let metadata: CommandPaletteSwitcherSearchMetadata + let surfaces: [CommandPaletteSwitcherFingerprintSurface] +} + +struct CommandPaletteSwitcherFingerprintSurface: Sendable { + let id: UUID + let displayName: String + let kindLabel: String + let metadata: CommandPaletteSwitcherSearchMetadata +} + +struct CommandPaletteSwitcherFingerprintContext: Sendable { + let windowId: UUID + let windowLabel: String? + let selectedWorkspaceId: UUID? + let workspaces: [CommandPaletteSwitcherFingerprintWorkspace] +} + extension ContentView { static func commandPaletteViewCommandContributions() -> [CommandPaletteCommandContribution] { func constant(_ value: String) -> (CommandPaletteContextSnapshot) -> String { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 6687f1d0a884..b3a665bf8bf6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1131,141 +1131,6 @@ struct ContentView: View { @FocusState private var isCommandPaletteSearchFocused: Bool @FocusState private var isCommandPaletteRenameFocused: Bool - private enum CommandPaletteMode { - case commands - case renameInput(CommandPaletteRenameTarget) - case renameConfirm(CommandPaletteRenameTarget, proposedName: String) - case workspaceDescriptionInput(CommandPaletteWorkspaceDescriptionTarget) - } - - private enum CommandPaletteListScope: String { - case commands - case switcher - } - - enum CommandPalettePendingActivation: Equatable { - case selected(requestID: UInt64, fallbackSelectedIndex: Int, preferredCommandID: String?) - case command(requestID: UInt64, commandID: String) - } - - enum CommandPaletteResolvedActivation: Equatable { - case selected(index: Int) - case command(commandID: String) - } - - private struct CommandPaletteRenameTarget: Equatable { - enum Kind: Equatable { - case workspace(workspaceId: UUID) - case tab(workspaceId: UUID, panelId: UUID) - } - - let kind: Kind - let currentName: String - - var title: String { - switch kind { - case .workspace: - return String(localized: "commandPalette.rename.workspaceTitle", defaultValue: "Rename Workspace") - case .tab: - return String(localized: "commandPalette.rename.tabTitle", defaultValue: "Rename Tab") - } - } - - var description: String { - switch kind { - case .workspace: - return String(localized: "commandPalette.rename.workspaceDescription", defaultValue: "Choose a custom workspace name.") - case .tab: - return String(localized: "commandPalette.rename.tabDescription", defaultValue: "Choose a custom tab name.") - } - } - - var placeholder: String { - switch kind { - case .workspace: - return String(localized: "commandPalette.rename.workspacePlaceholder", defaultValue: "Workspace name") - case .tab: - return String(localized: "commandPalette.rename.tabPlaceholder", defaultValue: "Tab name") - } - } - } - - private struct CommandPaletteWorkspaceDescriptionTarget: Equatable { - let workspaceId: UUID - let currentDescription: String - - var placeholder: String { - String( - localized: "commandPalette.description.workspacePlaceholder", - defaultValue: "Workspace description" - ) - } - - var inputHint: String { - String( - localized: "commandPalette.description.workspaceInputHint", - defaultValue: "Press Enter to save. Press Shift-Enter for a new line, or Escape to cancel." - ) - } - } - - private struct CommandPaletteRestoreFocusTarget { - let workspaceId: UUID - let panelId: UUID - let intent: PanelFocusIntent - } - - private enum CommandPaletteInputFocusTarget { - case search - case rename - } - - private enum CommandPaletteTextSelectionBehavior { - case caretAtEnd - case selectAll - } - - private enum CommandPaletteTrailingLabelStyle { - case shortcut - case kind - } - - private struct CommandPaletteTrailingLabel { - let text: String - let style: CommandPaletteTrailingLabelStyle - } - - private struct CommandPaletteInputFocusPolicy { - let focusTarget: CommandPaletteInputFocusTarget - let selectionBehavior: CommandPaletteTextSelectionBehavior - - static let search = CommandPaletteInputFocusPolicy( - focusTarget: .search, - selectionBehavior: .caretAtEnd - ) - } - - private struct CommandPaletteCommand: Identifiable { - let id: String - let rank: Int - let title: String - let subtitle: String - let shortcutHint: String? - let kindLabel: String? - let keywords: [String] - let dismissOnRun: Bool - let action: () -> Void - - var searchableTexts: [String] { - [title, subtitle] + keywords - } - } - - private struct CommandPaletteUsageEntry: Codable, Sendable { - var useCount: Int - var lastUsedAt: TimeInterval - } - static func tmuxWorkspacePaneExactRect( for panel: Panel, in contentView: NSView @@ -1410,162 +1275,6 @@ struct ContentView: View { ) } - struct CommandPaletteContextSnapshot { - private var boolValues: [String: Bool] = [:] - private var stringValues: [String: String] = [:] - - init() {} - - mutating func setBool(_ key: String, _ value: Bool) { - boolValues[key] = value - } - - mutating func setString(_ key: String, _ value: String?) { - guard let value, !value.isEmpty else { - stringValues.removeValue(forKey: key) - return - } - stringValues[key] = value - } - - func bool(_ key: String) -> Bool { - boolValues[key] ?? false - } - - func string(_ key: String) -> String? { - stringValues[key] - } - - func fingerprint() -> Int { - ContentView.commandPaletteContextFingerprint( - boolValues: boolValues, - stringValues: stringValues - ) - } - } - - private struct CommandPaletteCommandsContext { - let snapshot: CommandPaletteContextSnapshot - } - - enum CommandPaletteContextKeys { - static let hasWorkspace = "workspace.hasSelection" - static let workspaceName = "workspace.name" - static let workspaceHasCustomName = "workspace.hasCustomName" - static let workspaceHasCustomDescription = "workspace.hasCustomDescription" - static let workspaceMinimalModeEnabled = "workspace.minimalModeEnabled" - static let workspaceShouldPin = "workspace.shouldPin" - static let workspaceHasPullRequests = "workspace.hasPullRequests" - static let workspaceHasSplits = "workspace.hasSplits" - static let workspaceHasPeers = "workspace.hasPeers" - static let workspaceHasAbove = "workspace.hasAbove" - static let workspaceHasBelow = "workspace.hasBelow" - static let workspaceCanMarkRead = "workspace.canMarkRead" - static let workspaceCanMarkUnread = "workspace.canMarkUnread" - static let sidebarMatchTerminalBackground = "sidebar.matchTerminalBackground" - static let hasFocusedPanel = "panel.hasFocus" - static let panelName = "panel.name" - static let panelIsBrowser = "panel.isBrowser" - static let panelIsTerminal = "panel.isTerminal" - static let panelHasPane = "panel.hasPane" - static let panelHasCustomName = "panel.hasCustomName" - static let panelShouldPin = "panel.shouldPin" - static let panelHasUnread = "panel.hasUnread" - static let panelCanMoveToNewWorkspace = "panel.canMoveToNewWorkspace" - static let updateHasAvailable = "update.hasAvailable" - static let cliInstalledInPATH = "cli.installedInPATH" - static let browserDisabled = "browser.disabled" - static let supportedFileRoutingDisabled = "filePreview.supportedFileRoutingDisabled" - static func terminalOpenTargetAvailable(_ target: TerminalDirectoryOpenTarget) -> String { - "terminal.openTarget.\(target.rawValue).available" - } - } - - struct CommandPaletteCommandContribution { - let commandId: String - let title: (CommandPaletteContextSnapshot) -> String - let subtitle: (CommandPaletteContextSnapshot) -> String - let shortcutHint: String? - let keywords: [String] - let dismissOnRun: Bool - let when: (CommandPaletteContextSnapshot) -> Bool - let enablement: (CommandPaletteContextSnapshot) -> Bool - - init( - commandId: String, - title: @escaping (CommandPaletteContextSnapshot) -> String, - subtitle: @escaping (CommandPaletteContextSnapshot) -> String, - shortcutHint: String? = nil, - keywords: [String] = [], - dismissOnRun: Bool = true, - when: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true }, - enablement: @escaping (CommandPaletteContextSnapshot) -> Bool = { _ in true } - ) { - self.commandId = commandId - self.title = title - self.subtitle = subtitle - self.shortcutHint = shortcutHint - self.keywords = keywords - self.dismissOnRun = dismissOnRun - self.when = when - self.enablement = enablement - } - } - - struct CommandPaletteHandlerRegistry { - private var handlers: [String: () -> Void] = [:] - - mutating func register(commandId: String, handler: @escaping () -> Void) { - handlers[commandId] = handler - } - - func handler(for commandId: String) -> (() -> Void)? { - handlers[commandId] - } - } - - private struct CommandPaletteSearchResult: Identifiable { - let command: CommandPaletteCommand - let score: Int - let titleMatchIndices: Set - - var id: String { command.id } - } - - private struct CommandPaletteResolvedSearchMatch: Sendable { - let commandID: String - let score: Int - let titleMatchIndices: Set - } - - private struct CommandPaletteSwitcherWindowContext { - let windowId: UUID - let tabManager: TabManager - let selectedWorkspaceId: UUID? - let windowLabel: String? - } - - struct CommandPaletteSwitcherFingerprintWorkspace: Sendable { - let id: UUID - let displayName: String - let metadata: CommandPaletteSwitcherSearchMetadata - let surfaces: [CommandPaletteSwitcherFingerprintSurface] - } - - struct CommandPaletteSwitcherFingerprintSurface: Sendable { - let id: UUID - let displayName: String - let kindLabel: String - let metadata: CommandPaletteSwitcherSearchMetadata - } - - struct CommandPaletteSwitcherFingerprintContext: Sendable { - let windowId: UUID - let windowLabel: String? - let selectedWorkspaceId: UUID? - let workspaces: [CommandPaletteSwitcherFingerprintWorkspace] - } - private static let fixedSidebarResizeCursor = NSCursor( image: NSCursor.resizeLeftRight.image, hotSpot: NSCursor.resizeLeftRight.hotSpot diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 45a3c01ee2fe..4cba26c7566c 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -4362,18 +4362,42 @@ func browserOmnibarField(panelId: UUID?, in window: NSWindow?) -> OmnibarNativeT return focusedField } - guard let root = window?.contentView?.superview ?? window?.contentView else { + guard let window, + let root = window.contentView?.superview ?? window.contentView else { return nil } var stack: [NSView] = [root] + var fallback: OmnibarNativeTextField? while let view = stack.popLast() { if let field = view as? OmnibarNativeTextField, field.panelId == panelId { - return field + if browserOmnibarFieldIsLive(field, in: window, root: root) { + return field + } + fallback = fallback ?? field } stack.append(contentsOf: view.subviews) } - return nil + return fallback +} + +private func browserOmnibarFieldIsLive( + _ field: OmnibarNativeTextField, + in window: NSWindow, + root: NSView +) -> Bool { + guard field.window === window, field.isDescendant(of: root) else { return false } + var current: NSView? = field + while let view = current { + if view.isHidden || view.alphaValue <= 0 { + return false + } + if view === root { + return true + } + current = view.superview + } + return false } @discardableResult @@ -7177,7 +7201,8 @@ struct WebViewRepresentable: NSViewRepresentable { return BrowserPaneDropContext( workspaceId: panel.workspaceId, panelId: panel.id, - paneId: paneId + paneId: paneId, + allowsPanelFocusAfterFileDrop: false ) } diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index adfd204f35da..1b49f54765aa 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -409,6 +409,9 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { guard inlineInput.utf8.count > Self.maxInlineStartupInputBytes else { return inlineInput } + guard !Self.containsPreservedClaudeAuthSelectionEnvironment(command) else { + return nil + } guard let scriptURL = AgentResumeScriptStore.writeLauncherScript( command: command, kind: kind, @@ -422,6 +425,12 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { let scriptInput = "/bin/zsh \(shellSingleQuoted(scriptURL.path))\n" return scriptInput.utf8.count <= Self.maxInlineStartupInputBytes ? scriptInput : nil } + + private static func containsPreservedClaudeAuthSelectionEnvironment(_ command: String) -> Bool { + command.contains("'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1'") || + command.contains("\"CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1\"") || + command.contains(" CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1") + } } private enum AgentResumeScriptStore { diff --git a/Sources/TerminalNotificationStore.swift b/Sources/TerminalNotificationStore.swift index e14962c3e457..6cebf40085c0 100644 --- a/Sources/TerminalNotificationStore.swift +++ b/Sources/TerminalNotificationStore.swift @@ -1467,6 +1467,7 @@ final class TerminalNotificationStore: ObservableObject { if !idsToClear.isEmpty { notifications = updated center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear) + center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear) } } diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 08019024f0cc..5880ed822db7 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -1961,6 +1961,40 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { XCTAssertEqual(permissions, 0o600) } + func testRestorableAgentStartupInputSkipsLauncherScriptForPreservedClaudeAuthSelectionEnvironment() throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-agent-resume-test-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let longBaseURL = "https://api.example.test/" + String(repeating: "very-long-segment/", count: 90) + let snapshot = SessionRestorableAgentSnapshot( + kind: .claude, + sessionId: "claude-session-env", + workingDirectory: nil, + launchCommand: AgentLaunchCommandSnapshot( + launcher: "claude", + executablePath: "claude", + arguments: ["claude"], + workingDirectory: nil, + environment: [ + "ANTHROPIC_BASE_URL": longBaseURL, + "ANTHROPIC_MODEL": "sonnet" + ], + capturedAt: nil, + source: nil + ) + ) + + XCTAssertGreaterThan( + snapshot.resumeCommand?.appending("\n").utf8.count ?? 0, + SessionRestorableAgentSnapshot.maxInlineStartupInputBytes + ) + XCTAssertNil(snapshot.resumeStartupInput(temporaryDirectory: tempDir)) + let scriptDirectory = tempDir.appendingPathComponent("cmux-agent-resume", isDirectory: true) + XCTAssertFalse(FileManager.default.fileExists(atPath: scriptDirectory.path)) + } + func testRestorableAgentStartupInputSkipsOversizedCommandWhenScriptCannotBeWritten() throws { let tempDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-agent-resume-test-\(UUID().uuidString)", isDirectory: true) From 0edcf7401ef78ccd7e6421b98ac34237750fced2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 11:59:43 -0700 Subject: [PATCH 35/46] fix: add browser drop context focus initializer --- Sources/BrowserWindowPortal.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 9b5be99593b7..81bb0421543a 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -1334,7 +1334,19 @@ struct BrowserPaneDropContext: Equatable { let workspaceId: UUID let panelId: UUID let paneId: PaneID - let allowsPanelFocusAfterFileDrop: Bool = true + let allowsPanelFocusAfterFileDrop: Bool + + init( + workspaceId: UUID, + panelId: UUID, + paneId: PaneID, + allowsPanelFocusAfterFileDrop: Bool = true + ) { + self.workspaceId = workspaceId + self.panelId = panelId + self.paneId = paneId + self.allowsPanelFocusAfterFileDrop = allowsPanelFocusAfterFileDrop + } } struct BrowserPaneDragTransfer: Equatable { From 465dc906a85313571967f4b46ac90a40d8d46816 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 12:04:20 -0700 Subject: [PATCH 36/46] fix: clarify command palette dedupe type --- Sources/ContentView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b3a665bf8bf6..178f553c974b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -4790,13 +4790,13 @@ struct ContentView: View { } #endif var seenCommandIDs: Set = [] - let entries = rawEntries + let entries = Array(rawEntries .reversed() - .compactMap { entry in + .compactMap { (entry: CommandPaletteCommand) -> CommandPaletteCommand? in guard seenCommandIDs.insert(entry.id).inserted else { return nil } return entry } - .reversed() + .reversed()) commandPaletteSearchCommandsByID = Dictionary(uniqueKeysWithValues: entries.map { ($0.id, $0) }) let searchCorpus = entries.map { entry in CommandPaletteSearchCorpusEntry( From 80aa36aa839625aba75b80ae8567eb2d2678289a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 12:33:26 -0700 Subject: [PATCH 37/46] fix: preserve deferred socket text semantics --- Sources/TerminalController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index c587efff766e..1bc5c480cd44 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -7477,7 +7477,7 @@ class TerminalController { queued = false } else { // Avoid blocking the main actor waiting for view/surface attachment. - terminalPanel.sendText(text) + terminalPanel.sendInput(text) terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() queued = true } From 38521cc28f10edfa89552c67a07f40bfaa5be891 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 14:11:53 -0700 Subject: [PATCH 38/46] fix: restore agent fork support for unit target --- GhosttyTabs.xcodeproj/project.pbxproj | 8 + .../AgentLaunchSanitizer.swift | 163 ++++++- Sources/AgentForkSupport.swift | 425 ++++++++++++++++++ Sources/RestorableAgentSession.swift | 169 ++++++- Sources/SemanticVersion.swift | 39 ++ 5 files changed, 797 insertions(+), 7 deletions(-) create mode 100644 Sources/AgentForkSupport.swift create mode 100644 Sources/SemanticVersion.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index b0bd89a5946e..0a688c1dde95 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -332,6 +332,8 @@ A5001623 /* cmux.sdef in Resources */ = {isa = PBXBuildFile; fileRef = A5001622 /* cmux.sdef */; }; A5001640 /* RemoteRelayZshBootstrap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001641 /* RemoteRelayZshBootstrap.swift */; }; A5001660 /* RestorableAgentSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001661 /* RestorableAgentSession.swift */; }; + A5001662 /* AgentForkSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001663 /* AgentForkSupport.swift */; }; + A5001664 /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001665 /* SemanticVersion.swift */; }; A5001650 /* CmuxConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001651 /* CmuxConfig.swift */; }; C10D00030000000000000003 /* CmuxSurfaceTabBarBuiltInAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10D00040000000000000004 /* CmuxSurfaceTabBarBuiltInAction.swift */; }; E30750000000000000000004 /* CmuxWorkspaceDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30750000000000000000003 /* CmuxWorkspaceDefinition.swift */; }; @@ -863,6 +865,8 @@ A5001641 /* RemoteRelayZshBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteRelayZshBootstrap.swift; sourceTree = ""; }; D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxApplicationSupportDirectories.swift; sourceTree = ""; }; A5001661 /* RestorableAgentSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestorableAgentSession.swift; sourceTree = ""; }; + A5001663 /* AgentForkSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentForkSupport.swift; sourceTree = ""; }; + A5001665 /* SemanticVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemanticVersion.swift; sourceTree = ""; }; A5001651 /* CmuxConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxConfig.swift; sourceTree = ""; }; C10D00040000000000000004 /* CmuxSurfaceTabBarBuiltInAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxSurfaceTabBarBuiltInAction.swift; sourceTree = ""; }; E30750000000000000000003 /* CmuxWorkspaceDefinition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWorkspaceDefinition.swift; sourceTree = ""; }; @@ -1399,6 +1403,8 @@ A5001671 /* SessionRestoredTerminalCommandStore.swift */, B35751000000000000000001 /* TmuxResumeParser.swift */, A5001661 /* RestorableAgentSession.swift */, + A5001663 /* AgentForkSupport.swift */, + A5001665 /* SemanticVersion.swift */, A5001641 /* RemoteRelayZshBootstrap.swift */, D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */, A5001651 /* CmuxConfig.swift */, @@ -2102,6 +2108,8 @@ A5001670 /* SessionRestoredTerminalCommandStore.swift in Sources */, B35751000000000000000002 /* TmuxResumeParser.swift in Sources */, A5001660 /* RestorableAgentSession.swift in Sources */, + A5001662 /* AgentForkSupport.swift in Sources */, + A5001664 /* SemanticVersion.swift in Sources */, A5001640 /* RemoteRelayZshBootstrap.swift in Sources */, A5001650 /* CmuxConfig.swift in Sources */, C10D00030000000000000003 /* CmuxSurfaceTabBarBuiltInAction.swift in Sources */, diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift index d1a417d96fa4..c42cfccfd10d 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift @@ -1,6 +1,13 @@ import Foundation public enum AgentLaunchSanitizer { + // Runtime/interpreter flags may appear in captured process argv, but they + // are not portable agent session options to replay after a resume command. + // Values are token widths, including the option token itself. + private static let runtimeOnlyOptionWidths: [String: Int] = [ + "--use-system-ca": 1, + ] + struct Policy { var valueOptions: Set var optionalValueOptions: Set = [] @@ -34,7 +41,7 @@ public enum AgentLaunchSanitizer { if tail.first == "codex-teams" { tail.removeFirst() } - guard let preserved = preservedArguments(kind: "codex", args: tail) else { return nil } + guard let preserved = preservedCodexLaunchArguments(args: tail) else { return nil } return [executable, "codex-teams"] + preserved case "omo": if tail.first == "omo" { @@ -49,6 +56,9 @@ public enum AgentLaunchSanitizer { } switch fallbackKind { + case "codex": + guard let preserved = preservedCodexLaunchArguments(args: tail) else { return nil } + return [executable] + preserved case "rovodev": guard let preserved = preservedArguments(kind: fallbackKind, args: tail) else { return nil } return [executable, "rovodev", "run"] + preserved @@ -132,6 +142,92 @@ public enum AgentLaunchSanitizer { } } + public static func preservedCodexForkArguments(args: [String]) -> [String]? { + var tail = args + if let forkCommand = codexForkCommand(in: tail) { + tail = dropCodexForkPositionals(tail, forkCommand: forkCommand) + } + return preserveOptions(tail, policy: codexPolicy) + } + + private static func preservedCodexLaunchArguments(args: [String]) -> [String]? { + if codexForkCommand(in: args) != nil { + return preservedCodexForkArguments(args: args) + } + return preservedArguments(kind: "codex", args: args) + } + + private struct CodexForkCommand { + let forkIndex: Int + let sessionIndex: Int + } + + private static func codexForkCommand(in args: [String]) -> CodexForkCommand? { + var index = 0 + while index < args.count { + let arg = args[index] + if arg == "--" { + return nil + } + if !arg.hasPrefix("-") || arg == "-" { + guard arg == "fork", + let sessionIndex = codexForkCommandSessionIndex(args, forkIndex: index) else { + return nil + } + return CodexForkCommand(forkIndex: index, sessionIndex: sessionIndex) + } + let width = optionWidth(args, index: index, policy: codexPolicy) + if codexPolicy.variadicOptions.contains(arg) { + let end = min(args.count, index + width) + if index + 2 < end { + for candidateIndex in (index + 2).. Int? { + var index = forkIndex + 1 + while index < args.count { + let argument = args[index] + if argument == "--" { + return nil + } + if !argument.hasPrefix("-") || argument == "-" { + return looksLikeCodexSessionIdentifier(argument) ? index : nil + } + let width = optionWidth(args, index: index, policy: codexPolicy) + if codexPolicy.variadicOptions.contains(argument) { + let end = min(args.count, index + width) + if index + 2 < end { + for candidateIndex in (index + 2).. Bool { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count >= 20 else { return false } + if trimmed.hasPrefix("019") { + return true + } + let allowed = CharacterSet(charactersIn: "0123456789abcdefABCDEF-") + return trimmed.unicodeScalars.allSatisfy { allowed.contains($0) } && trimmed.contains("-") + } + private static func preserveOptions(_ args: [String], policy: Policy) -> [String]? { var result: [String] = [] var index = 0 @@ -177,8 +273,9 @@ public enum AgentLaunchSanitizer { continue } - let width = optionWidth(args, index: index, policy: policy) - if shouldDropOption(arg, droppedOptions: policy.droppedOptions) { + let runtimeOnlyWidth = runtimeOnlyOptionWidth(arg) + let width = runtimeOnlyWidth ?? optionWidth(args, index: index, policy: policy) + if runtimeOnlyWidth != nil || shouldDropOption(arg, droppedOptions: policy.droppedOptions) { index += width continue } @@ -195,12 +292,72 @@ public enum AgentLaunchSanitizer { return result } + private static func dropCodexForkPositionals(_ args: [String], forkCommand: CodexForkCommand) -> [String] { + var result: [String] = [] + var index = 0 + + while index < args.count { + let arg = args[index] + if arg == "--" { + break + } + if index == forkCommand.forkIndex { + index += 1 + continue + } + if index == forkCommand.sessionIndex { + index += 1 + while index < args.count, !args[index].hasPrefix("-") { + index += 1 + } + continue + } + if !arg.hasPrefix("-") || arg == "-" { + index += 1 + continue + } + + let width = optionWidth(args, index: index, policy: codexPolicy) + let end = min(args.count, index + width) + if codexPolicy.variadicOptions.contains(arg), + forkCommand.forkIndex > index, + forkCommand.forkIndex < end { + if forkCommand.forkIndex > index + 1 { + result.append(contentsOf: args[index.. index, + forkCommand.sessionIndex < end { + if forkCommand.sessionIndex > index + 1 { + result.append(contentsOf: args[index..) -> Bool { if droppedOptions.contains(arg) { return true } guard let equals = arg.firstIndex(of: "=") else { return false } return droppedOptions.contains(String(arg[.. Int? { + if let width = runtimeOnlyOptionWidths[arg] { + return width + } + guard let equals = arg.firstIndex(of: "=") else { return nil } + return runtimeOnlyOptionWidths[String(arg[.. Int { let arg = args[index] if arg.contains("=") { diff --git a/Sources/AgentForkSupport.swift b/Sources/AgentForkSupport.swift new file mode 100644 index 000000000000..f94ee5236d87 --- /dev/null +++ b/Sources/AgentForkSupport.swift @@ -0,0 +1,425 @@ +import Foundation +import CMUXAgentLaunch +import Darwin + +private actor OpenCodeVersionProbeCache { + private var valuesByKey: [String: Bool] = [:] + + func value(for key: String) -> Bool? { + valuesByKey[key] + } + + func store(_ value: Bool, for key: String) { + valuesByKey[key] = value + } +} + +enum AgentForkSupport { + static let minimumOpenCodeForkVersion = SemanticVersion(major: 1, minor: 14, patch: 50) + private static let commandOutputTimeoutNanoseconds: Int64 = 3_000_000_000 + private static let commandTerminateTimeoutNanoseconds: Int64 = 500_000_000 + private static let openCodeVersionProbeCache = OpenCodeVersionProbeCache() + + private final class CommandOutputBuffer: @unchecked Sendable { + private let lock = NSLock() + private var data = Data() + + func append(_ chunk: Data) { + lock.lock() + data.append(chunk) + lock.unlock() + } + + func value() -> Data { + lock.lock() + let snapshot = data + lock.unlock() + return snapshot + } + } + + private final class CommandOutputRunner: @unchecked Sendable { + private let executable: String + private let arguments: [String] + private let environment: [String: String]? + private let workingDirectory: String? + private let outputBuffer = CommandOutputBuffer() + private let lock = NSLock() + private var process: Process? + private var pipe: Pipe? + private var timeoutTimer: DispatchSourceTimer? + private var killTimer: DispatchSourceTimer? + private var continuation: CheckedContinuation? + private var completed = false + private var timedOut = false + + init( + executable: String, + arguments: [String], + environment: [String: String]?, + workingDirectory: String? + ) { + self.executable = executable + self.arguments = arguments + self.environment = environment + self.workingDirectory = workingDirectory + } + + func start(continuation: CheckedContinuation) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [executable] + arguments + if let workingDirectoryURL = AgentForkSupport.localDirectoryURL(path: workingDirectory) { + process.currentDirectoryURL = workingDirectoryURL + } + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + pipe.fileHandleForReading.readabilityHandler = { [outputBuffer] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + outputBuffer.append(data) + } + process.environment = AgentForkSupport.processEnvironmentForOpenCodeProbe(environment: environment) + process.terminationHandler = { [weak self] _ in + self?.finish() + } + + lock.lock() + if completed || timedOut { + completed = true + lock.unlock() + pipe.fileHandleForReading.readabilityHandler = nil + process.terminationHandler = nil + continuation.resume(returning: nil) + return + } + self.continuation = continuation + self.process = process + self.pipe = pipe + lock.unlock() + + startTimeoutTimer() + + do { + try process.run() + } catch { + markFailedBeforeLaunch() + return + } + + lock.lock() + let shouldTerminateAfterStart = timedOut && !completed + lock.unlock() + if shouldTerminateAfterStart { + process.terminate() + startKillTimer(processIdentifier: process.processIdentifier) + } + } + + func cancel() { + markTimedOutAndTerminate() + } + + private func startTimeoutTimer() { + let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) + timer.schedule(deadline: .now() + .nanoseconds(Int(AgentForkSupport.commandOutputTimeoutNanoseconds))) + timer.setEventHandler { [self] in + markTimedOutAndTerminate() + } + lock.lock() + if completed { + lock.unlock() + timer.resume() + timer.cancel() + return + } + timeoutTimer = timer + lock.unlock() + timer.resume() + } + + private func markFailedBeforeLaunch() { + lock.lock() + timedOut = true + lock.unlock() + finish() + } + + private func markTimedOutAndTerminate() { + let process: Process? + lock.lock() + guard !completed else { + lock.unlock() + return + } + timedOut = true + process = self.process + lock.unlock() + + guard let process else { + return + } + guard process.isRunning else { + return + } + process.terminate() + startKillTimer(processIdentifier: process.processIdentifier) + } + + private func startKillTimer(processIdentifier: pid_t) { + let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility)) + timer.schedule(deadline: .now() + .nanoseconds(Int(AgentForkSupport.commandTerminateTimeoutNanoseconds))) + timer.setEventHandler { [self] in + lock.lock() + let shouldKill = !completed && process?.isRunning == true + lock.unlock() + if shouldKill { + kill(processIdentifier, SIGKILL) + } + } + lock.lock() + if completed { + lock.unlock() + timer.resume() + timer.cancel() + return + } + killTimer?.cancel() + killTimer = timer + lock.unlock() + timer.resume() + } + + private func finish() { + let continuation: CheckedContinuation? + let pipe: Pipe? + let process: Process? + let timeoutTimer: DispatchSourceTimer? + let killTimer: DispatchSourceTimer? + let timedOut: Bool + + lock.lock() + guard !completed else { + lock.unlock() + return + } + completed = true + continuation = self.continuation + self.continuation = nil + pipe = self.pipe + self.pipe = nil + process = self.process + self.process = nil + timeoutTimer = self.timeoutTimer + self.timeoutTimer = nil + killTimer = self.killTimer + self.killTimer = nil + timedOut = self.timedOut + lock.unlock() + + timeoutTimer?.cancel() + killTimer?.cancel() + process?.terminationHandler = nil + pipe?.fileHandleForReading.readabilityHandler = nil + if let remainingData = pipe?.fileHandleForReading.readDataToEndOfFile() { + outputBuffer.append(remainingData) + } + guard !timedOut else { + continuation?.resume(returning: nil) + return + } + continuation?.resume(returning: String(data: outputBuffer.value(), encoding: .utf8)) + } + } + + static func supportsFork( + snapshot: SessionRestorableAgentSnapshot, + isRemoteContext: Bool = false + ) async -> Bool { + guard snapshot.forkCommand != nil else { return false } + if isRemoteContext, + snapshot.forkStartupInput(allowLauncherScript: false) == nil { + return false + } + guard snapshot.kind == .opencode else { return true } + if snapshot.launchCommand?.launcher == "omo" { + return true + } + if isRemoteContext { + return true + } + guard let probe = AgentResumeCommandBuilder.openCodeVersionProbe( + launchCommand: snapshot.launchCommand + ) else { + return false + } + let workingDirectory = openCodeProbeWorkingDirectory(snapshot: snapshot) + switch localOpenCodeVersionProbeDecision( + probe: probe, + workingDirectory: workingDirectory + ) { + case .run: + break + case .skipRemoteLikeContext: + return true + case .rejectMissingExecutable: + return false + } + let cacheKey = openCodeVersionProbeCacheKey( + probe: probe, + environment: snapshot.launchCommand?.environment, + workingDirectory: workingDirectory + ) + if let cached = await openCodeVersionProbeCache.value(for: cacheKey) { + return cached + } + guard let output = await commandOutput( + executable: probe.executable, + arguments: probe.arguments, + environment: snapshot.launchCommand?.environment, + workingDirectory: workingDirectory + ) else { + return false + } + let supportsFork = openCodeVersionSupportsFork(output) + await openCodeVersionProbeCache.store(supportsFork, for: cacheKey) + return supportsFork + } + + static func openCodeVersionSupportsFork(_ output: String) -> Bool { + guard let version = SemanticVersion.first(in: output) else { + return false + } + return version >= minimumOpenCodeForkVersion + } + + private static func openCodeVersionProbeCacheKey( + probe: (executable: String, arguments: [String]), + environment: [String: String]?, + workingDirectory: String? + ) -> String { + let processEnvironment = processEnvironmentForOpenCodeProbe(environment: environment) + let relevantEnvironmentKeys = [ + "PATH", + "OPENCODE_BIN", + "OPENCODE_CONFIG_DIR" + ] + let environmentParts = relevantEnvironmentKeys.map { key in + "\(key)=\(processEnvironment[key] ?? "")" + } + return ([probe.executable] + probe.arguments + environmentParts + ["cwd=\(workingDirectory ?? "")"]) + .joined(separator: "\u{1f}") + } + + private static func commandOutput( + executable: String, + arguments: [String], + environment: [String: String]?, + workingDirectory: String? + ) async -> String? { + let runner = CommandOutputRunner( + executable: executable, + arguments: arguments, + environment: environment, + workingDirectory: workingDirectory + ) + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + runner.start(continuation: continuation) + } + } onCancel: { + runner.cancel() + } + } + + static func processEnvironmentForOpenCodeProbe( + environment: [String: String]?, + baseEnvironment: [String: String] = ProcessInfo.processInfo.environment + ) -> [String: String] { + var processEnvironment = sanitizedBaseEnvironmentForOpenCodeProbe(baseEnvironment) + if let environment { + let selectedEnvironment = AgentLaunchEnvironmentPolicy.selectedEnvironment(from: environment) + for (key, value) in selectedEnvironment { + processEnvironment[key] = value + } + } + if let path = environment?["PATH"], + !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + processEnvironment["PATH"] = path + } else if processEnvironment["PATH"] == nil { + processEnvironment["PATH"] = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" + } + return processEnvironment + } + + private static func sanitizedBaseEnvironmentForOpenCodeProbe(_ environment: [String: String]) -> [String: String] { + let safeBaseKeys = [ + "HOME", + "LANG", + "LC_ALL", + "LC_CTYPE", + "LOGNAME", + "PATH", + "TMPDIR", + "USER" + ] + var processEnvironment: [String: String] = [:] + for key in safeBaseKeys { + guard let value = environment[key], + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + continue + } + processEnvironment[key] = value + } + let selectedEnvironment = AgentLaunchEnvironmentPolicy.selectedEnvironment(from: environment) + for (key, value) in selectedEnvironment { + processEnvironment[key] = value + } + return processEnvironment + } + + private static func openCodeProbeWorkingDirectory(snapshot: SessionRestorableAgentSnapshot) -> String? { + normalized(snapshot.launchCommand?.workingDirectory) ?? normalized(snapshot.workingDirectory) + } + + private enum LocalOpenCodeVersionProbeDecision { + case run + case skipRemoteLikeContext + case rejectMissingExecutable + } + + private static func localOpenCodeVersionProbeDecision( + probe: (executable: String, arguments: [String]), + workingDirectory: String? + ) -> LocalOpenCodeVersionProbeDecision { + if let workingDirectory, localDirectoryURL(path: workingDirectory) == nil { + return .skipRemoteLikeContext + } + if probe.executable.hasPrefix("/") { + return FileManager.default.isExecutableFile(atPath: probe.executable) + ? .run + : .rejectMissingExecutable + } + return .run + } + + private static func localDirectoryURL(path: String?) -> URL? { + guard let path = normalized(path) else { return nil } + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), + isDirectory.boolValue else { + return nil + } + return URL(fileURLWithPath: path, isDirectory: true) + } + + private static func normalized(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } +} diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 1b49f54765aa..4c36da115cd4 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -37,6 +37,53 @@ enum AgentResumeCommandBuilder { return nil } + return shellCommand( + argv: argv, + kind: kind, + launchCommand: launchCommand, + workingDirectory: workingDirectory, + customRegistration: customRegistration, + includeWorkingDirectoryPrefix: includeWorkingDirectoryPrefix + ) + } + + static func forkShellCommand( + kind: RestorableAgentKind, + sessionId: String, + launchCommand: AgentLaunchCommandSnapshot?, + workingDirectory: String?, + registrationOverride: CmuxVaultAgentRegistration? = nil, + includeWorkingDirectoryPrefix: Bool = true + ) -> String? { + let customRegistration = registrationOverride + guard !sessionId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let argv = forkArguments( + kind: kind, + sessionId: sessionId, + launchCommand: launchCommand + ), + !argv.isEmpty else { + return nil + } + + return shellCommand( + argv: argv, + kind: kind, + launchCommand: launchCommand, + workingDirectory: workingDirectory, + customRegistration: customRegistration, + includeWorkingDirectoryPrefix: includeWorkingDirectoryPrefix + ) + } + + private static func shellCommand( + argv: [String], + kind: RestorableAgentKind, + launchCommand: AgentLaunchCommandSnapshot?, + workingDirectory: String?, + customRegistration: CmuxVaultAgentRegistration?, + includeWorkingDirectoryPrefix: Bool + ) -> String { var commandParts: [String] = [] let environmentParts = launchEnvironmentParts(kind: kind, environment: launchCommand?.environment) if !environmentParts.isEmpty { @@ -55,6 +102,20 @@ enum AgentResumeCommandBuilder { return shellCommand } + static func openCodeVersionProbe( + launchCommand: AgentLaunchCommandSnapshot? + ) -> (executable: String, arguments: [String])? { + switch launchCommand?.launcher { + case "omo": + return nil + case "omx", "omc": + return nil + default: + let original = commandParts(launchCommand: launchCommand, fallbackExecutable: "opencode") + return (original.executable, ["--version"]) + } + } + private static func launchEnvironmentParts( kind: RestorableAgentKind, environment: [String: String]? @@ -111,7 +172,7 @@ enum AgentResumeCommandBuilder { if args.first == "codex-teams" { args.removeFirst() } - guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "codex", args: args) else { return nil } + guard let preserved = AgentLaunchSanitizer.preservedCodexForkArguments(args: args) else { return nil } return [original.executable, "codex-teams", "resume", sessionId] + preserved case "omo": let original = commandParts( @@ -152,7 +213,7 @@ enum AgentResumeCommandBuilder { ) case .codex: let original = commandParts(launchCommand: launchCommand, fallbackExecutable: "codex") - guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "codex", args: original.tail) else { return nil } + guard let preserved = AgentLaunchSanitizer.preservedCodexForkArguments(args: original.tail) else { return nil } return [original.executable, "resume", sessionId] + preserved case .grok: return resumeWithOption( @@ -239,6 +300,69 @@ enum AgentResumeCommandBuilder { } } + private static func forkArguments( + kind: RestorableAgentKind, + sessionId: String, + launchCommand: AgentLaunchCommandSnapshot? + ) -> [String]? { + switch launchCommand?.launcher { + case "claudeTeams": + let original = commandParts( + launchCommand: launchCommand, + fallbackExecutable: "cmux" + ) + var args = original.tail + if args.first == "claude-teams" { + args.removeFirst() + } + guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "claude", args: args) else { return nil } + return [original.executable, "claude-teams", "--resume", sessionId, "--fork-session"] + preserved + case "codexTeams": + let original = commandParts( + launchCommand: launchCommand, + fallbackExecutable: "cmux" + ) + var args = original.tail + if args.first == "codex-teams" { + args.removeFirst() + } + guard let preserved = AgentLaunchSanitizer.preservedCodexForkArguments(args: args) else { return nil } + return [original.executable, "codex-teams", "fork", sessionId] + preserved + case "omo": + let original = commandParts( + launchCommand: launchCommand, + fallbackExecutable: "cmux" + ) + var args = original.tail + if args.first == "omo" { + args.removeFirst() + } + guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "opencode", args: args) else { return nil } + return [original.executable, "omo", "--session", sessionId, "--fork"] + preserved + case "omx", "omc": + return nil + default: + break + } + + switch kind { + case .claude: + let original = commandParts(launchCommand: launchCommand, fallbackExecutable: "claude") + guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "claude", args: original.tail) else { return nil } + return [original.executable, "--resume", sessionId, "--fork-session"] + preserved + case .codex: + let original = commandParts(launchCommand: launchCommand, fallbackExecutable: "codex") + guard let preserved = AgentLaunchSanitizer.preservedCodexForkArguments(args: original.tail) else { return nil } + return [original.executable, "fork", sessionId] + preserved + case .opencode: + let original = commandParts(launchCommand: launchCommand, fallbackExecutable: "opencode") + guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "opencode", args: original.tail) else { return nil } + return [original.executable, "--session", sessionId, "--fork"] + preserved + default: + return nil + } + } + private static func customResumeArguments( registration: CmuxVaultAgentRegistration, sessionId: String, @@ -399,16 +523,52 @@ struct SessionRestorableAgentSnapshot: Codable, Sendable { ) } + var forkCommand: String? { + AgentResumeCommandBuilder.forkShellCommand( + kind: kind, + sessionId: sessionId, + launchCommand: launchCommand, + workingDirectory: workingDirectory, + registrationOverride: registration + ) + } + func resumeStartupInput( fileManager: FileManager = .default, temporaryDirectory: URL = FileManager.default.temporaryDirectory ) -> String? { - guard let command = resumeCommand else { return nil } + startupInput( + command: resumeCommand, + fileManager: fileManager, + temporaryDirectory: temporaryDirectory + ) + } + + func forkStartupInput( + fileManager: FileManager = .default, + temporaryDirectory: URL = FileManager.default.temporaryDirectory, + allowLauncherScript: Bool = true + ) -> String? { + startupInput( + command: forkCommand, + fileManager: fileManager, + temporaryDirectory: temporaryDirectory, + allowLauncherScript: allowLauncherScript + ) + } + private func startupInput( + command: String?, + fileManager: FileManager, + temporaryDirectory: URL, + allowLauncherScript: Bool = true + ) -> String? { + guard let command else { return nil } let inlineInput = command + "\n" guard inlineInput.utf8.count > Self.maxInlineStartupInputBytes else { return inlineInput } + guard allowLauncherScript else { return nil } guard !Self.containsPreservedClaudeAuthSelectionEnvironment(command) else { return nil } @@ -630,7 +790,8 @@ struct RestorableAgentSessionIndex: Sendable { } for (key, detected) in detectedSnapshots { - if let existing = resolved[key], existing.updatedAt > detected.updatedAt { + if let existing = resolved[key], + existing.updatedAt > detected.updatedAt { continue } resolved[key] = detected diff --git a/Sources/SemanticVersion.swift b/Sources/SemanticVersion.swift new file mode 100644 index 000000000000..974bffe6cc5e --- /dev/null +++ b/Sources/SemanticVersion.swift @@ -0,0 +1,39 @@ +import Foundation + +struct SemanticVersion: Comparable, Sendable { + let major: Int + let minor: Int + let patch: Int + + static func < (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + if lhs.minor != rhs.minor { return lhs.minor < rhs.minor } + return lhs.patch < rhs.patch + } + + static func first(in output: String) -> SemanticVersion? { + let pattern = #"(\d+)\.(\d+)(?:\.(\d+))?"# + guard let expression = try? NSRegularExpression(pattern: pattern) else { + return nil + } + let range = NSRange(output.startIndex.. Int? { + let captureRange = match.range(at: captureIndex) + guard captureRange.location != NSNotFound, + let range = Range(captureRange, in: output) else { + return defaultValue + } + return Int(output[range]) + } + + guard let major = integer(at: 1), + let minor = integer(at: 2) else { + return nil + } + return SemanticVersion(major: major, minor: minor, patch: integer(at: 3, fallback: 0) ?? 0) + } +} From 8dfb6aaeccf35dfe8c8083094bd523ee2a9a6c81 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 14:19:00 -0700 Subject: [PATCH 39/46] fix: address latest review feedback --- Sources/ContentView+ViewCommandPalette.swift | 4 +++ Sources/ContentView.swift | 32 ++++++++++++------- Sources/GhosttyTerminalView.swift | 15 +++++++-- .../Panels/TerminalSidekickCoordinator.swift | 4 +-- Sources/Workspace.swift | 9 ++++-- .../CommandPaletteSearchEngineTests.swift | 4 +-- cmuxTests/FeedCoordinatorTests.swift | 2 +- cmuxTests/SessionPersistenceTests.swift | 9 +++++- 8 files changed, 57 insertions(+), 22 deletions(-) diff --git a/Sources/ContentView+ViewCommandPalette.swift b/Sources/ContentView+ViewCommandPalette.swift index 8061f1eab6ba..a5410984e642 100644 --- a/Sources/ContentView+ViewCommandPalette.swift +++ b/Sources/ContentView+ViewCommandPalette.swift @@ -241,6 +241,10 @@ struct CommandPaletteHandlerRegistry { private var handlers: [String: () -> Void] = [:] mutating func register(commandId: String, handler: @escaping () -> Void) { + guard handlers[commandId] == nil else { + assertionFailure("Duplicate command palette handler id: \(commandId)") + return + } handlers[commandId] = handler } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 178f553c974b..56b01e207b24 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3490,6 +3490,7 @@ struct ContentView: View { Self.commandPaletteResultLabelContent( title: result.command.title, matchedIndices: result.titleMatchIndices, + subtitle: result.command.subtitle, trailingLabel: trailingLabel ) .padding(.horizontal, 9) @@ -4985,9 +4986,9 @@ struct ContentView: View { } static func commandPaletteShouldSynchronouslySeedResults( - hasVisibleResultsForScope: Bool + hasVisibleResultsForScope _: Bool ) -> Bool { - !hasVisibleResultsForScope + true } static func commandPaletteShouldPreserveEmptyStateWhileSearchPending( @@ -5259,17 +5260,26 @@ struct ContentView: View { private static func commandPaletteResultLabelContent( title: String, matchedIndices: Set, + subtitle: String?, trailingLabel: CommandPaletteTrailingLabel? ) -> some View { - HStack(spacing: 8) { - commandPaletteHighlightedTitleText( - title, - matchedIndices: matchedIndices - ) - .font(.system(size: 13, weight: .regular)) - .lineLimit(1) - Spacer() - commandPaletteTrailingLabelView(trailingLabel) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + commandPaletteHighlightedTitleText( + title, + matchedIndices: matchedIndices + ) + .font(.system(size: 13, weight: .regular)) + .lineLimit(1) + Spacer() + commandPaletteTrailingLabelView(trailingLabel) + } + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(.secondary) + .lineLimit(1) + } } } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2958648681ed..590e9348c350 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5889,7 +5889,11 @@ final class TerminalSurface: Identifiable, ObservableObject { } guard allowsRuntimeSurfaceCreation() else { return } - guard surface == nil, attachedView != nil else { return } + guard surface == nil, + let attachedView, + attachedView.window != nil else { + return + } guard !backgroundSurfaceStartQueued else { return } backgroundSurfaceStartQueued = true @@ -5897,7 +5901,11 @@ final class TerminalSurface: Identifiable, ObservableObject { guard let self else { return } self.backgroundSurfaceStartQueued = false guard self.allowsRuntimeSurfaceCreation() else { return } - guard self.surface == nil, let view = self.attachedView else { return } + guard self.surface == nil, + let view = self.attachedView, + view.window != nil else { + return + } #if DEBUG let startedAt = ProcessInfo.processInfo.systemUptime #endif @@ -9958,6 +9966,9 @@ final class GhosttySurfaceScrollView: NSView { private let flashOverlayView: GhosttyFlashOverlayView private let flashLayer: CAShapeLayer private var reparentFocusSuppressionCount = 0 + var isSuppressingReparentFocus: Bool { + reparentFocusSuppressionCount > 0 + } var isRightSidebarDockSurface: Bool { surfaceView.terminalSurface?.focusPlacement == .rightSidebarDock } diff --git a/Sources/Panels/TerminalSidekickCoordinator.swift b/Sources/Panels/TerminalSidekickCoordinator.swift index a2a66f945214..1cd0c46327b3 100644 --- a/Sources/Panels/TerminalSidekickCoordinator.swift +++ b/Sources/Panels/TerminalSidekickCoordinator.swift @@ -231,8 +231,8 @@ final class TerminalSidekickCoordinator { private func replaceState(_ next: TerminalSidekickState) { guard state != next else { return } - notifyChanged() state = next + notifyChanged() } private func replaceBrowserPanel(_ next: BrowserPanel?) { @@ -243,8 +243,8 @@ final class TerminalSidekickCoordinator { return } - notifyChanged() browserPanel = next observeBrowserPanelChanges() + notifyChanged() } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c1cc2b21fb94..4350a0638c1d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -8559,8 +8559,8 @@ final class Workspace: Identifiable, ObservableObject { private func syncPanelDerivedWorkspaceUnreadState() { let hasPanelUnreadIndicator = - manualUnreadPanelIds.contains { panels[$0] != nil } || - restoredUnreadPanelIds.contains { panels[$0] != nil } + !manualUnreadPanelIds.isEmpty || + !restoredUnreadPanelIds.isEmpty if let notificationStore = AppDelegate.shared?.notificationStore { if hasPanelUnreadIndicator { notificationStore.restoreUnreadIndicator(forTabId: id) @@ -12051,7 +12051,10 @@ final class Workspace: Identifiable, ObservableObject { return bonsplitController.focusedPaneId == targetPaneId && bonsplitController.selectedTab(inPane: targetPaneId)?.id == tabId }() - let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged + let shouldSuppressReentrantRefocus = + trigger == .terminalFirstResponder && + selectionAlreadyConverged && + previousTerminalHostedView?.isSuppressingReparentFocus == true #if DEBUG let targetPaneShort = targetPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" let focusedPaneShort = bonsplitController.focusedPaneId.map { String($0.id.uuidString.prefix(5)) } ?? "nil" diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift index 2f69dfa663b1..d3bb824fa7f9 100644 --- a/cmuxTests/CommandPaletteSearchEngineTests.swift +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -472,13 +472,13 @@ final class CommandPaletteSearchEngineTests: XCTestCase { XCTAssertEqual(previewCandidateIDs.last, "command.191") } - func testSynchronousSeedRunsOnlyWhenScopeChanges() { + func testSynchronousSeedAlwaysRunsForRefreshes() { XCTAssertTrue( ContentView.commandPaletteShouldSynchronouslySeedResults( hasVisibleResultsForScope: false ) ) - XCTAssertFalse( + XCTAssertTrue( ContentView.commandPaletteShouldSynchronouslySeedResults( hasVisibleResultsForScope: true ) diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index 2a1cd96e2671..6c6118619d00 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -135,7 +135,7 @@ final class FeedCoordinatorTests: XCTestCase { ) } - func testBlockingIngestExpiresWaiterWhenAgentProcessExitsBeforeNotificationDisplay() async { + func testBlockingIngestExpiresWaiterWhenAgentProcessExitsBeforeNotificationDisplay() async throws { #if DEBUG let requestId = "agent-exit-request" let ppid = 424_242 diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 5880ed822db7..052bd02cc783 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -1986,8 +1986,15 @@ final class SocketListenerAcceptPolicyTests: XCTestCase { ) ) + let resumeCommand = try XCTUnwrap(snapshot.resumeCommand) + XCTAssertTrue(resumeCommand.contains("'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV=1'")) + XCTAssertTrue( + resumeCommand.contains( + "'CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS=ANTHROPIC_BASE_URL,ANTHROPIC_MODEL'" + ) + ) XCTAssertGreaterThan( - snapshot.resumeCommand?.appending("\n").utf8.count ?? 0, + resumeCommand.appending("\n").utf8.count, SessionRestorableAgentSnapshot.maxInlineStartupInputBytes ) XCTAssertNil(snapshot.resumeStartupInput(temporaryDirectory: tempDir)) From fbfc175a5410496152fe0b5df68d3e83758a37b1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 14:27:00 -0700 Subject: [PATCH 40/46] fix: restore cmux scope compile support --- Sources/CmuxTopSnapshotScopeCache.swift | 127 ++++++++++++++---------- cmuxTests/FeedCoordinatorTests.swift | 4 +- 2 files changed, 75 insertions(+), 56 deletions(-) diff --git a/Sources/CmuxTopSnapshotScopeCache.swift b/Sources/CmuxTopSnapshotScopeCache.swift index a4f6a1fd4eab..67832eaa6acb 100644 --- a/Sources/CmuxTopSnapshotScopeCache.swift +++ b/Sources/CmuxTopSnapshotScopeCache.swift @@ -85,76 +85,93 @@ nonisolated extension CmuxTopProcessSnapshot { } static func cmuxScope(fromKernProcArgs bytes: [UInt8]) -> CmuxTopProcessScope? { - guard bytes.count > MemoryLayout.size else { return nil } - - var argcRaw: Int32 = 0 - withUnsafeMutableBytes(of: &argcRaw) { rawBuffer in - rawBuffer.copyBytes(from: bytes.prefix(MemoryLayout.size)) + guard let process = processArgumentsAndEnvironment(fromKernProcArgs: bytes) else { + return nil } - let argc = Int(Int32(littleEndian: argcRaw)) - guard argc > 0 else { return nil } - - var index = MemoryLayout.size - skipString(in: bytes, index: &index) - skipNulls(in: bytes, index: &index) + return cmuxScope(arguments: process.arguments, environment: process.environment) + } - for _ in 0.. CmuxTopProcessScope? { + if let environmentScope = cmuxScopeFromEnvironment(environment) { + return environmentScope } + if let hookScope = cmuxHookMonitorScope(arguments: arguments) { + return hookScope + } + return nil + } - var workspaceID: UUID? - var surfaceID: UUID? - while index < bytes.count { - skipNulls(in: bytes, index: &index) - guard index < bytes.count else { break } - - let start = index - skipString(in: bytes, index: &index) - guard start < index, - let entry = String(bytes: bytes[start.. CmuxTopProcessScope? { + let workspaceID = uuidValue(in: environment, keys: ["CMUX_WORKSPACE_ID", "CMUX_TAB_ID"]) + let surfaceID = uuidValue(in: environment, keys: ["CMUX_SURFACE_ID", "CMUX_PANEL_ID"]) + guard workspaceID != nil || surfaceID != nil else { return nil } + return CmuxTopProcessScope( + workspaceID: workspaceID, + surfaceID: surfaceID, + attributionReason: "cmux-environment" + ) + } - if workspaceID != nil, surfaceID != nil { - break - } + private static func cmuxHookMonitorScope(arguments: [String]) -> CmuxTopProcessScope? { + guard containsSubcommandPath(["hooks", "codex", "monitor"], in: arguments) else { + return nil } - + let workspaceID = uuidOptionValue(in: arguments, names: ["--workspace"]) + let surfaceID = uuidOptionValue(in: arguments, names: ["--surface", "--panel"]) guard workspaceID != nil || surfaceID != nil else { return nil } - return CmuxTopProcessScope(workspaceID: workspaceID, surfaceID: surfaceID) + return CmuxTopProcessScope( + workspaceID: workspaceID, + surfaceID: surfaceID, + attributionReason: "cmux-hook-arguments" + ) } - private static func value(inEnvironmentEntry entry: String, forKey key: String) -> String? { - let prefix = "\(key)=" - guard entry.hasPrefix(prefix) else { return nil } - let value = String(entry.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) - return value.isEmpty ? nil : value + private static func containsSubcommandPath(_ path: [String], in arguments: [String]) -> Bool { + let normalizedPath = path.map { $0.lowercased() } + guard !normalizedPath.isEmpty, arguments.count >= normalizedPath.count + 1 else { return false } + let executableName = URL(fileURLWithPath: arguments[0]) + .lastPathComponent + .lowercased() + guard executableName == "cmux" else { return false } + let subcommands = arguments + .dropFirst() + .prefix(normalizedPath.count) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + return Array(subcommands) == normalizedPath } - private static func skipString(in bytes: [UInt8], index: inout Int) { - while index < bytes.count, bytes[index] != 0 { - index += 1 + private static func uuidValue(in environment: [String: String], keys: [String]) -> UUID? { + for key in keys { + guard let raw = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty, + let uuid = UUID(uuidString: raw) else { + continue + } + return uuid } + return nil } - private static func skipNulls(in bytes: [UInt8], index: inout Int) { - while index < bytes.count, bytes[index] == 0 { - index += 1 + private static func uuidOptionValue(in arguments: [String], names: Set) -> UUID? { + for index in arguments.indices { + let argument = arguments[index].trimmingCharacters(in: .whitespacesAndNewlines) + for name in names { + let prefix = "\(name)=" + guard argument.hasPrefix(prefix) else { continue } + let raw = String(argument.dropFirst(prefix.count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !raw.isEmpty, let uuid = UUID(uuidString: raw) else { continue } + return uuid + } + + guard names.contains(argument) else { continue } + let valueIndex = arguments.index(after: index) + guard valueIndex < arguments.endIndex else { continue } + let raw = arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines) + guard !raw.isEmpty, let uuid = UUID(uuidString: raw) else { continue } + return uuid } + return nil } private static func kinfoProc(for pid: Int) -> kinfo_proc? { diff --git a/cmuxTests/FeedCoordinatorTests.swift b/cmuxTests/FeedCoordinatorTests.swift index 6c6118619d00..b86f2d9da11a 100644 --- a/cmuxTests/FeedCoordinatorTests.swift +++ b/cmuxTests/FeedCoordinatorTests.swift @@ -150,7 +150,9 @@ final class FeedCoordinatorTests: XCTestCase { FeedCoordinator.shared.install(store: store) FeedCoordinatorTestHooks.afterBlockingEventIngested = { _, ingestedRequestId in guard ingestedRequestId == requestId else { return } - FeedCoordinator.shared.debugExpirePendingItemsAndWaiters(forPpid: ppid) + MainActor.assumeIsolated { + FeedCoordinator.shared.debugExpirePendingItemsAndWaiters(forPpid: ppid) + } } FeedCoordinatorTestHooks.isAppActiveOverride = { false } FeedCoordinatorTestHooks.notificationPostObserver = { _, postedRequestId in From 8f3c70b9253105db04d32ea0a277cc14e93c7db7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 14:32:10 -0700 Subject: [PATCH 41/46] fix: route find from sidekick focus --- Sources/ContentView.swift | 8 +++++-- Sources/TabManager.swift | 48 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 56b01e207b24..dd7cf49a3b3d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3436,7 +3436,7 @@ struct ContentView: View { let commandPaletteListIdentity = Self.commandPaletteListIdentity(for: commandPaletteQuery) let shouldShowEmptyState = commandPaletteShouldShowEmptyState let commandPaletteListMaxHeight: CGFloat = 450 - let commandPaletteRowHeight: CGFloat = 24 + let commandPaletteRowHeight: CGFloat = 36 let commandPaletteEmptyStateHeight: CGFloat = 44 let commandPaletteListContentHeight = visibleResults.isEmpty ? commandPaletteEmptyStateHeight : CGFloat(visibleResults.count) * commandPaletteRowHeight let commandPaletteListHeight = min(commandPaletteListMaxHeight, commandPaletteListContentHeight) @@ -3495,7 +3495,11 @@ struct ContentView: View { ) .padding(.horizontal, 9) .padding(.vertical, 2) - .frame(maxWidth: .infinity, alignment: .leading) + .frame( + maxWidth: .infinity, + minHeight: commandPaletteRowHeight, + alignment: .leading + ) .background(rowBackground) .contentShape(Rectangle()) } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 6fc0d33df854..99755ea5c82a 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1960,15 +1960,44 @@ class TabManager: ObservableObject { selectedWorkspace?.focusedTerminalPanel } + private var focusedTerminalSidekickBrowserPanel: BrowserPanel? { + guard let panel = selectedTerminalPanel, + panel.sidekickState.isOpen, + let sidekickPanel = panel.sidekickBrowserPanel else { + return nil + } + let window = sidekickPanel.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow + guard let window, + let responder = window.firstResponder, + sidekickPanel.ownedFocusIntent(for: responder, in: window) != nil else { + return nil + } + return sidekickPanel + } + var isFindVisible: Bool { - selectedTerminalPanel?.searchState != nil || focusedBrowserPanel?.searchState != nil + selectedTerminalPanel?.searchState != nil || + focusedBrowserPanel?.searchState != nil || + focusedTerminalSidekickBrowserPanel?.searchState != nil } var canUseSelectionForFind: Bool { + guard focusedTerminalSidekickBrowserPanel == nil, + focusedBrowserPanel == nil else { + return false + } selectedTerminalPanel?.hasSelection() == true } func startSearch() { + if let sidekickPanel = focusedTerminalSidekickBrowserPanel { + sidekickPanel.startFind() + return + } + if let browserPanel = focusedBrowserPanel { + browserPanel.startFind() + return + } if let panel = selectedTerminalPanel { let hadExistingSearch = panel.searchState != nil panel.hostedView.preparePanelFocusIntentForActivation(.findField) @@ -1990,11 +2019,12 @@ class TabManager: ObservableObject { #endif return } - focusedBrowserPanel?.startFind() } func searchSelection() { - guard let panel = selectedTerminalPanel else { return } + guard focusedTerminalSidekickBrowserPanel == nil, + focusedBrowserPanel == nil, + let panel = selectedTerminalPanel else { return } if panel.searchState == nil { panel.searchState = TerminalSurface.SearchState() } @@ -2009,6 +2039,10 @@ class TabManager: ObservableObject { } func findNext() { + if let sidekickPanel = focusedTerminalSidekickBrowserPanel { + sidekickPanel.findNext() + return + } if let panel = selectedTerminalPanel { _ = panel.performBindingAction("search:next") return @@ -2018,6 +2052,10 @@ class TabManager: ObservableObject { } func findPrevious() { + if let sidekickPanel = focusedTerminalSidekickBrowserPanel { + sidekickPanel.findPrevious() + return + } if let panel = selectedTerminalPanel { _ = panel.performBindingAction("search:previous") return @@ -2033,6 +2071,10 @@ class TabManager: ObservableObject { } func hideFind() { + if let sidekickPanel = focusedTerminalSidekickBrowserPanel { + sidekickPanel.hideFind() + return + } if let panel = selectedTerminalPanel { panel.searchState = nil return From fa0403e8705cb276f58534532ab49c8fa70e8362 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 14:35:17 -0700 Subject: [PATCH 42/46] fix: compile sidekick find routing --- Sources/TabManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 99755ea5c82a..d3cf484fa5cf 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1986,7 +1986,7 @@ class TabManager: ObservableObject { focusedBrowserPanel == nil else { return false } - selectedTerminalPanel?.hasSelection() == true + return selectedTerminalPanel?.hasSelection() == true } func startSearch() { From a394d0e7f454e5457927c4f09a4164aa988e11cc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 14:41:33 -0700 Subject: [PATCH 43/46] fix: update command palette test context type --- cmuxTests/RightSidebarCommandPaletteTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/RightSidebarCommandPaletteTests.swift b/cmuxTests/RightSidebarCommandPaletteTests.swift index 79f10ac94cda..c32ea03056d7 100644 --- a/cmuxTests/RightSidebarCommandPaletteTests.swift +++ b/cmuxTests/RightSidebarCommandPaletteTests.swift @@ -14,7 +14,7 @@ final class RightSidebarCommandPaletteTests: XCTestCase { defaults.removeObject(forKey: RightSidebarBetaFeatureSettings.dockEnabledKey) let contributions = ContentView.commandPaletteRightSidebarModeCommandContributions() let contributionsByID = Dictionary(uniqueKeysWithValues: contributions.map { ($0.commandId, $0) }) - let context = ContentView.CommandPaletteContextSnapshot() + let context = CommandPaletteContextSnapshot() for mode in RightSidebarMode.availableModes() { let commandID = ContentView.commandPaletteRightSidebarModeCommandID(mode) From 197a702ee01cf41925bbf083e734822bcf8aff41 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 14:48:08 -0700 Subject: [PATCH 44/46] fix: update reparent focus tests --- cmuxTests/AppDelegateShortcutRoutingTests.swift | 4 ++-- cmuxTests/WorkspaceUnitTests.swift | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index 351fa1f7bb90..e8a7a07fea55 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -1614,9 +1614,9 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) workspace.focusPanel(rightPanel.id) XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected Bonsplit selection to stay on the right pane") - leftPanel.hostedView.suppressReparentFocus() + leftPanel.hostedView.beginSuppressingReparentFocus() XCTAssertTrue(window.makeFirstResponder(leftSurfaceView)) - leftPanel.hostedView.clearSuppressReparentFocus() + leftPanel.hostedView.endSuppressingReparentFocus() XCTAssertTrue(window.firstResponder === leftSurfaceView, "Expected left Ghostty surface to stay first responder") XCTAssertEqual(workspace.focusedPanelId, rightPanel.id, "Expected selected pane to stay stale after first-responder change") XCTAssertEqual(leftSurfaceView.tabId, workspace.id, "Expected focused Ghostty view to keep its workspace ID") diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 944f6f52240b..8ea124b790c1 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -3910,7 +3910,7 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { leftPanel.hostedView.setActive(true) rightPanel.surface.setFocus(false) rightPanel.hostedView.setActive(false) - rightPanel.hostedView.suppressReparentFocus() + rightPanel.hostedView.beginSuppressingReparentFocus() guard let rightSurfaceView = surfaceView(in: rightPanel.hostedView) else { XCTFail("Expected right terminal surface view") @@ -3972,7 +3972,7 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { leftPanel.surface.setFocus(false) rightPanel.surface.setFocus(true) - leftPanel.hostedView.suppressReparentFocus() + leftPanel.hostedView.beginSuppressingReparentFocus() XCTAssertTrue(window.makeFirstResponder(leftSurfaceView)) RunLoop.current.run(until: Date().addingTimeInterval(0.05)) @@ -3982,7 +3982,7 @@ final class WorkspaceTerminalFocusRecoveryTests: XCTestCase { "Suppressed reparent focus should not immediately flip the Ghostty focus bit" ) - leftPanel.hostedView.clearSuppressReparentFocus() + leftPanel.hostedView.endSuppressingReparentFocus() let focusRecovered = XCTNSPredicateExpectation( predicate: NSPredicate { _, _ in leftPanel.surface.debugDesiredFocusState() From 6afc973865853c2f8d3b53a313c93136d459da36 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 14:57:09 -0700 Subject: [PATCH 45/46] fix: update remaining unit test APIs --- cmuxTests/BrowserConfigTests.swift | 26 ++++++++++++++++ .../CommandPaletteSearchEngineTests.swift | 30 +++++++++---------- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index 28fba826d873..01612fbfe7fa 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -164,6 +164,8 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { private final class FieldEditorProbeTextView: NSTextView { private(set) var delegateReadCount = 0 + private(set) var keyDownKeyCodes: [UInt16] = [] + var reportsMarkedText = false override var delegate: NSTextViewDelegate? { get { @@ -179,6 +181,26 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { get { true } set {} } + + override func keyDown(with event: NSEvent) { + keyDownKeyCodes.append(event.keyCode) + } + + override func hasMarkedText() -> Bool { + reportsMarkedText + } + + func resetKeyDownKeyCodes() { + keyDownKeyCodes.removeAll() + } + } + + private final class FieldEditorProbeWindow: NSWindow { + let testFieldEditor = FieldEditorProbeTextView(frame: NSRect(x: 0, y: 0, width: 240, height: 24)) + + override func fieldEditor(_ createFlag: Bool, for object: Any?) -> NSText? { + testFieldEditor + } } func testCmdNRoutesToMainMenuWhenWebViewIsFirstResponder() { let spy = ActionSpy() @@ -3382,6 +3404,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { portalZPriority: 0, paneDropZone: nil, searchOverlay: nil, + omnibarSuggestions: nil, paneTopChromeHeight: 0 ) let coordinator = representable.makeCoordinator() @@ -3425,6 +3448,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { portalZPriority: 0, paneDropZone: nil, searchOverlay: nil, + omnibarSuggestions: nil, paneTopChromeHeight: 0 ) let coordinator = representable.makeCoordinator() @@ -3536,6 +3560,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { portalZPriority: 0, paneDropZone: nil, searchOverlay: nil, + omnibarSuggestions: nil, paneTopChromeHeight: 0 ) @@ -3620,6 +3645,7 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { portalZPriority: 0, paneDropZone: nil, searchOverlay: nil, + omnibarSuggestions: nil, paneTopChromeHeight: 0 ) diff --git a/cmuxTests/CommandPaletteSearchEngineTests.swift b/cmuxTests/CommandPaletteSearchEngineTests.swift index d3bb824fa7f9..5a29824f0694 100644 --- a/cmuxTests/CommandPaletteSearchEngineTests.swift +++ b/cmuxTests/CommandPaletteSearchEngineTests.swift @@ -676,12 +676,12 @@ final class CommandPaletteSearchEngineTests: XCTestCase { let workspaceID = UUID() let base = ContentView.commandPaletteSwitcherFingerprint( windowContexts: [ - ContentView.CommandPaletteSwitcherFingerprintContext( + CommandPaletteSwitcherFingerprintContext( windowId: windowID, windowLabel: "Window 2", selectedWorkspaceId: workspaceID, workspaces: [ - ContentView.CommandPaletteSwitcherFingerprintWorkspace( + CommandPaletteSwitcherFingerprintWorkspace( id: workspaceID, displayName: "Workspace Alpha", metadata: CommandPaletteSwitcherSearchMetadata( @@ -697,12 +697,12 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) let changedMetadata = ContentView.commandPaletteSwitcherFingerprint( windowContexts: [ - ContentView.CommandPaletteSwitcherFingerprintContext( + CommandPaletteSwitcherFingerprintContext( windowId: windowID, windowLabel: "Window 2", selectedWorkspaceId: workspaceID, workspaces: [ - ContentView.CommandPaletteSwitcherFingerprintWorkspace( + CommandPaletteSwitcherFingerprintWorkspace( id: workspaceID, displayName: "Workspace Alpha", metadata: CommandPaletteSwitcherSearchMetadata( @@ -718,12 +718,12 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) let changedDisplayName = ContentView.commandPaletteSwitcherFingerprint( windowContexts: [ - ContentView.CommandPaletteSwitcherFingerprintContext( + CommandPaletteSwitcherFingerprintContext( windowId: windowID, windowLabel: "Window 2", selectedWorkspaceId: workspaceID, workspaces: [ - ContentView.CommandPaletteSwitcherFingerprintWorkspace( + CommandPaletteSwitcherFingerprintWorkspace( id: workspaceID, displayName: "Workspace Beta", metadata: CommandPaletteSwitcherSearchMetadata( @@ -749,17 +749,17 @@ final class CommandPaletteSearchEngineTests: XCTestCase { let base = ContentView.commandPaletteSwitcherFingerprint( windowContexts: [ - ContentView.CommandPaletteSwitcherFingerprintContext( + CommandPaletteSwitcherFingerprintContext( windowId: windowID, windowLabel: nil, selectedWorkspaceId: workspaceID, workspaces: [ - ContentView.CommandPaletteSwitcherFingerprintWorkspace( + CommandPaletteSwitcherFingerprintWorkspace( id: workspaceID, displayName: "Workspace Alpha", metadata: CommandPaletteSwitcherSearchMetadata(), surfaces: [ - ContentView.CommandPaletteSwitcherFingerprintSurface( + CommandPaletteSwitcherFingerprintSurface( id: surfaceID, displayName: "Terminal", kindLabel: "Terminal", @@ -777,17 +777,17 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) let changedSurfaceMetadata = ContentView.commandPaletteSwitcherFingerprint( windowContexts: [ - ContentView.CommandPaletteSwitcherFingerprintContext( + CommandPaletteSwitcherFingerprintContext( windowId: windowID, windowLabel: nil, selectedWorkspaceId: workspaceID, workspaces: [ - ContentView.CommandPaletteSwitcherFingerprintWorkspace( + CommandPaletteSwitcherFingerprintWorkspace( id: workspaceID, displayName: "Workspace Alpha", metadata: CommandPaletteSwitcherSearchMetadata(), surfaces: [ - ContentView.CommandPaletteSwitcherFingerprintSurface( + CommandPaletteSwitcherFingerprintSurface( id: surfaceID, displayName: "Terminal", kindLabel: "Terminal", @@ -805,17 +805,17 @@ final class CommandPaletteSearchEngineTests: XCTestCase { ) let changedSurfaceKind = ContentView.commandPaletteSwitcherFingerprint( windowContexts: [ - ContentView.CommandPaletteSwitcherFingerprintContext( + CommandPaletteSwitcherFingerprintContext( windowId: windowID, windowLabel: nil, selectedWorkspaceId: workspaceID, workspaces: [ - ContentView.CommandPaletteSwitcherFingerprintWorkspace( + CommandPaletteSwitcherFingerprintWorkspace( id: workspaceID, displayName: "Workspace Alpha", metadata: CommandPaletteSwitcherSearchMetadata(), surfaces: [ - ContentView.CommandPaletteSwitcherFingerprintSurface( + CommandPaletteSwitcherFingerprintSurface( id: surfaceID, displayName: "Terminal", kindLabel: "Browser", From 12f266c04f376bc42cb3655e8df3b38797b9f979 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 22 May 2026 13:20:14 -0700 Subject: [PATCH 46/46] fix: address activation workflow review --- .github/workflows/perf-activation.yml | 27 ++++++++++++++++++++++++++ Sources/AppDelegate.swift | 28 --------------------------- 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/.github/workflows/perf-activation.yml b/.github/workflows/perf-activation.yml index f5d9ee00282d..7a4130aadf0a 100644 --- a/.github/workflows/perf-activation.yml +++ b/.github/workflows/perf-activation.yml @@ -77,6 +77,33 @@ jobs: run: | ./scripts/install-zig-ci.sh + - name: Cache Swift packages + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: .ci-source-packages + key: spm-activation-${{ hashFiles('cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved') }} + restore-keys: spm-activation- + + - name: Resolve Swift packages + run: | + set -euo pipefail + SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" + mkdir -p "$SOURCE_PACKAGES_DIR" + + for attempt in 1 2 3; do + if xcodebuild -project cmux.xcodeproj -scheme cmux -configuration Debug \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \ + -resolvePackageDependencies; then + exit 0 + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to resolve Swift packages after 3 attempts" >&2 + exit 1 + fi + echo "Package resolution failed on attempt $attempt, retrying..." + sleep $((attempt * 5)) + done + - name: Build tagged app run: | set -euo pipefail diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index fbe14615c14c..8a51b4e2c9d3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11905,13 +11905,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - // Let omnibar-local Emacs navigation (Cmd/Ctrl+N/P) win while the browser - // address bar is focused. Without this, app-level Cmd+N can steal focus. - if hasFocusedAddressBarInShortcutContext, - shouldBypassAppShortcutForFocusedBrowserAddressBar(flags: flags, chars: chars) { - return false - } - if activeConfiguredShortcutChordPrefixForCurrentEvent == nil { let focusContext = shortcutEventFocusContext(event) let availableChordActions = currentConfiguredShortcutChordActions().filter { action in @@ -12857,27 +12850,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent focusBrowserAddressBar(panelId: panelId) } - private func shouldBypassAppShortcutForFocusedBrowserAddressBar( - flags: NSEvent.ModifierFlags, - chars: String - ) -> Bool { - guard browserAddressBarFocusedPanelId != nil else { return false } - let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) - let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] - guard isCommandOrControlOnly else { return false } - let shouldBypass = chars == "n" || chars == "p" -#if DEBUG - if shouldBypass { - let panelToken = browserAddressBarFocusedPanelId.map { String($0.uuidString.prefix(5)) } ?? "nil" - cmuxDebugLog( - "browser.focus.addressBar.shortcutBypass panel=\(panelToken) " + - "chars=\(chars) flags=\(normalizedFlags.rawValue)" - ) - } -#endif - return shouldBypass - } - private func commandOmnibarSelectionDelta( hasFocusedAddressBar: Bool, flags: NSEvent.ModifierFlags,