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:
Lawrence Chen 2026-02-23 03:26:36 -08:00 committed by GitHub
parent 2499ba1bb2
commit 5d63c5f035
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 6581 additions and 13 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View 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())

View file

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

View 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())

View 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())

View 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())

View 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())

View 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())