Add command palette (Cmd+Shift+P) (#358)
Implements a VS Code-style command palette with fuzzy search, workspace/surface switching, rename mode, and keyboard navigation. Closes https://github.com/manaflow-ai/cmux/issues/133
This commit is contained in:
parent
2499ba1bb2
commit
5d63c5f035
24 changed files with 6581 additions and 13 deletions
|
|
@ -103,12 +103,57 @@ func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool {
|
||||||
return normalizedFlags == [] || normalizedFlags == [.shift]
|
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 {
|
enum BrowserZoomShortcutAction: Equatable {
|
||||||
case zoomIn
|
case zoomIn
|
||||||
case zoomOut
|
case zoomOut
|
||||||
case reset
|
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(
|
func browserZoomShortcutAction(
|
||||||
flags: NSEvent.ModifierFlags,
|
flags: NSEvent.ModifierFlags,
|
||||||
chars: String,
|
chars: String,
|
||||||
|
|
@ -337,6 +382,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
|
|
||||||
private var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:]
|
private var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:]
|
||||||
private var mainWindowControllers: [MainWindowController] = []
|
private var mainWindowControllers: [MainWindowController] = []
|
||||||
|
private var commandPaletteVisibilityByWindowId: [UUID: Bool] = [:]
|
||||||
|
private var commandPaletteSelectionByWindowId: [UUID: Int] = [:]
|
||||||
|
private var commandPaletteSnapshotByWindowId: [UUID: CommandPaletteDebugSnapshot] = [:]
|
||||||
|
|
||||||
var updateViewModel: UpdateViewModel {
|
var updateViewModel: UpdateViewModel {
|
||||||
updateController.viewModel
|
updateController.viewModel
|
||||||
|
|
@ -563,6 +611,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
self.unregisterMainWindow(closing)
|
self.unregisterMainWindow(closing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
commandPaletteVisibilityByWindowId[windowId] = false
|
||||||
|
commandPaletteSelectionByWindowId[windowId] = 0
|
||||||
|
commandPaletteSnapshotByWindowId[windowId] = .empty
|
||||||
|
|
||||||
if window.isKeyWindow {
|
if window.isKeyWindow {
|
||||||
setActiveMainWindow(window)
|
setActiveMainWindow(window)
|
||||||
|
|
@ -599,6 +650,111 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
mainWindowContexts.values.first(where: { $0.tabManager === tabManager })?.windowId
|
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)? {
|
func locateSurface(surfaceId: UUID) -> (windowId: UUID, workspaceId: UUID, tabManager: TabManager)? {
|
||||||
for ctx in mainWindowContexts.values {
|
for ctx in mainWindowContexts.values {
|
||||||
for ws in ctx.tabManager.tabs {
|
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 })
|
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?) {
|
@objc func openNewMainWindow(_ sender: Any?) {
|
||||||
_ = createMainWindow()
|
_ = createMainWindow()
|
||||||
}
|
}
|
||||||
|
|
@ -1865,7 +2114,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
return false
|
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" {
|
if normalizedFlags == [.command], chars == "q" {
|
||||||
return handleQuitShortcutWarning()
|
return handleQuitShortcutWarning()
|
||||||
}
|
}
|
||||||
|
|
@ -1895,6 +2173,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
return true
|
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:
|
// Keep keyboard routing deterministic after split close/reparent transitions:
|
||||||
// before processing shortcuts, converge first responder with the focused terminal panel.
|
// before processing shortcuts, converge first responder with the focused terminal panel.
|
||||||
if isControlD {
|
if isControlD {
|
||||||
|
|
@ -1942,7 +2224,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
|
|
||||||
// Primary UI shortcuts
|
// Primary UI shortcuts
|
||||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSidebar)) {
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSidebar)) {
|
||||||
sidebarState?.toggle()
|
_ = toggleSidebarInActiveMainWindow()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2926,6 +3208,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
private func unregisterMainWindow(_ window: NSWindow) {
|
private func unregisterMainWindow(_ window: NSWindow) {
|
||||||
let key = ObjectIdentifier(window)
|
let key = ObjectIdentifier(window)
|
||||||
guard let removed = mainWindowContexts.removeValue(forKey: key) else { return }
|
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.
|
// Avoid stale notifications that can no longer be opened once the owning window is gone.
|
||||||
if let store = notificationStore {
|
if let store = notificationStore {
|
||||||
|
|
@ -3873,6 +4158,19 @@ private var cmuxFirstResponderGuardHitViewOverride: NSView?
|
||||||
|
|
||||||
private extension NSWindow {
|
private extension NSWindow {
|
||||||
@objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool {
|
@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,
|
if let responder,
|
||||||
let webView = Self.cmuxOwningWebView(for: responder),
|
let webView = Self.cmuxOwningWebView(for: responder),
|
||||||
!webView.allowsFirstResponderAcquisitionEffective {
|
!webView.allowsFirstResponderAcquisitionEffective {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1450,7 +1450,11 @@ class TabManager: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func focusTab(_ tabId: UUID, surfaceId: UUID? = nil, suppressFlash: Bool = false) {
|
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
|
selectedTabId = tabId
|
||||||
NotificationCenter.default.post(
|
NotificationCenter.default.post(
|
||||||
name: .ghosttyDidFocusTab,
|
name: .ghosttyDidFocusTab,
|
||||||
|
|
@ -1469,7 +1473,7 @@ class TabManager: ObservableObject {
|
||||||
if let surfaceId {
|
if let surfaceId {
|
||||||
if !suppressFlash {
|
if !suppressFlash {
|
||||||
focusSurface(tabId: tabId, surfaceId: surfaceId)
|
focusSurface(tabId: tabId, surfaceId: surfaceId)
|
||||||
} else if let tab = tabs.first(where: { $0.id == tabId }) {
|
} else {
|
||||||
tab.focusPanel(surfaceId)
|
tab.focusPanel(surfaceId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3055,6 +3059,13 @@ enum ResizeDirection {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Notification.Name {
|
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 ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle")
|
||||||
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
|
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
|
||||||
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")
|
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ class TerminalController {
|
||||||
"browser.focus_webview",
|
"browser.focus_webview",
|
||||||
"browser.focus",
|
"browser.focus",
|
||||||
"browser.tab.switch",
|
"browser.tab.switch",
|
||||||
|
"debug.command_palette.toggle",
|
||||||
"debug.notification.focus",
|
"debug.notification.focus",
|
||||||
"debug.app.activate"
|
"debug.app.activate"
|
||||||
]
|
]
|
||||||
|
|
@ -1279,6 +1280,26 @@ class TerminalController {
|
||||||
return v2Result(id: id, self.v2DebugType(params: params))
|
return v2Result(id: id, self.v2DebugType(params: params))
|
||||||
case "debug.app.activate":
|
case "debug.app.activate":
|
||||||
return v2Result(id: id, self.v2DebugActivateApp())
|
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":
|
case "debug.terminal.is_focused":
|
||||||
return v2Result(id: id, self.v2DebugIsTerminalFocused(params: params))
|
return v2Result(id: id, self.v2DebugIsTerminalFocused(params: params))
|
||||||
case "debug.terminal.read_text":
|
case "debug.terminal.read_text":
|
||||||
|
|
@ -1475,6 +1496,16 @@ class TerminalController {
|
||||||
"debug.shortcut.simulate",
|
"debug.shortcut.simulate",
|
||||||
"debug.type",
|
"debug.type",
|
||||||
"debug.app.activate",
|
"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.is_focused",
|
||||||
"debug.terminal.read_text",
|
"debug.terminal.read_text",
|
||||||
"debug.terminal.render_stats",
|
"debug.terminal.render_stats",
|
||||||
|
|
@ -7564,6 +7595,268 @@ class TerminalController {
|
||||||
return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil)
|
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 {
|
private func v2DebugIsTerminalFocused(params: [String: Any]) -> V2CallResult {
|
||||||
guard let surfaceId = v2String(params, "surface_id") else {
|
guard let surfaceId = v2String(params, "surface_id") else {
|
||||||
return .err(code: "invalid_params", message: "Missing surface_id", data: nil)
|
return .err(code: "invalid_params", message: "Missing surface_id", data: nil)
|
||||||
|
|
@ -8003,17 +8296,24 @@ class TerminalController {
|
||||||
|
|
||||||
var result = "ERROR: Failed to create event"
|
var result = "ERROR: Failed to create event"
|
||||||
DispatchQueue.main.sync {
|
DispatchQueue.main.sync {
|
||||||
// Tests can run while the app is activating (no keyWindow yet). Prefer a visible
|
// Prefer the current active-tab-manager window so shortcut simulation stays
|
||||||
// window to keep input simulation deterministic in debug builds.
|
// scoped to the intended window even when NSApp.keyWindow is stale.
|
||||||
let targetWindow = NSApp.keyWindow
|
let targetWindow: NSWindow? = {
|
||||||
?? NSApp.mainWindow
|
if let activeTabManager = self.tabManager,
|
||||||
?? NSApp.windows.first(where: { $0.isVisible })
|
let windowId = AppDelegate.shared?.windowId(for: activeTabManager),
|
||||||
?? NSApp.windows.first
|
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 {
|
if let targetWindow {
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
targetWindow.makeKeyAndOrderFront(nil)
|
targetWindow.makeKeyAndOrderFront(nil)
|
||||||
}
|
}
|
||||||
let windowNumber = (NSApp.keyWindow ?? targetWindow)?.windowNumber ?? 0
|
let windowNumber = targetWindow?.windowNumber ?? 0
|
||||||
guard let keyDownEvent = NSEvent.keyEvent(
|
guard let keyDownEvent = NSEvent.keyEvent(
|
||||||
with: .keyDown,
|
with: .keyDown,
|
||||||
location: .zero,
|
location: .zero,
|
||||||
|
|
@ -8706,6 +9006,10 @@ class TerminalController {
|
||||||
let charactersIgnoringModifiers: String
|
let charactersIgnoringModifiers: String
|
||||||
|
|
||||||
switch keyToken.lowercased() {
|
switch keyToken.lowercased() {
|
||||||
|
case "esc", "escape":
|
||||||
|
storedKey = "\u{1b}"
|
||||||
|
keyCode = UInt16(kVK_Escape)
|
||||||
|
charactersIgnoringModifiers = storedKey
|
||||||
case "left":
|
case "left":
|
||||||
storedKey = "←"
|
storedKey = "←"
|
||||||
keyCode = 123
|
keyCode = 123
|
||||||
|
|
@ -8726,6 +9030,10 @@ class TerminalController {
|
||||||
storedKey = "\r"
|
storedKey = "\r"
|
||||||
keyCode = UInt16(kVK_Return)
|
keyCode = UInt16(kVK_Return)
|
||||||
charactersIgnoringModifiers = storedKey
|
charactersIgnoringModifiers = storedKey
|
||||||
|
case "backspace", "delete", "del":
|
||||||
|
storedKey = "\u{7f}"
|
||||||
|
keyCode = UInt16(kVK_Delete)
|
||||||
|
charactersIgnoringModifiers = storedKey
|
||||||
default:
|
default:
|
||||||
let key = keyToken.lowercased()
|
let key = keyToken.lowercased()
|
||||||
guard let code = keyCodeForShortcutKey(key) else { return nil }
|
guard let code = keyCodeForShortcutKey(key) else { return nil }
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,20 @@ struct cmuxApp: App {
|
||||||
|
|
||||||
// Close tab/workspace
|
// Close tab/workspace
|
||||||
CommandGroup(after: .newItem) {
|
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:
|
// Terminal semantics:
|
||||||
// Cmd+W closes the focused tab (with confirmation if needed). If this is the last
|
// Cmd+W closes the focused tab (with confirmation if needed). If this is the last
|
||||||
// tab in the last workspace, it closes the window.
|
// tab in the last workspace, it closes the window.
|
||||||
|
|
@ -422,7 +436,9 @@ struct cmuxApp: App {
|
||||||
// Tab navigation
|
// Tab navigation
|
||||||
CommandGroup(after: .toolbar) {
|
CommandGroup(after: .toolbar) {
|
||||||
splitCommandButton(title: "Toggle Sidebar", shortcut: toggleSidebarMenuShortcut) {
|
splitCommandButton(title: "Toggle Sidebar", shortcut: toggleSidebarMenuShortcut) {
|
||||||
sidebarState.toggle()
|
if AppDelegate.shared?.toggleSidebarInActiveMainWindow() != true {
|
||||||
|
sidebarState.toggle()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
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 {
|
enum ClaudeCodeIntegrationSettings {
|
||||||
static let hooksEnabledKey = "claudeCodeHooksEnabled"
|
static let hooksEnabledKey = "claudeCodeHooksEnabled"
|
||||||
static let defaultHooksEnabled = true
|
static let defaultHooksEnabled = true
|
||||||
|
|
@ -2565,6 +2593,8 @@ struct SettingsView: View {
|
||||||
@AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
|
@AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
|
||||||
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
||||||
@AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
@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(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
||||||
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
||||||
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||||
|
|
@ -2761,6 +2791,19 @@ struct SettingsView: View {
|
||||||
|
|
||||||
SettingsCardDivider()
|
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(
|
SettingsCardRow(
|
||||||
"Sidebar Branch Layout",
|
"Sidebar Branch Layout",
|
||||||
subtitle: sidebarBranchVerticalLayout
|
subtitle: sidebarBranchVerticalLayout
|
||||||
|
|
@ -3310,6 +3353,7 @@ struct SettingsView: View {
|
||||||
browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
|
browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
|
||||||
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
||||||
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
||||||
|
commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
||||||
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
||||||
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
||||||
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
final class SidebarCommandHintPolicyTests: XCTestCase {
|
||||||
func testCommandHintRequiresCommandOnlyModifier() {
|
func testCommandHintRequiresCommandOnlyModifier() {
|
||||||
XCTAssertTrue(SidebarCommandHintPolicy.shouldShowHints(for: [.command]))
|
XCTAssertTrue(SidebarCommandHintPolicy.shouldShowHints(for: [.command]))
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import XCTest
|
import XCTest
|
||||||
|
import AppKit
|
||||||
|
|
||||||
#if canImport(cmux_DEV)
|
#if canImport(cmux_DEV)
|
||||||
@testable import 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]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -918,6 +918,27 @@ class cmux:
|
||||||
def activate_app(self) -> None:
|
def activate_app(self) -> None:
|
||||||
self._call("debug.app.activate")
|
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:
|
def is_terminal_focused(self, panel: Union[str, int]) -> bool:
|
||||||
sid = self._resolve_surface_id(panel)
|
sid = self._resolve_surface_id(panel)
|
||||||
res = self._call("debug.terminal.is_focused", {"surface_id": sid}) or {}
|
res = self._call("debug.terminal.is_focused", {"surface_id": sid}) or {}
|
||||||
|
|
|
||||||
158
tests_v2/test_command_palette_backspace_go_back.py
Normal file
158
tests_v2/test_command_palette_backspace_go_back.py
Normal file
|
|
@ -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())
|
||||||
97
tests_v2/test_command_palette_focus.py
Normal file
97
tests_v2/test_command_palette_focus.py
Normal file
|
|
@ -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())
|
||||||
125
tests_v2/test_command_palette_focus_lock_workspace_spawn.py
Normal file
125
tests_v2/test_command_palette_focus_lock_workspace_spawn.py
Normal file
|
|
@ -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())
|
||||||
133
tests_v2/test_command_palette_fuzzy_ranking.py
Normal file
133
tests_v2/test_command_palette_fuzzy_ranking.py
Normal file
|
|
@ -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())
|
||||||
194
tests_v2/test_command_palette_modes.py
Normal file
194
tests_v2/test_command_palette_modes.py
Normal file
|
|
@ -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())
|
||||||
143
tests_v2/test_command_palette_navigation_keys.py
Normal file
143
tests_v2/test_command_palette_navigation_keys.py
Normal file
|
|
@ -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())
|
||||||
106
tests_v2/test_command_palette_rename_enter.py
Normal file
106
tests_v2/test_command_palette_rename_enter.py
Normal file
|
|
@ -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())
|
||||||
185
tests_v2/test_command_palette_rename_select_all.py
Normal file
185
tests_v2/test_command_palette_rename_select_all.py
Normal file
|
|
@ -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())
|
||||||
122
tests_v2/test_command_palette_search_action_sync.py
Normal file
122
tests_v2/test_command_palette_search_action_sync.py
Normal file
|
|
@ -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())
|
||||||
121
tests_v2/test_command_palette_search_typing_stability.py
Normal file
121
tests_v2/test_command_palette_search_typing_stability.py
Normal file
|
|
@ -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())
|
||||||
|
|
@ -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())
|
||||||
160
tests_v2/test_command_palette_switcher_renamed_surface.py
Normal file
160
tests_v2/test_command_palette_switcher_renamed_surface.py
Normal file
|
|
@ -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())
|
||||||
155
tests_v2/test_command_palette_switcher_surface_precedence.py
Normal file
155
tests_v2/test_command_palette_switcher_surface_precedence.py
Normal file
|
|
@ -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())
|
||||||
127
tests_v2/test_command_palette_switcher_type_labels.py
Normal file
127
tests_v2/test_command_palette_switcher_type_labels.py
Normal file
|
|
@ -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())
|
||||||
99
tests_v2/test_command_palette_window_scope.py
Normal file
99
tests_v2/test_command_palette_window_scope.py
Normal file
|
|
@ -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())
|
||||||
107
tests_v2/test_shortcut_window_scope.py
Normal file
107
tests_v2/test_shortcut_window_scope.py
Normal file
|
|
@ -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())
|
||||||
Loading…
Add table
Add a link
Reference in a new issue