diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f9003b88..419276d6 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -103,12 +103,57 @@ func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { return normalizedFlags == [] || normalizedFlags == [.shift] } +func commandPaletteSelectionDeltaForKeyboardNavigation( + flags: NSEvent.ModifierFlags, + chars: String, + keyCode: UInt16 +) -> Int? { + let normalizedFlags = flags + .intersection(.deviceIndependentFlagsMask) + .subtracting([.numericPad, .function]) + let normalizedChars = chars.lowercased() + + if normalizedFlags == [] { + switch keyCode { + case 125: return 1 // Down arrow + case 126: return -1 // Up arrow + default: break + } + } + + if normalizedFlags == [.control] { + // Control modifiers can surface as either printable chars or ASCII control chars. + if keyCode == 45 || normalizedChars == "n" || normalizedChars == "\u{0e}" { return 1 } // Ctrl+N + if keyCode == 35 || normalizedChars == "p" || normalizedChars == "\u{10}" { return -1 } // Ctrl+P + if keyCode == 38 || normalizedChars == "j" || normalizedChars == "\u{0a}" { return 1 } // Ctrl+J + if keyCode == 40 || normalizedChars == "k" || normalizedChars == "\u{0b}" { return -1 } // Ctrl+K + } + + return nil +} + enum BrowserZoomShortcutAction: Equatable { case zoomIn case zoomOut case reset } +struct CommandPaletteDebugResultRow { + let commandId: String + let title: String + let shortcutHint: String? + let trailingLabel: String? + let score: Int +} + +struct CommandPaletteDebugSnapshot { + let query: String + let mode: String + let results: [CommandPaletteDebugResultRow] + + static let empty = CommandPaletteDebugSnapshot(query: "", mode: "commands", results: []) +} + func browserZoomShortcutAction( flags: NSEvent.ModifierFlags, chars: String, @@ -337,6 +382,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:] private var mainWindowControllers: [MainWindowController] = [] + private var commandPaletteVisibilityByWindowId: [UUID: Bool] = [:] + private var commandPaletteSelectionByWindowId: [UUID: Int] = [:] + private var commandPaletteSnapshotByWindowId: [UUID: CommandPaletteDebugSnapshot] = [:] var updateViewModel: UpdateViewModel { updateController.viewModel @@ -563,6 +611,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.unregisterMainWindow(closing) } } + commandPaletteVisibilityByWindowId[windowId] = false + commandPaletteSelectionByWindowId[windowId] = 0 + commandPaletteSnapshotByWindowId[windowId] = .empty if window.isKeyWindow { setActiveMainWindow(window) @@ -599,6 +650,111 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent mainWindowContexts.values.first(where: { $0.tabManager === tabManager })?.windowId } + func mainWindow(for windowId: UUID) -> NSWindow? { + windowForMainWindowId(windowId) + } + + func setCommandPaletteVisible(_ visible: Bool, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + commandPaletteVisibilityByWindowId[windowId] = visible + } + + func isCommandPaletteVisible(windowId: UUID) -> Bool { + commandPaletteVisibilityByWindowId[windowId] ?? false + } + + func setCommandPaletteSelectionIndex(_ index: Int, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + commandPaletteSelectionByWindowId[windowId] = max(0, index) + } + + func commandPaletteSelectionIndex(windowId: UUID) -> Int { + commandPaletteSelectionByWindowId[windowId] ?? 0 + } + + func setCommandPaletteSnapshot(_ snapshot: CommandPaletteDebugSnapshot, for window: NSWindow) { + guard let windowId = mainWindowId(for: window) else { return } + commandPaletteSnapshotByWindowId[windowId] = snapshot + } + + func commandPaletteSnapshot(windowId: UUID) -> CommandPaletteDebugSnapshot { + commandPaletteSnapshotByWindowId[windowId] ?? .empty + } + + func isCommandPaletteVisible(for window: NSWindow) -> Bool { + guard let windowId = mainWindowId(for: window) else { return false } + return commandPaletteVisibilityByWindowId[windowId] ?? false + } + + func shouldBlockFirstResponderChangeWhileCommandPaletteVisible( + window: NSWindow, + responder: NSResponder? + ) -> Bool { + guard isCommandPaletteVisible(for: window) else { return false } + guard let responder else { return false } + guard !isCommandPaletteResponder(responder) else { return false } + return isFocusStealingResponderWhileCommandPaletteVisible(responder) + } + + private func isCommandPaletteResponder(_ responder: NSResponder) -> Bool { + if let textView = responder as? NSTextView, textView.isFieldEditor { + if let delegateView = textView.delegate as? NSView { + return isInsideCommandPaletteOverlay(delegateView) + } + // SwiftUI can attach a non-view delegate to TextField editors. + // When command palette is visible, its search/rename editor is the + // only expected field editor inside the main window. + return true + } + if let view = responder as? NSView { + return isInsideCommandPaletteOverlay(view) + } + return false + } + + private func isFocusStealingResponderWhileCommandPaletteVisible(_ responder: NSResponder) -> Bool { + if responder is GhosttyNSView || responder is WKWebView { + return true + } + + if let textView = responder as? NSTextView, + !textView.isFieldEditor, + let delegateView = textView.delegate as? NSView { + return isTerminalOrBrowserView(delegateView) + } + + if let view = responder as? NSView { + return isTerminalOrBrowserView(view) + } + + return false + } + + private func isTerminalOrBrowserView(_ view: NSView) -> Bool { + if view is GhosttyNSView || view is WKWebView { + return true + } + var current: NSView? = view.superview + while let candidate = current { + if candidate is GhosttyNSView || candidate is WKWebView { + return true + } + current = candidate.superview + } + return false + } + + private func isInsideCommandPaletteOverlay(_ view: NSView) -> Bool { + var current: NSView? = view + while let candidate = current { + if candidate.identifier == commandPaletteOverlayContainerIdentifier { + return true + } + current = candidate.superview + } + return false + } + func locateSurface(surfaceId: UUID) -> (windowId: UUID, workspaceId: UUID, tabManager: TabManager)? { for ctx in mainWindowContexts.values { for ws in ctx.tabManager.tabs { @@ -656,6 +812,99 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier }) } + private func mainWindowId(for window: NSWindow) -> UUID? { + if let context = mainWindowContexts[ObjectIdentifier(window)] { + return context.windowId + } + guard let rawIdentifier = window.identifier?.rawValue, + rawIdentifier.hasPrefix("cmux.main.") else { return nil } + let idPart = String(rawIdentifier.dropFirst("cmux.main.".count)) + return UUID(uuidString: idPart) + } + + private func activeCommandPaletteWindow() -> NSWindow? { + if let keyWindow = NSApp.keyWindow, + let windowId = mainWindowId(for: keyWindow), + commandPaletteVisibilityByWindowId[windowId] == true { + return keyWindow + } + if let mainWindow = NSApp.mainWindow, + let windowId = mainWindowId(for: mainWindow), + commandPaletteVisibilityByWindowId[windowId] == true { + return mainWindow + } + if let visibleWindowId = commandPaletteVisibilityByWindowId.first(where: { $0.value })?.key { + return windowForMainWindowId(visibleWindowId) + } + return nil + } + + private func contextForMainWindow(_ window: NSWindow?) -> MainWindowContext? { + guard let window, isMainTerminalWindow(window) else { return nil } + return mainWindowContexts[ObjectIdentifier(window)] + } + + private func preferredMainWindowContextForShortcuts(event: NSEvent) -> MainWindowContext? { + if let context = contextForMainWindow(event.window) { + return context + } + if let context = contextForMainWindow(NSApp.keyWindow) { + return context + } + if let context = contextForMainWindow(NSApp.mainWindow) { + return context + } + return mainWindowContexts.values.first + } + + private func activateMainWindowContextForShortcutEvent(_ event: NSEvent) { + guard let context = preferredMainWindowContextForShortcuts(event: event), + let window = context.window ?? windowForMainWindowId(context.windowId) else { return } + setActiveMainWindow(window) + } + + @discardableResult + func toggleSidebarInActiveMainWindow() -> Bool { + if let activeManager = tabManager, + let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + if let window = activeContext.window ?? windowForMainWindowId(activeContext.windowId) { + setActiveMainWindow(window) + } + activeContext.sidebarState.toggle() + return true + } + if let keyContext = contextForMainWindow(NSApp.keyWindow) { + if let window = keyContext.window ?? windowForMainWindowId(keyContext.windowId) { + setActiveMainWindow(window) + } + keyContext.sidebarState.toggle() + return true + } + if let mainContext = contextForMainWindow(NSApp.mainWindow) { + if let window = mainContext.window ?? windowForMainWindowId(mainContext.windowId) { + setActiveMainWindow(window) + } + mainContext.sidebarState.toggle() + return true + } + if let fallbackContext = mainWindowContexts.values.first { + if let window = fallbackContext.window ?? windowForMainWindowId(fallbackContext.windowId) { + setActiveMainWindow(window) + } + fallbackContext.sidebarState.toggle() + return true + } + if let sidebarState { + sidebarState.toggle() + return true + } + return false + } + + func sidebarVisibility(windowId: UUID) -> Bool? { + mainWindowContexts.values.first(where: { $0.windowId == windowId })?.sidebarState.isVisible + } + @objc func openNewMainWindow(_ sender: Any?) { _ = createMainWindow() } @@ -1865,7 +2114,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return false } - let normalizedFlags = flags.subtracting([.numericPad, .function]) + let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) + + if let delta = commandPaletteSelectionDeltaForKeyboardNavigation( + flags: event.modifierFlags, + chars: chars, + keyCode: event.keyCode + ), + let paletteWindow = activeCommandPaletteWindow() { + NotificationCenter.default.post( + name: .commandPaletteMoveSelection, + object: paletteWindow, + userInfo: ["delta": delta] + ) + return true + } + + let isCommandP = normalizedFlags == [.command] && (chars == "p" || event.keyCode == 35) + if isCommandP { + let targetWindow = activeCommandPaletteWindow() ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow) + return true + } + + let isCommandShiftP = normalizedFlags == [.command, .shift] && (chars == "p" || event.keyCode == 35) + if isCommandShiftP { + let targetWindow = activeCommandPaletteWindow() ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow) + return true + } + if normalizedFlags == [.command], chars == "q" { return handleQuitShortcutWarning() } @@ -1895,6 +2173,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + // Route all shortcut handling through the window that actually produced + // the event to avoid cross-window actions when app-global pointers are stale. + activateMainWindowContextForShortcutEvent(event) + // Keep keyboard routing deterministic after split close/reparent transitions: // before processing shortcuts, converge first responder with the focused terminal panel. if isControlD { @@ -1942,7 +2224,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Primary UI shortcuts if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSidebar)) { - sidebarState?.toggle() + _ = toggleSidebarInActiveMainWindow() return true } @@ -2926,6 +3208,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func unregisterMainWindow(_ window: NSWindow) { let key = ObjectIdentifier(window) guard let removed = mainWindowContexts.removeValue(forKey: key) else { return } + commandPaletteVisibilityByWindowId.removeValue(forKey: removed.windowId) + commandPaletteSelectionByWindowId.removeValue(forKey: removed.windowId) + commandPaletteSnapshotByWindowId.removeValue(forKey: removed.windowId) // Avoid stale notifications that can no longer be opened once the owning window is gone. if let store = notificationStore { @@ -3873,6 +4158,19 @@ private var cmuxFirstResponderGuardHitViewOverride: NSView? private extension NSWindow { @objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool { + if AppDelegate.shared?.shouldBlockFirstResponderChangeWhileCommandPaletteVisible( + window: self, + responder: responder + ) == true { +#if DEBUG + dlog( + "focus.guard commandPaletteBlocked responder=\(String(describing: responder.map { type(of: $0) })) " + + "window=\(ObjectIdentifier(self))" + ) +#endif + return false + } + if let responder, let webView = Self.cmuxOwningWebView(for: responder), !webView.allowsFirstResponderAcquisitionEffective { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7a8cb326..8359f265 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -718,6 +718,272 @@ final class FileDropOverlayView: NSView { } var fileDropOverlayKey: UInt8 = 0 +private var commandPaletteWindowOverlayKey: UInt8 = 0 +let commandPaletteOverlayContainerIdentifier = NSUserInterfaceItemIdentifier("cmux.commandPalette.overlay.container") + +@MainActor +private final class CommandPaletteOverlayContainerView: NSView { + var capturesMouseEvents = false + + override var isOpaque: Bool { false } + override var acceptsFirstResponder: Bool { true } + + override func hitTest(_ point: NSPoint) -> NSView? { + guard capturesMouseEvents else { return nil } + return super.hitTest(point) + } +} + +@MainActor +private final class WindowCommandPaletteOverlayController: NSObject { + private weak var window: NSWindow? + private let containerView = CommandPaletteOverlayContainerView(frame: .zero) + private let hostingView = NSHostingView(rootView: AnyView(EmptyView())) + private var installConstraints: [NSLayoutConstraint] = [] + private weak var installedThemeFrame: NSView? + private var focusLockTimer: DispatchSourceTimer? + private var scheduledFocusWorkItem: DispatchWorkItem? + + init(window: NSWindow) { + self.window = window + super.init() + containerView.translatesAutoresizingMaskIntoConstraints = false + containerView.wantsLayer = true + containerView.layer?.backgroundColor = NSColor.clear.cgColor + containerView.isHidden = true + containerView.alphaValue = 0 + containerView.capturesMouseEvents = false + containerView.identifier = commandPaletteOverlayContainerIdentifier + hostingView.translatesAutoresizingMaskIntoConstraints = false + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = NSColor.clear.cgColor + containerView.addSubview(hostingView) + NSLayoutConstraint.activate([ + hostingView.topAnchor.constraint(equalTo: containerView.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + hostingView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + ]) + _ = ensureInstalled() + } + + @discardableResult + private func ensureInstalled() -> Bool { + guard let window, + let contentView = window.contentView, + let themeFrame = contentView.superview else { return false } + + if containerView.superview !== themeFrame { + NSLayoutConstraint.deactivate(installConstraints) + installConstraints.removeAll() + containerView.removeFromSuperview() + themeFrame.addSubview(containerView, positioned: .above, relativeTo: nil) + installConstraints = [ + containerView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ] + NSLayoutConstraint.activate(installConstraints) + installedThemeFrame = themeFrame + } else if themeFrame.subviews.last !== containerView { + themeFrame.addSubview(containerView, positioned: .above, relativeTo: nil) + } + + return true + } + + private func isPaletteResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + + if let view = responder as? NSView, view.isDescendant(of: containerView) { + return true + } + + if let textView = responder as? NSTextView { + if let delegateView = textView.delegate as? NSView, + delegateView.isDescendant(of: containerView) { + return true + } + } + + return false + } + + private func isPaletteFieldEditor(_ textView: NSTextView) -> Bool { + guard textView.isFieldEditor else { return false } + + if let delegateView = textView.delegate as? NSView, + delegateView.isDescendant(of: containerView) { + return true + } + + // SwiftUI text fields can keep a field editor delegate that isn't an NSView. + // Fall back to validating editor ownership from the mounted palette text field. + if let textField = firstEditableTextField(in: hostingView), + textField.currentEditor() === textView { + return true + } + + return false + } + + private func isPaletteTextInputFirstResponder(_ responder: NSResponder?) -> Bool { + guard let responder else { return false } + + if let textView = responder as? NSTextView { + return isPaletteFieldEditor(textView) + } + + if let textField = responder as? NSTextField { + return textField.isDescendant(of: containerView) + } + + return false + } + + private func firstEditableTextField(in view: NSView) -> NSTextField? { + if let textField = view as? NSTextField, + textField.isEditable, + textField.isEnabled, + !textField.isHiddenOrHasHiddenAncestor { + return textField + } + + for subview in view.subviews { + if let match = firstEditableTextField(in: subview) { + return match + } + } + return nil + } + + private func scheduleFocusIntoPalette(retries: Int = 4) { + scheduledFocusWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.scheduledFocusWorkItem = nil + self?.focusIntoPalette(retries: retries) + } + scheduledFocusWorkItem = workItem + DispatchQueue.main.async(execute: workItem) + } + + private func focusIntoPalette(retries: Int) { + guard let window else { return } + if isPaletteTextInputFirstResponder(window.firstResponder) { + return + } + + if let textField = firstEditableTextField(in: hostingView), + window.makeFirstResponder(textField), + isPaletteTextInputFirstResponder(window.firstResponder) { + normalizeSelectionAfterProgrammaticFocus() + return + } + + if window.makeFirstResponder(containerView) { + if let textField = firstEditableTextField(in: hostingView), + window.makeFirstResponder(textField), + isPaletteTextInputFirstResponder(window.firstResponder) { + normalizeSelectionAfterProgrammaticFocus() + return + } + } + + guard retries > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { [weak self] in + self?.focusIntoPalette(retries: retries - 1) + } + } + + private func startFocusLockTimer() { + guard focusLockTimer == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now(), repeating: .milliseconds(80), leeway: .milliseconds(12)) + timer.setEventHandler { [weak self] in + guard let self else { return } + guard let window = self.window else { + self.stopFocusLockTimer() + return + } + if self.isPaletteTextInputFirstResponder(window.firstResponder) { + return + } + self.focusIntoPalette(retries: 1) + } + focusLockTimer = timer + timer.resume() + } + + private func stopFocusLockTimer() { + focusLockTimer?.cancel() + focusLockTimer = nil + scheduledFocusWorkItem?.cancel() + scheduledFocusWorkItem = nil + } + + private func normalizeSelectionAfterProgrammaticFocus() { + guard let window, + let editor = window.firstResponder as? NSTextView, + editor.isFieldEditor else { return } + + let text = editor.string + let length = (text as NSString).length + let selection = editor.selectedRange() + guard length > 0 else { return } + guard selection.location == 0, selection.length == length else { return } + + // Keep commands-mode prefix semantics stable after focus re-assertions: + // if AppKit selected the entire query (e.g. ">foo"), restore caret-at-end + // so the next keystroke appends instead of replacing and switching modes. + guard text.hasPrefix(">") else { return } + editor.setSelectedRange(NSRange(location: length, length: 0)) + } + + func update(rootView: AnyView, isVisible: Bool) { + guard ensureInstalled() else { return } + if isVisible { + hostingView.rootView = rootView + containerView.capturesMouseEvents = true + containerView.isHidden = false + containerView.alphaValue = 1 + if let themeFrame = installedThemeFrame, themeFrame.subviews.last !== containerView { + themeFrame.addSubview(containerView, positioned: .above, relativeTo: nil) + } + startFocusLockTimer() + if let window, !isPaletteTextInputFirstResponder(window.firstResponder) { + scheduleFocusIntoPalette(retries: 8) + } + } else { + stopFocusLockTimer() + if let window, isPaletteResponder(window.firstResponder) { + _ = window.makeFirstResponder(nil) + } + hostingView.rootView = AnyView(EmptyView()) + containerView.capturesMouseEvents = false + containerView.alphaValue = 0 + containerView.isHidden = true + } + } +} + +@MainActor +private func commandPaletteWindowOverlayController(for window: NSWindow) -> WindowCommandPaletteOverlayController { + if let existing = objc_getAssociatedObject(window, &commandPaletteWindowOverlayKey) as? WindowCommandPaletteOverlayController { + return existing + } + let controller = WindowCommandPaletteOverlayController(window: window) + objc_setAssociatedObject(window, &commandPaletteWindowOverlayKey, controller, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return controller +} + +private struct CommandPaletteRowFramePreferenceKey: PreferenceKey { + static var defaultValue: [Int: CGRect] = [:] + + static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) { + value.merge(nextValue(), uniquingKeysWith: { _, rhs in rhs }) + } +} enum WorkspaceMountPolicy { // Keep only the selected workspace mounted to minimize layer-tree traversal. @@ -848,11 +1114,226 @@ struct ContentView: View { @State private var isResizerBandActive = false @State private var isSidebarResizerCursorActive = false @State private var sidebarResizerCursorStabilizer: DispatchSourceTimer? + @State private var isCommandPalettePresented = false + @State private var commandPaletteQuery: String = "" + @State private var commandPaletteMode: CommandPaletteMode = .commands + @State private var commandPaletteRenameDraft: String = "" + @State private var commandPaletteSelectedResultIndex: Int = 0 + @State private var commandPaletteHoveredResultIndex: Int? + @State private var commandPaletteLastSelectionIndex: Int = 0 + @State private var commandPaletteRowFrames: [Int: CGRect] = [:] + @State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? + @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] + @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + @FocusState private var isCommandPaletteSearchFocused: Bool + @FocusState private var isCommandPaletteRenameFocused: Bool + + private enum CommandPaletteMode { + case commands + case renameInput(CommandPaletteRenameTarget) + case renameConfirm(CommandPaletteRenameTarget, proposedName: String) + } + + private enum CommandPaletteListScope: String { + case commands + case switcher + } + + 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 "Rename Workspace" + case .tab: + return "Rename Tab" + } + } + + var description: String { + switch kind { + case .workspace: + return "Choose a custom workspace name." + case .tab: + return "Choose a custom tab name." + } + } + + var placeholder: String { + switch kind { + case .workspace: + return "Workspace name" + case .tab: + return "Tab name" + } + } + } + + private struct CommandPaletteRestoreFocusTarget { + let workspaceId: UUID + let panelId: UUID + } + + private enum CommandPaletteInputFocusTarget { + case search + case rename + } + + private enum CommandPaletteTextSelectionBehavior { + case caretAtEnd + case selectAll + } + + private enum CommandPaletteTrailingLabelStyle { + case shortcut + case kind + } + + enum CommandPaletteScrollAnchor: Equatable { + case top + case bottom + } + + 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 keywords: [String] + let dismissOnRun: Bool + let action: () -> Void + + var searchableTexts: [String] { + [title, subtitle] + keywords + } + } + + private struct CommandPaletteUsageEntry: Codable { + var useCount: Int + var lastUsedAt: TimeInterval + } + + private struct CommandPaletteContextSnapshot { + private var boolValues: [String: Bool] = [:] + private var stringValues: [String: String] = [:] + + 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] + } + } + + private enum CommandPaletteContextKeys { + static let hasWorkspace = "workspace.hasSelection" + static let workspaceName = "workspace.name" + static let workspaceHasCustomName = "workspace.hasCustomName" + static let workspaceShouldPin = "workspace.shouldPin" + + static let hasFocusedPanel = "panel.hasFocus" + static let panelName = "panel.name" + static let panelIsBrowser = "panel.isBrowser" + static let panelIsTerminal = "panel.isTerminal" + static let panelHasCustomName = "panel.hasCustomName" + static let panelShouldPin = "panel.shouldPin" + static let panelHasUnread = "panel.hasUnread" + } + + private 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 + } + } + + private 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 static let fixedSidebarResizeCursor = NSCursor( image: NSCursor.resizeLeftRight.image, hotSpot: NSCursor.resizeLeftRight.hotSpot ) + private static let commandPaletteUsageDefaultsKey = "commandPalette.commandUsage.v1" + private static let commandPaletteCommandsPrefix = ">" private enum SidebarResizerHandle: Hashable { case divider @@ -1193,7 +1674,7 @@ struct ContentView: View { TitlebarControlsView( notificationStore: TerminalNotificationStore.shared, viewModel: fullscreenControlsViewModel, - onToggleSidebar: { AppDelegate.shared?.sidebarState?.toggle() }, + onToggleSidebar: { sidebarState.toggle() }, onToggleNotifications: { [fullscreenControlsViewModel] in AppDelegate.shared?.toggleNotificationsPopover( animated: true, @@ -1367,6 +1848,7 @@ struct ContentView: View { var body: some View { var view = AnyView( contentAndSidebarLayout + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .overlay(alignment: .topLeading) { if isFullScreen && sidebarState.isVisible { fullscreenControls @@ -1496,6 +1978,97 @@ struct ContentView: View { #endif }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteToggleRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + toggleCommandPalette() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + openCommandPaletteCommands() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteSwitcherRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + openCommandPaletteSwitcher() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameTabRequested)) { notification in + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + openCommandPaletteRenameTabInput() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteMoveSelection)) { notification in + guard isCommandPalettePresented else { return } + guard case .commands = commandPaletteMode else { return } + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + guard let delta = notification.userInfo?["delta"] as? Int, delta != 0 else { return } + moveCommandPaletteSelection(by: delta) + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameInputInteractionRequested)) { notification in + guard isCommandPalettePresented else { return } + guard case .renameInput = commandPaletteMode else { return } + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + handleCommandPaletteRenameInputInteraction() + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameInputDeleteBackwardRequested)) { notification in + guard isCommandPalettePresented else { return } + guard case .renameInput = commandPaletteMode else { return } + let requestedWindow = notification.object as? NSWindow + guard Self.shouldHandleCommandPaletteRequest( + observedWindow: observedWindow, + requestedWindow: requestedWindow, + keyWindow: NSApp.keyWindow, + mainWindow: NSApp.mainWindow + ) else { return } + _ = handleCommandPaletteRenameDeleteBackward(modifiers: []) + }) + + view = AnyView(view.background(WindowAccessor(dedupeByWindow: false) { window in + MainActor.assumeIsolated { + let overlayController = commandPaletteWindowOverlayController(for: window) + overlayController.update(rootView: AnyView(commandPaletteOverlay), isVisible: isCommandPalettePresented) + } + })) + view = AnyView(view.onChange(of: bgGlassTintHex) { _ in updateWindowGlassTint() }) @@ -1547,6 +2120,7 @@ struct ContentView: View { DispatchQueue.main.async { observedWindow = window isFullScreen = window.styleMask.contains(.fullScreen) + syncCommandPaletteDebugStateForObservedWindow() installSidebarResizerPointerMonitorIfNeeded() updateSidebarResizerBandState() } @@ -1748,6 +2322,1956 @@ struct ContentView: View { #endif } + private var commandPaletteOverlay: some View { + GeometryReader { proxy in + let maxAllowedWidth = max(340, proxy.size.width - 260) + let targetWidth = min(560, maxAllowedWidth) + + ZStack(alignment: .top) { + Color.clear + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture { + dismissCommandPalette() + } + + VStack(spacing: 0) { + switch commandPaletteMode { + case .commands: + commandPaletteCommandListView + case .renameInput(let target): + commandPaletteRenameInputView(target: target) + case let .renameConfirm(target, proposedName): + commandPaletteRenameConfirmView(target: target, proposedName: proposedName) + } + } + .frame(width: targetWidth) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor).opacity(0.98)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.24), radius: 10, x: 0, y: 5) + .padding(.top, 40) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .onExitCommand { + dismissCommandPalette() + } + .zIndex(2000) + } + + private var commandPaletteCommandListView: some View { + let visibleResults = Array(commandPaletteResults) + let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) + let commandPaletteListMaxHeight: CGFloat = 216 + let commandPaletteRowHeight: CGFloat = 24 + let commandPaletteEmptyStateHeight: CGFloat = 44 + let commandPaletteListContentHeight = visibleResults.isEmpty + ? commandPaletteEmptyStateHeight + : CGFloat(visibleResults.count) * commandPaletteRowHeight + let commandPaletteListHeight = min(commandPaletteListMaxHeight, commandPaletteListContentHeight) + return VStack(spacing: 0) { + HStack(spacing: 8) { + TextField(commandPaletteSearchPlaceholder, text: $commandPaletteQuery) + .textFieldStyle(.plain) + .font(.system(size: 13, weight: .regular)) + .tint(.blue) + .focused($isCommandPaletteSearchFocused) + .onSubmit { + runSelectedCommandPaletteResult(visibleResults: visibleResults) + } + .backport.onKeyPress(.downArrow) { _ in + moveCommandPaletteSelection(by: 1) + return .handled + } + .backport.onKeyPress(.upArrow) { _ in + moveCommandPaletteSelection(by: -1) + return .handled + } + .backport.onKeyPress("n") { modifiers in + handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: 1) + } + .backport.onKeyPress("p") { modifiers in + handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1) + } + .backport.onKeyPress("j") { modifiers in + handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: 1) + } + .backport.onKeyPress("k") { modifiers in + handleCommandPaletteControlNavigationKey(modifiers: modifiers, delta: -1) + } + + } + .padding(.horizontal, 9) + .padding(.vertical, 7) + + Divider() + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + if visibleResults.isEmpty { + Text(commandPaletteEmptyStateText) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 12) + } else { + ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in + let isSelected = index == selectedIndex + let isHovered = commandPaletteHoveredResultIndex == index + let rowBackground: Color = isSelected + ? Color.accentColor.opacity(0.12) + : (isHovered ? Color.primary.opacity(0.08) : .clear) + + Button { + runCommandPaletteCommand(result.command) + } label: { + HStack(spacing: 8) { + commandPaletteHighlightedTitleText( + result.command.title, + matchedIndices: result.titleMatchIndices + ) + .font(.system(size: 13, weight: .regular)) + .lineLimit(1) + Spacer() + + if let trailingLabel = commandPaletteTrailingLabel(for: result.command) { + switch trailingLabel.style { + case .shortcut: + Text(trailingLabel.text) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 4, style: .continuous)) + case .kind: + Text(trailingLabel.text) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + .padding(.horizontal, 9) + .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackground) + .background( + GeometryReader { geometry in + Color.clear.preference( + key: CommandPaletteRowFramePreferenceKey.self, + value: [index: geometry.frame(in: .named("commandPaletteListScroll"))] + ) + } + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .id(index) + .onHover { hovering in + if hovering { + commandPaletteHoveredResultIndex = index + } else if commandPaletteHoveredResultIndex == index { + commandPaletteHoveredResultIndex = nil + } + } + } + } + } + // Force a fresh row tree per query so rendered labels/actions stay in lockstep. + .id(commandPaletteQuery) + } + .coordinateSpace(name: "commandPaletteListScroll") + .frame(height: commandPaletteListHeight) + .onChange(of: commandPaletteSelectedResultIndex) { _ in + guard !visibleResults.isEmpty else { return } + let index = commandPaletteSelectedIndex(resultCount: visibleResults.count) + let previousIndex = commandPaletteLastSelectionIndex + defer { commandPaletteLastSelectionIndex = index } + + guard let anchorDecision = Self.commandPaletteScrollAnchor( + selectedIndex: index, + previousIndex: previousIndex, + resultCount: visibleResults.count, + selectedFrame: commandPaletteRowFrames[index], + viewportHeight: commandPaletteListHeight, + contentHeight: commandPaletteListContentHeight + ) else { return } + + let anchor: UnitPoint + switch anchorDecision { + case .top: + anchor = .top + case .bottom: + anchor = .bottom + } + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 0.1)) { + proxy.scrollTo(index, anchor: anchor) + } + } + } + .onChange(of: visibleResults.count) { _ in + commandPaletteLastSelectionIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) + } + .onPreferenceChange(CommandPaletteRowFramePreferenceKey.self) { frames in + commandPaletteRowFrames = frames + guard !visibleResults.isEmpty else { return } + let index = commandPaletteSelectedIndex(resultCount: visibleResults.count) + guard let anchorDecision = Self.commandPaletteEdgeVisibilityCorrectionAnchor( + selectedIndex: index, + resultCount: visibleResults.count, + selectedFrame: frames[index], + viewportHeight: commandPaletteListHeight, + contentHeight: commandPaletteListContentHeight + ) else { return } + let anchor: UnitPoint = anchorDecision == .top ? .top : .bottom + DispatchQueue.main.async { + withAnimation(.easeOut(duration: 0.08)) { + proxy.scrollTo(index, anchor: anchor) + } + } + } + } + + // Keep Esc-to-close behavior without showing footer controls. + Button(action: { dismissCommandPalette() }) { + EmptyView() + } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + .onAppear { + commandPaletteHoveredResultIndex = nil + commandPaletteLastSelectionIndex = commandPaletteSelectedResultIndex + commandPaletteRowFrames = [:] + resetCommandPaletteSearchFocus() + } + .onChange(of: commandPaletteQuery) { _ in + commandPaletteSelectedResultIndex = 0 + commandPaletteHoveredResultIndex = nil + commandPaletteLastSelectionIndex = 0 + commandPaletteRowFrames = [:] + syncCommandPaletteDebugStateForObservedWindow() + } + .onChange(of: visibleResults.count) { _ in + commandPaletteSelectedResultIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) + commandPaletteLastSelectionIndex = commandPaletteSelectedResultIndex + if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= visibleResults.count { + commandPaletteHoveredResultIndex = nil + } + syncCommandPaletteDebugStateForObservedWindow() + } + .onChange(of: commandPaletteSelectedResultIndex) { _ in + syncCommandPaletteDebugStateForObservedWindow() + } + } + + private func commandPaletteRenameInputView(target: CommandPaletteRenameTarget) -> some View { + VStack(spacing: 0) { + TextField(target.placeholder, text: $commandPaletteRenameDraft) + .textFieldStyle(.plain) + .font(.system(size: 13, weight: .regular)) + .tint(.blue) + .focused($isCommandPaletteRenameFocused) + .backport.onKeyPress(.delete) { modifiers in + handleCommandPaletteRenameDeleteBackward(modifiers: modifiers) + } + .onSubmit { + continueRenameFlow(target: target) + } + .onTapGesture { + handleCommandPaletteRenameInputInteraction() + } + .padding(.horizontal, 9) + .padding(.vertical, 7) + + Divider() + + Text("Enter a \(renameTargetNoun(target)) name. Press Enter to rename, Escape to cancel.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 9) + .padding(.vertical, 6) + + Button(action: { + continueRenameFlow(target: target) + }) { + EmptyView() + } + .buttonStyle(.plain) + .keyboardShortcut(.defaultAction) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + .onAppear { + resetCommandPaletteRenameFocus() + } + } + + private func commandPaletteRenameConfirmView( + target: CommandPaletteRenameTarget, + proposedName: String + ) -> some View { + let trimmedName = proposedName.trimmingCharacters(in: .whitespacesAndNewlines) + let nextName = trimmedName.isEmpty ? "(clear custom name)" : trimmedName + + return VStack(spacing: 0) { + Text(nextName) + .font(.system(size: 13, weight: .regular)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 9) + .padding(.vertical, 7) + + Divider() + + Text("Press Enter to apply this \(renameTargetNoun(target)) name, or Escape to cancel.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 9) + .padding(.vertical, 6) + + Button(action: { + applyRenameFlow(target: target, proposedName: proposedName) + }) { + EmptyView() + } + .buttonStyle(.plain) + .keyboardShortcut(.defaultAction) + .frame(width: 0, height: 0) + .opacity(0) + .accessibilityHidden(true) + } + } + + private func renameTargetNoun(_ target: CommandPaletteRenameTarget) -> String { + switch target.kind { + case .workspace: + return "workspace" + case .tab: + return "tab" + } + } + + private var commandPaletteListScope: CommandPaletteListScope { + if commandPaletteQuery.hasPrefix(Self.commandPaletteCommandsPrefix) { + return .commands + } + return .switcher + } + + private var commandPaletteSearchPlaceholder: String { + switch commandPaletteListScope { + case .commands: + return "Type a command" + case .switcher: + return "Search workspaces and tabs" + } + } + + private var commandPaletteEmptyStateText: String { + switch commandPaletteListScope { + case .commands: + return "No commands match your search." + case .switcher: + return "No workspaces or tabs match your search." + } + } + + private var commandPaletteQueryForMatching: String { + switch commandPaletteListScope { + case .commands: + let suffix = String(commandPaletteQuery.dropFirst(Self.commandPaletteCommandsPrefix.count)) + return suffix.trimmingCharacters(in: .whitespacesAndNewlines) + case .switcher: + return commandPaletteQuery.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + private var commandPaletteEntries: [CommandPaletteCommand] { + switch commandPaletteListScope { + case .commands: + return commandPaletteCommands() + case .switcher: + return commandPaletteSwitcherEntries() + } + } + + private var commandPaletteResults: [CommandPaletteSearchResult] { + let entries = commandPaletteEntries + let query = commandPaletteQueryForMatching + let queryIsEmpty = query.isEmpty + + let results: [CommandPaletteSearchResult] = queryIsEmpty + ? entries.map { entry in + CommandPaletteSearchResult( + command: entry, + score: commandPaletteHistoryBoost(for: entry.id, queryIsEmpty: true), + titleMatchIndices: [] + ) + } + : entries.compactMap { entry in + guard let fuzzyScore = CommandPaletteFuzzyMatcher.score(query: query, candidates: entry.searchableTexts) else { + return nil + } + return CommandPaletteSearchResult( + command: entry, + score: fuzzyScore + commandPaletteHistoryBoost(for: entry.id, queryIsEmpty: false), + titleMatchIndices: CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: query, + candidate: entry.title + ) + ) + } + + return results + .sorted { lhs, rhs in + if lhs.score != rhs.score { return lhs.score > rhs.score } + if lhs.command.rank != rhs.command.rank { return lhs.command.rank < rhs.command.rank } + return lhs.command.title.localizedCaseInsensitiveCompare(rhs.command.title) == .orderedAscending + } + } + + private func commandPaletteHighlightedTitleText(_ title: String, matchedIndices: Set) -> Text { + guard !matchedIndices.isEmpty else { + return Text(title).foregroundColor(.primary) + } + + let chars = Array(title) + var index = 0 + var result = Text("") + + while index < chars.count { + let isMatched = matchedIndices.contains(index) + var end = index + 1 + while end < chars.count, matchedIndices.contains(end) == isMatched { + end += 1 + } + + let segment = String(chars[index.. CommandPaletteTrailingLabel? { + if let shortcutHint = command.shortcutHint { + return CommandPaletteTrailingLabel(text: shortcutHint, style: .shortcut) + } + + guard commandPaletteListScope == .switcher else { return nil } + if command.id.hasPrefix("switcher.workspace.") { + return CommandPaletteTrailingLabel(text: "Workspace", style: .kind) + } + if command.id.hasPrefix("switcher.surface.") { + return CommandPaletteTrailingLabel(text: "Surface", style: .kind) + } + return nil + } + + private func commandPaletteSwitcherEntries() -> [CommandPaletteCommand] { + var workspaces = tabManager.tabs + guard !workspaces.isEmpty else { return [] } + + if let selectedWorkspaceId = tabManager.selectedTabId, + let selectedIndex = workspaces.firstIndex(where: { $0.id == selectedWorkspaceId }) { + let selectedWorkspace = workspaces.remove(at: selectedIndex) + workspaces.insert(selectedWorkspace, at: 0) + } + + var entries: [CommandPaletteCommand] = [] + entries.reserveCapacity(workspaces.count * 4) + var nextRank = 0 + + for workspace in workspaces { + let workspaceName = workspaceDisplayName(workspace) + let workspaceCommandId = "switcher.workspace.\(workspace.id.uuidString.lowercased())" + let workspaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: [ + "workspace", + "switch", + "go", + "open", + workspaceName + ], + metadata: commandPaletteWorkspaceSearchMetadata(for: workspace), + detail: .workspace + ) + entries.append( + CommandPaletteCommand( + id: workspaceCommandId, + rank: nextRank, + title: workspaceName, + subtitle: "Workspace", + shortcutHint: nil, + keywords: workspaceKeywords, + dismissOnRun: true, + action: { + tabManager.focusTab(workspace.id, suppressFlash: true) + } + ) + ) + nextRank += 1 + + var orderedPanelIds = workspace.sidebarOrderedPanelIds() + if let focusedPanelId = workspace.focusedPanelId, + let focusedIndex = orderedPanelIds.firstIndex(of: focusedPanelId) { + orderedPanelIds.remove(at: focusedIndex) + orderedPanelIds.insert(focusedPanelId, at: 0) + } + + for panelId in orderedPanelIds { + guard let panel = workspace.panels[panelId] else { continue } + let panelTitle = panelDisplayName(workspace: workspace, panelId: panelId, fallback: panel.displayTitle) + let typeLabel: String = (panel.panelType == .browser) ? "Browser" : "Terminal" + let panelKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: [ + "tab", + "surface", + "panel", + "switch", + "go", + workspaceName, + panelTitle, + typeLabel.lowercased() + ], + metadata: commandPalettePanelSearchMetadata(in: workspace, panelId: panelId) + ) + entries.append( + CommandPaletteCommand( + id: "switcher.surface.\(workspace.id.uuidString.lowercased()).\(panelId.uuidString.lowercased())", + rank: nextRank, + title: panelTitle, + subtitle: "\(typeLabel) • \(workspaceName)", + shortcutHint: nil, + keywords: panelKeywords, + dismissOnRun: true, + action: { + tabManager.focusTab(workspace.id, surfaceId: panelId, suppressFlash: true) + } + ) + ) + nextRank += 1 + } + } + + return entries + } + + private func commandPaletteWorkspaceSearchMetadata(for workspace: Workspace) -> CommandPaletteSwitcherSearchMetadata { + // Keep workspace rows coarse so surface rows win for directory/branch-specific queries. + let directories = [workspace.currentDirectory] + let branches = [workspace.gitBranch?.branch].compactMap { $0 } + let ports = workspace.listeningPorts + return CommandPaletteSwitcherSearchMetadata( + directories: directories, + branches: branches, + ports: ports + ) + } + + private func commandPalettePanelSearchMetadata(in workspace: Workspace, panelId: UUID) -> CommandPaletteSwitcherSearchMetadata { + var directories: [String] = [] + if let directory = workspace.panelDirectories[panelId] { + directories.append(directory) + } else if workspace.focusedPanelId == panelId { + directories.append(workspace.currentDirectory) + } + + var branches: [String] = [] + if let branch = workspace.panelGitBranches[panelId]?.branch { + branches.append(branch) + } else if workspace.focusedPanelId == panelId, let branch = workspace.gitBranch?.branch { + branches.append(branch) + } + + var ports = workspace.surfaceListeningPorts[panelId] ?? [] + if ports.isEmpty, workspace.panels.count == 1 { + ports = workspace.listeningPorts + } + + return CommandPaletteSwitcherSearchMetadata( + directories: directories, + branches: branches, + ports: ports + ) + } + + private func commandPaletteCommands() -> [CommandPaletteCommand] { + let context = commandPaletteContextSnapshot() + let contributions = commandPaletteCommandContributions() + var handlerRegistry = CommandPaletteHandlerRegistry() + registerCommandPaletteHandlers(&handlerRegistry) + + var commands: [CommandPaletteCommand] = [] + commands.reserveCapacity(contributions.count) + var nextRank = 0 + + for contribution in contributions { + guard contribution.when(context), contribution.enablement(context) else { continue } + guard let action = handlerRegistry.handler(for: contribution.commandId) else { + assertionFailure("No command palette handler registered for \(contribution.commandId)") + continue + } + commands.append( + CommandPaletteCommand( + id: contribution.commandId, + rank: nextRank, + title: contribution.title(context), + subtitle: contribution.subtitle(context), + shortcutHint: commandPaletteShortcutHint(for: contribution), + keywords: contribution.keywords, + dismissOnRun: contribution.dismissOnRun, + action: action + ) + ) + nextRank += 1 + } + + return commands + } + + private func commandPaletteShortcutHint(for contribution: CommandPaletteCommandContribution) -> String? { + if let action = commandPaletteShortcutAction(for: contribution.commandId) { + return KeyboardShortcutSettings.shortcut(for: action).displayString + } + if let staticShortcut = commandPaletteStaticShortcutHint(for: contribution.commandId) { + return staticShortcut + } + return contribution.shortcutHint + } + + private func commandPaletteShortcutAction(for commandId: String) -> KeyboardShortcutSettings.Action? { + switch commandId { + case "palette.newWorkspace": + return .newTab + case "palette.newTerminalTab": + return .newSurface + case "palette.newBrowserTab": + return .openBrowser + case "palette.toggleSidebar": + return .toggleSidebar + case "palette.showNotifications": + return .showNotifications + case "palette.jumpUnread": + return .jumpToUnread + case "palette.renameWorkspace": + return .renameWorkspace + case "palette.nextWorkspace": + return .nextSidebarTab + case "palette.previousWorkspace": + return .prevSidebarTab + case "palette.nextTabInPane": + return .nextSurface + case "palette.previousTabInPane": + return .prevSurface + case "palette.browserToggleDevTools": + return .toggleBrowserDeveloperTools + case "palette.browserConsole": + return .showBrowserJavaScriptConsole + case "palette.browserSplitRight", "palette.terminalSplitBrowserRight": + return .splitBrowserRight + case "palette.browserSplitDown", "palette.terminalSplitBrowserDown": + return .splitBrowserDown + case "palette.terminalSplitRight": + return .splitRight + case "palette.terminalSplitDown": + return .splitDown + default: + return nil + } + } + + private func commandPaletteStaticShortcutHint(for commandId: String) -> String? { + switch commandId { + case "palette.closeTab": + return "⌘W" + case "palette.closeWorkspace": + return "⌘⇧W" + case "palette.reopenClosedBrowserTab": + return "⌘⇧T" + case "palette.openSettings": + return "⌘," + case "palette.browserBack": + return "⌘[" + case "palette.browserForward": + return "⌘]" + case "palette.browserReload": + return "⌘R" + case "palette.browserFocusAddressBar": + return "⌘L" + case "palette.browserZoomIn": + return "⌘=" + case "palette.browserZoomOut": + return "⌘-" + case "palette.browserZoomReset": + return "⌘0" + case "palette.terminalFind": + return "⌘F" + case "palette.terminalFindNext": + return "⌘G" + case "palette.terminalFindPrevious": + return "⌘⇧G" + case "palette.terminalHideFind": + return "⌘⇧F" + case "palette.terminalUseSelectionForFind": + return "⌘E" + default: + return nil + } + } + + private func commandPaletteContextSnapshot() -> CommandPaletteContextSnapshot { + var snapshot = CommandPaletteContextSnapshot() + + if let workspace = tabManager.selectedWorkspace { + snapshot.setBool(CommandPaletteContextKeys.hasWorkspace, true) + snapshot.setString(CommandPaletteContextKeys.workspaceName, workspaceDisplayName(workspace)) + snapshot.setBool(CommandPaletteContextKeys.workspaceHasCustomName, workspace.customTitle != nil) + snapshot.setBool(CommandPaletteContextKeys.workspaceShouldPin, !workspace.isPinned) + } + + if let panelContext = focusedPanelContext { + let workspace = panelContext.workspace + let panelId = panelContext.panelId + snapshot.setBool(CommandPaletteContextKeys.hasFocusedPanel, true) + snapshot.setString( + CommandPaletteContextKeys.panelName, + panelDisplayName(workspace: workspace, panelId: panelId, fallback: panelContext.panel.displayTitle) + ) + snapshot.setBool(CommandPaletteContextKeys.panelIsBrowser, panelContext.panel.panelType == .browser) + snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelContext.panel.panelType == .terminal) + snapshot.setBool(CommandPaletteContextKeys.panelHasCustomName, workspace.panelCustomTitles[panelId] != nil) + snapshot.setBool(CommandPaletteContextKeys.panelShouldPin, !workspace.isPanelPinned(panelId)) + let hasUnread = workspace.manualUnreadPanelIds.contains(panelId) + || notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId) + snapshot.setBool(CommandPaletteContextKeys.panelHasUnread, hasUnread) + } + + return snapshot + } + + private func commandPaletteCommandContributions() -> [CommandPaletteCommandContribution] { + func constant(_ value: String) -> (CommandPaletteContextSnapshot) -> String { + { _ in value } + } + + func workspaceSubtitle(_ context: CommandPaletteContextSnapshot) -> String { + let name = context.string(CommandPaletteContextKeys.workspaceName) ?? "Workspace" + return "Workspace • \(name)" + } + + func panelSubtitle(_ context: CommandPaletteContextSnapshot) -> String { + let name = context.string(CommandPaletteContextKeys.panelName) ?? "Tab" + return "Tab • \(name)" + } + + func browserPanelSubtitle(_ context: CommandPaletteContextSnapshot) -> String { + let name = context.string(CommandPaletteContextKeys.panelName) ?? "Tab" + return "Browser • \(name)" + } + + func terminalPanelSubtitle(_ context: CommandPaletteContextSnapshot) -> String { + let name = context.string(CommandPaletteContextKeys.panelName) ?? "Tab" + return "Terminal • \(name)" + } + + var contributions: [CommandPaletteCommandContribution] = [] + + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newWorkspace", + title: constant("New Workspace"), + subtitle: constant("Workspace"), + keywords: ["create", "new", "workspace"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newTerminalTab", + title: constant("New Tab (Terminal)"), + subtitle: constant("Tab"), + shortcutHint: "⌘T", + keywords: ["new", "terminal", "tab"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.newBrowserTab", + title: constant("New Tab (Browser)"), + subtitle: constant("Tab"), + shortcutHint: "⌘⇧L", + keywords: ["new", "browser", "tab", "web"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeTab", + title: constant("Close Tab"), + subtitle: constant("Tab"), + shortcutHint: "⌘W", + keywords: ["close", "tab"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.closeWorkspace", + title: constant("Close Workspace"), + subtitle: constant("Workspace"), + shortcutHint: "⌘⇧W", + keywords: ["close", "workspace"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.reopenClosedBrowserTab", + title: constant("Reopen Closed Browser Tab"), + subtitle: constant("Browser"), + shortcutHint: "⌘⇧T", + keywords: ["reopen", "closed", "browser"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleSidebar", + title: constant("Toggle Sidebar"), + subtitle: constant("Layout"), + keywords: ["toggle", "sidebar", "layout"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.showNotifications", + title: constant("Show Notifications"), + subtitle: constant("Notifications"), + keywords: ["notifications", "inbox"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.jumpUnread", + title: constant("Jump to Latest Unread"), + subtitle: constant("Notifications"), + keywords: ["jump", "unread", "notification"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.openSettings", + title: constant("Open Settings"), + subtitle: constant("Global"), + shortcutHint: "⌘,", + keywords: ["settings", "preferences"] + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.checkForUpdates", + title: constant("Check for Updates"), + subtitle: constant("Global"), + keywords: ["update", "upgrade", "release"] + ) + ) + + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.renameWorkspace", + title: constant("Rename Workspace…"), + subtitle: workspaceSubtitle, + keywords: ["rename", "workspace", "title"], + dismissOnRun: false, + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.clearWorkspaceName", + title: constant("Clear Workspace Name"), + subtitle: workspaceSubtitle, + keywords: ["clear", "workspace", "name"], + when: { + $0.bool(CommandPaletteContextKeys.hasWorkspace) + && $0.bool(CommandPaletteContextKeys.workspaceHasCustomName) + } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleWorkspacePin", + title: { context in + context.bool(CommandPaletteContextKeys.workspaceShouldPin) ? "Pin Workspace" : "Unpin Workspace" + }, + subtitle: workspaceSubtitle, + keywords: ["workspace", "pin", "pinned"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.nextWorkspace", + title: constant("Next Workspace"), + subtitle: constant("Workspace Navigation"), + keywords: ["next", "workspace", "navigate"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.previousWorkspace", + title: constant("Previous Workspace"), + subtitle: constant("Workspace Navigation"), + keywords: ["previous", "workspace", "navigate"], + when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } + ) + ) + + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.renameTab", + title: constant("Rename Tab…"), + subtitle: panelSubtitle, + keywords: ["rename", "tab", "title"], + dismissOnRun: false, + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.clearTabName", + title: constant("Clear Tab Name"), + subtitle: panelSubtitle, + keywords: ["clear", "tab", "name"], + when: { + $0.bool(CommandPaletteContextKeys.hasFocusedPanel) + && $0.bool(CommandPaletteContextKeys.panelHasCustomName) + } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleTabPin", + title: { context in + context.bool(CommandPaletteContextKeys.panelShouldPin) ? "Pin Tab" : "Unpin Tab" + }, + subtitle: panelSubtitle, + keywords: ["tab", "pin", "pinned"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.toggleTabUnread", + title: { context in + context.bool(CommandPaletteContextKeys.panelHasUnread) ? "Mark Tab as Read" : "Mark Tab as Unread" + }, + subtitle: panelSubtitle, + keywords: ["tab", "read", "unread", "notification"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.nextTabInPane", + title: constant("Next Tab in Pane"), + subtitle: constant("Tab Navigation"), + keywords: ["next", "tab", "pane"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.previousTabInPane", + title: constant("Previous Tab in Pane"), + subtitle: constant("Tab Navigation"), + keywords: ["previous", "tab", "pane"], + when: { $0.bool(CommandPaletteContextKeys.hasFocusedPanel) } + ) + ) + + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserBack", + title: constant("Back"), + subtitle: browserPanelSubtitle, + shortcutHint: "⌘[", + keywords: ["browser", "back", "history"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserForward", + title: constant("Forward"), + subtitle: browserPanelSubtitle, + shortcutHint: "⌘]", + keywords: ["browser", "forward", "history"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserReload", + title: constant("Reload Page"), + subtitle: browserPanelSubtitle, + shortcutHint: "⌘R", + keywords: ["browser", "reload", "refresh"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserOpenDefault", + title: constant("Open Current Page in Default Browser"), + subtitle: browserPanelSubtitle, + keywords: ["open", "default", "external", "browser"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserFocusAddressBar", + title: constant("Focus Address Bar"), + subtitle: browserPanelSubtitle, + shortcutHint: "⌘L", + keywords: ["browser", "address", "omnibar", "url"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserToggleDevTools", + title: constant("Toggle Developer Tools"), + subtitle: browserPanelSubtitle, + keywords: ["browser", "devtools", "inspector"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserConsole", + title: constant("Show JavaScript Console"), + subtitle: browserPanelSubtitle, + keywords: ["browser", "console", "javascript"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserZoomIn", + title: constant("Zoom In"), + subtitle: browserPanelSubtitle, + keywords: ["browser", "zoom", "in"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserZoomOut", + title: constant("Zoom Out"), + subtitle: browserPanelSubtitle, + keywords: ["browser", "zoom", "out"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserZoomReset", + title: constant("Actual Size"), + subtitle: browserPanelSubtitle, + keywords: ["browser", "zoom", "reset", "actual size"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserClearHistory", + title: constant("Clear Browser History"), + subtitle: constant("Browser"), + keywords: ["browser", "history", "clear"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserSplitRight", + title: constant("Split Browser Right"), + subtitle: constant("Browser Layout"), + keywords: ["browser", "split", "right"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserSplitDown", + title: constant("Split Browser Down"), + subtitle: constant("Browser Layout"), + keywords: ["browser", "split", "down"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.browserDuplicateRight", + title: constant("Duplicate Browser to the Right"), + subtitle: constant("Browser Layout"), + keywords: ["browser", "duplicate", "clone", "split"], + when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) } + ) + ) + + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalOpenDirectory", + title: constant("Open Current Directory in IDE"), + subtitle: terminalPanelSubtitle, + keywords: ["terminal", "directory", "open", "ide", "code", "default app"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalFind", + title: constant("Find…"), + subtitle: terminalPanelSubtitle, + shortcutHint: "⌘F", + keywords: ["terminal", "find", "search"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalFindNext", + title: constant("Find Next"), + subtitle: terminalPanelSubtitle, + shortcutHint: "⌘G", + keywords: ["terminal", "find", "next", "search"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalFindPrevious", + title: constant("Find Previous"), + subtitle: terminalPanelSubtitle, + shortcutHint: "⌘⇧G", + keywords: ["terminal", "find", "previous", "search"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalHideFind", + title: constant("Hide Find Bar"), + subtitle: terminalPanelSubtitle, + shortcutHint: "⌘⇧F", + keywords: ["terminal", "hide", "find", "search"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalUseSelectionForFind", + title: constant("Use Selection for Find"), + subtitle: terminalPanelSubtitle, + keywords: ["terminal", "selection", "find"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalSplitRight", + title: constant("Split Right"), + subtitle: constant("Terminal Layout"), + keywords: ["terminal", "split", "right"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalSplitDown", + title: constant("Split Down"), + subtitle: constant("Terminal Layout"), + keywords: ["terminal", "split", "down"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalSplitBrowserRight", + title: constant("Split Browser Right"), + subtitle: constant("Terminal Layout"), + keywords: ["terminal", "split", "browser", "right"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.terminalSplitBrowserDown", + title: constant("Split Browser Down"), + subtitle: constant("Terminal Layout"), + keywords: ["terminal", "split", "browser", "down"], + when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + ) + ) + + return contributions + } + + private func registerCommandPaletteHandlers(_ registry: inout CommandPaletteHandlerRegistry) { + registry.register(commandId: "palette.newWorkspace") { + tabManager.addWorkspace() + } + registry.register(commandId: "palette.newTerminalTab") { + tabManager.newSurface() + } + registry.register(commandId: "palette.newBrowserTab") { + _ = tabManager.openBrowser() + } + registry.register(commandId: "palette.closeTab") { + tabManager.closeCurrentPanelWithConfirmation() + } + registry.register(commandId: "palette.closeWorkspace") { + tabManager.closeCurrentWorkspaceWithConfirmation() + } + registry.register(commandId: "palette.reopenClosedBrowserTab") { + _ = tabManager.reopenMostRecentlyClosedBrowserPanel() + } + registry.register(commandId: "palette.toggleSidebar") { + sidebarState.toggle() + } + registry.register(commandId: "palette.showNotifications") { + AppDelegate.shared?.toggleNotificationsPopover(animated: false) + } + registry.register(commandId: "palette.jumpUnread") { + AppDelegate.shared?.jumpToLatestUnread() + } + registry.register(commandId: "palette.openSettings") { + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } + registry.register(commandId: "palette.checkForUpdates") { + AppDelegate.shared?.checkForUpdates(nil) + } + + registry.register(commandId: "palette.renameWorkspace") { + beginRenameWorkspaceFlow() + } + registry.register(commandId: "palette.clearWorkspaceName") { + guard let workspace = tabManager.selectedWorkspace else { + NSSound.beep() + return + } + tabManager.clearCustomTitle(tabId: workspace.id) + } + registry.register(commandId: "palette.toggleWorkspacePin") { + guard let workspace = tabManager.selectedWorkspace else { + NSSound.beep() + return + } + tabManager.setPinned(workspace, pinned: !workspace.isPinned) + } + registry.register(commandId: "palette.nextWorkspace") { + tabManager.selectNextTab() + } + registry.register(commandId: "palette.previousWorkspace") { + tabManager.selectPreviousTab() + } + + registry.register(commandId: "palette.renameTab") { + beginRenameTabFlow() + } + registry.register(commandId: "palette.clearTabName") { + guard let panelContext = focusedPanelContext else { + NSSound.beep() + return + } + panelContext.workspace.setPanelCustomTitle(panelId: panelContext.panelId, title: nil) + } + registry.register(commandId: "palette.toggleTabPin") { + guard let panelContext = focusedPanelContext else { + NSSound.beep() + return + } + panelContext.workspace.setPanelPinned( + panelId: panelContext.panelId, + pinned: !panelContext.workspace.isPanelPinned(panelContext.panelId) + ) + } + registry.register(commandId: "palette.toggleTabUnread") { + guard let panelContext = focusedPanelContext else { + NSSound.beep() + return + } + let hasUnread = panelContext.workspace.manualUnreadPanelIds.contains(panelContext.panelId) + || notificationStore.hasUnreadNotification(forTabId: panelContext.workspace.id, surfaceId: panelContext.panelId) + if hasUnread { + panelContext.workspace.markPanelRead(panelContext.panelId) + } else { + panelContext.workspace.markPanelUnread(panelContext.panelId) + } + } + registry.register(commandId: "palette.nextTabInPane") { + tabManager.selectNextSurface() + } + registry.register(commandId: "palette.previousTabInPane") { + tabManager.selectPreviousSurface() + } + + registry.register(commandId: "palette.browserBack") { + tabManager.focusedBrowserPanel?.goBack() + } + registry.register(commandId: "palette.browserForward") { + tabManager.focusedBrowserPanel?.goForward() + } + registry.register(commandId: "palette.browserReload") { + tabManager.focusedBrowserPanel?.reload() + } + registry.register(commandId: "palette.browserOpenDefault") { + if !openFocusedBrowserInDefaultBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserFocusAddressBar") { + if !focusFocusedBrowserAddressBar() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserToggleDevTools") { + if !tabManager.toggleDeveloperToolsFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserConsole") { + if !tabManager.showJavaScriptConsoleFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserZoomIn") { + if !tabManager.zoomInFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserZoomOut") { + if !tabManager.zoomOutFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserZoomReset") { + if !tabManager.resetZoomFocusedBrowser() { + NSSound.beep() + } + } + registry.register(commandId: "palette.browserClearHistory") { + BrowserHistoryStore.shared.clearHistory() + } + registry.register(commandId: "palette.browserSplitRight") { + _ = tabManager.createBrowserSplit(direction: .right) + } + registry.register(commandId: "palette.browserSplitDown") { + _ = tabManager.createBrowserSplit(direction: .down) + } + registry.register(commandId: "palette.browserDuplicateRight") { + let url = tabManager.focusedBrowserPanel?.preferredURLStringForOmnibar().flatMap(URL.init(string:)) + _ = tabManager.createBrowserSplit(direction: .right, url: url) + } + + registry.register(commandId: "palette.terminalOpenDirectory") { + if !openFocusedDirectoryInDefaultApp() { + NSSound.beep() + } + } + registry.register(commandId: "palette.terminalFind") { + tabManager.startSearch() + } + registry.register(commandId: "palette.terminalFindNext") { + tabManager.findNext() + } + registry.register(commandId: "palette.terminalFindPrevious") { + tabManager.findPrevious() + } + registry.register(commandId: "palette.terminalHideFind") { + tabManager.hideFind() + } + registry.register(commandId: "palette.terminalUseSelectionForFind") { + tabManager.searchSelection() + } + registry.register(commandId: "palette.terminalSplitRight") { + tabManager.createSplit(direction: .right) + } + registry.register(commandId: "palette.terminalSplitDown") { + tabManager.createSplit(direction: .down) + } + registry.register(commandId: "palette.terminalSplitBrowserRight") { + _ = tabManager.createBrowserSplit(direction: .right) + } + registry.register(commandId: "palette.terminalSplitBrowserDown") { + _ = tabManager.createBrowserSplit(direction: .down) + } + } + + private var focusedPanelContext: (workspace: Workspace, panelId: UUID, panel: any Panel)? { + guard let workspace = tabManager.selectedWorkspace, + let panelId = workspace.focusedPanelId, + let panel = workspace.panels[panelId] else { + return nil + } + return (workspace, panelId, panel) + } + + private func workspaceDisplayName(_ workspace: Workspace) -> String { + let custom = workspace.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !custom.isEmpty { + return custom + } + let title = workspace.title.trimmingCharacters(in: .whitespacesAndNewlines) + return title.isEmpty ? "Workspace" : title + } + + private func panelDisplayName(workspace: Workspace, panelId: UUID, fallback: String) -> String { + let title = workspace.panelTitle(panelId: panelId)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !title.isEmpty { + return title + } + let trimmedFallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedFallback.isEmpty ? "Tab" : trimmedFallback + } + + private func commandPaletteSelectedIndex(resultCount: Int) -> Int { + guard resultCount > 0 else { return 0 } + return min(max(commandPaletteSelectedResultIndex, 0), resultCount - 1) + } + + static func commandPaletteScrollAnchor( + selectedIndex: Int, + previousIndex: Int, + resultCount: Int, + selectedFrame: CGRect?, + viewportHeight: CGFloat, + contentHeight: CGFloat, + epsilon: CGFloat = 0.5 + ) -> CommandPaletteScrollAnchor? { + guard resultCount > 0 else { return nil } + guard contentHeight > viewportHeight else { return nil } + + // Always pin edges exactly into view when selection reaches first/last. + if selectedIndex <= 0 { + return .top + } + if selectedIndex >= resultCount - 1 { + return .bottom + } + + if let frame = selectedFrame, + frame.minY >= (0 - epsilon), + frame.maxY <= (viewportHeight + epsilon) { + return nil + } + + return selectedIndex >= previousIndex ? .bottom : .top + } + + static func commandPaletteEdgeVisibilityCorrectionAnchor( + selectedIndex: Int, + resultCount: Int, + selectedFrame: CGRect?, + viewportHeight: CGFloat, + contentHeight: CGFloat, + epsilon: CGFloat = 0.5 + ) -> CommandPaletteScrollAnchor? { + guard resultCount > 0 else { return nil } + guard contentHeight > viewportHeight else { return nil } + + let isTop = selectedIndex <= 0 + let isBottom = selectedIndex >= (resultCount - 1) + guard isTop || isBottom else { return nil } + + guard let frame = selectedFrame else { + return isTop ? .top : .bottom + } + + if isTop { + let topDelta = abs(frame.minY) + return topDelta > epsilon ? .top : nil + } + + let bottomDelta = abs(frame.maxY - viewportHeight) + return bottomDelta > epsilon ? .bottom : nil + } + + private func moveCommandPaletteSelection(by delta: Int) { + let count = commandPaletteResults.count + guard count > 0 else { + NSSound.beep() + return + } + let current = commandPaletteSelectedIndex(resultCount: count) + commandPaletteSelectedResultIndex = min(max(current + delta, 0), count - 1) + syncCommandPaletteDebugStateForObservedWindow() + } + + private func handleCommandPaletteControlNavigationKey( + modifiers: EventModifiers, + delta: Int + ) -> BackportKeyPressResult { + guard modifiers.contains(.control), + !modifiers.contains(.command), + !modifiers.contains(.shift), + !modifiers.contains(.option) else { + return .ignored + } + moveCommandPaletteSelection(by: delta) + return .handled + } + + static func commandPaletteShouldPopRenameInputOnDelete( + renameDraft: String, + modifiers: EventModifiers + ) -> Bool { + let blockedModifiers: EventModifiers = [.command, .control, .option, .shift] + guard modifiers.intersection(blockedModifiers).isEmpty else { return false } + return renameDraft.isEmpty + } + + private func handleCommandPaletteRenameDeleteBackward( + modifiers: EventModifiers + ) -> BackportKeyPressResult { + guard case .renameInput = commandPaletteMode else { return .ignored } + let blockedModifiers: EventModifiers = [.command, .control, .option, .shift] + guard modifiers.intersection(blockedModifiers).isEmpty else { return .ignored } + + if Self.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: commandPaletteRenameDraft, + modifiers: modifiers + ) { + commandPaletteMode = .commands + resetCommandPaletteSearchFocus() + syncCommandPaletteDebugStateForObservedWindow() + return .handled + } + + if let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow, + let editor = window.firstResponder as? NSTextView, + editor.isFieldEditor { + editor.deleteBackward(nil) + commandPaletteRenameDraft = editor.string + } else if !commandPaletteRenameDraft.isEmpty { + commandPaletteRenameDraft.removeLast() + } + + syncCommandPaletteDebugStateForObservedWindow() + return .handled + } + + private func runSelectedCommandPaletteResult(visibleResults: [CommandPaletteSearchResult]? = nil) { + let visibleResults = visibleResults ?? Array(commandPaletteResults) + guard !visibleResults.isEmpty else { + NSSound.beep() + return + } + let index = commandPaletteSelectedIndex(resultCount: visibleResults.count) + runCommandPaletteCommand(visibleResults[index].command) + } + + private func runCommandPaletteCommand(_ command: CommandPaletteCommand) { + recordCommandPaletteUsage(command.id) + command.action() + if command.dismissOnRun { + dismissCommandPalette(restoreFocus: false) + } + } + + private func toggleCommandPalette() { + if isCommandPalettePresented { + dismissCommandPalette() + } else { + presentCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix) + } + } + + private func openCommandPaletteCommands() { + toggleCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix) + } + + private func openCommandPaletteSwitcher() { + toggleCommandPalette(initialQuery: "") + } + + private func toggleCommandPalette(initialQuery: String) { + if isCommandPalettePresented { + dismissCommandPalette() + } else { + presentCommandPalette(initialQuery: initialQuery) + } + } + + private func openCommandPaletteRenameTabInput() { + if !isCommandPalettePresented { + presentCommandPalette(initialQuery: Self.commandPaletteCommandsPrefix) + } + beginRenameTabFlow() + } + + static func shouldHandleCommandPaletteRequest( + observedWindow: NSWindow?, + requestedWindow: NSWindow?, + keyWindow: NSWindow?, + mainWindow: NSWindow? + ) -> Bool { + guard let observedWindow else { return false } + if let requestedWindow { + return requestedWindow === observedWindow + } + if let keyWindow { + return keyWindow === observedWindow + } + if let mainWindow { + return mainWindow === observedWindow + } + return false + } + + private func syncCommandPaletteDebugStateForObservedWindow() { + guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return } + AppDelegate.shared?.setCommandPaletteVisible(isCommandPalettePresented, for: window) + let visibleResultCount = commandPaletteResults.count + let selectedIndex = isCommandPalettePresented ? commandPaletteSelectedIndex(resultCount: visibleResultCount) : 0 + AppDelegate.shared?.setCommandPaletteSelectionIndex(selectedIndex, for: window) + AppDelegate.shared?.setCommandPaletteSnapshot(commandPaletteDebugSnapshot(), for: window) + } + + private func commandPaletteDebugSnapshot() -> CommandPaletteDebugSnapshot { + guard isCommandPalettePresented else { return .empty } + + let mode: String + switch commandPaletteMode { + case .commands: + mode = commandPaletteListScope.rawValue + case .renameInput: + mode = "rename_input" + case .renameConfirm: + mode = "rename_confirm" + } + + let rows = Array(commandPaletteResults.prefix(20)).map { result in + CommandPaletteDebugResultRow( + commandId: result.command.id, + title: result.command.title, + shortcutHint: result.command.shortcutHint, + trailingLabel: commandPaletteTrailingLabel(for: result.command)?.text, + score: result.score + ) + } + + return CommandPaletteDebugSnapshot( + query: commandPaletteQueryForMatching, + mode: mode, + results: rows + ) + } + + private func presentCommandPalette(initialQuery: String) { + if let panelContext = focusedPanelContext { + commandPaletteRestoreFocusTarget = CommandPaletteRestoreFocusTarget( + workspaceId: panelContext.workspace.id, + panelId: panelContext.panelId + ) + } else { + commandPaletteRestoreFocusTarget = nil + } + isCommandPalettePresented = true + refreshCommandPaletteUsageHistory() + resetCommandPaletteListState(initialQuery: initialQuery) + } + + private func resetCommandPaletteListState(initialQuery: String) { + commandPaletteMode = .commands + commandPaletteQuery = initialQuery + commandPaletteRenameDraft = "" + commandPaletteSelectedResultIndex = 0 + commandPaletteHoveredResultIndex = nil + commandPaletteLastSelectionIndex = 0 + commandPaletteRowFrames = [:] + resetCommandPaletteSearchFocus() + syncCommandPaletteDebugStateForObservedWindow() + } + + private func dismissCommandPalette(restoreFocus: Bool = true) { + let focusTarget = commandPaletteRestoreFocusTarget + isCommandPalettePresented = false + commandPaletteMode = .commands + commandPaletteQuery = "" + commandPaletteRenameDraft = "" + commandPaletteSelectedResultIndex = 0 + commandPaletteHoveredResultIndex = nil + commandPaletteLastSelectionIndex = 0 + commandPaletteRowFrames = [:] + isCommandPaletteSearchFocused = false + isCommandPaletteRenameFocused = false + commandPaletteRestoreFocusTarget = nil + if let window = observedWindow { + _ = window.makeFirstResponder(nil) + } + syncCommandPaletteDebugStateForObservedWindow() + + guard restoreFocus, let focusTarget else { return } + restoreCommandPaletteFocus(target: focusTarget, attemptsRemaining: 6) + } + + private func restoreCommandPaletteFocus( + target: CommandPaletteRestoreFocusTarget, + attemptsRemaining: Int + ) { + guard !isCommandPalettePresented else { return } + guard tabManager.tabs.contains(where: { $0.id == target.workspaceId }) else { return } + + if let window = observedWindow, !window.isKeyWindow { + window.makeKeyAndOrderFront(nil) + } + tabManager.focusTab(target.workspaceId, surfaceId: target.panelId, suppressFlash: true) + + guard attemptsRemaining > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { + guard !isCommandPalettePresented else { return } + if let context = focusedPanelContext, + context.workspace.id == target.workspaceId, + context.panelId == target.panelId { + return + } + restoreCommandPaletteFocus(target: target, attemptsRemaining: attemptsRemaining - 1) + } + } + + private func resetCommandPaletteSearchFocus() { + applyCommandPaletteInputFocusPolicy(.search) + } + + private func resetCommandPaletteRenameFocus() { + applyCommandPaletteInputFocusPolicy(commandPaletteRenameInputFocusPolicy()) + } + + private func handleCommandPaletteRenameInputInteraction() { + guard isCommandPalettePresented else { return } + guard case .renameInput = commandPaletteMode else { return } + applyCommandPaletteInputFocusPolicy(commandPaletteRenameInputFocusPolicy()) + } + + private func commandPaletteRenameInputFocusPolicy() -> CommandPaletteInputFocusPolicy { + let selectAllOnFocus = CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled() + let selectionBehavior: CommandPaletteTextSelectionBehavior = selectAllOnFocus + ? .selectAll + : .caretAtEnd + return CommandPaletteInputFocusPolicy( + focusTarget: .rename, + selectionBehavior: selectionBehavior + ) + } + + private func applyCommandPaletteInputFocusPolicy(_ policy: CommandPaletteInputFocusPolicy) { + DispatchQueue.main.async { + switch policy.focusTarget { + case .search: + isCommandPaletteRenameFocused = false + isCommandPaletteSearchFocused = true + case .rename: + isCommandPaletteSearchFocused = false + isCommandPaletteRenameFocused = true + } + applyCommandPaletteTextSelection(policy.selectionBehavior) + } + } + + private func applyCommandPaletteTextSelection( + _ behavior: CommandPaletteTextSelectionBehavior, + attemptsRemaining: Int = 20 + ) { + guard isCommandPalettePresented else { return } + switch behavior { + case .selectAll: + guard case .renameInput = commandPaletteMode else { return } + case .caretAtEnd: + switch commandPaletteMode { + case .commands, .renameInput: + break + case .renameConfirm: + return + } + } + guard let window = observedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow else { return } + + if let editor = window.firstResponder as? NSTextView, editor.isFieldEditor { + let length = (editor.string as NSString).length + switch behavior { + case .selectAll: + editor.setSelectedRange(NSRange(location: 0, length: length)) + case .caretAtEnd: + editor.setSelectedRange(NSRange(location: length, length: 0)) + } + return + } + + guard attemptsRemaining > 0 else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { + applyCommandPaletteTextSelection(behavior, attemptsRemaining: attemptsRemaining - 1) + } + } + + private func refreshCommandPaletteUsageHistory() { + commandPaletteUsageHistoryByCommandId = loadCommandPaletteUsageHistory() + } + + private func loadCommandPaletteUsageHistory() -> [String: CommandPaletteUsageEntry] { + guard let data = UserDefaults.standard.data(forKey: Self.commandPaletteUsageDefaultsKey) else { + return [:] + } + return (try? JSONDecoder().decode([String: CommandPaletteUsageEntry].self, from: data)) ?? [:] + } + + private func persistCommandPaletteUsageHistory(_ history: [String: CommandPaletteUsageEntry]) { + guard let data = try? JSONEncoder().encode(history) else { return } + UserDefaults.standard.set(data, forKey: Self.commandPaletteUsageDefaultsKey) + } + + private func recordCommandPaletteUsage(_ commandId: String) { + var history = commandPaletteUsageHistoryByCommandId + var entry = history[commandId] ?? CommandPaletteUsageEntry(useCount: 0, lastUsedAt: 0) + entry.useCount += 1 + entry.lastUsedAt = Date().timeIntervalSince1970 + history[commandId] = entry + commandPaletteUsageHistoryByCommandId = history + persistCommandPaletteUsageHistory(history) + } + + private func commandPaletteHistoryBoost(for commandId: String, queryIsEmpty: Bool) -> Int { + guard let entry = commandPaletteUsageHistoryByCommandId[commandId] else { return 0 } + + let now = Date().timeIntervalSince1970 + let ageDays = max(0, now - entry.lastUsedAt) / 86_400 + let recencyBoost = max(0, 320 - Int(ageDays * 20)) + let countBoost = min(180, entry.useCount * 12) + let totalBoost = recencyBoost + countBoost + + return queryIsEmpty ? totalBoost : max(0, totalBoost / 3) + } + + private func beginRenameWorkspaceFlow() { + guard let workspace = tabManager.selectedWorkspace else { + NSSound.beep() + return + } + let target = CommandPaletteRenameTarget( + kind: .workspace(workspaceId: workspace.id), + currentName: workspaceDisplayName(workspace) + ) + startRenameFlow(target) + } + + private func beginRenameTabFlow() { + guard let panelContext = focusedPanelContext else { + NSSound.beep() + return + } + let panelName = panelDisplayName( + workspace: panelContext.workspace, + panelId: panelContext.panelId, + fallback: panelContext.panel.displayTitle + ) + let target = CommandPaletteRenameTarget( + kind: .tab(workspaceId: panelContext.workspace.id, panelId: panelContext.panelId), + currentName: panelName + ) + startRenameFlow(target) + } + + private func startRenameFlow(_ target: CommandPaletteRenameTarget) { + commandPaletteRenameDraft = target.currentName + commandPaletteMode = .renameInput(target) + resetCommandPaletteRenameFocus() + syncCommandPaletteDebugStateForObservedWindow() + } + + private func continueRenameFlow(target: CommandPaletteRenameTarget) { + guard case .renameInput(let activeTarget) = commandPaletteMode, + activeTarget == target else { return } + applyRenameFlow(target: target, proposedName: commandPaletteRenameDraft) + } + + private func applyRenameFlow(target: CommandPaletteRenameTarget, proposedName: String) { + let trimmedName = proposedName.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedName: String? = trimmedName.isEmpty ? nil : trimmedName + + switch target.kind { + case .workspace(let workspaceId): + tabManager.setCustomTitle(tabId: workspaceId, title: normalizedName) + case .tab(let workspaceId, let panelId): + guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { + NSSound.beep() + return + } + workspace.setPanelCustomTitle(panelId: panelId, title: normalizedName) + } + + dismissCommandPalette() + } + + private func focusFocusedBrowserAddressBar() -> Bool { + guard let panel = tabManager.focusedBrowserPanel else { return false } + _ = panel.requestAddressBarFocus() + NotificationCenter.default.post(name: .browserFocusAddressBar, object: panel.id) + return true + } + + private func openFocusedBrowserInDefaultBrowser() -> Bool { + guard let panel = tabManager.focusedBrowserPanel, + let rawURL = panel.preferredURLStringForOmnibar(), + let url = URL(string: rawURL), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + return false + } + return NSWorkspace.shared.open(url) + } + + private func openFocusedDirectoryInDefaultApp() -> Bool { + guard let directoryURL = focusedTerminalDirectoryURL() else { return false } + return NSWorkspace.shared.open(directoryURL) + } + + private func focusedTerminalDirectoryURL() -> URL? { + guard let workspace = tabManager.selectedWorkspace else { return nil } + let rawDirectory: String = { + if let focusedPanelId = workspace.focusedPanelId, + let directory = workspace.panelDirectories[focusedPanelId] { + return directory + } + return workspace.currentDirectory + }() + let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard FileManager.default.fileExists(atPath: trimmed) else { return nil } + return URL(fileURLWithPath: trimmed, isDirectory: true) + } + #if DEBUG private func debugShortWorkspaceId(_ id: UUID?) -> String { guard let id else { return "nil" } @@ -1765,6 +4289,572 @@ struct ContentView: View { #endif } +struct CommandPaletteSwitcherSearchMetadata { + let directories: [String] + let branches: [String] + let ports: [Int] + + init( + directories: [String] = [], + branches: [String] = [], + ports: [Int] = [] + ) { + self.directories = directories + self.branches = branches + self.ports = ports + } +} + +enum CommandPaletteSwitcherSearchIndexer { + enum MetadataDetail { + case workspace + case surface + } + + private static let metadataDelimiters = CharacterSet(charactersIn: "/\\.:_- ") + + static func keywords( + baseKeywords: [String], + metadata: CommandPaletteSwitcherSearchMetadata, + detail: MetadataDetail = .surface + ) -> [String] { + let metadataKeywords = metadataKeywordsForSearch(metadata, detail: detail) + return uniqueNormalizedPreservingOrder(baseKeywords + metadataKeywords) + } + + private static func metadataKeywordsForSearch( + _ metadata: CommandPaletteSwitcherSearchMetadata, + detail: MetadataDetail + ) -> [String] { + let directoryTokens = metadata.directories.flatMap { directoryTokensForSearch($0, detail: detail) } + let branchTokens = metadata.branches.flatMap { branchTokensForSearch($0, detail: detail) } + let portTokens = metadata.ports.flatMap(portTokensForSearch) + + var contextKeywords: [String] = [] + if !directoryTokens.isEmpty { + contextKeywords.append(contentsOf: ["directory", "dir", "cwd", "path"]) + } + if !branchTokens.isEmpty { + contextKeywords.append(contentsOf: ["branch", "git"]) + } + if !portTokens.isEmpty { + contextKeywords.append(contentsOf: ["port", "ports"]) + } + + return contextKeywords + directoryTokens + branchTokens + portTokens + } + + private static func directoryTokensForSearch( + _ rawDirectory: String, + detail: MetadataDetail + ) -> [String] { + let trimmed = rawDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + let standardized = (trimmed as NSString).standardizingPath + let canonical = standardized.isEmpty ? trimmed : standardized + let abbreviated = (canonical as NSString).abbreviatingWithTildeInPath + switch detail { + case .workspace: + return uniqueNormalizedPreservingOrder([trimmed, canonical, abbreviated]) + case .surface: + let basename = URL(fileURLWithPath: canonical, isDirectory: true).lastPathComponent + let components = canonical.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder( + [trimmed, canonical, abbreviated, basename] + components + ) + } + } + + private static func branchTokensForSearch( + _ rawBranch: String, + detail: MetadataDetail + ) -> [String] { + let trimmed = rawBranch.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + switch detail { + case .workspace: + return [trimmed] + case .surface: + let components = trimmed.components(separatedBy: metadataDelimiters).filter { !$0.isEmpty } + return uniqueNormalizedPreservingOrder([trimmed] + components) + } + } + + private static func portTokensForSearch(_ port: Int) -> [String] { + guard (1...65535).contains(port) else { return [] } + let portText = String(port) + return [portText, ":\(portText)"] + } + + private static func uniqueNormalizedPreservingOrder(_ values: [String]) -> [String] { + var result: [String] = [] + var seen: Set = [] + result.reserveCapacity(values.count) + + for value in values { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let normalizedKey = trimmed + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + guard seen.insert(normalizedKey).inserted else { continue } + result.append(trimmed) + } + return result + } +} + +enum CommandPaletteFuzzyMatcher { + private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] + + static func score(query: String, candidate: String) -> Int? { + score(query: query, candidates: [candidate]) + } + + static func score(query: String, candidates: [String]) -> Int? { + let normalizedQuery = normalize(query) + guard !normalizedQuery.isEmpty else { return 0 } + let tokens = normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } + guard !tokens.isEmpty else { return 0 } + + let normalizedCandidates = candidates + .map(normalize) + .filter { !$0.isEmpty } + guard !normalizedCandidates.isEmpty else { return nil } + + var totalScore = 0 + for token in tokens { + var bestTokenScore: Int? + for candidate in normalizedCandidates { + guard let candidateScore = scoreToken(token, in: candidate) else { continue } + bestTokenScore = max(bestTokenScore ?? candidateScore, candidateScore) + } + guard let bestTokenScore else { return nil } + totalScore += bestTokenScore + } + return totalScore + } + + static func matchCharacterIndices(query: String, candidate: String) -> Set { + let normalizedQuery = normalize(query) + guard !normalizedQuery.isEmpty else { return [] } + + let tokens = normalizedQuery.split(separator: " ").map(String.init).filter { !$0.isEmpty } + guard !tokens.isEmpty else { return [] } + + let loweredCandidate = normalize(candidate) + guard !loweredCandidate.isEmpty else { return [] } + + let candidateChars = Array(loweredCandidate) + var matched: Set = [] + + for token in tokens { + if token == loweredCandidate { + matched.formUnion(0.. String { + text + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + + private static func scoreToken(_ token: String, in candidate: String) -> Int? { + guard !token.isEmpty else { return 0 } + + let candidateChars = Array(candidate) + let tokenChars = Array(token) + guard tokenChars.count <= candidateChars.count else { return nil } + + if token == candidate { + return 8000 + } + if candidate.hasPrefix(token) { + return 6800 - max(0, candidate.count - token.count) + } + + var bestScore: Int? + if let wordExactScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: true) { + bestScore = max(bestScore ?? wordExactScore, wordExactScore) + } + if let wordPrefixScore = bestWordScore(tokenChars: tokenChars, candidateChars: candidateChars, requireExactWord: false) { + bestScore = max(bestScore ?? wordPrefixScore, wordPrefixScore) + } + + if let range = candidate.range(of: token) { + let distance = candidate.distance(from: candidate.startIndex, to: range.lowerBound) + let lengthPenalty = max(0, candidate.count - token.count) + let boundaryBoost: Int = { + guard distance > 0 else { return 220 } + let prior = candidateChars[distance - 1] + return tokenBoundaryChars.contains(prior) ? 180 : 0 + }() + let containsScore = 4200 + boundaryBoost - (distance * 9) - lengthPenalty + bestScore = max(bestScore ?? containsScore, containsScore) + } + + if let initialismScore = initialismScore(tokenChars: tokenChars, candidateChars: candidateChars) { + bestScore = max(bestScore ?? initialismScore, initialismScore) + } + + if let stitchedScore = stitchedWordPrefixScore(tokenChars: tokenChars, candidateChars: candidateChars) { + bestScore = max(bestScore ?? stitchedScore, stitchedScore) + } + + if tokenChars.count <= 3, let subsequence = subsequenceScore(token: token, candidate: candidate) { + bestScore = max(bestScore ?? subsequence, subsequence) + } + + guard let bestScore else { return nil } + return max(1, bestScore) + } + + private static func bestWordScore( + tokenChars: [Character], + candidateChars: [Character], + requireExactWord: Bool + ) -> Int? { + guard !tokenChars.isEmpty else { return nil } + + var best: Int? + for segment in wordSegments(candidateChars) { + let wordLength = segment.end - segment.start + guard tokenChars.count <= wordLength else { continue } + + var matchesPrefix = true + for offset in 0.. Int? { + guard !tokenChars.isEmpty else { return nil } + let segments = wordSegments(candidateChars) + guard tokenChars.count <= segments.count else { return nil } + + var matchedStarts: [Int] = [] + var searchWordIndex = 0 + + for tokenChar in tokenChars { + var found = false + while searchWordIndex < segments.count { + let segment = segments[searchWordIndex] + searchWordIndex += 1 + if candidateChars[segment.start] == tokenChar { + matchedStarts.append(segment.start) + found = true + break + } + } + if !found { return nil } + } + + let firstStart = matchedStarts.first ?? 0 + let skippedWords = max(0, segments.count - tokenChars.count) + return 3000 + (tokenChars.count * 160) - (firstStart * 5) - (skippedWords * 30) + } + + private static func tokenPrefixMatches( + tokenChars: [Character], + tokenStart: Int, + length: Int, + candidateChars: [Character], + candidateStart: Int + ) -> Bool { + guard length > 0 else { return false } + guard tokenStart + length <= tokenChars.count else { return false } + guard candidateStart + length <= candidateChars.count else { return false } + + for offset in 0.. Int? { + guard tokenChars.count >= 4 else { return nil } + let segments = wordSegments(candidateChars) + guard segments.count >= 2 else { return nil } + + struct StitchState: Hashable { + let tokenIndex: Int + let wordIndex: Int + let usedWords: Int + } + + var memo: [StitchState: Int?] = [:] + + func dfs(tokenIndex: Int, wordIndex: Int, usedWords: Int) -> Int? { + if tokenIndex == tokenChars.count { + return usedWords >= 2 ? 0 : nil + } + guard wordIndex < segments.count else { return nil } + + let state = StitchState(tokenIndex: tokenIndex, wordIndex: wordIndex, usedWords: usedWords) + if let cached = memo[state] { + return cached + } + + var best: Int? + let remainingChars = tokenChars.count - tokenIndex + for segmentIndex in wordIndex.. 0 else { continue } + + let skippedWords = max(0, segmentIndex - wordIndex) + let skipPenalty = skippedWords * 120 + for chunkLength in stride(from: maxChunk, through: 1, by: -1) { + guard tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: tokenIndex, + length: chunkLength, + candidateChars: candidateChars, + candidateStart: segment.start + ) else { + continue + } + guard let suffixScore = dfs( + tokenIndex: tokenIndex + chunkLength, + wordIndex: segmentIndex + 1, + usedWords: min(2, usedWords + 1) + ) else { + continue + } + + let chunkCoverage = chunkLength * 220 + let contiguityBonus = segmentIndex == wordIndex ? 80 : 0 + let segmentRemainderPenalty = max(0, segmentLength - chunkLength) * 9 + let distancePenalty = segment.start * 4 + let chunkScore = chunkCoverage + contiguityBonus - segmentRemainderPenalty - distancePenalty - skipPenalty + let totalScore = suffixScore + chunkScore + best = max(best ?? totalScore, totalScore) + } + } + + memo[state] = best + return best + } + + guard let stitchedScore = dfs(tokenIndex: 0, wordIndex: 0, usedWords: 0) else { return nil } + let lengthPenalty = max(0, candidateChars.count - tokenChars.count) + return 3500 + stitchedScore - lengthPenalty + } + + private static func stitchedWordPrefixMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count >= 4 else { return nil } + + let segments = wordSegments(candidateChars) + guard segments.count >= 2 else { return nil } + + var tokenIndex = 0 + var nextWordIndex = 0 + var usedWords = 0 + var matchedIndices: Set = [] + + while tokenIndex < tokenChars.count { + let remainingChars = tokenChars.count - tokenIndex + var foundMatch = false + + for segmentIndex in nextWordIndex.. 0 else { continue } + + for chunkLength in stride(from: maxChunk, through: 1, by: -1) { + guard tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: tokenIndex, + length: chunkLength, + candidateChars: candidateChars, + candidateStart: segment.start + ) else { + continue + } + + matchedIndices.formUnion(segment.start..<(segment.start + chunkLength)) + tokenIndex += chunkLength + nextWordIndex = segmentIndex + 1 + usedWords += 1 + foundMatch = true + break + } + + if foundMatch { break } + } + + if !foundMatch { return nil } + } + + guard usedWords >= 2 else { return nil } + return matchedIndices + } + + private static func wordSegments(_ candidateChars: [Character]) -> [(start: Int, end: Int)] { + var segments: [(start: Int, end: Int)] = [] + var index = 0 + + while index < candidateChars.count { + while index < candidateChars.count, tokenBoundaryChars.contains(candidateChars[index]) { + index += 1 + } + guard index < candidateChars.count else { break } + let start = index + while index < candidateChars.count, !tokenBoundaryChars.contains(candidateChars[index]) { + index += 1 + } + segments.append((start: start, end: index)) + } + + return segments + } + + private static func subsequenceScore(token: String, candidate: String) -> Int? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count <= candidateChars.count else { return nil } + + var searchIndex = 0 + var previousMatch = -1 + var consecutiveRun = 0 + var score = 0 + + for tokenChar in tokenChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == tokenChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchedIndex = foundIndex else { return nil } + + score += 90 + if matchedIndex == 0 || tokenBoundaryChars.contains(candidateChars[matchedIndex - 1]) { + score += 140 + } + if matchedIndex == previousMatch + 1 { + consecutiveRun += 1 + score += min(200, consecutiveRun * 45) + } else { + consecutiveRun = 0 + score -= min(120, max(0, matchedIndex - previousMatch - 1) * 4) + } + + previousMatch = matchedIndex + searchIndex = matchedIndex + 1 + } + + score -= max(0, candidateChars.count - tokenChars.count) + return max(1, score) + } + + private static func subsequenceMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard tokenChars.count <= candidateChars.count else { return nil } + + var indices: Set = [] + var searchIndex = 0 + + for tokenChar in tokenChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == tokenChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchIndex = foundIndex else { return nil } + indices.insert(matchIndex) + searchIndex = matchIndex + 1 + } + + return indices + } + + private static func initialismMatchIndices(token: String, candidate: String) -> Set? { + let tokenChars = Array(token) + let candidateChars = Array(candidate) + guard !tokenChars.isEmpty else { return nil } + + let segments = wordSegments(candidateChars) + guard tokenChars.count <= segments.count else { return nil } + + var matched: Set = [] + var searchWordIndex = 0 + + for tokenChar in tokenChars { + var found = false + while searchWordIndex < segments.count { + let segment = segments[searchWordIndex] + searchWordIndex += 1 + if candidateChars[segment.start] == tokenChar { + matched.insert(segment.start) + found = true + break + } + } + if !found { return nil } + } + + return matched + } +} + private struct SidebarResizerAccessibilityModifier: ViewModifier { let accessibilityIdentifier: String? diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index ba6c3261..4dbd0a23 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1450,7 +1450,11 @@ class TabManager: ObservableObject { } func focusTab(_ tabId: UUID, surfaceId: UUID? = nil, suppressFlash: Bool = false) { - guard tabs.contains(where: { $0.id == tabId }) else { return } + guard let tab = tabs.first(where: { $0.id == tabId }) else { return } + if let surfaceId, tab.panels[surfaceId] != nil { + // Keep selected-surface intent stable across selectedTabId didSet async restore. + lastFocusedPanelByTab[tabId] = surfaceId + } selectedTabId = tabId NotificationCenter.default.post( name: .ghosttyDidFocusTab, @@ -1469,7 +1473,7 @@ class TabManager: ObservableObject { if let surfaceId { if !suppressFlash { focusSurface(tabId: tabId, surfaceId: surfaceId) - } else if let tab = tabs.first(where: { $0.id == tabId }) { + } else { tab.focusPanel(surfaceId) } } @@ -3055,6 +3059,13 @@ enum ResizeDirection { } extension Notification.Name { + static let commandPaletteToggleRequested = Notification.Name("cmux.commandPaletteToggleRequested") + static let commandPaletteRequested = Notification.Name("cmux.commandPaletteRequested") + static let commandPaletteSwitcherRequested = Notification.Name("cmux.commandPaletteSwitcherRequested") + static let commandPaletteRenameTabRequested = Notification.Name("cmux.commandPaletteRenameTabRequested") + static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection") + static let commandPaletteRenameInputInteractionRequested = Notification.Name("cmux.commandPaletteRenameInputInteractionRequested") + static let commandPaletteRenameInputDeleteBackwardRequested = Notification.Name("cmux.commandPaletteRenameInputDeleteBackwardRequested") static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle") static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab") static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface") diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 3f61f26b..3622c596 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -45,6 +45,7 @@ class TerminalController { "browser.focus_webview", "browser.focus", "browser.tab.switch", + "debug.command_palette.toggle", "debug.notification.focus", "debug.app.activate" ] @@ -1279,6 +1280,26 @@ class TerminalController { return v2Result(id: id, self.v2DebugType(params: params)) case "debug.app.activate": return v2Result(id: id, self.v2DebugActivateApp()) + case "debug.command_palette.toggle": + return v2Result(id: id, self.v2DebugToggleCommandPalette(params: params)) + case "debug.command_palette.rename_tab.open": + return v2Result(id: id, self.v2DebugOpenCommandPaletteRenameTabInput(params: params)) + case "debug.command_palette.visible": + return v2Result(id: id, self.v2DebugCommandPaletteVisible(params: params)) + case "debug.command_palette.selection": + return v2Result(id: id, self.v2DebugCommandPaletteSelection(params: params)) + case "debug.command_palette.results": + return v2Result(id: id, self.v2DebugCommandPaletteResults(params: params)) + case "debug.command_palette.rename_input.interact": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputInteraction(params: params)) + case "debug.command_palette.rename_input.delete_backward": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputDeleteBackward(params: params)) + case "debug.command_palette.rename_input.selection": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelection(params: params)) + case "debug.command_palette.rename_input.select_all": + return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelectAll(params: params)) + case "debug.sidebar.visible": + return v2Result(id: id, self.v2DebugSidebarVisible(params: params)) case "debug.terminal.is_focused": return v2Result(id: id, self.v2DebugIsTerminalFocused(params: params)) case "debug.terminal.read_text": @@ -1475,6 +1496,16 @@ class TerminalController { "debug.shortcut.simulate", "debug.type", "debug.app.activate", + "debug.command_palette.toggle", + "debug.command_palette.rename_tab.open", + "debug.command_palette.visible", + "debug.command_palette.selection", + "debug.command_palette.results", + "debug.command_palette.rename_input.interact", + "debug.command_palette.rename_input.delete_backward", + "debug.command_palette.rename_input.selection", + "debug.command_palette.rename_input.select_all", + "debug.sidebar.visible", "debug.terminal.is_focused", "debug.terminal.read_text", "debug.terminal.render_stats", @@ -7564,6 +7595,268 @@ class TerminalController { return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil) } + private func v2DebugToggleCommandPalette(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: ["window_id": requestedWindowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: requestedWindowId)] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: targetWindow) + } + return result + } + + private func v2DebugOpenCommandPaletteRenameTabInput(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: [ + "window_id": requestedWindowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: requestedWindowId) + ] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteRenameTabRequested, object: targetWindow) + } + return result + } + + private func v2DebugCommandPaletteVisible(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + var visible = false + DispatchQueue.main.sync { + visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + } + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible + ]) + } + + private func v2DebugCommandPaletteSelection(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + var visible = false + var selectedIndex = 0 + DispatchQueue.main.sync { + visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0 + } + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible, + "selected_index": max(0, selectedIndex) + ]) + } + + private func v2DebugCommandPaletteResults(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + let requestedLimit = params["limit"] as? Int + let limit = max(1, min(100, requestedLimit ?? 20)) + + var visible = false + var selectedIndex = 0 + var snapshot = CommandPaletteDebugSnapshot.empty + + DispatchQueue.main.sync { + visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false + selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0 + snapshot = AppDelegate.shared?.commandPaletteSnapshot(windowId: windowId) ?? .empty + } + + let rows = Array(snapshot.results.prefix(limit)).map { row in + [ + "command_id": row.commandId, + "title": row.title, + "shortcut_hint": v2OrNull(row.shortcutHint), + "trailing_label": v2OrNull(row.trailingLabel), + "score": row.score + ] as [String: Any] + } + + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible, + "selected_index": max(0, selectedIndex), + "query": snapshot.query, + "mode": snapshot.mode, + "results": rows + ]) + } + + private func v2DebugCommandPaletteRenameInputInteraction(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: [ + "window_id": requestedWindowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: requestedWindowId) + ] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteRenameInputInteractionRequested, object: targetWindow) + } + return result + } + + private func v2DebugCommandPaletteRenameInputDeleteBackward(params: [String: Any]) -> V2CallResult { + let requestedWindowId = v2UUID(params, "window_id") + var result: V2CallResult = .ok([:]) + DispatchQueue.main.sync { + let targetWindow: NSWindow? + if let requestedWindowId { + guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: [ + "window_id": requestedWindowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: requestedWindowId) + ] + ) + return + } + targetWindow = window + } else { + targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + } + NotificationCenter.default.post(name: .commandPaletteRenameInputDeleteBackwardRequested, object: targetWindow) + } + return result + } + + private func v2DebugCommandPaletteRenameInputSelection(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + + var result: V2CallResult = .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "focused": false, + "selection_location": 0, + "selection_length": 0, + "text_length": 0 + ]) + + DispatchQueue.main.sync { + guard let window = AppDelegate.shared?.mainWindow(for: windowId) else { + result = .err( + code: "not_found", + message: "Window not found", + data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)] + ) + return + } + guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else { + return + } + let selectedRange = editor.selectedRange() + let textLength = (editor.string as NSString).length + result = .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "focused": true, + "selection_location": max(0, selectedRange.location), + "selection_length": max(0, selectedRange.length), + "text_length": max(0, textLength) + ]) + } + + return result + } + + private func v2DebugCommandPaletteRenameInputSelectAll(params: [String: Any]) -> V2CallResult { + if let rawEnabled = params["enabled"] { + guard let enabled = rawEnabled as? Bool else { + return .err( + code: "invalid_params", + message: "enabled must be a bool", + data: ["enabled": rawEnabled] + ) + } + DispatchQueue.main.sync { + UserDefaults.standard.set( + enabled, + forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey + ) + } + } + + var enabled = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus + DispatchQueue.main.sync { + enabled = CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled() + } + + return .ok([ + "enabled": enabled + ]) + } + + private func v2DebugSidebarVisible(params: [String: Any]) -> V2CallResult { + guard let windowId = v2UUID(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + var visibility: Bool? + DispatchQueue.main.sync { + visibility = AppDelegate.shared?.sidebarVisibility(windowId: windowId) + } + guard let visible = visibility else { + return .err( + code: "not_found", + message: "Window not found", + data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)] + ) + } + return .ok([ + "window_id": windowId.uuidString, + "window_ref": v2Ref(kind: .window, uuid: windowId), + "visible": visible + ]) + } + private func v2DebugIsTerminalFocused(params: [String: Any]) -> V2CallResult { guard let surfaceId = v2String(params, "surface_id") else { return .err(code: "invalid_params", message: "Missing surface_id", data: nil) @@ -8003,17 +8296,24 @@ class TerminalController { var result = "ERROR: Failed to create event" DispatchQueue.main.sync { - // Tests can run while the app is activating (no keyWindow yet). Prefer a visible - // window to keep input simulation deterministic in debug builds. - let targetWindow = NSApp.keyWindow - ?? NSApp.mainWindow - ?? NSApp.windows.first(where: { $0.isVisible }) - ?? NSApp.windows.first + // Prefer the current active-tab-manager window so shortcut simulation stays + // scoped to the intended window even when NSApp.keyWindow is stale. + let targetWindow: NSWindow? = { + if let activeTabManager = self.tabManager, + let windowId = AppDelegate.shared?.windowId(for: activeTabManager), + let window = AppDelegate.shared?.mainWindow(for: windowId) { + return window + } + return NSApp.keyWindow + ?? NSApp.mainWindow + ?? NSApp.windows.first(where: { $0.isVisible }) + ?? NSApp.windows.first + }() if let targetWindow { NSApp.activate(ignoringOtherApps: true) targetWindow.makeKeyAndOrderFront(nil) } - let windowNumber = (NSApp.keyWindow ?? targetWindow)?.windowNumber ?? 0 + let windowNumber = targetWindow?.windowNumber ?? 0 guard let keyDownEvent = NSEvent.keyEvent( with: .keyDown, location: .zero, @@ -8706,6 +9006,10 @@ class TerminalController { let charactersIgnoringModifiers: String switch keyToken.lowercased() { + case "esc", "escape": + storedKey = "\u{1b}" + keyCode = UInt16(kVK_Escape) + charactersIgnoringModifiers = storedKey case "left": storedKey = "←" keyCode = 123 @@ -8726,6 +9030,10 @@ class TerminalController { storedKey = "\r" keyCode = UInt16(kVK_Return) charactersIgnoringModifiers = storedKey + case "backspace", "delete", "del": + storedKey = "\u{7f}" + keyCode = UInt16(kVK_Delete) + charactersIgnoringModifiers = storedKey default: let key = keyToken.lowercased() guard let code = keyCodeForShortcutKey(key) else { return nil } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 091d274e..a5950c24 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -363,6 +363,20 @@ struct cmuxApp: App { // Close tab/workspace CommandGroup(after: .newItem) { + Button("Go to Workspace or Tab…") { + let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow) + } + .keyboardShortcut("p", modifiers: [.command]) + + Button("Command Palette…") { + let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow + NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow) + } + .keyboardShortcut("p", modifiers: [.command, .shift]) + + Divider() + // Terminal semantics: // Cmd+W closes the focused tab (with confirmation if needed). If this is the last // tab in the last workspace, it closes the window. @@ -422,7 +436,9 @@ struct cmuxApp: App { // Tab navigation CommandGroup(after: .toolbar) { splitCommandButton(title: "Toggle Sidebar", shortcut: toggleSidebarMenuShortcut) { - sidebarState.toggle() + if AppDelegate.shared?.toggleSidebarInActiveMainWindow() != true { + sidebarState.toggle() + } } Divider() @@ -2533,6 +2549,18 @@ enum QuitWarningSettings { } } +enum CommandPaletteRenameSelectionSettings { + static let selectAllOnFocusKey = "commandPalette.renameSelectAllOnFocus" + static let defaultSelectAllOnFocus = true + + static func selectAllOnFocusEnabled(defaults: UserDefaults = .standard) -> Bool { + if defaults.object(forKey: selectAllOnFocusKey) == nil { + return defaultSelectAllOnFocus + } + return defaults.bool(forKey: selectAllOnFocusKey) + } +} + enum ClaudeCodeIntegrationSettings { static let hooksEnabledKey = "claudeCodeHooksEnabled" static let defaultHooksEnabled = true @@ -2565,6 +2593,8 @@ struct SettingsView: View { @AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText @AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled @AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit + @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus @AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue @AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue @AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout @@ -2761,6 +2791,19 @@ struct SettingsView: View { SettingsCardDivider() + SettingsCardRow( + "Rename Selects Existing Name", + subtitle: commandPaletteRenameSelectAllOnFocus + ? "Command Palette rename starts with all text selected." + : "Command Palette rename keeps the caret at the end." + ) { + Toggle("", isOn: $commandPaletteRenameSelectAllOnFocus) + .labelsHidden() + .controlSize(.small) + } + + SettingsCardDivider() + SettingsCardRow( "Sidebar Branch Layout", subtitle: sidebarBranchVerticalLayout @@ -3310,6 +3353,7 @@ struct SettingsView: View { browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit + commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 05af20eb..a9a8eba6 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1125,6 +1125,267 @@ final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase { } } +final class CommandPaletteKeyboardNavigationTests: XCTestCase { + func testArrowKeysMoveSelectionWithoutModifiers() { + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [], + chars: "", + keyCode: 125 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [], + chars: "", + keyCode: 126 + ), + -1 + ) + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.shift], + chars: "", + keyCode: 125 + ) + ) + } + + func testControlLetterNavigationSupportsPrintableAndControlChars() { + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "n", + keyCode: 45 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{0e}", + keyCode: 45 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "p", + keyCode: 35 + ), + -1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{10}", + keyCode: 35 + ), + -1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "j", + keyCode: 38 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{0a}", + keyCode: 38 + ), + 1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "k", + keyCode: 40 + ), + -1 + ) + XCTAssertEqual( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "\u{0b}", + keyCode: 40 + ), + -1 + ) + } + + func testIgnoresUnsupportedModifiersAndKeys() { + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.command], + chars: "n", + keyCode: 45 + ) + ) + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control, .shift], + chars: "n", + keyCode: 45 + ) + ) + XCTAssertNil( + commandPaletteSelectionDeltaForKeyboardNavigation( + flags: [.control], + chars: "x", + keyCode: 7 + ) + ) + } +} + +final class CommandPaletteRenameSelectionSettingsTests: XCTestCase { + private let suiteName = "cmux.tests.commandPaletteRenameSelection.\(UUID().uuidString)" + + private func makeDefaults() -> UserDefaults { + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return defaults + } + + func testDefaultsToSelectAllWhenUnset() { + let defaults = makeDefaults() + XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) + } + + func testReturnsFalseWhenStoredFalse() { + let defaults = makeDefaults() + defaults.set(false, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + XCTAssertFalse(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) + } + + func testReturnsTrueWhenStoredTrue() { + let defaults = makeDefaults() + defaults.set(true, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) + XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults)) + } +} + +final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase { + func testFirstEntryAlwaysPinsToTopWhenScrollable() { + let anchor = ContentView.commandPaletteScrollAnchor( + selectedIndex: 0, + previousIndex: 1, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: 8, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertEqual(anchor, .top) + } + + func testLastEntryAlwaysPinsToBottomWhenScrollable() { + let anchor = ContentView.commandPaletteScrollAnchor( + selectedIndex: 19, + previousIndex: 18, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: 188, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertEqual(anchor, .bottom) + } + + func testFullyVisibleMiddleEntryDoesNotScroll() { + let anchor = ContentView.commandPaletteScrollAnchor( + selectedIndex: 6, + previousIndex: 5, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: 120, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertNil(anchor) + } + + func testOutOfViewMiddleEntryUsesDirectionForAnchor() { + let downAnchor = ContentView.commandPaletteScrollAnchor( + selectedIndex: 9, + previousIndex: 8, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: 210, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertEqual(downAnchor, .bottom) + + let upAnchor = ContentView.commandPaletteScrollAnchor( + selectedIndex: 8, + previousIndex: 9, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: -6, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertEqual(upAnchor, .top) + } +} + +final class CommandPaletteEdgeVisibilityCorrectionTests: XCTestCase { + func testTopEdgeReturnsTopWhenNotPinned() { + let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( + selectedIndex: 0, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: 6, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertEqual(anchor, .top) + } + + func testBottomEdgeReturnsBottomWhenNotPinned() { + let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( + selectedIndex: 19, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: 170, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertEqual(anchor, .bottom) + } + + func testPinnedTopAndBottomReturnNil() { + let topAnchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( + selectedIndex: 0, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: 0, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertNil(topAnchor) + + let bottomAnchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( + selectedIndex: 19, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: 192, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertNil(bottomAnchor) + } + + func testMiddleSelectionNeverForcesCorrection() { + let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( + selectedIndex: 8, + resultCount: 20, + selectedFrame: CGRect(x: 0, y: 96, width: 200, height: 24), + viewportHeight: 216, + contentHeight: 480 + ) + XCTAssertNil(anchor) + } +} + final class SidebarCommandHintPolicyTests: XCTestCase { func testCommandHintRequiresCommandOnlyModifier() { XCTAssertTrue(SidebarCommandHintPolicy.shouldShowHints(for: [.command])) diff --git a/cmuxTests/WorkspaceManualUnreadTests.swift b/cmuxTests/WorkspaceManualUnreadTests.swift index d5464d73..1610dc34 100644 --- a/cmuxTests/WorkspaceManualUnreadTests.swift +++ b/cmuxTests/WorkspaceManualUnreadTests.swift @@ -1,4 +1,5 @@ import XCTest +import AppKit #if canImport(cmux_DEV) @testable import cmux_DEV @@ -106,3 +107,333 @@ final class WorkspaceManualUnreadTests: XCTestCase { ) } } + +final class CommandPaletteFuzzyMatcherTests: XCTestCase { + func testExactMatchScoresHigherThanPrefixAndContains() { + let exact = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "rename tab") + let prefix = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "rename tab now") + let contains = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "command rename tab flow") + + XCTAssertNotNil(exact) + XCTAssertNotNil(prefix) + XCTAssertNotNil(contains) + XCTAssertGreaterThan(exact ?? 0, prefix ?? 0) + XCTAssertGreaterThan(prefix ?? 0, contains ?? 0) + } + + func testInitialismMatchReturnsScore() { + let score = CommandPaletteFuzzyMatcher.score(query: "ocdi", candidate: "open current directory in ide") + XCTAssertNotNil(score) + XCTAssertGreaterThan(score ?? 0, 0) + } + + func testLongTokenLooseSubsequenceDoesNotMatch() { + let score = CommandPaletteFuzzyMatcher.score(query: "rename", candidate: "open current directory in ide") + XCTAssertNil(score) + } + + func testStitchedWordPrefixMatchesRetabForRenameTab() { + let score = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Rename Tab…") + XCTAssertNotNil(score) + XCTAssertGreaterThan(score ?? 0, 0) + } + + func testRetabPrefersRenameTabOverDistantTabWord() { + let renameTabScore = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Rename Tab…") + let reopenTabScore = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Reopen Closed Browser Tab") + + XCTAssertNotNil(renameTabScore) + XCTAssertNotNil(reopenTabScore) + XCTAssertGreaterThan(renameTabScore ?? 0, reopenTabScore ?? 0) + } + + func testRenameScoresHigherThanUnrelatedCommand() { + let renameScore = CommandPaletteFuzzyMatcher.score( + query: "rename", + candidates: ["Rename Tab…", "Tab • Terminal 1", "rename", "tab", "title"] + ) + let unrelatedScore = CommandPaletteFuzzyMatcher.score( + query: "rename", + candidates: [ + "Open Current Directory in IDE", + "Terminal • Terminal 1", + "terminal", + "directory", + "open", + "ide", + "code", + "default app" + ] + ) + + XCTAssertNotNil(renameScore) + XCTAssertNotNil(unrelatedScore) + XCTAssertGreaterThan(renameScore ?? 0, unrelatedScore ?? 0) + } + + func testTokenMatchingRequiresAllTokens() { + let match = CommandPaletteFuzzyMatcher.score( + query: "rename workspace", + candidates: ["Rename Workspace", "Workspace settings"] + ) + let miss = CommandPaletteFuzzyMatcher.score( + query: "rename workspace", + candidates: ["Rename Tab", "Tab settings"] + ) + + XCTAssertNotNil(match) + XCTAssertNil(miss) + } + + func testEmptyQueryReturnsZeroScore() { + let score = CommandPaletteFuzzyMatcher.score(query: " ", candidate: "anything") + XCTAssertEqual(score, 0) + } + + func testMatchCharacterIndicesForContainsMatch() { + let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: "workspace", + candidate: "New Workspace" + ) + XCTAssertTrue(indices.contains(4)) + XCTAssertTrue(indices.contains(12)) + XCTAssertFalse(indices.contains(0)) + } + + func testMatchCharacterIndicesForSubsequenceMatch() { + let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: "nws", + candidate: "New Workspace" + ) + XCTAssertTrue(indices.contains(0)) + XCTAssertTrue(indices.contains(2)) + XCTAssertTrue(indices.contains(8)) + } + + func testMatchCharacterIndicesForStitchedWordPrefixMatch() { + let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices( + query: "retab", + candidate: "Rename Tab…" + ) + XCTAssertTrue(indices.contains(0)) + XCTAssertTrue(indices.contains(1)) + XCTAssertTrue(indices.contains(7)) + XCTAssertTrue(indices.contains(8)) + XCTAssertTrue(indices.contains(9)) + } +} + +final class CommandPaletteSwitcherSearchIndexerTests: XCTestCase { + func testKeywordsIncludeDirectoryBranchAndPortMetadata() { + let metadata = CommandPaletteSwitcherSearchMetadata( + directories: ["/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"], + branches: ["feature/cmd-palette-indexing"], + ports: [3000, 9222] + ) + + let keywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["workspace", "switch"], + metadata: metadata + ) + + XCTAssertTrue(keywords.contains("/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette")) + XCTAssertTrue(keywords.contains("feat-cmd-palette")) + XCTAssertTrue(keywords.contains("feature/cmd-palette-indexing")) + XCTAssertTrue(keywords.contains("cmd-palette-indexing")) + XCTAssertTrue(keywords.contains("3000")) + XCTAssertTrue(keywords.contains(":9222")) + } + + func testFuzzyMatcherMatchesDirectoryBranchAndPortMetadata() { + let metadata = CommandPaletteSwitcherSearchMetadata( + directories: ["/tmp/cmuxterm/worktrees/issue-123-switcher-search"], + branches: ["fix/switcher-metadata"], + ports: [4317] + ) + + let candidates = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["workspace"], + metadata: metadata + ) + + XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "switcher-search", candidates: candidates)) + XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "switcher-metadata", candidates: candidates)) + XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "4317", candidates: candidates)) + } + + func testWorkspaceDetailOmitsSplitDirectoryAndBranchTokens() { + let metadata = CommandPaletteSwitcherSearchMetadata( + directories: ["/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"], + branches: ["feature/cmd-palette-indexing"], + ports: [3000] + ) + + let keywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["workspace"], + metadata: metadata, + detail: .workspace + ) + + XCTAssertTrue(keywords.contains("/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette")) + XCTAssertTrue(keywords.contains("feature/cmd-palette-indexing")) + XCTAssertTrue(keywords.contains("3000")) + XCTAssertFalse(keywords.contains("feat-cmd-palette")) + XCTAssertFalse(keywords.contains("cmd-palette-indexing")) + } + + func testSurfaceDetailOutranksWorkspaceDetailForPathToken() { + let metadata = CommandPaletteSwitcherSearchMetadata( + directories: ["/tmp/worktrees/cmux"], + branches: ["feature/cmd-palette"], + ports: [] + ) + + let workspaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["workspace"], + metadata: metadata, + detail: .workspace + ) + let surfaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords( + baseKeywords: ["surface"], + metadata: metadata, + detail: .surface + ) + + let workspaceScore = try XCTUnwrap( + CommandPaletteFuzzyMatcher.score(query: "cmux", candidates: workspaceKeywords) + ) + let surfaceScore = try XCTUnwrap( + CommandPaletteFuzzyMatcher.score(query: "cmux", candidates: surfaceKeywords) + ) + + XCTAssertGreaterThan( + surfaceScore, + workspaceScore, + "Surface rows should rank ahead of workspace rows for directory-token matches." + ) + } +} + +@MainActor +final class CommandPaletteRequestRoutingTests: XCTestCase { + private func makeWindow() -> NSWindow { + NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + } + + func testRequestedWindowTargetsOnlyMatchingObservedWindow() { + let windowA = makeWindow() + let windowB = makeWindow() + + XCTAssertTrue( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: windowA, + requestedWindow: windowA, + keyWindow: windowA, + mainWindow: windowA + ) + ) + XCTAssertFalse( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: windowB, + requestedWindow: windowA, + keyWindow: windowA, + mainWindow: windowA + ) + ) + } + + func testNilRequestedWindowFallsBackToKeyWindow() { + let key = makeWindow() + let other = makeWindow() + + XCTAssertTrue( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: key, + requestedWindow: nil, + keyWindow: key, + mainWindow: nil + ) + ) + XCTAssertFalse( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: other, + requestedWindow: nil, + keyWindow: key, + mainWindow: nil + ) + ) + } + + func testNilRequestedAndKeyFallsBackToMainWindow() { + let main = makeWindow() + let other = makeWindow() + + XCTAssertTrue( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: main, + requestedWindow: nil, + keyWindow: nil, + mainWindow: main + ) + ) + XCTAssertFalse( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: other, + requestedWindow: nil, + keyWindow: nil, + mainWindow: main + ) + ) + } + + func testNoObservedWindowNeverHandlesRequest() { + XCTAssertFalse( + ContentView.shouldHandleCommandPaletteRequest( + observedWindow: nil, + requestedWindow: makeWindow(), + keyWindow: makeWindow(), + mainWindow: makeWindow() + ) + ) + } +} + +final class CommandPaletteBackNavigationTests: XCTestCase { + func testBackspaceOnEmptyRenameInputReturnsToCommandList() { + XCTAssertTrue( + ContentView.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: "", + modifiers: [] + ) + ) + } + + func testBackspaceWithRenameTextDoesNotReturnToCommandList() { + XCTAssertFalse( + ContentView.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: "Terminal 1", + modifiers: [] + ) + ) + } + + func testModifiedBackspaceDoesNotReturnToCommandList() { + XCTAssertFalse( + ContentView.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: "", + modifiers: [.control] + ) + ) + XCTAssertFalse( + ContentView.commandPaletteShouldPopRenameInputOnDelete( + renameDraft: "", + modifiers: [.command] + ) + ) + } +} diff --git a/tests_v2/cmux.py b/tests_v2/cmux.py index cf94aae2..18af2284 100755 --- a/tests_v2/cmux.py +++ b/tests_v2/cmux.py @@ -918,6 +918,27 @@ class cmux: def activate_app(self) -> None: self._call("debug.app.activate") + def open_command_palette_rename_tab_input(self, window_id: Optional[str] = None) -> None: + params: Dict[str, Any] = {} + if window_id is not None: + params["window_id"] = str(window_id) + self._call("debug.command_palette.rename_tab.open", params) + + def command_palette_results(self, window_id: str, limit: int = 20) -> dict: + res = self._call( + "debug.command_palette.results", + {"window_id": str(window_id), "limit": int(limit)}, + ) or {} + return dict(res) + + def command_palette_rename_select_all(self) -> bool: + res = self._call("debug.command_palette.rename_input.select_all") or {} + return bool(res.get("enabled")) + + def set_command_palette_rename_select_all(self, enabled: bool) -> bool: + res = self._call("debug.command_palette.rename_input.select_all", {"enabled": bool(enabled)}) or {} + return bool(res.get("enabled")) + def is_terminal_focused(self, panel: Union[str, int]) -> bool: sid = self._resolve_surface_id(panel) res = self._call("debug.terminal.is_focused", {"surface_id": sid}) or {} diff --git a/tests_v2/test_command_palette_backspace_go_back.py b/tests_v2/test_command_palette_backspace_go_back.py new file mode 100644 index 00000000..7b152daa --- /dev/null +++ b/tests_v2/test_command_palette_backspace_go_back.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +Regression test: backspace on empty rename input returns to command list. + +Coverage: +- First backspace clears selected rename text. +- Second backspace on empty rename input navigates back to command list mode. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"): + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client, window_id): + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _palette_results(client, window_id): + return client.command_palette_results(window_id, limit=20) + + +def _rename_selection(client, window_id): + return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {} + + +def _int_or(value, default): + try: + return int(value) + except (TypeError, ValueError): + return int(default) + + +def _open_rename_input(client, window_id): + client.activate_app() + client.focus_window(window_id) + time.sleep(0.1) + + if _palette_visible(client, window_id): + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: not _palette_visible(client, window_id), + message="command palette failed to close before setup", + ) + + client.open_command_palette_rename_tab_input(window_id=window_id) + _wait_until( + lambda: _palette_visible(client, window_id), + message="command palette failed to open", + ) + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "rename_input", + message="command palette did not enter rename input mode", + ) + + +def main(): + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + window_id = client.current_window() + + original_select_all = client.command_palette_rename_select_all() + + try: + client.set_command_palette_rename_select_all(True) + _open_rename_input(client, window_id) + + _wait_until( + lambda: bool(_rename_selection(client, window_id).get("focused")), + message="rename input did not focus", + ) + + selection = _rename_selection(client, window_id) + text_length = _int_or(selection.get("text_length"), 0) + selection_location = _int_or(selection.get("selection_location"), -1) + selection_length = _int_or(selection.get("selection_length"), -1) + if not ( + text_length > 0 + and selection_location in (-1, 0) + and selection_length == text_length + ): + raise cmuxError( + "rename input was not select-all on open: " + f"text_length={text_length} selection=({selection_location}, {selection_length})" + ) + + client._call( + "debug.command_palette.rename_input.delete_backward", + {"window_id": window_id}, + ) + + first_backspace_cleared = False + last_selection = {} + for _ in range(40): + last_selection = _rename_selection(client, window_id) + if _int_or(last_selection.get("text_length"), -1) == 0: + first_backspace_cleared = True + break + time.sleep(0.05) + if not first_backspace_cleared: + raise cmuxError( + "first backspace did not clear rename input: " + f"selection={last_selection} results={_palette_results(client, window_id)}" + ) + after_first = _palette_results(client, window_id) + if str(after_first.get("mode") or "") != "rename_input": + raise cmuxError(f"palette exited rename mode too early after first backspace: {after_first}") + + client._call( + "debug.command_palette.rename_input.delete_backward", + {"window_id": window_id}, + ) + + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands", + message="second backspace on empty input did not return to commands mode", + ) + + if not _palette_visible(client, window_id): + raise cmuxError("palette closed unexpectedly instead of navigating back to command list") + + finally: + try: + client.set_command_palette_rename_select_all(original_select_all) + except Exception: + pass + + if _palette_visible(client, window_id): + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: not _palette_visible(client, window_id), + message="command palette failed to close during cleanup", + ) + + print("PASS: backspace on empty rename input navigates back to command list") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_focus.py b/tests_v2/test_command_palette_focus.py new file mode 100644 index 00000000..859de7b8 --- /dev/null +++ b/tests_v2/test_command_palette_focus.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +Regression test: opening the command palette must move focus away from terminal. + +Why: if terminal remains first responder under the palette, typing goes into the shell +instead of the palette search field. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _focused_surface_id(client: cmux) -> str: + surfaces = client.list_surfaces() + for _, sid, focused in surfaces: + if focused: + return sid + raise cmuxError(f"No focused surface in list_surfaces: {surfaces}") + + +def _palette_visible(client: cmux, window_id: str) -> bool: + res = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(res.get("visible")) + + +def _wait_until(predicate, timeout_s: float = 3.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def main() -> int: + token = "CMUX_PALETTE_FOCUS_PROBE_9412" + restore_token = "CMUX_PALETTE_RESTORE_PROBE_7731" + + with cmux(SOCKET_PATH) as client: + client.new_workspace() + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + panel_id = _focused_surface_id(client) + _wait_until( + lambda: client.is_terminal_focused(panel_id), + timeout_s=5.0, + message=f"terminal never became focused for panel {panel_id}", + ) + + pre_text = client.read_terminal_text(panel_id) + + # Open palette via debug method and assert terminal focus drops. + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id), + timeout_s=3.0, + message="command palette did not open", + ) + + # Typing now should target palette input, not the terminal. + client.simulate_type(token) + time.sleep(0.15) + post_text = client.read_terminal_text(panel_id) + + if token in post_text and token not in pre_text: + raise cmuxError("typed probe text leaked into terminal while palette is open") + + # Close palette and ensure focus returns to previously-focused terminal. + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: not _palette_visible(client, window_id), + timeout_s=3.0, + message="command palette did not close", + ) + + client.simulate_type(restore_token) + time.sleep(0.15) + restore_text = client.read_terminal_text(panel_id) + if restore_token not in restore_text: + raise cmuxError("terminal did not receive typing after closing command palette") + + print("PASS: command palette steals and restores terminal focus") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_focus_lock_workspace_spawn.py b/tests_v2/test_command_palette_focus_lock_workspace_spawn.py new file mode 100644 index 00000000..d859b912 --- /dev/null +++ b/tests_v2/test_command_palette_focus_lock_workspace_spawn.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +""" +Regression test: command palette focus must remain stable while a new workspace shell spawns. + +Why: when a terminal steals first responder during workspace bootstrap, the command-palette +search field can re-focus with full selection, so the next keystroke replaces the whole query. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _palette_input_selection(client: cmux, window_id: str) -> dict: + return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {} + + +def _close_palette_if_open(client: cmux, window_id: str) -> None: + if _palette_visible(client, window_id): + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: not _palette_visible(client, window_id), + message="command palette failed to close", + ) + + +def _assert_caret_at_end(selection: dict, context: str) -> None: + if not selection.get("focused"): + raise cmuxError(f"{context}: palette input is not focused") + text_length = int(selection.get("text_length") or 0) + selection_location = int(selection.get("selection_location") or 0) + selection_length = int(selection.get("selection_length") or 0) + if selection_location != text_length or selection_length != 0: + raise cmuxError( + f"{context}: expected caret-at-end, got location={selection_location}, " + f"length={selection_length}, text_length={text_length}" + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + _close_palette_if_open(client, window_id) + workspace_count_before = len(client.list_workspaces(window_id=window_id)) + + client.simulate_shortcut("cmd+shift+p") + _wait_until( + lambda: _palette_visible(client, window_id), + message="cmd+shift+p did not open command palette", + ) + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands", + message="palette did not open in commands mode", + ) + + selection = _palette_input_selection(client, window_id) + _assert_caret_at_end(selection, "initial state") + + client.new_workspace(window_id=window_id) + _wait_until( + lambda: len(client.list_workspaces(window_id=window_id)) >= workspace_count_before + 1, + message="workspace.create did not add a new workspace", + ) + + # Sample across shell bootstrap; focus and caret should stay stable. + sample_deadline = time.time() + 2.0 + while time.time() < sample_deadline: + selection = _palette_input_selection(client, window_id) + _assert_caret_at_end(selection, "after workspace spawn") + time.sleep(0.01) + + client.simulate_type("focuslock") + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands", + message="typing after workspace spawn switched palette out of commands mode", + ) + _wait_until( + lambda: "focuslock" in str(_palette_results(client, window_id).get("query") or "").lower(), + message="typing after workspace spawn did not append into command query", + ) + + print("PASS: command palette keeps focus/caret during workspace shell spawn") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_fuzzy_ranking.py b/tests_v2/test_command_palette_fuzzy_ranking.py new file mode 100644 index 00000000..8d6e30b2 --- /dev/null +++ b/tests_v2/test_command_palette_fuzzy_ranking.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Regression test: command palette fuzzy ranking for rename commands. + +Validates: +- Typing `rename` is captured by the palette query. +- The top-ranked command is a rename command. +- Pressing Enter opens rename input (instead of running an unrelated command). +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") +RENAME_COMMAND_IDS = {"palette.renameTab", "palette.renameWorkspace"} + + +def _wait_until(predicate, timeout_s=5.0, interval_s=0.05, message="timeout"): + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _rename_input_selection(client: cmux, window_id: str) -> dict: + return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {} + + +def _palette_results(client: cmux, window_id: str, limit: int = 10) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + message=f"palette visibility did not become {visible}", + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + workspace_id = client.new_workspace(window_id=window_id) + client.select_workspace(workspace_id) + time.sleep(0.2) + + _set_palette_visible(client, window_id, False) + _set_palette_visible(client, window_id, True) + + # Force command mode query regardless transient field-editor selection state. + time.sleep(0.2) + client.simulate_shortcut("cmd+a") + client.simulate_type(">rename") + _wait_until( + lambda: "rename" in str(_palette_results(client, window_id).get("query") or "").strip().lower(), + message="palette query did not update to 'rename'", + ) + + payload = _palette_results(client, window_id, limit=12) + rows = payload.get("results") or [] + if not rows: + raise cmuxError(f"palette returned no results for rename query: {payload}") + + top = rows[0] or {} + top_id = str(top.get("command_id") or "") + top_title = str(top.get("title") or "") + if top_id not in RENAME_COMMAND_IDS: + titles = [str(row.get("title") or "") for row in rows] + raise cmuxError( + f"unexpected top result for 'rename': id={top_id!r} title={top_title!r} results={titles}" + ) + + client.simulate_shortcut("cmd+a") + client.simulate_type(">retab") + _wait_until( + lambda: "retab" in str(_palette_results(client, window_id).get("query") or "").strip().lower(), + message="palette query did not update to 'retab'", + ) + + retab_payload = _palette_results(client, window_id, limit=12) + retab_rows = retab_payload.get("results") or [] + if not retab_rows: + raise cmuxError(f"palette returned no results for retab query: {retab_payload}") + top_retabs = [str(row.get("command_id") or "") for row in retab_rows[:3]] + if "palette.renameTab" not in top_retabs: + raise cmuxError( + f"'retab' did not rank Rename Tab near top: top3={top_retabs} rows={retab_rows}" + ) + + client.simulate_shortcut("enter") + _wait_until( + lambda: _palette_visible(client, window_id) + and bool(_rename_input_selection(client, window_id).get("focused")), + message="Enter did not open rename input for top rename result", + ) + + _set_palette_visible(client, window_id, False) + + print("PASS: command palette fuzzy ranking prioritizes rename commands") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_modes.py b/tests_v2/test_command_palette_modes.py new file mode 100644 index 00000000..482e1c45 --- /dev/null +++ b/tests_v2/test_command_palette_modes.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +Regression test: VSCode-like command palette modes. + +Validates: +- Cmd+Shift+P opens commands mode (leading '>' semantics). +- Cmd+P opens workspace/tab switcher mode. +- Repeating Cmd+Shift+P or Cmd+P toggles visibility (open/close). +- Switcher search can jump to another workspace by pressing Enter. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _palette_input_selection(client: cmux, window_id: str) -> dict: + return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {} + + +def _wait_for_palette_input_caret_at_end( + client: cmux, + window_id: str, + expected_text_length: int, + message: str, + timeout_s: float = 1.2, +) -> None: + def _matches() -> bool: + selection = _palette_input_selection(client, window_id) + if not selection.get("focused"): + return False + text_length = int(selection.get("text_length") or 0) + selection_location = int(selection.get("selection_location") or 0) + selection_length = int(selection.get("selection_length") or 0) + return ( + text_length == expected_text_length + and selection_location == expected_text_length + and selection_length == 0 + ) + + _wait_until(_matches, timeout_s=timeout_s, message=message) + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + timeout_s=3.0, + message=f"palette visibility did not become {visible}", + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + ws_a = client.new_workspace(window_id=window_id) + client.select_workspace(ws_a) + client.rename_workspace("alpha-workspace", workspace=ws_a) + + ws_b = client.new_workspace(window_id=window_id) + client.select_workspace(ws_b) + client.rename_workspace("bravo-workspace", workspace=ws_b) + + client.select_workspace(ws_a) + _wait_until( + lambda: client.current_workspace() == ws_a, + message="failed to select workspace alpha before switcher jump", + ) + + _set_palette_visible(client, window_id, False) + + # Cmd+P: switcher mode. + client.simulate_shortcut("cmd+p") + _wait_until( + lambda: _palette_visible(client, window_id), + message="cmd+p did not open command palette", + ) + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher", + message="cmd+p did not open switcher mode", + ) + + time.sleep(0.2) + client.simulate_type("bravo") + _wait_until( + lambda: "bravo" in str(_palette_results(client, window_id).get("query") or "").strip().lower(), + message="switcher query did not include bravo", + ) + switched_rows = (_palette_results(client, window_id, limit=12).get("results") or []) + if not switched_rows: + raise cmuxError("switcher returned no rows for workspace query") + top_id = str((switched_rows[0] or {}).get("command_id") or "") + if not top_id.startswith("switcher."): + raise cmuxError(f"expected switcher row on top for cmd+p query, got: {switched_rows[0]}") + + client.simulate_shortcut("enter") + _wait_until( + lambda: not _palette_visible(client, window_id), + message="palette did not close after selecting switcher row", + ) + _wait_until( + lambda: client.current_workspace() == ws_b, + message="Enter on switcher result did not move to target workspace", + ) + + # Cmd+Shift+P: commands mode. + client.simulate_shortcut("cmd+shift+p") + _wait_until( + lambda: _palette_visible(client, window_id), + message="cmd+shift+p did not open command palette", + ) + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "commands", + message="cmd+shift+p did not open commands mode", + ) + _wait_for_palette_input_caret_at_end( + client, + window_id, + expected_text_length=1, + message="cmd+shift+p should prefill '>' with caret at end (not selected)", + ) + + command_rows = (_palette_results(client, window_id, limit=8).get("results") or []) + if not command_rows: + raise cmuxError("commands mode returned no rows") + top_command_id = str((command_rows[0] or {}).get("command_id") or "") + if not top_command_id.startswith("palette."): + raise cmuxError(f"expected command row in commands mode, got: {command_rows[0]}") + + # Repeating either shortcut should toggle visibility. + client.simulate_shortcut("cmd+shift+p") + _wait_until( + lambda: not _palette_visible(client, window_id), + message="second cmd+shift+p did not close the command palette", + ) + + client.simulate_shortcut("cmd+p") + _wait_until( + lambda: _palette_visible(client, window_id) + and str(_palette_results(client, window_id).get("mode") or "") == "switcher", + message="cmd+p did not reopen switcher mode after toggle-close", + ) + client.simulate_shortcut("cmd+p") + _wait_until( + lambda: not _palette_visible(client, window_id), + message="second cmd+p did not close the command palette", + ) + + print("PASS: command palette cmd+p/cmd+shift+p open correct modes and toggle on repeat") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_navigation_keys.py b/tests_v2/test_command_palette_navigation_keys.py new file mode 100644 index 00000000..6a3d4b2a --- /dev/null +++ b/tests_v2/test_command_palette_navigation_keys.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +Regression test: command palette list navigation keys. + +Validates: +- Down: ArrowDown, Ctrl+N, Ctrl+J +- Up: ArrowUp, Ctrl+P, Ctrl+K +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until( + predicate, + timeout_s: float = 4.0, + interval_s: float = 0.05, + message: str = "timeout", +) -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + res = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(res.get("visible")) + + +def _palette_selected_index(client: cmux, window_id: str) -> int: + res = client._call("debug.command_palette.selection", {"window_id": window_id}) or {} + return int(res.get("selected_index") or 0) + + +def _has_focused_surface(client: cmux) -> bool: + try: + return any(bool(row[2]) for row in client.list_surfaces()) + except Exception: + return False + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + message=f"palette visibility did not become {visible}", + ) + + +def _open_palette_with_query(client: cmux, window_id: str, query: str) -> None: + _set_palette_visible(client, window_id, False) + _set_palette_visible(client, window_id, True) + client.simulate_type(query) + _wait_until( + lambda: _palette_selected_index(client, window_id) == 0, + message="palette selected index did not reset to zero", + ) + + +def _assert_move(client: cmux, window_id: str, combo: str, start_index: int, expected_index: int) -> None: + _open_palette_with_query(client, window_id, "new") + for _ in range(start_index): + client.simulate_shortcut("down") + _wait_until( + lambda: _palette_selected_index(client, window_id) == start_index, + message=f"failed to seed start index {start_index}", + ) + + client.simulate_shortcut(combo) + _wait_until( + lambda: _palette_visible(client, window_id) + and _palette_selected_index(client, window_id) == expected_index, + message=f"{combo} did not move selection from {start_index} to {expected_index}", + ) + + +def _assert_can_navigate_past_ten_results(client: cmux, window_id: str) -> None: + _open_palette_with_query(client, window_id, "") + + for _ in range(12): + client.simulate_shortcut("down") + + _wait_until( + lambda: _palette_visible(client, window_id) + and _palette_selected_index(client, window_id) >= 10, + message="selection did not move past index 9 (results may be capped)", + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + client.new_workspace() + time.sleep(0.2) + + window_id = client.current_window() + # Isolate this test to one window so stale palettes in other windows + # cannot steal navigation notifications. + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + _wait_until( + lambda: _has_focused_surface(client), + timeout_s=5.0, + message="no focused surface available for command palette context", + ) + + for combo in ("down", "ctrl+n", "ctrl+j"): + _assert_move(client, window_id, combo, start_index=0, expected_index=1) + + for combo in ("up", "ctrl+p", "ctrl+k"): + _assert_move(client, window_id, combo, start_index=1, expected_index=0) + + _assert_can_navigate_past_ten_results(client, window_id) + + _set_palette_visible(client, window_id, False) + + print("PASS: command palette navigation keys and uncapped result navigation") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_rename_enter.py b/tests_v2/test_command_palette_rename_enter.py new file mode 100644 index 00000000..749e0ac0 --- /dev/null +++ b/tests_v2/test_command_palette_rename_enter.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +""" +Regression test: command-palette rename flow responds to Enter. + +Coverage: +- Enter in rename input applies the new tab name and closes the palette. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"): + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client, window_id): + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _rename_input_selection(client, window_id): + return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {} + + +def _focused_pane_id(client): + panes = client.list_panes() + focused = [row for row in panes if bool(row[3])] + if not focused: + raise cmuxError(f"no focused pane: {panes}") + return str(focused[0][1]) + + +def _selected_surface_title(client, pane_id): + rows = client.list_pane_surfaces(pane_id) + selected = [row for row in rows if bool(row[3])] + if not selected: + raise cmuxError(f"no selected surface in pane {pane_id}: {rows}") + return str(selected[0][2]) + + +def main(): + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + workspace_id = client.new_workspace(window_id=window_id) + client.select_workspace(workspace_id) + time.sleep(0.2) + + pane_id = _focused_pane_id(client) + rename_to = f"rename-enter-{int(time.time())}" + + client.open_command_palette_rename_tab_input(window_id=window_id) + _wait_until( + lambda: _palette_visible(client, window_id), + message="command palette did not open", + ) + _wait_until( + lambda: bool(_rename_input_selection(client, window_id).get("focused")), + message="rename input did not focus", + ) + + client.simulate_type(rename_to) + time.sleep(0.1) + + client.simulate_shortcut("enter") + _wait_until( + lambda: not _palette_visible(client, window_id), + message="Enter did not apply rename and close palette", + ) + + new_title = _selected_surface_title(client, pane_id) + if new_title != rename_to: + raise cmuxError(f"rename not applied: expected '{rename_to}', got '{new_title}'") + + print("PASS: command-palette rename flow accepts Enter in input") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_rename_select_all.py b/tests_v2/test_command_palette_rename_select_all.py new file mode 100644 index 00000000..0b05ab4a --- /dev/null +++ b/tests_v2/test_command_palette_rename_select_all.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +Regression test: command-palette rename input keeps select-all on interaction. + +Coverage: +- With select-all setting enabled, rename input selects all existing text + immediately and stays selected after interaction. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"): + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client, window_id): + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _rename_input_selection(client, window_id): + return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {} + + +def _rename_select_all_setting(client): + payload = client._call("debug.command_palette.rename_input.select_all", {}) or {} + return bool(payload.get("enabled")) + + +def _set_rename_select_all_setting(client, enabled): + payload = client._call( + "debug.command_palette.rename_input.select_all", + {"enabled": bool(enabled)}, + ) or {} + return bool(payload.get("enabled")) + + +def _wait_for_rename_selection( + client, + window_id, + expect_select_all, + message, + timeout_s=0.6, +): + def _matches(): + selection = _rename_input_selection(client, window_id) + if not selection.get("focused"): + return False + text_length = int(selection.get("text_length") or 0) + selection_location = int(selection.get("selection_location") or 0) + selection_length = int(selection.get("selection_length") or 0) + if expect_select_all: + return text_length > 0 and selection_location == 0 and selection_length == text_length + return selection_location == text_length and selection_length == 0 + + _wait_until(_matches, timeout_s=timeout_s, message=message) + + +def _exercise_rename_selection_setting( + client, + window_id, + expect_select_all, + cycles, + label, +): + for cycle in range(cycles): + _open_rename_tab_input(client, window_id) + _wait_for_rename_selection( + client, + window_id, + expect_select_all=expect_select_all, + timeout_s=0.4, + message=( + f"{label}: rename input not ready with expected selection " + f"on open (cycle {cycle + 1}/{cycles})" + ), + ) + client._call("debug.command_palette.rename_input.interact", {"window_id": window_id}) + _wait_for_rename_selection( + client, + window_id, + expect_select_all=expect_select_all, + timeout_s=0.6, + message=( + f"{label}: rename input selection changed after interaction " + f"(cycle {cycle + 1}/{cycles})" + ), + ) + + if _palette_visible(client, window_id): + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: not _palette_visible(client, window_id), + message=f"{label}: command palette failed to close (cycle {cycle + 1}/{cycles})", + ) + + +def _open_rename_tab_input(client, window_id): + client.activate_app() + client.focus_window(window_id) + time.sleep(0.1) + + if _palette_visible(client, window_id): + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: not _palette_visible(client, window_id), + message="command palette failed to close before setup", + ) + + client.open_command_palette_rename_tab_input(window_id=window_id) + _wait_until( + lambda: _palette_visible(client, window_id), + message="command palette failed to open rename-tab input", + ) + + +def main(): + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + original_select_all = _rename_select_all_setting(client) + + workspace_id = client.new_workspace() + client.select_workspace(workspace_id) + client.rename_workspace("SeedName", workspace_id) + time.sleep(0.25) + window_id = client.current_window() + + try: + stress_cycles = 8 + + # ON: immediate select-all and interaction-preserved select-all. + _set_rename_select_all_setting(client, True) + _exercise_rename_selection_setting( + client, + window_id, + expect_select_all=True, + cycles=stress_cycles, + label="select-all enabled", + ) + + # OFF: immediate caret-at-end and interaction-preserved caret-at-end. + _set_rename_select_all_setting(client, False) + _exercise_rename_selection_setting( + client, + window_id, + expect_select_all=False, + cycles=stress_cycles, + label="select-all disabled", + ) + + finally: + try: + _set_rename_select_all_setting(client, original_select_all) + except Exception: + pass + if _palette_visible(client, window_id): + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: not _palette_visible(client, window_id), + message="command palette failed to close during cleanup", + ) + + print("PASS: command-palette rename input obeys select-all setting (on/off)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_search_action_sync.py b/tests_v2/test_command_palette_search_action_sync.py new file mode 100644 index 00000000..533cb7e3 --- /dev/null +++ b/tests_v2/test_command_palette_search_action_sync.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +Regression test: command-palette search updates rows and executed action in sync. + +Why: if query replacement doesn't fully refresh the result list, the top row text +can lag behind the action executed on Enter. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s=4.0, interval_s=0.05, message="timeout"): + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client, window_id): + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _set_palette_visible(client, window_id, visible): + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + message=f"command palette did not become visible={visible}", + ) + + +def _palette_results(client, window_id, limit=10): + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _palette_input_selection(client, window_id): + # Shared field-editor probe used by other command palette regressions. + return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {} + + +def main(): + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + workspace_id = client.new_workspace(window_id=window_id) + client.select_workspace(workspace_id) + time.sleep(0.2) + + _set_palette_visible(client, window_id, False) + _set_palette_visible(client, window_id, True) + _wait_until( + lambda: bool(_palette_input_selection(client, window_id).get("focused")), + message="palette search input did not focus", + ) + + client.simulate_shortcut("cmd+a") + client.simulate_type(">open") + _wait_until( + lambda: "open" in str(_palette_results(client, window_id).get("query") or "").strip().lower(), + message="palette query did not become 'open'", + ) + + before = _palette_results(client, window_id, limit=8) + before_rows = before.get("results") or [] + if not before_rows: + raise cmuxError(f"no results for 'open': {before}") + if str(before_rows[0].get("command_id") or "") != "palette.terminalOpenDirectory": + raise cmuxError(f"unexpected top command for 'open': {before_rows[0]}") + + client.simulate_shortcut("cmd+a") + client.simulate_type(">rename") + _wait_until( + lambda: "rename" in str(_palette_results(client, window_id).get("query") or "").strip().lower(), + message="palette query did not become 'rename' after replacement", + ) + after = _palette_results(client, window_id, limit=8) + after_rows = after.get("results") or [] + if not after_rows: + raise cmuxError(f"no results for 'rename' after replacement: {after}") + top_after = str(after_rows[0].get("command_id") or "") + if top_after not in {"palette.renameWorkspace", "palette.renameTab"}: + raise cmuxError(f"top result did not update to rename command after replacement: {after_rows[0]}") + + client.simulate_shortcut("enter") + _wait_until( + lambda: bool(_palette_input_selection(client, window_id).get("focused")), + message="Enter did not trigger renamed top command input", + ) + + _set_palette_visible(client, window_id, False) + + print("PASS: command-palette search replacement keeps row text/action in sync") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_search_typing_stability.py b/tests_v2/test_command_palette_search_typing_stability.py new file mode 100644 index 00000000..09b34722 --- /dev/null +++ b/tests_v2/test_command_palette_search_typing_stability.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +Regression test: command-palette search typing should not reset selection. + +Why: if focus-lock logic repeatedly re-focuses the text field, typing behaves +like Cmd+A is being spammed and each character replaces the previous query. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s=4.0, interval_s=0.04, message="timeout"): + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client, window_id): + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _palette_input_selection(client, window_id): + # Uses the shared field-editor probe; works for search and rename modes. + return client._call("debug.command_palette.rename_input.selection", {"window_id": window_id}) or {} + + +def _wait_for_input_state(client, window_id, expected_text_length, message, timeout_s=0.8): + def _matches(): + selection = _palette_input_selection(client, window_id) + if not selection.get("focused"): + return False + text_length = int(selection.get("text_length") or 0) + selection_location = int(selection.get("selection_location") or 0) + selection_length = int(selection.get("selection_length") or 0) + return ( + text_length == expected_text_length + and selection_location == expected_text_length + and selection_length == 0 + ) + + _wait_until(_matches, timeout_s=timeout_s, message=message) + + +def _close_palette_if_open(client, window_id): + if _palette_visible(client, window_id): + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: not _palette_visible(client, window_id), + message="command palette failed to close", + ) + + +def _open_palette(client, window_id): + _close_palette_if_open(client, window_id) + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id), + message="command palette failed to open", + ) + _wait_for_input_state( + client, + window_id, + expected_text_length=0, + message="search input did not focus with empty query", + ) + + +def main(): + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + + # Keep a single active window for deterministic first-responder behavior. + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + probe = "typingstability" + cycles = 4 + for cycle in range(cycles): + _open_palette(client, window_id) + for idx, ch in enumerate(probe, start=1): + client.simulate_type(ch) + _wait_for_input_state( + client, + window_id, + expected_text_length=idx, + timeout_s=0.7, + message=( + f"search typing did not accumulate at cycle {cycle + 1}/{cycles}, " + f"char {idx}/{len(probe)}" + ), + ) + _close_palette_if_open(client, window_id) + + print("PASS: command-palette search typing accumulates text without select-all churn") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_switcher_cross_workspace_surface_focus.py b/tests_v2/test_command_palette_switcher_cross_workspace_surface_focus.py new file mode 100644 index 00000000..ba427506 --- /dev/null +++ b/tests_v2/test_command_palette_switcher_cross_workspace_surface_focus.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +""" +Regression test: cmd+p switcher surface selection across workspaces must focus that surface. + +Why: switching workspaces with an explicit target surface could be overridden by stale +per-workspace remembered focus, leaving the destination workspace selected but the wrong +surface focused. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + message=f"palette visibility did not become {visible}", + ) + + +def _open_switcher(client: cmux, window_id: str) -> None: + _set_palette_visible(client, window_id, False) + client.simulate_shortcut("cmd+p") + _wait_until( + lambda: _palette_visible(client, window_id), + message="cmd+p did not open switcher", + ) + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher", + message="cmd+p did not open switcher mode", + ) + + +def _rename_surface(client: cmux, surface_id: str, title: str) -> None: + client._call( + "surface.action", + { + "surface_id": surface_id, + "action": "rename", + "title": title, + }, + ) + + +def _current_surface_id(client: cmux, workspace_id: str) -> str: + payload = client._call("surface.current", {"workspace_id": workspace_id}) or {} + return str(payload.get("surface_id") or "") + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + ws_a = client.new_workspace(window_id=window_id) + client.select_workspace(ws_a) + client.rename_workspace("source-workspace", workspace=ws_a) + + ws_b = client.new_workspace(window_id=window_id) + client.select_workspace(ws_b) + client.rename_workspace("target-workspace", workspace=ws_b) + time.sleep(0.2) + + right_surface_id = client.new_split("right") + time.sleep(0.2) + + payload = client._call("surface.list", {"workspace_id": ws_b}) or {} + rows = payload.get("surfaces") or [] + if len(rows) < 2: + raise cmuxError(f"expected at least two surfaces after split: {payload}") + + left_surface_id = "" + for row in rows: + sid = str(row.get("id") or "") + if sid and sid != right_surface_id: + left_surface_id = sid + break + if not left_surface_id: + raise cmuxError(f"failed to resolve left surface id: {payload}") + + token = f"cmdp-crossws-{int(time.time() * 1000)}" + _rename_surface(client, right_surface_id, token) + time.sleep(0.2) + + client.focus_surface(left_surface_id) + _wait_until( + lambda: _current_surface_id(client, ws_b).lower() == left_surface_id.lower(), + message="failed to prime remembered focus on non-target surface", + ) + + client.select_workspace(ws_a) + _wait_until( + lambda: client.current_workspace() == ws_a, + message="failed to return to source workspace before cmd+p navigation", + ) + + _open_switcher(client, window_id) + client.simulate_type(token) + _wait_until( + lambda: token in str(_palette_results(client, window_id).get("query") or "").strip().lower(), + message="switcher query did not update to target token", + ) + + target_command_id = f"switcher.surface.{ws_b.lower()}.{right_surface_id.lower()}" + _wait_until( + lambda: str(((_palette_results(client, window_id, limit=24).get("results") or [{}])[0] or {}).get("command_id") or "") == target_command_id, + message="target surface row did not become top switcher result", + ) + + client.simulate_shortcut("enter") + _wait_until( + lambda: not _palette_visible(client, window_id), + message="palette did not close after selecting cross-workspace surface row", + ) + _wait_until( + lambda: client.current_workspace() == ws_b, + message="Enter on switcher surface row did not move to target workspace", + ) + _wait_until( + lambda: _current_surface_id(client, ws_b).lower() == right_surface_id.lower(), + message="Enter on cross-workspace switcher surface row did not focus target surface", + ) + + client.close_workspace(ws_b) + client.close_workspace(ws_a) + + print("PASS: cmd+p switcher focuses selected surface after cross-workspace navigation") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_switcher_renamed_surface.py b/tests_v2/test_command_palette_switcher_renamed_surface.py new file mode 100644 index 00000000..99b2fce0 --- /dev/null +++ b/tests_v2/test_command_palette_switcher_renamed_surface.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +""" +Regression test: cmd+p switcher should search and navigate to renamed surfaces. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + message=f"palette visibility did not become {visible}", + ) + + +def _open_switcher(client: cmux, window_id: str) -> None: + _set_palette_visible(client, window_id, False) + client.simulate_shortcut("cmd+p") + _wait_until( + lambda: _palette_visible(client, window_id), + message="cmd+p did not open switcher", + ) + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher", + message="cmd+p did not open switcher mode", + ) + + +def _rename_surface(client: cmux, surface_id: str, title: str) -> None: + client._call( + "surface.action", + { + "surface_id": surface_id, + "action": "rename", + "title": title, + }, + ) + + +def _current_surface_id(client: cmux, workspace_id: str) -> str: + payload = client._call("surface.current", {"workspace_id": workspace_id}) or {} + return str(payload.get("surface_id") or "") + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + workspace_id = client.new_workspace(window_id=window_id) + client.select_workspace(workspace_id) + time.sleep(0.2) + + right_surface_id = client.new_split("right") + time.sleep(0.2) + + payload = client._call("surface.list", {"workspace_id": workspace_id}) or {} + rows = payload.get("surfaces") or [] + if len(rows) < 2: + raise cmuxError(f"expected at least two surfaces after split: {payload}") + + left_surface_id = "" + for row in rows: + sid = str(row.get("id") or "") + if sid and sid != right_surface_id: + left_surface_id = sid + break + if not left_surface_id: + raise cmuxError(f"failed to resolve left surface id: {payload}") + + token = f"renamed-surface-{int(time.time() * 1000)}" + _rename_surface(client, right_surface_id, token) + time.sleep(0.2) + + client.focus_surface(left_surface_id) + time.sleep(0.2) + + _open_switcher(client, window_id) + client.simulate_type(token) + _wait_until( + lambda: token in str(_palette_results(client, window_id).get("query") or "").strip().lower(), + message="switcher query did not update to renamed surface token", + ) + + result_rows = (_palette_results(client, window_id, limit=24).get("results") or []) + if not result_rows: + raise cmuxError("switcher returned no rows for renamed surface query") + + top_row = result_rows[0] or {} + top_id = str(top_row.get("command_id") or "") + top_title = str(top_row.get("title") or "") + if not top_id.startswith("switcher.surface."): + raise cmuxError( + f"expected renamed surface row on top, got top={top_id!r} rows={result_rows}" + ) + if top_title != token: + raise cmuxError( + f"expected top surface row title to match renamed title {token!r}, got {top_title!r}" + ) + + client.simulate_shortcut("enter") + _wait_until( + lambda: not _palette_visible(client, window_id), + message="palette did not close after selecting renamed surface row", + ) + + _wait_until( + lambda: _current_surface_id(client, workspace_id).lower() == right_surface_id.lower(), + message="Enter on renamed surface switcher row did not focus target surface", + ) + + client.close_workspace(workspace_id) + + print("PASS: cmd+p switcher searches and navigates renamed surfaces") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_switcher_surface_precedence.py b/tests_v2/test_command_palette_switcher_surface_precedence.py new file mode 100644 index 00000000..ec3850f5 --- /dev/null +++ b/tests_v2/test_command_palette_switcher_surface_precedence.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +""" +Regression test: switcher should prioritize matching surfaces over workspace rows. + +Why: workspace rows used to index metadata from all surfaces, so a path-token query +could rank the workspace row above the actual surface row (because of stable rank +tie-breaks), making Enter jump to workspace instead of the intended surface. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + message=f"palette visibility did not become {visible}", + ) + + +def _open_switcher(client: cmux, window_id: str) -> None: + _set_palette_visible(client, window_id, False) + client.simulate_shortcut("cmd+p") + _wait_until( + lambda: _palette_visible(client, window_id), + message="cmd+p did not open switcher", + ) + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher", + message="cmd+p did not open switcher mode", + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + workspace_id = client.new_workspace(window_id=window_id) + client.select_workspace(workspace_id) + client.rename_workspace("workspace-no-token", workspace=workspace_id) + time.sleep(0.2) + + right_surface_id = client.new_split("right") + time.sleep(0.2) + + payload = client._call("surface.list", {"workspace_id": workspace_id}) or {} + rows = payload.get("surfaces") or [] + if len(rows) < 2: + raise cmuxError(f"expected at least two surfaces after split: {payload}") + + left_surface_id = "" + for row in rows: + sid = str(row.get("id") or "") + if sid and sid != right_surface_id: + left_surface_id = sid + break + if not left_surface_id: + raise cmuxError(f"failed to resolve left surface id: {payload}") + + token = f"cmdp-switcher-target-{int(time.time() * 1000)}" + target_dir = f"/tmp/{token}" + + client.send_surface(left_surface_id, "cd /tmp\n") + client.send_surface( + right_surface_id, + f"mkdir -p {target_dir} && cd {target_dir}\n", + ) + client.focus_surface(left_surface_id) + time.sleep(0.8) + + _open_switcher(client, window_id) + client.simulate_type(token) + _wait_until( + lambda: token in str(_palette_results(client, window_id).get("query") or "").strip().lower(), + message="switcher query did not update to target token", + ) + + def _has_surface_match() -> bool: + result_rows = (_palette_results(client, window_id, limit=24).get("results") or []) + return any(str((row or {}).get("command_id") or "").startswith("switcher.surface.") for row in result_rows) + + _wait_until( + _has_surface_match, + timeout_s=8.0, + message="switcher results never produced a matching surface row for token query", + ) + + result_rows = (_palette_results(client, window_id, limit=24).get("results") or []) + if not result_rows: + raise cmuxError("switcher returned no rows for token query") + + top_id = str((result_rows[0] or {}).get("command_id") or "") + if not top_id.startswith("switcher.surface."): + raise cmuxError(f"expected a surface row on top for token query, got top={top_id!r} rows={result_rows}") + + workspace_matches = [ + str((row or {}).get("command_id") or "") + for row in result_rows + if str((row or {}).get("command_id") or "").startswith("switcher.workspace.") + ] + if workspace_matches: + raise cmuxError( + f"workspace row should not match a non-focused surface path token; workspace matches={workspace_matches} rows={result_rows}" + ) + + _set_palette_visible(client, window_id, False) + client.close_workspace(workspace_id) + + print("PASS: switcher ranks matching surface rows ahead of workspace rows for path-token queries") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_switcher_type_labels.py b/tests_v2/test_command_palette_switcher_type_labels.py new file mode 100644 index 00000000..dbbe2fcd --- /dev/null +++ b/tests_v2/test_command_palette_switcher_type_labels.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +Regression test: cmd+p switcher rows expose right-side type labels. + +Expected trailing labels: +- switcher.workspace.* => Workspace +- switcher.surface.* => Surface +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 6.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _palette_results(client: cmux, window_id: str, limit: int = 20) -> dict: + return client.command_palette_results(window_id=window_id, limit=limit) + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + message=f"palette visibility did not become {visible}", + ) + + +def _open_switcher(client: cmux, window_id: str) -> None: + _set_palette_visible(client, window_id, False) + client.simulate_shortcut("cmd+p") + _wait_until( + lambda: _palette_visible(client, window_id), + message="cmd+p did not open switcher", + ) + _wait_until( + lambda: str(_palette_results(client, window_id).get("mode") or "") == "switcher", + message="cmd+p did not open switcher mode", + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_id = client.current_window() + for row in client.list_windows(): + other_id = str(row.get("id") or "") + if other_id and other_id != window_id: + client.close_window(other_id) + time.sleep(0.2) + + client.focus_window(window_id) + client.activate_app() + time.sleep(0.2) + + workspace_id = client.new_workspace(window_id=window_id) + client.select_workspace(workspace_id) + token = f"switchertype{int(time.time() * 1000)}" + client.rename_workspace(token, workspace=workspace_id) + _ = client.new_split("right") + time.sleep(0.3) + + _open_switcher(client, window_id) + client.simulate_type(token) + _wait_until( + lambda: token in str(_palette_results(client, window_id, limit=60).get("query") or "").strip().lower(), + message="switcher query did not update to workspace token", + ) + + rows = (_palette_results(client, window_id, limit=60).get("results") or []) + if not rows: + raise cmuxError("switcher returned no rows for token query") + + workspace_rows = [ + row for row in rows + if str((row or {}).get("command_id") or "").startswith("switcher.workspace.") + ] + surface_rows = [ + row for row in rows + if str((row or {}).get("command_id") or "").startswith("switcher.surface.") + ] + + if not workspace_rows: + raise cmuxError(f"expected workspace rows for switcher query: rows={rows}") + if not surface_rows: + raise cmuxError(f"expected surface rows for switcher query: rows={rows}") + + bad_workspace = [row for row in workspace_rows if str((row or {}).get("trailing_label") or "") != "Workspace"] + if bad_workspace: + raise cmuxError(f"workspace rows missing 'Workspace' trailing label: {bad_workspace}") + + bad_surface = [row for row in surface_rows if str((row or {}).get("trailing_label") or "") != "Surface"] + if bad_surface: + raise cmuxError(f"surface rows missing 'Surface' trailing label: {bad_surface}") + + _set_palette_visible(client, window_id, False) + client.close_workspace(workspace_id) + + print("PASS: cmd+p switcher rows report Workspace/Surface trailing labels") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_command_palette_window_scope.py b/tests_v2/test_command_palette_window_scope.py new file mode 100644 index 00000000..63236c34 --- /dev/null +++ b/tests_v2/test_command_palette_window_scope.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +""" +Regression test: command palette should open only in the active window. + +Why: if command-palette toggle is broadcast to all windows, inactive windows can +end up with an open palette that steals focus once they become key. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 5.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _palette_visible(client: cmux, window_id: str) -> bool: + res = client._call("debug.command_palette.visible", {"window_id": window_id}) or {} + return bool(res.get("visible")) + + +def _set_palette_visible(client: cmux, window_id: str, visible: bool) -> None: + if _palette_visible(client, window_id) == visible: + return + client._call("debug.command_palette.toggle", {"window_id": window_id}) + _wait_until( + lambda: _palette_visible(client, window_id) == visible, + timeout_s=3.0, + message=f"palette in {window_id} did not become {visible}", + ) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + w1 = client.current_window() + w2 = client.new_window() + time.sleep(0.25) + + ws1 = client.new_workspace(window_id=w1) + ws2 = client.new_workspace(window_id=w2) + time.sleep(0.25) + _set_palette_visible(client, w1, False) + _set_palette_visible(client, w2, False) + + # Open palette in window1 and verify window2 remains untouched. + client._call("debug.command_palette.toggle", {"window_id": w1}) + _wait_until( + lambda: _palette_visible(client, w1), + timeout_s=3.0, + message="window1 command palette did not open", + ) + if _palette_visible(client, w2): + raise cmuxError("window2 palette became visible when toggling window1") + + # Closing window1 palette should not affect window2. + client._call("debug.command_palette.toggle", {"window_id": w1}) + _wait_until( + lambda: not _palette_visible(client, w1), + timeout_s=3.0, + message="window1 command palette did not close", + ) + + # Mirror the same check in the other direction. + client._call("debug.command_palette.toggle", {"window_id": w2}) + _wait_until( + lambda: _palette_visible(client, w2), + timeout_s=3.0, + message="window2 command palette did not open", + ) + if _palette_visible(client, w1): + raise cmuxError("window1 palette became visible when toggling window2") + client._call("debug.command_palette.toggle", {"window_id": w2}) + _wait_until( + lambda: not _palette_visible(client, w2), + timeout_s=3.0, + message="window2 command palette did not close", + ) + + print("PASS: command palette is scoped to active window") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_v2/test_shortcut_window_scope.py b/tests_v2/test_shortcut_window_scope.py new file mode 100644 index 00000000..a13750e2 --- /dev/null +++ b/tests_v2/test_shortcut_window_scope.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +Regression test: app shortcuts must apply to the focused window only. + +Covers: +- Cmd+B (toggle sidebar) should only affect the active window. +- Cmd+T (new terminal tab/surface) should only affect the active window. +""" + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmux, cmuxError + + +SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock") + + +def _wait_until(predicate, timeout_s: float = 4.0, interval_s: float = 0.05, message: str = "timeout") -> None: + start = time.time() + while time.time() - start < timeout_s: + if predicate(): + return + time.sleep(interval_s) + raise cmuxError(message) + + +def _sidebar_visible(client: cmux, window_id: str) -> bool: + payload = client._call("debug.sidebar.visible", {"window_id": window_id}) or {} + return bool(payload.get("visible")) + + +def _surface_count(client: cmux, workspace_id: str) -> int: + payload = client._call("surface.list", {"workspace_id": workspace_id}) or {} + return len(payload.get("surfaces") or []) + + +def main() -> int: + with cmux(SOCKET_PATH) as client: + client.activate_app() + time.sleep(0.2) + + window_a = client.current_window() + window_b = client.new_window() + time.sleep(0.25) + + workspace_a = client.new_workspace(window_id=window_a) + workspace_b = client.new_workspace(window_id=window_b) + time.sleep(0.25) + + client.focus_window(window_a) + client.activate_app() + time.sleep(0.2) + + a_before = _sidebar_visible(client, window_a) + b_before = _sidebar_visible(client, window_b) + + client.simulate_shortcut("cmd+b") + _wait_until( + lambda: _sidebar_visible(client, window_a) != a_before, + message="Cmd+B did not toggle sidebar in active window A", + ) + a_after = _sidebar_visible(client, window_a) + b_after = _sidebar_visible(client, window_b) + if b_after != b_before: + raise cmuxError("Cmd+B in window A incorrectly toggled sidebar in window B") + + client.focus_window(window_b) + client.activate_app() + time.sleep(0.2) + + client.simulate_shortcut("cmd+b") + _wait_until( + lambda: _sidebar_visible(client, window_b) != b_after, + message="Cmd+B did not toggle sidebar in active window B", + ) + if _sidebar_visible(client, window_a) != a_after: + raise cmuxError("Cmd+B in window B incorrectly toggled sidebar in window A") + + client.focus_window(window_a) + client.activate_app() + time.sleep(0.2) + client.select_workspace(workspace_a) + time.sleep(0.1) + + count_a_before = _surface_count(client, workspace_a) + count_b_before = _surface_count(client, workspace_b) + + client.simulate_shortcut("cmd+t") + _wait_until( + lambda: _surface_count(client, workspace_a) == count_a_before + 1, + message="Cmd+T did not create a new surface in active window A", + ) + + count_b_after = _surface_count(client, workspace_b) + if count_b_after != count_b_before: + raise cmuxError("Cmd+T in window A incorrectly created a surface in window B") + + print("PASS: window-scoped shortcuts stay in the active window (Cmd+B, Cmd+T)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())