Cmd+P: show workspaces only (#844)

* Limit Cmd+P switcher to workspaces

* Fix command palette Enter/Escape handling and add regression test

* Scope command palette key handling to event window
This commit is contained in:
Lawrence Chen 2026-03-04 00:19:01 -08:00 committed by GitHub
parent 1f62d770c7
commit dad52b09d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 196 additions and 113 deletions

View file

@ -1080,6 +1080,43 @@ func shouldConsumeShortcutWhileCommandPaletteVisible(
return true return true
} }
func shouldSubmitCommandPaletteWithReturn(
keyCode: UInt16,
flags: NSEvent.ModifierFlags
) -> Bool {
guard keyCode == 36 || keyCode == 76 else { return false }
let normalizedFlags = flags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function, .capsLock])
return normalizedFlags == [] || normalizedFlags == [.shift]
}
func commandPaletteFieldEditorHasMarkedText(in window: NSWindow) -> Bool {
guard let editor = window.firstResponder as? NSTextView,
editor.isFieldEditor else {
return false
}
return editor.hasMarkedText()
}
func shouldHandleCommandPaletteShortcutEvent(
_ event: NSEvent,
paletteWindow: NSWindow?
) -> Bool {
guard let paletteWindow else { return false }
if let eventWindow = event.window {
return eventWindow === paletteWindow
}
let eventWindowNumber = event.windowNumber
if eventWindowNumber > 0 {
return eventWindowNumber == paletteWindow.windowNumber
}
if let keyWindow = NSApp.keyWindow {
return keyWindow === paletteWindow
}
return false
}
enum BrowserZoomShortcutAction: Equatable { enum BrowserZoomShortcutAction: Equatable {
case zoomIn case zoomIn
case zoomOut case zoomOut
@ -5787,7 +5824,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock]) let normalizedFlags = flags.subtracting([.numericPad, .function, .capsLock])
let commandPaletteTargetWindow = commandPaletteWindowForShortcutEvent(event) let commandPaletteTargetWindow = commandPaletteWindowForShortcutEvent(event)
let commandPaletteVisibleInTargetWindow = commandPaletteTargetWindow.map { let commandPaletteShortcutWindow = shouldHandleCommandPaletteShortcutEvent(
event,
paletteWindow: commandPaletteTargetWindow
) ? commandPaletteTargetWindow : nil
let commandPaletteVisibleInTargetWindow = commandPaletteShortcutWindow.map {
isCommandPaletteVisible(for: $0) isCommandPaletteVisible(for: $0)
} ?? false } ?? false
@ -5797,7 +5838,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
keyCode: event.keyCode keyCode: event.keyCode
), ),
commandPaletteVisibleInTargetWindow, commandPaletteVisibleInTargetWindow,
let paletteWindow = commandPaletteTargetWindow { let paletteWindow = commandPaletteShortcutWindow {
NotificationCenter.default.post( NotificationCenter.default.post(
name: .commandPaletteMoveSelection, name: .commandPaletteMoveSelection,
object: paletteWindow, object: paletteWindow,
@ -5806,6 +5847,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true return true
} }
if commandPaletteVisibleInTargetWindow,
let paletteWindow = commandPaletteShortcutWindow {
let paletteFieldEditorHasMarkedText = commandPaletteFieldEditorHasMarkedText(in: paletteWindow)
if normalizedFlags.isEmpty, event.keyCode == 53 {
if paletteFieldEditorHasMarkedText {
return false
}
NotificationCenter.default.post(name: .commandPaletteDismissRequested, object: paletteWindow)
return true
}
if shouldSubmitCommandPaletteWithReturn(
keyCode: event.keyCode,
flags: event.modifierFlags
) {
if paletteFieldEditorHasMarkedText {
return false
}
NotificationCenter.default.post(name: .commandPaletteSubmitRequested, object: paletteWindow)
return true
}
}
// Guard against stale browserAddressBarFocusedPanelId after focus transitions // Guard against stale browserAddressBarFocusedPanelId after focus transitions
// (e.g., split that doesn't properly blur the address bar). If the first responder // (e.g., split that doesn't properly blur the address bar). If the first responder
// is a terminal surface, the address bar can't be focused. // is a terminal surface, the address bar can't be focused.

View file

@ -2282,6 +2282,30 @@ struct ContentView: View {
openCommandPaletteSwitcher() openCommandPaletteSwitcher()
}) })
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteSubmitRequested)) { notification in
guard isCommandPalettePresented else { return }
let requestedWindow = notification.object as? NSWindow
guard Self.shouldHandleCommandPaletteRequest(
observedWindow: observedWindow,
requestedWindow: requestedWindow,
keyWindow: NSApp.keyWindow,
mainWindow: NSApp.mainWindow
) else { return }
handleCommandPaletteSubmitRequest()
})
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteDismissRequested)) { notification in
guard isCommandPalettePresented else { return }
let requestedWindow = notification.object as? NSWindow
guard Self.shouldHandleCommandPaletteRequest(
observedWindow: observedWindow,
requestedWindow: requestedWindow,
keyWindow: NSApp.keyWindow,
mainWindow: NSApp.mainWindow
) else { return }
dismissCommandPalette()
})
view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameTabRequested)) { notification in view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteRenameTabRequested)) { notification in
let requestedWindow = notification.object as? NSWindow let requestedWindow = notification.object as? NSWindow
guard Self.shouldHandleCommandPaletteRequest( guard Self.shouldHandleCommandPaletteRequest(
@ -2963,7 +2987,7 @@ struct ContentView: View {
case .commands: case .commands:
return "Type a command" return "Type a command"
case .switcher: case .switcher:
return "Search workspaces and tabs" return "Search workspaces"
} }
} }
@ -2972,7 +2996,7 @@ struct ContentView: View {
case .commands: case .commands:
return "No commands match your search." return "No commands match your search."
case .switcher: case .switcher:
return "No workspaces or tabs match your search." return "No workspaces match your search."
} }
} }
@ -3067,9 +3091,6 @@ struct ContentView: View {
if command.id.hasPrefix("switcher.workspace.") { if command.id.hasPrefix("switcher.workspace.") {
return CommandPaletteTrailingLabel(text: "Workspace", style: .kind) return CommandPaletteTrailingLabel(text: "Workspace", style: .kind)
} }
if command.id.hasPrefix("switcher.surface.") {
return CommandPaletteTrailingLabel(text: "Surface", style: .kind)
}
return nil return nil
} }
@ -3079,7 +3100,7 @@ struct ContentView: View {
var entries: [CommandPaletteCommand] = [] var entries: [CommandPaletteCommand] = []
let estimatedCount = windowContexts.reduce(0) { partial, context in let estimatedCount = windowContexts.reduce(0) { partial, context in
partial + max(1, context.tabManager.tabs.count) * 4 partial + context.tabManager.tabs.count
} }
entries.reserveCapacity(estimatedCount) entries.reserveCapacity(estimatedCount)
var nextRank = 0 var nextRank = 0
@ -3126,62 +3147,12 @@ struct ContentView: View {
focusCommandPaletteSwitcherTarget( focusCommandPaletteSwitcherTarget(
windowId: windowId, windowId: windowId,
tabManager: windowTabManager, tabManager: windowTabManager,
workspaceId: workspaceId, workspaceId: workspaceId
panelId: nil
) )
} }
) )
) )
nextRank += 1 nextRank += 1
var orderedPanelIds = workspace.sidebarOrderedPanelIds()
if let focusedPanelId = workspace.focusedPanelId,
let focusedIndex = orderedPanelIds.firstIndex(of: focusedPanelId) {
orderedPanelIds.remove(at: focusedIndex)
orderedPanelIds.insert(focusedPanelId, at: 0)
}
for panelId in orderedPanelIds {
guard let panel = workspace.panels[panelId] else { continue }
let panelTitle = panelDisplayName(workspace: workspace, panelId: panelId, fallback: panel.displayTitle)
let typeLabel: String = (panel.panelType == .browser) ? "Browser" : "Terminal"
let panelKeywords = CommandPaletteSwitcherSearchIndexer.keywords(
baseKeywords: [
"tab",
"surface",
"panel",
"switch",
"go",
workspaceName,
panelTitle,
typeLabel.lowercased()
] + windowKeywords,
metadata: commandPalettePanelSearchMetadata(in: workspace, panelId: panelId)
)
entries.append(
CommandPaletteCommand(
id: "switcher.surface.\(workspace.id.uuidString.lowercased()).\(panelId.uuidString.lowercased())",
rank: nextRank,
title: panelTitle,
subtitle: commandPaletteSwitcherSubtitle(
base: "\(typeLabel)\(workspaceName)",
windowLabel: context.windowLabel
),
shortcutHint: nil,
keywords: panelKeywords,
dismissOnRun: true,
action: {
focusCommandPaletteSwitcherTarget(
windowId: windowId,
tabManager: windowTabManager,
workspaceId: workspaceId,
panelId: panelId
)
}
)
)
nextRank += 1
}
} }
} }
@ -3250,24 +3221,19 @@ struct ContentView: View {
private func focusCommandPaletteSwitcherTarget( private func focusCommandPaletteSwitcherTarget(
windowId: UUID, windowId: UUID,
tabManager: TabManager, tabManager: TabManager,
workspaceId: UUID, workspaceId: UUID
panelId: UUID?
) { ) {
// Switcher commands dismiss the palette after action dispatch. // Switcher commands dismiss the palette after action dispatch.
// Defer focus mutation one turn so browser omnibar autofocus can run // Defer focus mutation one turn so browser omnibar autofocus can run
// without being blocked by the palette-visibility guard. // without being blocked by the palette-visibility guard.
DispatchQueue.main.async { DispatchQueue.main.async {
_ = AppDelegate.shared?.focusMainWindow(windowId: windowId) _ = AppDelegate.shared?.focusMainWindow(windowId: windowId)
if let panelId {
tabManager.focusTab(workspaceId, surfaceId: panelId, suppressFlash: true)
} else {
tabManager.focusTab(workspaceId, suppressFlash: true) tabManager.focusTab(workspaceId, suppressFlash: true)
} }
} }
}
private func commandPaletteWorkspaceSearchMetadata(for workspace: Workspace) -> CommandPaletteSwitcherSearchMetadata { private func commandPaletteWorkspaceSearchMetadata(for workspace: Workspace) -> CommandPaletteSwitcherSearchMetadata {
// Keep workspace rows coarse so surface rows win for directory/branch-specific queries. // Keep workspace rows coarse and stable for predictable workspace switching queries.
let directories = [workspace.currentDirectory] let directories = [workspace.currentDirectory]
let branches = [workspace.gitBranch?.branch].compactMap { $0 } let branches = [workspace.gitBranch?.branch].compactMap { $0 }
let ports = workspace.listeningPorts let ports = workspace.listeningPorts
@ -3278,33 +3244,6 @@ struct ContentView: View {
) )
} }
private func commandPalettePanelSearchMetadata(in workspace: Workspace, panelId: UUID) -> CommandPaletteSwitcherSearchMetadata {
var directories: [String] = []
if let directory = workspace.panelDirectories[panelId] {
directories.append(directory)
} else if workspace.focusedPanelId == panelId {
directories.append(workspace.currentDirectory)
}
var branches: [String] = []
if let branch = workspace.panelGitBranches[panelId]?.branch {
branches.append(branch)
} else if workspace.focusedPanelId == panelId, let branch = workspace.gitBranch?.branch {
branches.append(branch)
}
var ports = workspace.surfaceListeningPorts[panelId] ?? []
if ports.isEmpty, workspace.panels.count == 1 {
ports = workspace.listeningPorts
}
return CommandPaletteSwitcherSearchMetadata(
directories: directories,
branches: branches,
ports: ports
)
}
private func commandPaletteCommands() -> [CommandPaletteCommand] { private func commandPaletteCommands() -> [CommandPaletteCommand] {
let context = commandPaletteContextSnapshot() let context = commandPaletteContextSnapshot()
let contributions = commandPaletteCommandContributions() let contributions = commandPaletteCommandContributions()
@ -4543,6 +4482,17 @@ struct ContentView: View {
runCommandPaletteCommand(visibleResults[index].command) runCommandPaletteCommand(visibleResults[index].command)
} }
private func handleCommandPaletteSubmitRequest() {
switch commandPaletteMode {
case .commands:
runSelectedCommandPaletteResult()
case .renameInput(let target):
continueRenameFlow(target: target)
case .renameConfirm(let target, let proposedName):
applyRenameFlow(target: target, proposedName: proposedName)
}
}
private func runCommandPaletteCommand(_ command: CommandPaletteCommand) { private func runCommandPaletteCommand(_ command: CommandPaletteCommand) {
#if DEBUG #if DEBUG
dlog("palette.run commandId=\(command.id) dismissOnRun=\(command.dismissOnRun ? 1 : 0)") dlog("palette.run commandId=\(command.id) dismissOnRun=\(command.dismissOnRun ? 1 : 0)")

View file

@ -3635,6 +3635,8 @@ extension Notification.Name {
static let commandPaletteToggleRequested = Notification.Name("cmux.commandPaletteToggleRequested") static let commandPaletteToggleRequested = Notification.Name("cmux.commandPaletteToggleRequested")
static let commandPaletteRequested = Notification.Name("cmux.commandPaletteRequested") static let commandPaletteRequested = Notification.Name("cmux.commandPaletteRequested")
static let commandPaletteSwitcherRequested = Notification.Name("cmux.commandPaletteSwitcherRequested") static let commandPaletteSwitcherRequested = Notification.Name("cmux.commandPaletteSwitcherRequested")
static let commandPaletteSubmitRequested = Notification.Name("cmux.commandPaletteSubmitRequested")
static let commandPaletteDismissRequested = Notification.Name("cmux.commandPaletteDismissRequested")
static let commandPaletteRenameTabRequested = Notification.Name("cmux.commandPaletteRenameTabRequested") static let commandPaletteRenameTabRequested = Notification.Name("cmux.commandPaletteRenameTabRequested")
static let commandPaletteRenameWorkspaceRequested = Notification.Name("cmux.commandPaletteRenameWorkspaceRequested") static let commandPaletteRenameWorkspaceRequested = Notification.Name("cmux.commandPaletteRenameWorkspaceRequested")
static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection") static let commandPaletteMoveSelection = Notification.Name("cmux.commandPaletteMoveSelection")

View file

@ -418,7 +418,7 @@ struct cmuxApp: App {
// Close tab/workspace // Close tab/workspace
CommandGroup(after: .newItem) { CommandGroup(after: .newItem) {
Button("Go to Workspace or Tab") { Button("Go to Workspace") {
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow) NotificationCenter.default.post(name: .commandPaletteSwitcherRequested, object: targetWindow)
} }

View file

@ -4,7 +4,8 @@ Regression test:
1. Focusing a blank browser surface should focus the omnibar. 1. Focusing a blank browser surface should focus the omnibar.
2. Focusing a pane that contains a blank browser should focus the omnibar. 2. Focusing a pane that contains a blank browser should focus the omnibar.
3. If command palette is open, focusing that blank browser surface must not steal input. 3. If command palette is open, focusing that blank browser surface must not steal input.
4. Cmd+P switcher focusing an existing blank browser surface should focus the omnibar. 4. Cmd+P switcher should list only workspaces, then switching to a workspace with a
focused blank browser should focus the omnibar.
""" """
import json import json
@ -281,24 +282,47 @@ def main() -> int:
workspace_ids.remove(workspace_id) workspace_ids.remove(workspace_id)
time.sleep(0.3) time.sleep(0.3)
# Scenario 4: Cmd+P switcher selecting an existing blank browser surface should focus omnibar. # Scenario 4: Cmd+P switcher should only list workspaces, and switching to a workspace
workspace_id = client.new_workspace() # that has a focused blank browser should focus the omnibar.
workspace_ids.append(workspace_id) target_workspace_id = client.new_workspace()
client.select_workspace(workspace_id) workspace_ids.append(target_workspace_id)
client.select_workspace(target_workspace_id)
time.sleep(0.4) time.sleep(0.4)
window_id = current_window_id(client) window_id = current_window_id(client)
if not set_command_palette_visible(client, window_id, False): if not set_command_palette_visible(client, window_id, False):
raise cmuxError("Failed to reset command palette before scenario 4") raise cmuxError("Failed to reset command palette before scenario 4 (target setup)")
switcher_browser_id = client.new_surface(panel_type="browser") switcher_browser_id = client.new_surface(panel_type="browser")
time.sleep(0.3) time.sleep(0.3)
client.focus_surface_by_panel(switcher_browser_id)
switcher_surfaces = client.list_surfaces() did_focus_target_browser = wait_for(
switcher_terminal_id = next((surface_id for _, surface_id, _ in switcher_surfaces if surface_id != switcher_browser_id), None) lambda: bool(
if not switcher_terminal_id: browser_address_bar_focus_state(
raise cmuxError("Missing terminal surface for Cmd+P switcher scenario") client,
surface_id=switcher_browser_id,
request_id="browser-focus-switcher-target-setup"
).get("focused")
),
timeout_s=3.0,
interval_s=0.1
)
if not did_focus_target_browser:
raise cmuxError("Failed to focus omnibar on target workspace browser before Cmd+P switch")
client.focus_surface_by_panel(switcher_terminal_id) source_workspace_id = client.new_workspace()
workspace_ids.append(source_workspace_id)
client.select_workspace(source_workspace_id)
time.sleep(0.4)
window_id = current_window_id(client)
if not set_command_palette_visible(client, window_id, False):
raise cmuxError("Failed to reset command palette before scenario 4 (source setup)")
source_surfaces = client.list_surfaces()
source_terminal_id = next((surface_id for _, surface_id, _ in source_surfaces), None)
if not source_terminal_id:
raise cmuxError("Missing terminal surface for Cmd+P workspace switcher scenario")
client.focus_surface_by_panel(source_terminal_id)
time.sleep(0.2) time.sleep(0.2)
client.simulate_shortcut("cmd+p") client.simulate_shortcut("cmd+p")
@ -316,11 +340,13 @@ def main() -> int:
): ):
raise cmuxError("Cmd+P did not open command palette switcher") raise cmuxError("Cmd+P did not open command palette switcher")
client.simulate_type("new tab") switcher_results = command_palette_results(client, window_id, limit=100)
time.sleep(0.2) switcher_ids = [row.get("command_id") for row in switcher_results if isinstance(row.get("command_id"), str)]
has_surface_rows = any(command_id.startswith("switcher.surface.") for command_id in switcher_ids)
if has_surface_rows:
raise cmuxError("Cmd+P switcher listed unexpected surface rows; expected workspace-only results")
target_command_id = f"switcher.surface.{workspace_id.lower()}.{switcher_browser_id.lower()}" target_command_id = f"switcher.workspace.{target_workspace_id.lower()}"
switcher_results = command_palette_results(client, window_id, limit=50)
target_index = next( target_index = next(
( (
idx for idx, row in enumerate(switcher_results) idx for idx, row in enumerate(switcher_results)
@ -329,7 +355,7 @@ def main() -> int:
None None
) )
if target_index is None: if target_index is None:
raise cmuxError(f"Cmd+P switcher did not list target surface command {target_command_id}") raise cmuxError(f"Cmd+P switcher did not list target workspace command {target_command_id}")
if not move_command_palette_selection_to_index(client, window_id, target_index): if not move_command_palette_selection_to_index(client, window_id, target_index):
raise cmuxError(f"Failed to move Cmd+P selection to result index {target_index}") raise cmuxError(f"Failed to move Cmd+P selection to result index {target_index}")
@ -358,9 +384,50 @@ def main() -> int:
interval_s=0.1 interval_s=0.1
) )
if not did_focus_switcher_target: if not did_focus_switcher_target:
raise cmuxError("Cmd+P switcher focus to blank browser did not focus omnibar") raise cmuxError("Cmd+P workspace switch did not restore blank browser omnibar focus")
print("PASS: blank-browser focus paths (surface, pane, and Cmd+P switcher) drive omnibar, while command palette visibility blocks focus stealing") # Scenario 5: Cmd+P switcher should dismiss on Escape reliably.
client.select_workspace(source_workspace_id)
time.sleep(0.4)
window_id = current_window_id(client)
if not set_command_palette_visible(client, window_id, False):
raise cmuxError("Failed to reset command palette before scenario 5")
client.focus_surface_by_panel(source_terminal_id)
time.sleep(0.2)
client.simulate_shortcut("cmd+p")
if not wait_for(
lambda: bool(
v2_call(
client,
"debug.command_palette.visible",
{"window_id": window_id},
request_id="palette-visible-switcher-open-escape"
).get("visible")
),
timeout_s=2.0,
interval_s=0.1
):
raise cmuxError("Cmd+P did not open command palette switcher before Escape scenario")
client.simulate_shortcut("escape")
did_dismiss_switcher_on_escape = wait_for(
lambda: not bool(
v2_call(
client,
"debug.command_palette.visible",
{"window_id": window_id},
request_id="palette-visible-switcher-after-escape"
).get("visible")
),
timeout_s=3.0,
interval_s=0.1
)
if not did_dismiss_switcher_on_escape:
raise cmuxError("Cmd+P Escape did not dismiss command palette switcher")
print("PASS: blank-browser focus paths (surface, pane, Cmd+P Enter switcher, and Cmd+P Escape dismiss) drive omnibar, while command palette visibility blocks focus stealing")
return 0 return 0
except cmuxError as exc: except cmuxError as exc: