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]
|
||||
}
|
||||
|
||||
func commandPaletteSelectionDeltaForKeyboardNavigation(
|
||||
flags: NSEvent.ModifierFlags,
|
||||
chars: String,
|
||||
keyCode: UInt16
|
||||
) -> Int? {
|
||||
let normalizedFlags = flags
|
||||
.intersection(.deviceIndependentFlagsMask)
|
||||
.subtracting([.numericPad, .function])
|
||||
let normalizedChars = chars.lowercased()
|
||||
|
||||
if normalizedFlags == [] {
|
||||
switch keyCode {
|
||||
case 125: return 1 // Down arrow
|
||||
case 126: return -1 // Up arrow
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
if normalizedFlags == [.control] {
|
||||
// Control modifiers can surface as either printable chars or ASCII control chars.
|
||||
if keyCode == 45 || normalizedChars == "n" || normalizedChars == "\u{0e}" { return 1 } // Ctrl+N
|
||||
if keyCode == 35 || normalizedChars == "p" || normalizedChars == "\u{10}" { return -1 } // Ctrl+P
|
||||
if keyCode == 38 || normalizedChars == "j" || normalizedChars == "\u{0a}" { return 1 } // Ctrl+J
|
||||
if keyCode == 40 || normalizedChars == "k" || normalizedChars == "\u{0b}" { return -1 } // Ctrl+K
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
enum BrowserZoomShortcutAction: Equatable {
|
||||
case zoomIn
|
||||
case zoomOut
|
||||
case reset
|
||||
}
|
||||
|
||||
struct CommandPaletteDebugResultRow {
|
||||
let commandId: String
|
||||
let title: String
|
||||
let shortcutHint: String?
|
||||
let trailingLabel: String?
|
||||
let score: Int
|
||||
}
|
||||
|
||||
struct CommandPaletteDebugSnapshot {
|
||||
let query: String
|
||||
let mode: String
|
||||
let results: [CommandPaletteDebugResultRow]
|
||||
|
||||
static let empty = CommandPaletteDebugSnapshot(query: "", mode: "commands", results: [])
|
||||
}
|
||||
|
||||
func browserZoomShortcutAction(
|
||||
flags: NSEvent.ModifierFlags,
|
||||
chars: String,
|
||||
|
|
@ -337,6 +382,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
private var mainWindowContexts: [ObjectIdentifier: MainWindowContext] = [:]
|
||||
private var mainWindowControllers: [MainWindowController] = []
|
||||
private var commandPaletteVisibilityByWindowId: [UUID: Bool] = [:]
|
||||
private var commandPaletteSelectionByWindowId: [UUID: Int] = [:]
|
||||
private var commandPaletteSnapshotByWindowId: [UUID: CommandPaletteDebugSnapshot] = [:]
|
||||
|
||||
var updateViewModel: UpdateViewModel {
|
||||
updateController.viewModel
|
||||
|
|
@ -563,6 +611,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
self.unregisterMainWindow(closing)
|
||||
}
|
||||
}
|
||||
commandPaletteVisibilityByWindowId[windowId] = false
|
||||
commandPaletteSelectionByWindowId[windowId] = 0
|
||||
commandPaletteSnapshotByWindowId[windowId] = .empty
|
||||
|
||||
if window.isKeyWindow {
|
||||
setActiveMainWindow(window)
|
||||
|
|
@ -599,6 +650,111 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
mainWindowContexts.values.first(where: { $0.tabManager === tabManager })?.windowId
|
||||
}
|
||||
|
||||
func mainWindow(for windowId: UUID) -> NSWindow? {
|
||||
windowForMainWindowId(windowId)
|
||||
}
|
||||
|
||||
func setCommandPaletteVisible(_ visible: Bool, for window: NSWindow) {
|
||||
guard let windowId = mainWindowId(for: window) else { return }
|
||||
commandPaletteVisibilityByWindowId[windowId] = visible
|
||||
}
|
||||
|
||||
func isCommandPaletteVisible(windowId: UUID) -> Bool {
|
||||
commandPaletteVisibilityByWindowId[windowId] ?? false
|
||||
}
|
||||
|
||||
func setCommandPaletteSelectionIndex(_ index: Int, for window: NSWindow) {
|
||||
guard let windowId = mainWindowId(for: window) else { return }
|
||||
commandPaletteSelectionByWindowId[windowId] = max(0, index)
|
||||
}
|
||||
|
||||
func commandPaletteSelectionIndex(windowId: UUID) -> Int {
|
||||
commandPaletteSelectionByWindowId[windowId] ?? 0
|
||||
}
|
||||
|
||||
func setCommandPaletteSnapshot(_ snapshot: CommandPaletteDebugSnapshot, for window: NSWindow) {
|
||||
guard let windowId = mainWindowId(for: window) else { return }
|
||||
commandPaletteSnapshotByWindowId[windowId] = snapshot
|
||||
}
|
||||
|
||||
func commandPaletteSnapshot(windowId: UUID) -> CommandPaletteDebugSnapshot {
|
||||
commandPaletteSnapshotByWindowId[windowId] ?? .empty
|
||||
}
|
||||
|
||||
func isCommandPaletteVisible(for window: NSWindow) -> Bool {
|
||||
guard let windowId = mainWindowId(for: window) else { return false }
|
||||
return commandPaletteVisibilityByWindowId[windowId] ?? false
|
||||
}
|
||||
|
||||
func shouldBlockFirstResponderChangeWhileCommandPaletteVisible(
|
||||
window: NSWindow,
|
||||
responder: NSResponder?
|
||||
) -> Bool {
|
||||
guard isCommandPaletteVisible(for: window) else { return false }
|
||||
guard let responder else { return false }
|
||||
guard !isCommandPaletteResponder(responder) else { return false }
|
||||
return isFocusStealingResponderWhileCommandPaletteVisible(responder)
|
||||
}
|
||||
|
||||
private func isCommandPaletteResponder(_ responder: NSResponder) -> Bool {
|
||||
if let textView = responder as? NSTextView, textView.isFieldEditor {
|
||||
if let delegateView = textView.delegate as? NSView {
|
||||
return isInsideCommandPaletteOverlay(delegateView)
|
||||
}
|
||||
// SwiftUI can attach a non-view delegate to TextField editors.
|
||||
// When command palette is visible, its search/rename editor is the
|
||||
// only expected field editor inside the main window.
|
||||
return true
|
||||
}
|
||||
if let view = responder as? NSView {
|
||||
return isInsideCommandPaletteOverlay(view)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isFocusStealingResponderWhileCommandPaletteVisible(_ responder: NSResponder) -> Bool {
|
||||
if responder is GhosttyNSView || responder is WKWebView {
|
||||
return true
|
||||
}
|
||||
|
||||
if let textView = responder as? NSTextView,
|
||||
!textView.isFieldEditor,
|
||||
let delegateView = textView.delegate as? NSView {
|
||||
return isTerminalOrBrowserView(delegateView)
|
||||
}
|
||||
|
||||
if let view = responder as? NSView {
|
||||
return isTerminalOrBrowserView(view)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func isTerminalOrBrowserView(_ view: NSView) -> Bool {
|
||||
if view is GhosttyNSView || view is WKWebView {
|
||||
return true
|
||||
}
|
||||
var current: NSView? = view.superview
|
||||
while let candidate = current {
|
||||
if candidate is GhosttyNSView || candidate is WKWebView {
|
||||
return true
|
||||
}
|
||||
current = candidate.superview
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isInsideCommandPaletteOverlay(_ view: NSView) -> Bool {
|
||||
var current: NSView? = view
|
||||
while let candidate = current {
|
||||
if candidate.identifier == commandPaletteOverlayContainerIdentifier {
|
||||
return true
|
||||
}
|
||||
current = candidate.superview
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func locateSurface(surfaceId: UUID) -> (windowId: UUID, workspaceId: UUID, tabManager: TabManager)? {
|
||||
for ctx in mainWindowContexts.values {
|
||||
for ws in ctx.tabManager.tabs {
|
||||
|
|
@ -656,6 +812,99 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return NSApp.windows.first(where: { $0.identifier?.rawValue == expectedIdentifier })
|
||||
}
|
||||
|
||||
private func mainWindowId(for window: NSWindow) -> UUID? {
|
||||
if let context = mainWindowContexts[ObjectIdentifier(window)] {
|
||||
return context.windowId
|
||||
}
|
||||
guard let rawIdentifier = window.identifier?.rawValue,
|
||||
rawIdentifier.hasPrefix("cmux.main.") else { return nil }
|
||||
let idPart = String(rawIdentifier.dropFirst("cmux.main.".count))
|
||||
return UUID(uuidString: idPart)
|
||||
}
|
||||
|
||||
private func activeCommandPaletteWindow() -> NSWindow? {
|
||||
if let keyWindow = NSApp.keyWindow,
|
||||
let windowId = mainWindowId(for: keyWindow),
|
||||
commandPaletteVisibilityByWindowId[windowId] == true {
|
||||
return keyWindow
|
||||
}
|
||||
if let mainWindow = NSApp.mainWindow,
|
||||
let windowId = mainWindowId(for: mainWindow),
|
||||
commandPaletteVisibilityByWindowId[windowId] == true {
|
||||
return mainWindow
|
||||
}
|
||||
if let visibleWindowId = commandPaletteVisibilityByWindowId.first(where: { $0.value })?.key {
|
||||
return windowForMainWindowId(visibleWindowId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func contextForMainWindow(_ window: NSWindow?) -> MainWindowContext? {
|
||||
guard let window, isMainTerminalWindow(window) else { return nil }
|
||||
return mainWindowContexts[ObjectIdentifier(window)]
|
||||
}
|
||||
|
||||
private func preferredMainWindowContextForShortcuts(event: NSEvent) -> MainWindowContext? {
|
||||
if let context = contextForMainWindow(event.window) {
|
||||
return context
|
||||
}
|
||||
if let context = contextForMainWindow(NSApp.keyWindow) {
|
||||
return context
|
||||
}
|
||||
if let context = contextForMainWindow(NSApp.mainWindow) {
|
||||
return context
|
||||
}
|
||||
return mainWindowContexts.values.first
|
||||
}
|
||||
|
||||
private func activateMainWindowContextForShortcutEvent(_ event: NSEvent) {
|
||||
guard let context = preferredMainWindowContextForShortcuts(event: event),
|
||||
let window = context.window ?? windowForMainWindowId(context.windowId) else { return }
|
||||
setActiveMainWindow(window)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func toggleSidebarInActiveMainWindow() -> Bool {
|
||||
if let activeManager = tabManager,
|
||||
let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) {
|
||||
if let window = activeContext.window ?? windowForMainWindowId(activeContext.windowId) {
|
||||
setActiveMainWindow(window)
|
||||
}
|
||||
activeContext.sidebarState.toggle()
|
||||
return true
|
||||
}
|
||||
if let keyContext = contextForMainWindow(NSApp.keyWindow) {
|
||||
if let window = keyContext.window ?? windowForMainWindowId(keyContext.windowId) {
|
||||
setActiveMainWindow(window)
|
||||
}
|
||||
keyContext.sidebarState.toggle()
|
||||
return true
|
||||
}
|
||||
if let mainContext = contextForMainWindow(NSApp.mainWindow) {
|
||||
if let window = mainContext.window ?? windowForMainWindowId(mainContext.windowId) {
|
||||
setActiveMainWindow(window)
|
||||
}
|
||||
mainContext.sidebarState.toggle()
|
||||
return true
|
||||
}
|
||||
if let fallbackContext = mainWindowContexts.values.first {
|
||||
if let window = fallbackContext.window ?? windowForMainWindowId(fallbackContext.windowId) {
|
||||
setActiveMainWindow(window)
|
||||
}
|
||||
fallbackContext.sidebarState.toggle()
|
||||
return true
|
||||
}
|
||||
if let sidebarState {
|
||||
sidebarState.toggle()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sidebarVisibility(windowId: UUID) -> Bool? {
|
||||
mainWindowContexts.values.first(where: { $0.windowId == windowId })?.sidebarState.isVisible
|
||||
}
|
||||
|
||||
@objc func openNewMainWindow(_ sender: Any?) {
|
||||
_ = createMainWindow()
|
||||
}
|
||||
|
|
@ -1865,7 +2114,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return false
|
||||
}
|
||||
|
||||
let normalizedFlags = flags.subtracting([.numericPad, .function])
|
||||
let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock])
|
||||
|
||||
if let delta = commandPaletteSelectionDeltaForKeyboardNavigation(
|
||||
flags: event.modifierFlags,
|
||||
chars: chars,
|
||||
keyCode: event.keyCode
|
||||
),
|
||||
let paletteWindow = activeCommandPaletteWindow() {
|
||||
NotificationCenter.default.post(
|
||||
name: .commandPaletteMoveSelection,
|
||||
object: paletteWindow,
|
||||
userInfo: ["delta": delta]
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
let isCommandP = normalizedFlags == [.command] && (chars == "p" || event.keyCode == 35)
|
||||
if isCommandP {
|
||||
let targetWindow = activeCommandPaletteWindow() ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
||||
NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow)
|
||||
return true
|
||||
}
|
||||
|
||||
let isCommandShiftP = normalizedFlags == [.command, .shift] && (chars == "p" || event.keyCode == 35)
|
||||
if isCommandShiftP {
|
||||
let targetWindow = activeCommandPaletteWindow() ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
||||
NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow)
|
||||
return true
|
||||
}
|
||||
|
||||
if normalizedFlags == [.command], chars == "q" {
|
||||
return handleQuitShortcutWarning()
|
||||
}
|
||||
|
|
@ -1895,6 +2173,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
return true
|
||||
}
|
||||
|
||||
// Route all shortcut handling through the window that actually produced
|
||||
// the event to avoid cross-window actions when app-global pointers are stale.
|
||||
activateMainWindowContextForShortcutEvent(event)
|
||||
|
||||
// Keep keyboard routing deterministic after split close/reparent transitions:
|
||||
// before processing shortcuts, converge first responder with the focused terminal panel.
|
||||
if isControlD {
|
||||
|
|
@ -1942,7 +2224,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
// Primary UI shortcuts
|
||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleSidebar)) {
|
||||
sidebarState?.toggle()
|
||||
_ = toggleSidebarInActiveMainWindow()
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -2926,6 +3208,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
private func unregisterMainWindow(_ window: NSWindow) {
|
||||
let key = ObjectIdentifier(window)
|
||||
guard let removed = mainWindowContexts.removeValue(forKey: key) else { return }
|
||||
commandPaletteVisibilityByWindowId.removeValue(forKey: removed.windowId)
|
||||
commandPaletteSelectionByWindowId.removeValue(forKey: removed.windowId)
|
||||
commandPaletteSnapshotByWindowId.removeValue(forKey: removed.windowId)
|
||||
|
||||
// Avoid stale notifications that can no longer be opened once the owning window is gone.
|
||||
if let store = notificationStore {
|
||||
|
|
@ -3873,6 +4158,19 @@ private var cmuxFirstResponderGuardHitViewOverride: NSView?
|
|||
|
||||
private extension NSWindow {
|
||||
@objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool {
|
||||
if AppDelegate.shared?.shouldBlockFirstResponderChangeWhileCommandPaletteVisible(
|
||||
window: self,
|
||||
responder: responder
|
||||
) == true {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"focus.guard commandPaletteBlocked responder=\(String(describing: responder.map { type(of: $0) })) " +
|
||||
"window=\(ObjectIdentifier(self))"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
if let responder,
|
||||
let webView = Self.cmuxOwningWebView(for: responder),
|
||||
!webView.allowsFirstResponderAcquisitionEffective {
|
||||
|
|
|
|||
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) {
|
||||
guard tabs.contains(where: { $0.id == tabId }) else { return }
|
||||
guard let tab = tabs.first(where: { $0.id == tabId }) else { return }
|
||||
if let surfaceId, tab.panels[surfaceId] != nil {
|
||||
// Keep selected-surface intent stable across selectedTabId didSet async restore.
|
||||
lastFocusedPanelByTab[tabId] = surfaceId
|
||||
}
|
||||
selectedTabId = tabId
|
||||
NotificationCenter.default.post(
|
||||
name: .ghosttyDidFocusTab,
|
||||
|
|
@ -1469,7 +1473,7 @@ class TabManager: ObservableObject {
|
|||
if let surfaceId {
|
||||
if !suppressFlash {
|
||||
focusSurface(tabId: tabId, surfaceId: surfaceId)
|
||||
} else if let tab = tabs.first(where: { $0.id == tabId }) {
|
||||
} else {
|
||||
tab.focusPanel(surfaceId)
|
||||
}
|
||||
}
|
||||
|
|
@ -3055,6 +3059,13 @@ enum ResizeDirection {
|
|||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let commandPaletteToggleRequested = Notification.Name("cmux.commandPaletteToggleRequested")
|
||||
static let commandPaletteRequested = Notification.Name("cmux.commandPaletteRequested")
|
||||
static let commandPaletteSwitcherRequested = Notification.Name("cmux.commandPaletteSwitcherRequested")
|
||||
static let commandPaletteRenameTabRequested = Notification.Name("cmux.commandPaletteRenameTabRequested")
|
||||
static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection")
|
||||
static let commandPaletteRenameInputInteractionRequested = Notification.Name("cmux.commandPaletteRenameInputInteractionRequested")
|
||||
static let commandPaletteRenameInputDeleteBackwardRequested = Notification.Name("cmux.commandPaletteRenameInputDeleteBackwardRequested")
|
||||
static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle")
|
||||
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
|
||||
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ class TerminalController {
|
|||
"browser.focus_webview",
|
||||
"browser.focus",
|
||||
"browser.tab.switch",
|
||||
"debug.command_palette.toggle",
|
||||
"debug.notification.focus",
|
||||
"debug.app.activate"
|
||||
]
|
||||
|
|
@ -1279,6 +1280,26 @@ class TerminalController {
|
|||
return v2Result(id: id, self.v2DebugType(params: params))
|
||||
case "debug.app.activate":
|
||||
return v2Result(id: id, self.v2DebugActivateApp())
|
||||
case "debug.command_palette.toggle":
|
||||
return v2Result(id: id, self.v2DebugToggleCommandPalette(params: params))
|
||||
case "debug.command_palette.rename_tab.open":
|
||||
return v2Result(id: id, self.v2DebugOpenCommandPaletteRenameTabInput(params: params))
|
||||
case "debug.command_palette.visible":
|
||||
return v2Result(id: id, self.v2DebugCommandPaletteVisible(params: params))
|
||||
case "debug.command_palette.selection":
|
||||
return v2Result(id: id, self.v2DebugCommandPaletteSelection(params: params))
|
||||
case "debug.command_palette.results":
|
||||
return v2Result(id: id, self.v2DebugCommandPaletteResults(params: params))
|
||||
case "debug.command_palette.rename_input.interact":
|
||||
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputInteraction(params: params))
|
||||
case "debug.command_palette.rename_input.delete_backward":
|
||||
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputDeleteBackward(params: params))
|
||||
case "debug.command_palette.rename_input.selection":
|
||||
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelection(params: params))
|
||||
case "debug.command_palette.rename_input.select_all":
|
||||
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelectAll(params: params))
|
||||
case "debug.sidebar.visible":
|
||||
return v2Result(id: id, self.v2DebugSidebarVisible(params: params))
|
||||
case "debug.terminal.is_focused":
|
||||
return v2Result(id: id, self.v2DebugIsTerminalFocused(params: params))
|
||||
case "debug.terminal.read_text":
|
||||
|
|
@ -1475,6 +1496,16 @@ class TerminalController {
|
|||
"debug.shortcut.simulate",
|
||||
"debug.type",
|
||||
"debug.app.activate",
|
||||
"debug.command_palette.toggle",
|
||||
"debug.command_palette.rename_tab.open",
|
||||
"debug.command_palette.visible",
|
||||
"debug.command_palette.selection",
|
||||
"debug.command_palette.results",
|
||||
"debug.command_palette.rename_input.interact",
|
||||
"debug.command_palette.rename_input.delete_backward",
|
||||
"debug.command_palette.rename_input.selection",
|
||||
"debug.command_palette.rename_input.select_all",
|
||||
"debug.sidebar.visible",
|
||||
"debug.terminal.is_focused",
|
||||
"debug.terminal.read_text",
|
||||
"debug.terminal.render_stats",
|
||||
|
|
@ -7564,6 +7595,268 @@ class TerminalController {
|
|||
return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil)
|
||||
}
|
||||
|
||||
private func v2DebugToggleCommandPalette(params: [String: Any]) -> V2CallResult {
|
||||
let requestedWindowId = v2UUID(params, "window_id")
|
||||
var result: V2CallResult = .ok([:])
|
||||
DispatchQueue.main.sync {
|
||||
let targetWindow: NSWindow?
|
||||
if let requestedWindowId {
|
||||
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
|
||||
result = .err(
|
||||
code: "not_found",
|
||||
message: "Window not found",
|
||||
data: ["window_id": requestedWindowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: requestedWindowId)]
|
||||
)
|
||||
return
|
||||
}
|
||||
targetWindow = window
|
||||
} else {
|
||||
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
|
||||
}
|
||||
NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: targetWindow)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func v2DebugOpenCommandPaletteRenameTabInput(params: [String: Any]) -> V2CallResult {
|
||||
let requestedWindowId = v2UUID(params, "window_id")
|
||||
var result: V2CallResult = .ok([:])
|
||||
DispatchQueue.main.sync {
|
||||
let targetWindow: NSWindow?
|
||||
if let requestedWindowId {
|
||||
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
|
||||
result = .err(
|
||||
code: "not_found",
|
||||
message: "Window not found",
|
||||
data: [
|
||||
"window_id": requestedWindowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: requestedWindowId)
|
||||
]
|
||||
)
|
||||
return
|
||||
}
|
||||
targetWindow = window
|
||||
} else {
|
||||
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
|
||||
}
|
||||
NotificationCenter.default.post(name: .commandPaletteRenameTabRequested, object: targetWindow)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func v2DebugCommandPaletteVisible(params: [String: Any]) -> V2CallResult {
|
||||
guard let windowId = v2UUID(params, "window_id") else {
|
||||
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
|
||||
}
|
||||
var visible = false
|
||||
DispatchQueue.main.sync {
|
||||
visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false
|
||||
}
|
||||
return .ok([
|
||||
"window_id": windowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||
"visible": visible
|
||||
])
|
||||
}
|
||||
|
||||
private func v2DebugCommandPaletteSelection(params: [String: Any]) -> V2CallResult {
|
||||
guard let windowId = v2UUID(params, "window_id") else {
|
||||
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
|
||||
}
|
||||
var visible = false
|
||||
var selectedIndex = 0
|
||||
DispatchQueue.main.sync {
|
||||
visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false
|
||||
selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0
|
||||
}
|
||||
return .ok([
|
||||
"window_id": windowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||
"visible": visible,
|
||||
"selected_index": max(0, selectedIndex)
|
||||
])
|
||||
}
|
||||
|
||||
private func v2DebugCommandPaletteResults(params: [String: Any]) -> V2CallResult {
|
||||
guard let windowId = v2UUID(params, "window_id") else {
|
||||
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
|
||||
}
|
||||
let requestedLimit = params["limit"] as? Int
|
||||
let limit = max(1, min(100, requestedLimit ?? 20))
|
||||
|
||||
var visible = false
|
||||
var selectedIndex = 0
|
||||
var snapshot = CommandPaletteDebugSnapshot.empty
|
||||
|
||||
DispatchQueue.main.sync {
|
||||
visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false
|
||||
selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0
|
||||
snapshot = AppDelegate.shared?.commandPaletteSnapshot(windowId: windowId) ?? .empty
|
||||
}
|
||||
|
||||
let rows = Array(snapshot.results.prefix(limit)).map { row in
|
||||
[
|
||||
"command_id": row.commandId,
|
||||
"title": row.title,
|
||||
"shortcut_hint": v2OrNull(row.shortcutHint),
|
||||
"trailing_label": v2OrNull(row.trailingLabel),
|
||||
"score": row.score
|
||||
] as [String: Any]
|
||||
}
|
||||
|
||||
return .ok([
|
||||
"window_id": windowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||
"visible": visible,
|
||||
"selected_index": max(0, selectedIndex),
|
||||
"query": snapshot.query,
|
||||
"mode": snapshot.mode,
|
||||
"results": rows
|
||||
])
|
||||
}
|
||||
|
||||
private func v2DebugCommandPaletteRenameInputInteraction(params: [String: Any]) -> V2CallResult {
|
||||
let requestedWindowId = v2UUID(params, "window_id")
|
||||
var result: V2CallResult = .ok([:])
|
||||
DispatchQueue.main.sync {
|
||||
let targetWindow: NSWindow?
|
||||
if let requestedWindowId {
|
||||
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
|
||||
result = .err(
|
||||
code: "not_found",
|
||||
message: "Window not found",
|
||||
data: [
|
||||
"window_id": requestedWindowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: requestedWindowId)
|
||||
]
|
||||
)
|
||||
return
|
||||
}
|
||||
targetWindow = window
|
||||
} else {
|
||||
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
|
||||
}
|
||||
NotificationCenter.default.post(name: .commandPaletteRenameInputInteractionRequested, object: targetWindow)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func v2DebugCommandPaletteRenameInputDeleteBackward(params: [String: Any]) -> V2CallResult {
|
||||
let requestedWindowId = v2UUID(params, "window_id")
|
||||
var result: V2CallResult = .ok([:])
|
||||
DispatchQueue.main.sync {
|
||||
let targetWindow: NSWindow?
|
||||
if let requestedWindowId {
|
||||
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
|
||||
result = .err(
|
||||
code: "not_found",
|
||||
message: "Window not found",
|
||||
data: [
|
||||
"window_id": requestedWindowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: requestedWindowId)
|
||||
]
|
||||
)
|
||||
return
|
||||
}
|
||||
targetWindow = window
|
||||
} else {
|
||||
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
|
||||
}
|
||||
NotificationCenter.default.post(name: .commandPaletteRenameInputDeleteBackwardRequested, object: targetWindow)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func v2DebugCommandPaletteRenameInputSelection(params: [String: Any]) -> V2CallResult {
|
||||
guard let windowId = v2UUID(params, "window_id") else {
|
||||
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
|
||||
}
|
||||
|
||||
var result: V2CallResult = .ok([
|
||||
"window_id": windowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||
"focused": false,
|
||||
"selection_location": 0,
|
||||
"selection_length": 0,
|
||||
"text_length": 0
|
||||
])
|
||||
|
||||
DispatchQueue.main.sync {
|
||||
guard let window = AppDelegate.shared?.mainWindow(for: windowId) else {
|
||||
result = .err(
|
||||
code: "not_found",
|
||||
message: "Window not found",
|
||||
data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)]
|
||||
)
|
||||
return
|
||||
}
|
||||
guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else {
|
||||
return
|
||||
}
|
||||
let selectedRange = editor.selectedRange()
|
||||
let textLength = (editor.string as NSString).length
|
||||
result = .ok([
|
||||
"window_id": windowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||
"focused": true,
|
||||
"selection_location": max(0, selectedRange.location),
|
||||
"selection_length": max(0, selectedRange.length),
|
||||
"text_length": max(0, textLength)
|
||||
])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func v2DebugCommandPaletteRenameInputSelectAll(params: [String: Any]) -> V2CallResult {
|
||||
if let rawEnabled = params["enabled"] {
|
||||
guard let enabled = rawEnabled as? Bool else {
|
||||
return .err(
|
||||
code: "invalid_params",
|
||||
message: "enabled must be a bool",
|
||||
data: ["enabled": rawEnabled]
|
||||
)
|
||||
}
|
||||
DispatchQueue.main.sync {
|
||||
UserDefaults.standard.set(
|
||||
enabled,
|
||||
forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var enabled = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
||||
DispatchQueue.main.sync {
|
||||
enabled = CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled()
|
||||
}
|
||||
|
||||
return .ok([
|
||||
"enabled": enabled
|
||||
])
|
||||
}
|
||||
|
||||
private func v2DebugSidebarVisible(params: [String: Any]) -> V2CallResult {
|
||||
guard let windowId = v2UUID(params, "window_id") else {
|
||||
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
|
||||
}
|
||||
var visibility: Bool?
|
||||
DispatchQueue.main.sync {
|
||||
visibility = AppDelegate.shared?.sidebarVisibility(windowId: windowId)
|
||||
}
|
||||
guard let visible = visibility else {
|
||||
return .err(
|
||||
code: "not_found",
|
||||
message: "Window not found",
|
||||
data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)]
|
||||
)
|
||||
}
|
||||
return .ok([
|
||||
"window_id": windowId.uuidString,
|
||||
"window_ref": v2Ref(kind: .window, uuid: windowId),
|
||||
"visible": visible
|
||||
])
|
||||
}
|
||||
|
||||
private func v2DebugIsTerminalFocused(params: [String: Any]) -> V2CallResult {
|
||||
guard let surfaceId = v2String(params, "surface_id") else {
|
||||
return .err(code: "invalid_params", message: "Missing surface_id", data: nil)
|
||||
|
|
@ -8003,17 +8296,24 @@ class TerminalController {
|
|||
|
||||
var result = "ERROR: Failed to create event"
|
||||
DispatchQueue.main.sync {
|
||||
// Tests can run while the app is activating (no keyWindow yet). Prefer a visible
|
||||
// window to keep input simulation deterministic in debug builds.
|
||||
let targetWindow = NSApp.keyWindow
|
||||
// Prefer the current active-tab-manager window so shortcut simulation stays
|
||||
// scoped to the intended window even when NSApp.keyWindow is stale.
|
||||
let targetWindow: NSWindow? = {
|
||||
if let activeTabManager = self.tabManager,
|
||||
let windowId = AppDelegate.shared?.windowId(for: activeTabManager),
|
||||
let window = AppDelegate.shared?.mainWindow(for: windowId) {
|
||||
return window
|
||||
}
|
||||
return NSApp.keyWindow
|
||||
?? NSApp.mainWindow
|
||||
?? NSApp.windows.first(where: { $0.isVisible })
|
||||
?? NSApp.windows.first
|
||||
}()
|
||||
if let targetWindow {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
targetWindow.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
let windowNumber = (NSApp.keyWindow ?? targetWindow)?.windowNumber ?? 0
|
||||
let windowNumber = targetWindow?.windowNumber ?? 0
|
||||
guard let keyDownEvent = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
|
|
@ -8706,6 +9006,10 @@ class TerminalController {
|
|||
let charactersIgnoringModifiers: String
|
||||
|
||||
switch keyToken.lowercased() {
|
||||
case "esc", "escape":
|
||||
storedKey = "\u{1b}"
|
||||
keyCode = UInt16(kVK_Escape)
|
||||
charactersIgnoringModifiers = storedKey
|
||||
case "left":
|
||||
storedKey = "←"
|
||||
keyCode = 123
|
||||
|
|
@ -8726,6 +9030,10 @@ class TerminalController {
|
|||
storedKey = "\r"
|
||||
keyCode = UInt16(kVK_Return)
|
||||
charactersIgnoringModifiers = storedKey
|
||||
case "backspace", "delete", "del":
|
||||
storedKey = "\u{7f}"
|
||||
keyCode = UInt16(kVK_Delete)
|
||||
charactersIgnoringModifiers = storedKey
|
||||
default:
|
||||
let key = keyToken.lowercased()
|
||||
guard let code = keyCodeForShortcutKey(key) else { return nil }
|
||||
|
|
|
|||
|
|
@ -363,6 +363,20 @@ struct cmuxApp: App {
|
|||
|
||||
// Close tab/workspace
|
||||
CommandGroup(after: .newItem) {
|
||||
Button("Go to Workspace or Tab…") {
|
||||
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
|
||||
NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow)
|
||||
}
|
||||
.keyboardShortcut("p", modifiers: [.command])
|
||||
|
||||
Button("Command Palette…") {
|
||||
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
|
||||
NotificationCenter.default.post(name: .commandPaletteRequested, object: targetWindow)
|
||||
}
|
||||
.keyboardShortcut("p", modifiers: [.command, .shift])
|
||||
|
||||
Divider()
|
||||
|
||||
// Terminal semantics:
|
||||
// Cmd+W closes the focused tab (with confirmation if needed). If this is the last
|
||||
// tab in the last workspace, it closes the window.
|
||||
|
|
@ -422,8 +436,10 @@ struct cmuxApp: App {
|
|||
// Tab navigation
|
||||
CommandGroup(after: .toolbar) {
|
||||
splitCommandButton(title: "Toggle Sidebar", shortcut: toggleSidebarMenuShortcut) {
|
||||
if AppDelegate.shared?.toggleSidebarInActiveMainWindow() != true {
|
||||
sidebarState.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
|
|
@ -2533,6 +2549,18 @@ enum QuitWarningSettings {
|
|||
}
|
||||
}
|
||||
|
||||
enum CommandPaletteRenameSelectionSettings {
|
||||
static let selectAllOnFocusKey = "commandPalette.renameSelectAllOnFocus"
|
||||
static let defaultSelectAllOnFocus = true
|
||||
|
||||
static func selectAllOnFocusEnabled(defaults: UserDefaults = .standard) -> Bool {
|
||||
if defaults.object(forKey: selectAllOnFocusKey) == nil {
|
||||
return defaultSelectAllOnFocus
|
||||
}
|
||||
return defaults.bool(forKey: selectAllOnFocusKey)
|
||||
}
|
||||
}
|
||||
|
||||
enum ClaudeCodeIntegrationSettings {
|
||||
static let hooksEnabledKey = "claudeCodeHooksEnabled"
|
||||
static let defaultHooksEnabled = true
|
||||
|
|
@ -2565,6 +2593,8 @@ struct SettingsView: View {
|
|||
@AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
|
||||
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
||||
@AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
||||
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
|
||||
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
||||
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
||||
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
||||
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||
|
|
@ -2761,6 +2791,19 @@ struct SettingsView: View {
|
|||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"Rename Selects Existing Name",
|
||||
subtitle: commandPaletteRenameSelectAllOnFocus
|
||||
? "Command Palette rename starts with all text selected."
|
||||
: "Command Palette rename keeps the caret at the end."
|
||||
) {
|
||||
Toggle("", isOn: $commandPaletteRenameSelectAllOnFocus)
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
SettingsCardDivider()
|
||||
|
||||
SettingsCardRow(
|
||||
"Sidebar Branch Layout",
|
||||
subtitle: sidebarBranchVerticalLayout
|
||||
|
|
@ -3310,6 +3353,7 @@ struct SettingsView: View {
|
|||
browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
|
||||
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
||||
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
|
||||
commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
|
||||
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
||||
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
||||
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
|
||||
|
|
|
|||
|
|
@ -1125,6 +1125,267 @@ final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
final class CommandPaletteKeyboardNavigationTests: XCTestCase {
|
||||
func testArrowKeysMoveSelectionWithoutModifiers() {
|
||||
XCTAssertEqual(
|
||||
commandPaletteSelectionDeltaForKeyboardNavigation(
|
||||
flags: [],
|
||||
chars: "",
|
||||
keyCode: 125
|
||||
),
|
||||
1
|
||||
)
|
||||
XCTAssertEqual(
|
||||
commandPaletteSelectionDeltaForKeyboardNavigation(
|
||||
flags: [],
|
||||
chars: "",
|
||||
keyCode: 126
|
||||
),
|
||||
-1
|
||||
)
|
||||
XCTAssertNil(
|
||||
commandPaletteSelectionDeltaForKeyboardNavigation(
|
||||
flags: [.shift],
|
||||
chars: "",
|
||||
keyCode: 125
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testControlLetterNavigationSupportsPrintableAndControlChars() {
|
||||
XCTAssertEqual(
|
||||
commandPaletteSelectionDeltaForKeyboardNavigation(
|
||||
flags: [.control],
|
||||
chars: "n",
|
||||
keyCode: 45
|
||||
),
|
||||
1
|
||||
)
|
||||
XCTAssertEqual(
|
||||
commandPaletteSelectionDeltaForKeyboardNavigation(
|
||||
flags: [.control],
|
||||
chars: "\u{0e}",
|
||||
keyCode: 45
|
||||
),
|
||||
1
|
||||
)
|
||||
XCTAssertEqual(
|
||||
commandPaletteSelectionDeltaForKeyboardNavigation(
|
||||
flags: [.control],
|
||||
chars: "p",
|
||||
keyCode: 35
|
||||
),
|
||||
-1
|
||||
)
|
||||
XCTAssertEqual(
|
||||
commandPaletteSelectionDeltaForKeyboardNavigation(
|
||||
flags: [.control],
|
||||
chars: "\u{10}",
|
||||
keyCode: 35
|
||||
),
|
||||
-1
|
||||
)
|
||||
XCTAssertEqual(
|
||||
commandPaletteSelectionDeltaForKeyboardNavigation(
|
||||
flags: [.control],
|
||||
chars: "j",
|
||||
keyCode: 38
|
||||
),
|
||||
1
|
||||
)
|
||||
XCTAssertEqual(
|
||||
commandPaletteSelectionDeltaForKeyboardNavigation(
|
||||
flags: [.control],
|
||||
chars: "\u{0a}",
|
||||
keyCode: 38
|
||||
),
|
||||
1
|
||||
)
|
||||
XCTAssertEqual(
|
||||
commandPaletteSelectionDeltaForKeyboardNavigation(
|
||||
flags: [.control],
|
||||
chars: "k",
|
||||
keyCode: 40
|
||||
),
|
||||
-1
|
||||
)
|
||||
XCTAssertEqual(
|
||||
commandPaletteSelectionDeltaForKeyboardNavigation(
|
||||
flags: [.control],
|
||||
chars: "\u{0b}",
|
||||
keyCode: 40
|
||||
),
|
||||
-1
|
||||
)
|
||||
}
|
||||
|
||||
func testIgnoresUnsupportedModifiersAndKeys() {
|
||||
XCTAssertNil(
|
||||
commandPaletteSelectionDeltaForKeyboardNavigation(
|
||||
flags: [.command],
|
||||
chars: "n",
|
||||
keyCode: 45
|
||||
)
|
||||
)
|
||||
XCTAssertNil(
|
||||
commandPaletteSelectionDeltaForKeyboardNavigation(
|
||||
flags: [.control, .shift],
|
||||
chars: "n",
|
||||
keyCode: 45
|
||||
)
|
||||
)
|
||||
XCTAssertNil(
|
||||
commandPaletteSelectionDeltaForKeyboardNavigation(
|
||||
flags: [.control],
|
||||
chars: "x",
|
||||
keyCode: 7
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class CommandPaletteRenameSelectionSettingsTests: XCTestCase {
|
||||
private let suiteName = "cmux.tests.commandPaletteRenameSelection.\(UUID().uuidString)"
|
||||
|
||||
private func makeDefaults() -> UserDefaults {
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
return defaults
|
||||
}
|
||||
|
||||
func testDefaultsToSelectAllWhenUnset() {
|
||||
let defaults = makeDefaults()
|
||||
XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults))
|
||||
}
|
||||
|
||||
func testReturnsFalseWhenStoredFalse() {
|
||||
let defaults = makeDefaults()
|
||||
defaults.set(false, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
|
||||
XCTAssertFalse(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults))
|
||||
}
|
||||
|
||||
func testReturnsTrueWhenStoredTrue() {
|
||||
let defaults = makeDefaults()
|
||||
defaults.set(true, forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
|
||||
XCTAssertTrue(CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled(defaults: defaults))
|
||||
}
|
||||
}
|
||||
|
||||
final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase {
|
||||
func testFirstEntryAlwaysPinsToTopWhenScrollable() {
|
||||
let anchor = ContentView.commandPaletteScrollAnchor(
|
||||
selectedIndex: 0,
|
||||
previousIndex: 1,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: 8, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
)
|
||||
XCTAssertEqual(anchor, .top)
|
||||
}
|
||||
|
||||
func testLastEntryAlwaysPinsToBottomWhenScrollable() {
|
||||
let anchor = ContentView.commandPaletteScrollAnchor(
|
||||
selectedIndex: 19,
|
||||
previousIndex: 18,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: 188, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
)
|
||||
XCTAssertEqual(anchor, .bottom)
|
||||
}
|
||||
|
||||
func testFullyVisibleMiddleEntryDoesNotScroll() {
|
||||
let anchor = ContentView.commandPaletteScrollAnchor(
|
||||
selectedIndex: 6,
|
||||
previousIndex: 5,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: 120, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
)
|
||||
XCTAssertNil(anchor)
|
||||
}
|
||||
|
||||
func testOutOfViewMiddleEntryUsesDirectionForAnchor() {
|
||||
let downAnchor = ContentView.commandPaletteScrollAnchor(
|
||||
selectedIndex: 9,
|
||||
previousIndex: 8,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: 210, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
)
|
||||
XCTAssertEqual(downAnchor, .bottom)
|
||||
|
||||
let upAnchor = ContentView.commandPaletteScrollAnchor(
|
||||
selectedIndex: 8,
|
||||
previousIndex: 9,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: -6, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
)
|
||||
XCTAssertEqual(upAnchor, .top)
|
||||
}
|
||||
}
|
||||
|
||||
final class CommandPaletteEdgeVisibilityCorrectionTests: XCTestCase {
|
||||
func testTopEdgeReturnsTopWhenNotPinned() {
|
||||
let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor(
|
||||
selectedIndex: 0,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: 6, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
)
|
||||
XCTAssertEqual(anchor, .top)
|
||||
}
|
||||
|
||||
func testBottomEdgeReturnsBottomWhenNotPinned() {
|
||||
let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor(
|
||||
selectedIndex: 19,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: 170, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
)
|
||||
XCTAssertEqual(anchor, .bottom)
|
||||
}
|
||||
|
||||
func testPinnedTopAndBottomReturnNil() {
|
||||
let topAnchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor(
|
||||
selectedIndex: 0,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: 0, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
)
|
||||
XCTAssertNil(topAnchor)
|
||||
|
||||
let bottomAnchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor(
|
||||
selectedIndex: 19,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: 192, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
)
|
||||
XCTAssertNil(bottomAnchor)
|
||||
}
|
||||
|
||||
func testMiddleSelectionNeverForcesCorrection() {
|
||||
let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor(
|
||||
selectedIndex: 8,
|
||||
resultCount: 20,
|
||||
selectedFrame: CGRect(x: 0, y: 96, width: 200, height: 24),
|
||||
viewportHeight: 216,
|
||||
contentHeight: 480
|
||||
)
|
||||
XCTAssertNil(anchor)
|
||||
}
|
||||
}
|
||||
|
||||
final class SidebarCommandHintPolicyTests: XCTestCase {
|
||||
func testCommandHintRequiresCommandOnlyModifier() {
|
||||
XCTAssertTrue(SidebarCommandHintPolicy.shouldShowHints(for: [.command]))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import XCTest
|
||||
import AppKit
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
|
|
@ -106,3 +107,333 @@ final class WorkspaceManualUnreadTests: XCTestCase {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class CommandPaletteFuzzyMatcherTests: XCTestCase {
|
||||
func testExactMatchScoresHigherThanPrefixAndContains() {
|
||||
let exact = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "rename tab")
|
||||
let prefix = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "rename tab now")
|
||||
let contains = CommandPaletteFuzzyMatcher.score(query: "rename tab", candidate: "command rename tab flow")
|
||||
|
||||
XCTAssertNotNil(exact)
|
||||
XCTAssertNotNil(prefix)
|
||||
XCTAssertNotNil(contains)
|
||||
XCTAssertGreaterThan(exact ?? 0, prefix ?? 0)
|
||||
XCTAssertGreaterThan(prefix ?? 0, contains ?? 0)
|
||||
}
|
||||
|
||||
func testInitialismMatchReturnsScore() {
|
||||
let score = CommandPaletteFuzzyMatcher.score(query: "ocdi", candidate: "open current directory in ide")
|
||||
XCTAssertNotNil(score)
|
||||
XCTAssertGreaterThan(score ?? 0, 0)
|
||||
}
|
||||
|
||||
func testLongTokenLooseSubsequenceDoesNotMatch() {
|
||||
let score = CommandPaletteFuzzyMatcher.score(query: "rename", candidate: "open current directory in ide")
|
||||
XCTAssertNil(score)
|
||||
}
|
||||
|
||||
func testStitchedWordPrefixMatchesRetabForRenameTab() {
|
||||
let score = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Rename Tab…")
|
||||
XCTAssertNotNil(score)
|
||||
XCTAssertGreaterThan(score ?? 0, 0)
|
||||
}
|
||||
|
||||
func testRetabPrefersRenameTabOverDistantTabWord() {
|
||||
let renameTabScore = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Rename Tab…")
|
||||
let reopenTabScore = CommandPaletteFuzzyMatcher.score(query: "retab", candidate: "Reopen Closed Browser Tab")
|
||||
|
||||
XCTAssertNotNil(renameTabScore)
|
||||
XCTAssertNotNil(reopenTabScore)
|
||||
XCTAssertGreaterThan(renameTabScore ?? 0, reopenTabScore ?? 0)
|
||||
}
|
||||
|
||||
func testRenameScoresHigherThanUnrelatedCommand() {
|
||||
let renameScore = CommandPaletteFuzzyMatcher.score(
|
||||
query: "rename",
|
||||
candidates: ["Rename Tab…", "Tab • Terminal 1", "rename", "tab", "title"]
|
||||
)
|
||||
let unrelatedScore = CommandPaletteFuzzyMatcher.score(
|
||||
query: "rename",
|
||||
candidates: [
|
||||
"Open Current Directory in IDE",
|
||||
"Terminal • Terminal 1",
|
||||
"terminal",
|
||||
"directory",
|
||||
"open",
|
||||
"ide",
|
||||
"code",
|
||||
"default app"
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertNotNil(renameScore)
|
||||
XCTAssertNotNil(unrelatedScore)
|
||||
XCTAssertGreaterThan(renameScore ?? 0, unrelatedScore ?? 0)
|
||||
}
|
||||
|
||||
func testTokenMatchingRequiresAllTokens() {
|
||||
let match = CommandPaletteFuzzyMatcher.score(
|
||||
query: "rename workspace",
|
||||
candidates: ["Rename Workspace", "Workspace settings"]
|
||||
)
|
||||
let miss = CommandPaletteFuzzyMatcher.score(
|
||||
query: "rename workspace",
|
||||
candidates: ["Rename Tab", "Tab settings"]
|
||||
)
|
||||
|
||||
XCTAssertNotNil(match)
|
||||
XCTAssertNil(miss)
|
||||
}
|
||||
|
||||
func testEmptyQueryReturnsZeroScore() {
|
||||
let score = CommandPaletteFuzzyMatcher.score(query: " ", candidate: "anything")
|
||||
XCTAssertEqual(score, 0)
|
||||
}
|
||||
|
||||
func testMatchCharacterIndicesForContainsMatch() {
|
||||
let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices(
|
||||
query: "workspace",
|
||||
candidate: "New Workspace"
|
||||
)
|
||||
XCTAssertTrue(indices.contains(4))
|
||||
XCTAssertTrue(indices.contains(12))
|
||||
XCTAssertFalse(indices.contains(0))
|
||||
}
|
||||
|
||||
func testMatchCharacterIndicesForSubsequenceMatch() {
|
||||
let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices(
|
||||
query: "nws",
|
||||
candidate: "New Workspace"
|
||||
)
|
||||
XCTAssertTrue(indices.contains(0))
|
||||
XCTAssertTrue(indices.contains(2))
|
||||
XCTAssertTrue(indices.contains(8))
|
||||
}
|
||||
|
||||
func testMatchCharacterIndicesForStitchedWordPrefixMatch() {
|
||||
let indices = CommandPaletteFuzzyMatcher.matchCharacterIndices(
|
||||
query: "retab",
|
||||
candidate: "Rename Tab…"
|
||||
)
|
||||
XCTAssertTrue(indices.contains(0))
|
||||
XCTAssertTrue(indices.contains(1))
|
||||
XCTAssertTrue(indices.contains(7))
|
||||
XCTAssertTrue(indices.contains(8))
|
||||
XCTAssertTrue(indices.contains(9))
|
||||
}
|
||||
}
|
||||
|
||||
final class CommandPaletteSwitcherSearchIndexerTests: XCTestCase {
|
||||
func testKeywordsIncludeDirectoryBranchAndPortMetadata() {
|
||||
let metadata = CommandPaletteSwitcherSearchMetadata(
|
||||
directories: ["/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"],
|
||||
branches: ["feature/cmd-palette-indexing"],
|
||||
ports: [3000, 9222]
|
||||
)
|
||||
|
||||
let keywords = CommandPaletteSwitcherSearchIndexer.keywords(
|
||||
baseKeywords: ["workspace", "switch"],
|
||||
metadata: metadata
|
||||
)
|
||||
|
||||
XCTAssertTrue(keywords.contains("/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"))
|
||||
XCTAssertTrue(keywords.contains("feat-cmd-palette"))
|
||||
XCTAssertTrue(keywords.contains("feature/cmd-palette-indexing"))
|
||||
XCTAssertTrue(keywords.contains("cmd-palette-indexing"))
|
||||
XCTAssertTrue(keywords.contains("3000"))
|
||||
XCTAssertTrue(keywords.contains(":9222"))
|
||||
}
|
||||
|
||||
func testFuzzyMatcherMatchesDirectoryBranchAndPortMetadata() {
|
||||
let metadata = CommandPaletteSwitcherSearchMetadata(
|
||||
directories: ["/tmp/cmuxterm/worktrees/issue-123-switcher-search"],
|
||||
branches: ["fix/switcher-metadata"],
|
||||
ports: [4317]
|
||||
)
|
||||
|
||||
let candidates = CommandPaletteSwitcherSearchIndexer.keywords(
|
||||
baseKeywords: ["workspace"],
|
||||
metadata: metadata
|
||||
)
|
||||
|
||||
XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "switcher-search", candidates: candidates))
|
||||
XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "switcher-metadata", candidates: candidates))
|
||||
XCTAssertNotNil(CommandPaletteFuzzyMatcher.score(query: "4317", candidates: candidates))
|
||||
}
|
||||
|
||||
func testWorkspaceDetailOmitsSplitDirectoryAndBranchTokens() {
|
||||
let metadata = CommandPaletteSwitcherSearchMetadata(
|
||||
directories: ["/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"],
|
||||
branches: ["feature/cmd-palette-indexing"],
|
||||
ports: [3000]
|
||||
)
|
||||
|
||||
let keywords = CommandPaletteSwitcherSearchIndexer.keywords(
|
||||
baseKeywords: ["workspace"],
|
||||
metadata: metadata,
|
||||
detail: .workspace
|
||||
)
|
||||
|
||||
XCTAssertTrue(keywords.contains("/Users/example/dev/cmuxterm-hq/worktrees/feat-cmd-palette"))
|
||||
XCTAssertTrue(keywords.contains("feature/cmd-palette-indexing"))
|
||||
XCTAssertTrue(keywords.contains("3000"))
|
||||
XCTAssertFalse(keywords.contains("feat-cmd-palette"))
|
||||
XCTAssertFalse(keywords.contains("cmd-palette-indexing"))
|
||||
}
|
||||
|
||||
func testSurfaceDetailOutranksWorkspaceDetailForPathToken() {
|
||||
let metadata = CommandPaletteSwitcherSearchMetadata(
|
||||
directories: ["/tmp/worktrees/cmux"],
|
||||
branches: ["feature/cmd-palette"],
|
||||
ports: []
|
||||
)
|
||||
|
||||
let workspaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords(
|
||||
baseKeywords: ["workspace"],
|
||||
metadata: metadata,
|
||||
detail: .workspace
|
||||
)
|
||||
let surfaceKeywords = CommandPaletteSwitcherSearchIndexer.keywords(
|
||||
baseKeywords: ["surface"],
|
||||
metadata: metadata,
|
||||
detail: .surface
|
||||
)
|
||||
|
||||
let workspaceScore = try XCTUnwrap(
|
||||
CommandPaletteFuzzyMatcher.score(query: "cmux", candidates: workspaceKeywords)
|
||||
)
|
||||
let surfaceScore = try XCTUnwrap(
|
||||
CommandPaletteFuzzyMatcher.score(query: "cmux", candidates: surfaceKeywords)
|
||||
)
|
||||
|
||||
XCTAssertGreaterThan(
|
||||
surfaceScore,
|
||||
workspaceScore,
|
||||
"Surface rows should rank ahead of workspace rows for directory-token matches."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class CommandPaletteRequestRoutingTests: XCTestCase {
|
||||
private func makeWindow() -> NSWindow {
|
||||
NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
||||
styleMask: [.titled, .closable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
}
|
||||
|
||||
func testRequestedWindowTargetsOnlyMatchingObservedWindow() {
|
||||
let windowA = makeWindow()
|
||||
let windowB = makeWindow()
|
||||
|
||||
XCTAssertTrue(
|
||||
ContentView.shouldHandleCommandPaletteRequest(
|
||||
observedWindow: windowA,
|
||||
requestedWindow: windowA,
|
||||
keyWindow: windowA,
|
||||
mainWindow: windowA
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
ContentView.shouldHandleCommandPaletteRequest(
|
||||
observedWindow: windowB,
|
||||
requestedWindow: windowA,
|
||||
keyWindow: windowA,
|
||||
mainWindow: windowA
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testNilRequestedWindowFallsBackToKeyWindow() {
|
||||
let key = makeWindow()
|
||||
let other = makeWindow()
|
||||
|
||||
XCTAssertTrue(
|
||||
ContentView.shouldHandleCommandPaletteRequest(
|
||||
observedWindow: key,
|
||||
requestedWindow: nil,
|
||||
keyWindow: key,
|
||||
mainWindow: nil
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
ContentView.shouldHandleCommandPaletteRequest(
|
||||
observedWindow: other,
|
||||
requestedWindow: nil,
|
||||
keyWindow: key,
|
||||
mainWindow: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testNilRequestedAndKeyFallsBackToMainWindow() {
|
||||
let main = makeWindow()
|
||||
let other = makeWindow()
|
||||
|
||||
XCTAssertTrue(
|
||||
ContentView.shouldHandleCommandPaletteRequest(
|
||||
observedWindow: main,
|
||||
requestedWindow: nil,
|
||||
keyWindow: nil,
|
||||
mainWindow: main
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
ContentView.shouldHandleCommandPaletteRequest(
|
||||
observedWindow: other,
|
||||
requestedWindow: nil,
|
||||
keyWindow: nil,
|
||||
mainWindow: main
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testNoObservedWindowNeverHandlesRequest() {
|
||||
XCTAssertFalse(
|
||||
ContentView.shouldHandleCommandPaletteRequest(
|
||||
observedWindow: nil,
|
||||
requestedWindow: makeWindow(),
|
||||
keyWindow: makeWindow(),
|
||||
mainWindow: makeWindow()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class CommandPaletteBackNavigationTests: XCTestCase {
|
||||
func testBackspaceOnEmptyRenameInputReturnsToCommandList() {
|
||||
XCTAssertTrue(
|
||||
ContentView.commandPaletteShouldPopRenameInputOnDelete(
|
||||
renameDraft: "",
|
||||
modifiers: []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testBackspaceWithRenameTextDoesNotReturnToCommandList() {
|
||||
XCTAssertFalse(
|
||||
ContentView.commandPaletteShouldPopRenameInputOnDelete(
|
||||
renameDraft: "Terminal 1",
|
||||
modifiers: []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testModifiedBackspaceDoesNotReturnToCommandList() {
|
||||
XCTAssertFalse(
|
||||
ContentView.commandPaletteShouldPopRenameInputOnDelete(
|
||||
renameDraft: "",
|
||||
modifiers: [.control]
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
ContentView.commandPaletteShouldPopRenameInputOnDelete(
|
||||
renameDraft: "",
|
||||
modifiers: [.command]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -918,6 +918,27 @@ class cmux:
|
|||
def activate_app(self) -> None:
|
||||
self._call("debug.app.activate")
|
||||
|
||||
def open_command_palette_rename_tab_input(self, window_id: Optional[str] = None) -> None:
|
||||
params: Dict[str, Any] = {}
|
||||
if window_id is not None:
|
||||
params["window_id"] = str(window_id)
|
||||
self._call("debug.command_palette.rename_tab.open", params)
|
||||
|
||||
def command_palette_results(self, window_id: str, limit: int = 20) -> dict:
|
||||
res = self._call(
|
||||
"debug.command_palette.results",
|
||||
{"window_id": str(window_id), "limit": int(limit)},
|
||||
) or {}
|
||||
return dict(res)
|
||||
|
||||
def command_palette_rename_select_all(self) -> bool:
|
||||
res = self._call("debug.command_palette.rename_input.select_all") or {}
|
||||
return bool(res.get("enabled"))
|
||||
|
||||
def set_command_palette_rename_select_all(self, enabled: bool) -> bool:
|
||||
res = self._call("debug.command_palette.rename_input.select_all", {"enabled": bool(enabled)}) or {}
|
||||
return bool(res.get("enabled"))
|
||||
|
||||
def is_terminal_focused(self, panel: Union[str, int]) -> bool:
|
||||
sid = self._resolve_surface_id(panel)
|
||||
res = self._call("debug.terminal.is_focused", {"surface_id": sid}) or {}
|
||||
|
|
|
|||
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