Use Ctrl+K for search on Windows and Linux (#39767)

* Use Ctrl+K for search on Linux and Windows

Preserve existing search and terminal clear shortcuts for legacy profiles until reset.

* Preserve existing Windows and Linux search shortcuts on rollout

* Preserve saved shortcuts in new profiles

* Keep window shortcut tests independent of defaults
This commit is contained in:
Jinjing
2026-11-07 23:16:47 -08:00
committed by GitHub
parent 4e66fb5510
commit ec07019a12
16 changed files with 244 additions and 64 deletions
@@ -205,7 +205,7 @@ describe('KeybindingService profile defaults', () => {
})
it.each(['linux', 'win32'] as const)(
'keeps tab defaults local and search unchanged on %s',
'preserves legacy search and tab defaults on %s',
(platform) => {
const older = new KeybindingService({
homePath: home,
@@ -217,7 +217,9 @@ describe('KeybindingService profile defaults', () => {
'tab.nextSameType': ['Mod+Shift+BracketRight'],
'tab.previousSameType': ['Mod+Shift+BracketLeft'],
'tab.nextAllTypes': ['Mod+Alt+BracketRight'],
'tab.previousAllTypes': ['Mod+Alt+BracketLeft']
'tab.previousAllTypes': ['Mod+Alt+BracketLeft'],
'worktree.palette': ['Mod+Shift+J'],
'terminal.clear': ['Mod+K']
})
older.setActionBindings('tab.nextAllTypes', [])
const reset = older.setActionBindings('tab.nextSameType', null)
@@ -231,6 +233,31 @@ describe('KeybindingService profile defaults', () => {
}
)
it.each(['linux', 'win32'] as const)(
'uses Ctrl+K for new profiles and preserves old shortcuts until reset on %s',
(platform) => {
const fresh = new KeybindingService({ homePath: home, platform })
const older = new KeybindingService({ homePath: home, platform, legacySearchDefaults: true })
const ctrlK = { key: 'k', code: 'KeyK', meta: false, control: true, alt: false, shift: false }
expect(
keybindingMatchesAction('worktree.palette', ctrlK, platform, fresh.getOverrides())
).toBe(true)
expect(keybindingMatchesAction('terminal.clear', ctrlK, platform, older.getOverrides())).toBe(
true
)
expect(
getEffectiveKeybindingsForAction('worktree.palette', platform, older.getOverrides())
).toEqual(['Mod+Shift+J'])
const reset = older.setActionBindings('worktree.palette', null)
expect(keybindingMatchesAction('worktree.palette', ctrlK, platform, reset.overrides)).toBe(
true
)
expect(keybindingMatchesAction('terminal.clear', ctrlK, platform, reset.overrides)).toBe(
false
)
}
)
it('yields inherited tab shortcuts to explicit assignments during loads and settings writes', () => {
write({ platforms: { darwin: { 'tab.previousAllTypes': ['Mod+Shift+BracketLeft'] } } })
const older = new KeybindingService({
+2 -4
View File
@@ -1,5 +1,5 @@
import {
LEGACY_MAC_SEARCH_BINDINGS,
getLegacySearchBindings,
LEGACY_TAB_SWITCH_BINDINGS,
type KeybindingActionId,
type KeybindingFileSnapshot,
@@ -33,9 +33,7 @@ export class KeybindingService {
this.platform = options.platform ?? process.platform
this.defaultOverrides = {
...(options.legacyTabSwitchDefaults ? LEGACY_TAB_SWITCH_BINDINGS : {}),
...(options.legacySearchDefaults && this.platform === 'darwin'
? LEGACY_MAC_SEARCH_BINDINGS
: {})
...(options.legacySearchDefaults ? getLegacySearchBindings(this.platform) : {})
}
const legacyOverrides = options.getLegacyOverrides?.()
try {
@@ -23,7 +23,11 @@ export class LoadedCohortMigrationOperations {
Object.keys(state.settings).length === 1 &&
isRecord(state.settings.telemetry)
const existingProfile = fileExistedOnLoad && !telemetryOnly
for (const key of ['tabSwitchKeybindingSeed', 'searchKeybindingSeed'] as const) {
for (const key of [
'tabSwitchKeybindingSeed',
'searchKeybindingSeed',
'nonMacSearchKeybindingSeed'
] as const) {
const existing = state.settings?.[key]
if (existing === 'legacy' || existing === 'done') {
continue
@@ -109,9 +109,28 @@ describe('Store', () => {
const store = await createStore()
expect(store.getSettings().tabSwitchKeybindingSeed).toBe('done')
expect(store.getSettings().searchKeybindingSeed).toBe('done')
expect(store.getSettings().nonMacSearchKeybindingSeed).toBe('done')
await closeTestStores()
const reopened = await createStore()
expect(reopened.getSettings().searchKeybindingSeed).toBe('done')
expect(reopened.getSettings().nonMacSearchKeybindingSeed).toBe('done')
})
it('keeps profiles created after the Mac rollout in the legacy non-Mac search cohort', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
ui: {},
settings: { searchKeybindingSeed: 'done' }
})
const store = await createStore()
expect(store.getSettings().nonMacSearchKeybindingSeed).toBe('legacy')
store.flush()
await closeTestStores()
const reopened = await createStore()
expect(reopened.getSettings().searchKeybindingSeed).toBe('done')
expect(reopened.getSettings().nonMacSearchKeybindingSeed).toBe('legacy')
})
})
@@ -79,7 +79,10 @@ export function initializeMainProcessAccountServices(): void {
homePath: app.getPath('home'),
getLegacyOverrides: () => store.getSettings().keybindings,
legacyTabSwitchDefaults: store.getSettings().tabSwitchKeybindingSeed === 'legacy',
legacySearchDefaults: store.getSettings().searchKeybindingSeed === 'legacy'
legacySearchDefaults:
(process.platform === 'darwin'
? store.getSettings().searchKeybindingSeed
: store.getSettings().nonMacSearchKeybindingSeed) === 'legacy'
})
browserManager.setSettingsResolver(() => ({ keybindings: state.keybindings?.getOverrides() }))
}
@@ -220,7 +220,7 @@ describe('createMainWindow', () => {
expect(webContents.send).toHaveBeenCalledWith('ui:dictationKeyDown')
})
it('forwards the search shortcut to the worktree palette toggle event', () => {
it('forwards the configured search shortcut to the worktree palette toggle event', () => {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
on: vi.fn((event, handler) => {
@@ -253,27 +253,29 @@ describe('createMainWindow', () => {
return browserWindowInstance
})
createMainWindow(null)
createMainWindow(null, {
getKeybindings: () => ({ 'worktree.palette': ['Mod+Alt+K'] })
})
const isDarwin = process.platform === 'darwin'
for (const input of [
{
type: 'keyDown',
code: isDarwin ? 'KeyK' : 'KeyJ',
key: isDarwin ? 'k' : 'j',
code: 'KeyK',
key: 'k',
meta: isDarwin,
control: !isDarwin,
alt: false,
shift: !isDarwin
alt: true,
shift: false
},
{
type: 'keyDown',
code: isDarwin ? 'KeyK' : 'KeyJ',
code: 'KeyK',
key: '',
meta: isDarwin,
control: !isDarwin,
alt: false,
shift: !isDarwin
alt: true,
shift: false
}
]) {
const preventDefault = vi.fn()
@@ -583,10 +585,13 @@ describe('createMainWindow', () => {
return browserWindowInstance
})
createMainWindow({
getUI: () => ({}),
getSettings: () => ({ terminalShortcutPolicy: 'orca-first' })
} as never)
createMainWindow(
{
getUI: () => ({}),
getSettings: () => ({ terminalShortcutPolicy: 'orca-first' })
} as never,
{ getKeybindings: () => ({ 'worktree.palette': ['Mod+Alt+K'] }) }
)
const setFocusedListener = vi
.mocked(ipcMain.on)
@@ -598,12 +603,12 @@ describe('createMainWindow', () => {
const isDarwin = process.platform === 'darwin'
windowHandlers['before-input-event']({ preventDefault } as never, {
type: 'keyDown',
code: isDarwin ? 'KeyK' : 'KeyJ',
key: isDarwin ? 'k' : 'j',
code: 'KeyK',
key: 'k',
meta: isDarwin,
control: !isDarwin,
alt: false,
shift: !isDarwin
alt: true,
shift: false
})
expect(preventDefault).toHaveBeenCalledTimes(1)
@@ -5,7 +5,7 @@ import {
getKeybindingPlatform,
getKeybindingResetBindings,
isKeybindingActionId,
LEGACY_MAC_SEARCH_BINDINGS,
getLegacySearchBindings,
normalizeKeybindingArrayForAction,
resolveKeybindingOverrides
} from '../../../../shared/keybindings'
@@ -40,15 +40,21 @@ export type WebKeybindingsApi = NonNullable<PreloadApi['keybindings']>
export const webKeybindingListeners = new Set<(snapshot: KeybindingFileSnapshot) => void>()
const SEARCH_DEFAULTS_STORAGE_KEY = 'orca.web.searchKeybindingSeed.v1'
const NON_MAC_SEARCH_DEFAULTS_STORAGE_KEY = 'orca.web.nonMacSearchKeybindingSeed.v1'
let legacySearchDefaults = false
function migrateWebSearchKeybindings(): void {
const cohort = window.localStorage.getItem(SEARCH_DEFAULTS_STORAGE_KEY)
const storageKey =
getWebKeybindingPlatform() === 'darwin'
? SEARCH_DEFAULTS_STORAGE_KEY
: NON_MAC_SEARCH_DEFAULTS_STORAGE_KEY
const cohort = window.localStorage.getItem(storageKey)
if (cohort === 'done' || cohort === 'legacy') {
legacySearchDefaults = cohort === 'legacy'
return
}
legacySearchDefaults = [
SEARCH_DEFAULTS_STORAGE_KEY,
SETTINGS_STORAGE_KEY,
KEYBINDINGS_STORAGE_KEY,
UI_STORAGE_KEY,
@@ -56,13 +62,11 @@ function migrateWebSearchKeybindings(): void {
ONBOARDING_STORAGE_KEY,
GITHUB_CACHE_STORAGE_KEY
].some((key) => window.localStorage.getItem(key) !== null)
window.localStorage.setItem(SEARCH_DEFAULTS_STORAGE_KEY, legacySearchDefaults ? 'legacy' : 'done')
window.localStorage.setItem(storageKey, legacySearchDefaults ? 'legacy' : 'done')
}
function getWebDefaultOverrides(): KeybindingOverrides {
return legacySearchDefaults && getWebKeybindingPlatform() === 'darwin'
? LEGACY_MAC_SEARCH_BINDINGS
: {}
return legacySearchDefaults ? getLegacySearchBindings(getWebKeybindingPlatform()) : {}
}
export function createEmptyWebKeybindingDocument(): WebKeybindingDocument {
@@ -1,5 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getEffectiveKeybindingsForAction } from '../../../shared/keybindings'
import {
getEffectiveKeybindingsForAction,
keybindingMatchesAction
} from '../../../shared/keybindings'
import { installApi, installBrowserGlobals } from './web-preload-api-test-harness'
describe('web keybindings preload API', () => {
@@ -55,6 +58,37 @@ describe('web keybindings preload API', () => {
})
})
it.each([
['Linux x86_64', 'linux'],
['Windows NT 10.0', 'win32']
] as const)(
'preserves existing %s web shortcuts and resets to Ctrl+K',
async (userAgent, platform) => {
const { storage } = installBrowserGlobals(userAgent)
storage.setItem('orca.web.settings.v1', '{}')
storage.setItem('orca.web.searchKeybindingSeed.v1', 'done')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
const old = await window.api.keybindings.get()
expect(getEffectiveKeybindingsForAction('worktree.palette', platform, old.overrides)).toEqual(
['Mod+Shift+J']
)
expect(getEffectiveKeybindingsForAction('terminal.clear', platform, old.overrides)).toEqual([
'Mod+K'
])
const reset = await window.api.keybindings.setAction({
actionId: 'worktree.palette',
bindings: null
})
expect(
getEffectiveKeybindingsForAction('worktree.palette', platform, reset.overrides)
).toEqual(['Mod+K'])
expect(getEffectiveKeybindingsForAction('terminal.clear', platform, reset.overrides)).toEqual(
[]
)
}
)
it.each(['common', 'platform'])(
'keeps an explicit %s palette chord ahead of the generated clear shortcut',
async (section) => {
@@ -77,6 +111,25 @@ describe('web keybindings preload API', () => {
}
)
it('keeps saved Ctrl+K clear bindings reachable after the earlier Mac rollout', async () => {
const { storage } = installBrowserGlobals('Windows NT 10.0')
storage.setItem('orca.web.searchKeybindingSeed.v1', 'done')
storage.setItem(
'orca.web.keybindings.v1',
JSON.stringify({ platforms: { win32: { 'terminal.clear': ['Ctrl+K'] } } })
)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
installWebPreloadApi()
const { overrides } = await window.api.keybindings.get()
const ctrlK = { key: 'k', control: true }
expect(keybindingMatchesAction('terminal.clear', ctrlK, 'win32', overrides)).toBe(true)
expect(keybindingMatchesAction('worktree.palette', ctrlK, 'win32', overrides)).toBe(false)
await expect(
window.api.keybindings.setAction({ actionId: 'worktree.palette', bindings: null })
).rejects.toThrow('conflicts')
})
it('leaves an invalid Mac section intact and retries after repair', async () => {
const { storage } = installBrowserGlobals('MacIntel')
const raw = '{"platforms":{"darwin":null}}'
@@ -112,14 +165,17 @@ describe('web keybindings preload API', () => {
})
})
it('keeps a fresh web profile on the new defaults after saving settings and restarting', async () => {
const { api, storage } = await installApi('MacIntel')
expect((await api.keybindings.get()).overrides).toEqual({})
storage.setItem('orca.web.settings.v1', '{}')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
expect((await window.api.keybindings.get()).overrides).toEqual({})
})
it.each(['MacIntel', 'Windows NT 10.0'])(
'keeps a fresh %s web profile on the new defaults after saving settings and restarting',
async (userAgent) => {
const { api, storage } = await installApi(userAgent)
expect((await api.keybindings.get()).overrides).toEqual({})
storage.setItem('orca.web.settings.v1', '{}')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
expect((await window.api.keybindings.get()).overrides).toEqual({})
}
)
it('does not overwrite unreadable shortcuts while upgrading an existing web profile', async () => {
const { storage } = installBrowserGlobals('MacIntel')
+50 -1
View File
@@ -5,10 +5,59 @@ import {
findKeybindingConflicts,
getEffectiveKeybindingsForAction,
keybindingMatchesAction,
KEYBINDING_DEFINITIONS
KEYBINDING_DEFINITIONS,
resolveKeybindingOverrides,
getLegacySearchBindings
} from './keybindings'
describe('keybindings', () => {
it('yields fresh-profile search to a saved global Ctrl+K assignment', () => {
expect(resolveKeybindingOverrides('linux', { 'worktree.quickOpen': ['Ctrl+K'] }, {})).toEqual({
'worktree.palette': [],
'worktree.quickOpen': ['Ctrl+K']
})
})
it('yields fresh-profile search to a saved terminal Ctrl+K assignment', () => {
expect(resolveKeybindingOverrides('win32', { 'terminal.clear': ['Ctrl+K'] }, {})).toEqual({
'worktree.palette': [],
'terminal.clear': ['Ctrl+K']
})
})
it('keeps the inherited search chord available for terminal-first chat toggling', () => {
const overrides = resolveKeybindingOverrides('win32', {}, getLegacySearchBindings('win32'))
const ctrlShiftJ = { key: 'j', control: true, shift: true }
const terminalFirst = { context: 'terminal', terminalShortcutPolicy: 'terminal-first' } as const
expect(
keybindingMatchesAction(
'terminal.toggleNativeChat',
ctrlShiftJ,
'win32',
overrides,
terminalFirst
)
).toBe(true)
expect(
keybindingMatchesAction('worktree.palette', ctrlShiftJ, 'win32', overrides, terminalFirst)
).toBe(false)
expect(
findKeybindingConflicts('win32', overrides, { includeMainProcessInterception: true })
).toEqual([])
expect(
keybindingMatchesAction(
'terminal.toggleNativeChat',
ctrlShiftJ,
'win32',
{
...overrides,
'worktree.navigateDown': ['Ctrl+Shift+J']
},
terminalFirst
)
).toBe(false)
})
it('uses overrides as the complete effective binding list for an action', () => {
const overrides = {
'worktree.quickOpen': ['Ctrl+Alt+O', 'not-a-shortcut']
+1 -5
View File
@@ -36,11 +36,7 @@ export const KEYBINDING_DEFINITION_CORE_1: readonly KeybindingDefinition[] = [
group: 'Global',
scope: 'global',
searchKeywords: ['shortcut', 'global', 'worktree', 'switch', 'jump'],
defaultBindings: {
darwin: ['Mod+K'],
linux: ['Mod+Shift+J'],
win32: ['Mod+Shift+J']
}
defaultBindings: platformBindings(['Mod+K'])
},
{
id: 'worktree.navigateUp',
+1 -1
View File
@@ -260,7 +260,7 @@ export const KEYBINDING_DEFINITION_CORE_3: readonly KeybindingDefinition[] = [
group: 'Terminal Panes',
scope: 'terminal',
searchKeywords: ['shortcut', 'pane', 'clear'],
defaultBindings: { darwin: [], linux: ['Mod+K'], win32: ['Mod+K'] }
defaultBindings: platformBindings([])
},
{
id: 'terminal.focusNextPane',
+6 -3
View File
@@ -6,6 +6,7 @@ import type {
AgentTabActionId,
KeybindingActionId,
KeybindingDefinition,
KeybindingOverrides,
PluginKeybindingActionId
} from './types'
import type { TuiAgent } from '../agent-launch/catalog/tui-agent'
@@ -53,9 +54,11 @@ export const LEGACY_TAB_SWITCH_BINDINGS: Readonly<Partial<Record<KeybindingActio
'tab.previousAllTypes': ['Mod+Alt+BracketLeft']
}
export const LEGACY_MAC_SEARCH_BINDINGS: Readonly<Partial<Record<KeybindingActionId, string[]>>> = {
'worktree.palette': ['Mod+J'],
'terminal.clear': ['Mod+K']
export function getLegacySearchBindings(platform: NodeJS.Platform): KeybindingOverrides {
return {
'worktree.palette': [platform === 'darwin' ? 'Mod+J' : 'Mod+Shift+J'],
'terminal.clear': ['Mod+K']
}
}
export const DEFINITIONS_BY_ID = new Map<KeybindingActionId, KeybindingDefinition>(
+14 -3
View File
@@ -9,6 +9,7 @@ import {
DEFINITIONS_BY_ID,
KEYBINDING_DEFINITIONS,
getKeybindingPlatform,
getLegacySearchBindings,
isDigitIndexActionId
} from './definitions'
import { keybindingConflictIdentities } from './matching-key'
@@ -84,6 +85,18 @@ export function bindingsOverlap(
)
}
export function isDefaultKeybinding(
definition: KeybindingDefinition,
binding: string,
platform: NodeJS.Platform
): boolean {
// Previous defaults retain intentional sharing when profiles inherit or save them.
return [
...getDefaultBindings(definition, platform),
...(getLegacySearchBindings(platform)[definition.id] ?? [])
].some((ownDefault) => bindingsOverlap(definition, binding, definition, ownDefault, platform))
}
export function getEffectiveKeybindingsForAction(
actionId: KeybindingActionId,
platform: NodeJS.Platform,
@@ -124,9 +137,7 @@ export function getEffectiveKeybindingsForDefinition(
getConfiguredBindings(other.id, platform, overrides, other).some(
(claim) =>
bindingsOverlap(definition, binding, other, claim, platform) &&
!getDefaultBindings(other, platform).some((ownDefault) =>
bindingsOverlap(other, claim, other, ownDefault, platform)
)
!isDefaultKeybinding(other, claim, platform)
)
)
)
+2 -7
View File
@@ -14,6 +14,7 @@ import {
getEffectiveKeybindingsForAction,
getEffectiveKeybindingsForDefinition,
getDefaultBindings,
isDefaultKeybinding,
keybindingConflictGroups,
bindingsOverlap,
isKeybindingInterceptedBeforeBrowserChords,
@@ -227,13 +228,7 @@ export function findKeybindingConflictsForDefinitions(
) ||
!Array.from(actionIds).every((otherId) => {
const other = byId.get(otherId)
return (
otherId === id ||
(other &&
getDefaultBindings(other, platform).some((ownDefault) =>
bindingsOverlap(definition, binding, other, ownDefault, platform)
))
)
return otherId === id || (other && isDefaultKeybinding(other, binding, platform))
})
)
})
+3 -1
View File
@@ -7,6 +7,8 @@ export type KeybindingGlobalSettings = {
keybindings?: KeybindingOverrides
/** Profile-owned defaults; legacy profiles keep their shortcuts without writing the shared file. */
tabSwitchKeybindingSeed?: 'pending' | 'done' | 'legacy'
/** The Mac search/clear rollout cohort, independent of the shared shortcut choices. */
/** The original Mac search/clear rollout cohort. */
searchKeybindingSeed?: 'pending' | 'done' | 'legacy'
/** The later Windows/Linux rollout must not reuse the Mac cohort. */
nonMacSearchKeybindingSeed?: 'pending' | 'done' | 'legacy'
}
+11 -3
View File
@@ -2,7 +2,7 @@ import { findKeybindingConflicts } from './formatting'
import {
getKeybindingDefinition,
isKeybindingActionId,
LEGACY_MAC_SEARCH_BINDINGS,
getLegacySearchBindings,
LEGACY_TAB_SWITCH_BINDINGS
} from './definitions'
import { getDefaultBindings } from './effective'
@@ -30,7 +30,15 @@ export function resolveKeybindingOverrides(
for (const conflict of conflicts) {
const identity = getKeybindingConflictIdentity(conflict.binding, platform)
for (const actionId of conflict.actionIds) {
const bindings = inherited[actionId]
const definition = getKeybindingDefinition(actionId)
// Fresh profiles must also yield the new search default to shared explicit choices.
const bindings =
inherited[actionId] ??
(actionId === 'worktree.palette' &&
definition &&
!Object.hasOwn(explicitOverrides, actionId)
? getDefaultBindings(definition, platform)
: undefined)
if (!bindings) {
continue
}
@@ -61,7 +69,7 @@ export function getKeybindingResetBindings(
// Persist rollout resets so another profile's inherited defaults cannot reappear.
const migratedAction =
Object.hasOwn(LEGACY_TAB_SWITCH_BINDINGS, actionId) ||
(platform === 'darwin' && Object.hasOwn(LEGACY_MAC_SEARCH_BINDINGS, actionId))
Object.hasOwn(getLegacySearchBindings(platform), actionId)
const definition = getKeybindingDefinition(actionId)
return migratedAction && definition ? getDefaultBindings(definition, platform) : null
}