diff --git a/src/main/index.ts b/src/main/index.ts index d69013e5689..f872f034fd0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1880,7 +1880,13 @@ app.whenReady().then(async () => { rateLimits.setNetworkProxySettingsResolver(() => store!.getSettings()) keybindings = new KeybindingService({ homePath: app.getPath('home'), - getLegacyOverrides: () => store!.getSettings().keybindings + getLegacyOverrides: () => store!.getSettings().keybindings, + legacyTabSwitchSeed: { + isPending: () => store!.getSettings().tabSwitchKeybindingSeed === 'pending', + markSeeded: () => { + store!.updateSettings({ tabSwitchKeybindingSeed: 'done' }) + } + } }) browserManager.setSettingsResolver(() => ({ keybindings: keybindings?.getOverrides() })) rateLimits.setInactiveClaudeAccountsResolver(() => { diff --git a/src/main/keybindings/keybinding-file.test.ts b/src/main/keybindings/keybinding-file.test.ts index cc7cb43c5c3..5d88511ebb0 100644 --- a/src/main/keybindings/keybinding-file.test.ts +++ b/src/main/keybindings/keybinding-file.test.ts @@ -2,10 +2,12 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { LEGACY_TAB_SWITCH_BINDINGS } from '../../shared/keybindings' import { getUserKeybindingsPath, migrateLegacyKeybindings, readKeybindingFile, + seedLegacyTabSwitchBindings, writeKeybindingOverride } from './keybinding-file' @@ -224,4 +226,50 @@ describe('keybinding-file', () => { 'view.tasks': ['Ctrl+Alt+T'] }) }) + + it('seeds the legacy tab-switch chords into the active platform section', () => { + const result = seedLegacyTabSwitchBindings(filePath, 'darwin', LEGACY_TAB_SWITCH_BINDINGS) + + expect(result.seeded).toBe(true) + const snapshot = readKeybindingFile(filePath, 'darwin') + // Written to the platform section so Settings reset still works normally. + expect(snapshot.platformOverrides.darwin).toEqual({ + 'tab.nextSameType': ['Mod+Shift+BracketRight'], + 'tab.previousSameType': ['Mod+Shift+BracketLeft'], + 'tab.nextAllTypes': ['Mod+Alt+BracketRight'], + 'tab.previousAllTypes': ['Mod+Alt+BracketLeft'] + }) + // Effective bindings reproduce the pre-swap behavior with no conflicts dropped. + expect(snapshot.overrides).toMatchObject({ + 'tab.nextSameType': ['Mod+Shift+BracketRight'], + 'tab.nextAllTypes': ['Mod+Alt+BracketRight'] + }) + expect(snapshot.diagnostics).toEqual([]) + }) + + it('skips the seed when the user has already customized any swapped action', () => { + writeKeybindingOverride(filePath, 'darwin', 'tab.nextSameType', ['Mod+Alt+K']) + + const result = seedLegacyTabSwitchBindings(filePath, 'darwin', LEGACY_TAB_SWITCH_BINDINGS) + + expect(result.seeded).toBe(false) + // The user's choice is untouched and the other actions stay on their defaults. + expect(readKeybindingFile(filePath, 'darwin').overrides).toEqual({ + 'tab.nextSameType': ['Mod+Alt+K'] + }) + }) + + it('leaves unrelated overrides intact and stays idempotent when seeding', () => { + writeKeybindingOverride(filePath, 'darwin', 'terminal.search', ['Mod+Shift+F']) + + const first = seedLegacyTabSwitchBindings(filePath, 'darwin', LEGACY_TAB_SWITCH_BINDINGS) + const second = seedLegacyTabSwitchBindings(filePath, 'darwin', LEGACY_TAB_SWITCH_BINDINGS) + + expect(first.seeded).toBe(true) + // A second pass is a no-op: the pins now read as existing customization. + expect(second.seeded).toBe(false) + const snapshot = readKeybindingFile(filePath, 'darwin') + expect(snapshot.overrides['terminal.search']).toEqual(['Mod+Shift+F']) + expect(snapshot.overrides['tab.nextAllTypes']).toEqual(['Mod+Alt+BracketRight']) + }) }) diff --git a/src/main/keybindings/keybinding-file.ts b/src/main/keybindings/keybinding-file.ts index 2e0f6137f9a..ed9726fb1fc 100644 --- a/src/main/keybindings/keybinding-file.ts +++ b/src/main/keybindings/keybinding-file.ts @@ -318,6 +318,67 @@ export function migrateLegacyKeybindings( writeJsonDocument(path, document) } +/** + * Pin the pre-swap tab-switch chords for a pre-existing install so upgrading + * users keep the shortcuts they learned. Writes into the active-platform + * section (mirroring `writeKeybindingOverride`) so the seeded values stay + * resettable from Settings. No-op when the user has already customized any of + * the swapped actions — that is custom territory, and a half-swapped pin would + * be worse than leaving their choices alone. + */ +export function seedLegacyTabSwitchBindings( + path: string, + platform: NodeJS.Platform, + legacyBindings: Readonly>> +): { seeded: boolean; snapshot: KeybindingFileSnapshot } { + const keybindingPlatform = getKeybindingPlatform(platform) + const actionIds = Object.keys(legacyBindings) as KeybindingActionId[] + const current = readKeybindingFile(path, platform) + const alreadyCustomized = actionIds.some( + (actionId) => + Object.prototype.hasOwnProperty.call(current.commonOverrides, actionId) || + PLATFORM_KEYS.some((os) => + Object.prototype.hasOwnProperty.call(current.platformOverrides[os] ?? {}, actionId) + ) + ) + if (alreadyCustomized) { + return { seeded: false, snapshot: current } + } + + const readResult = readJsonDocument(path) + const document = isJsonObject(readResult.document) + ? { ...readResult.document } + : createEmptyDocument() + for (const rootKey of Object.keys(document)) { + if (isKeybindingActionId(rootKey)) { + delete document[rootKey] + } + } + const common = isJsonObject(document.keybindings) ? { ...document.keybindings } : {} + const platforms = isJsonObject(document.platforms) ? { ...document.platforms } : {} + const activePlatform = isJsonObject(platforms[keybindingPlatform]) + ? { ...(platforms[keybindingPlatform] as JsonObject) } + : {} + for (const actionId of actionIds) { + const normalized = normalizeKeybindingArrayForAction(actionId, legacyBindings[actionId] ?? []) + if (Array.isArray(normalized)) { + activePlatform[actionId] = normalized + } + } + + document.version = FILE_VERSION + document.keybindings = common + document.platforms = { + ...platforms, + darwin: isJsonObject(platforms.darwin) ? platforms.darwin : {}, + linux: isJsonObject(platforms.linux) ? platforms.linux : {}, + win32: isJsonObject(platforms.win32) ? platforms.win32 : {}, + [keybindingPlatform]: activePlatform + } + writeJsonDocument(path, document) + return { seeded: true, snapshot: readKeybindingFile(path, platform) } +} + export function writeKeybindingOverride( path: string, platform: NodeJS.Platform, diff --git a/src/main/keybindings/keybinding-service.ts b/src/main/keybindings/keybinding-service.ts index d3cae4495d0..dc6c9329adc 100644 --- a/src/main/keybindings/keybinding-service.ts +++ b/src/main/keybindings/keybinding-service.ts @@ -1,13 +1,15 @@ -import type { - KeybindingActionId, - KeybindingFileSnapshot, - KeybindingOverrides +import { + LEGACY_TAB_SWITCH_BINDINGS, + type KeybindingActionId, + type KeybindingFileSnapshot, + type KeybindingOverrides } from '../../shared/keybindings' import { ensureKeybindingFile, getUserKeybindingsPath, migrateLegacyKeybindings, readKeybindingFile, + seedLegacyTabSwitchBindings, writeKeybindingOverride } from './keybinding-file' @@ -15,6 +17,13 @@ export type KeybindingServiceOptions = { homePath: string platform?: NodeJS.Platform getLegacyOverrides?: () => KeybindingOverrides | undefined + /** Cohort seed for the tab-switch convention swap. `isPending` is true only + * for pre-existing installs on the first launch after the swap; `markSeeded` + * freezes the one-shot so it never runs again. */ + legacyTabSwitchSeed?: { + isPending: () => boolean + markSeeded: () => void + } } export class KeybindingService { @@ -28,6 +37,17 @@ export class KeybindingService { // Why: older builds persisted custom shortcuts inside global settings. // Once a keybindings file exists, it is the sole source of truth. migrateLegacyKeybindings(this.configPath, this.platform, options.getLegacyOverrides?.()) + // Why: pre-existing installs keep the old tab-switch chords. Only mark the + // one-shot done on success so a transient IO failure retries next launch + // instead of silently dropping the pin. + if (options.legacyTabSwitchSeed?.isPending()) { + try { + seedLegacyTabSwitchBindings(this.configPath, this.platform, LEGACY_TAB_SWITCH_BINDINGS) + options.legacyTabSwitchSeed.markSeeded() + } catch (error) { + console.error('Failed to seed legacy tab-switch keybindings:', error) + } + } } getPath(): string { diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 39771208b1d..5ec90c93223 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -9797,6 +9797,63 @@ describe('Store', () => { }) }) +describe('Store.migrateTabSwitchKeybindings', () => { + // Freezes the install cohort for the tab-switch convention swap on first load. + // Keys on `fileExistedOnLoad` (not field presence) so the verdict is stable + // even after a fresh install writes its own data file on later launches. + + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + }) + + afterEach(() => { + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('marks a truly fresh install done so it adopts the new defaults', async () => { + const store = await createStore() + expect(store.getSettings().tabSwitchKeybindingSeed).toBe('done') + }) + + it('marks a pre-existing install pending so the legacy chords get seeded', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [makeRepo()], + worktreeMeta: {}, + settings: { theme: 'dark' }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + expect(store.getSettings().tabSwitchKeybindingSeed).toBe('pending') + expect(store.getSettings().theme).toBe('dark') + }) + + it('treats a corrupt data file as a pre-existing install', async () => { + mkdirSync(testState.dir, { recursive: true }) + writeFileSync(dataFile(), '{{{corrupt json', 'utf-8') + const store = await createStore() + expect(store.getSettings().tabSwitchKeybindingSeed).toBe('pending') + }) + + it('preserves an already-frozen cohort on subsequent launches', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { tabSwitchKeybindingSeed: 'done' }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + // Existing file but cohort already resolved to 'done' — must not flip to + // 'pending' just because the data file happens to exist. + expect(store.getSettings().tabSwitchKeybindingSeed).toBe('done') + }) +}) + describe('Store.migrateWorktreeIdentity', () => { const OLD = 'repo1::/ws/cunner' const NEW = 'repo1::/ws/worktree-creation-spinner' diff --git a/src/main/persistence.ts b/src/main/persistence.ts index a237e941150..a76802b9227 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -3544,7 +3544,10 @@ export class Store { this.loadNeedsSave = true } - const migrated = this.migrateTelemetry(result, fileExistedOnLoad) + const migrated = this.migrateTabSwitchKeybindings( + this.migrateTelemetry(result, fileExistedOnLoad), + fileExistedOnLoad + ) // githubCache lives in a sidecar file now (see getGithubCacheFile). A // legacy in-file cache (pre-sidecar build, or a downgrade round-trip) is @@ -3580,6 +3583,34 @@ export class Store { // the banner resolves (the consent resolver returns `pending_banner` // until then, so nothing transmits). // - `installId` — anonymous UUID v4. Stable across launches; not surfaced in the UI. + // One-shot cohort freeze for the tab-switch keybinding convention swap. Runs + // on every `load()` but is a no-op once `tabSwitchKeybindingSeed` is set. The + // decision must be frozen on the first post-swap launch: `fileExistedOnLoad` + // only distinguishes existing vs fresh on that first run (a fresh install's + // data file exists on every subsequent launch), so persist the verdict now. + private migrateTabSwitchKeybindings( + state: PersistedState, + fileExistedOnLoad: boolean + ): PersistedState { + const existing = state.settings?.tabSwitchKeybindingSeed + if (existing === 'pending' || existing === 'done') { + return state + } + // Why: mark dirty so the frozen cohort survives the next restart even if no + // other setting changes this session; without this a fresh install could be + // re-read as "existing" once its data file lands on disk. + this.loadNeedsSave = true + return { + ...state, + settings: { + ...state.settings, + // Existing installs pin the old chords via a keybindings.json seed; + // fresh installs need nothing beyond the new registry defaults. + tabSwitchKeybindingSeed: fileExistedOnLoad ? 'pending' : 'done' + } + } + } + private migrateTelemetry(state: PersistedState, fileExistedOnLoad: boolean): PersistedState { const existing = state.settings?.telemetry // Why: the one-shot is complete only when all three invariants hold. diff --git a/src/renderer/src/components/terminal/tab-type-cycle.ts b/src/renderer/src/components/terminal/tab-type-cycle.ts index 411cc81871d..990d00cd87a 100644 --- a/src/renderer/src/components/terminal/tab-type-cycle.ts +++ b/src/renderer/src/components/terminal/tab-type-cycle.ts @@ -44,8 +44,8 @@ type GetNextTabAcrossAllTypesParams = { direction: number } -// Why: companion to getNextTabWithinActiveType for the Cmd/Ctrl+Alt+Shift+]/[ -// "cycle across every tab" chord. Keeps the same dual-id matching semantics +// Why: companion to getNextTabWithinActiveType for the "cycle across every tab" +// chord (Cmd/Ctrl+Shift+]/[ on fresh installs). Keeps the same dual-id matching semantics // (prefer the active group's unified tabId to disambiguate split layouts, fall // back to the backing entity id) so behavior matches what the TabBar renders. export function getNextTabAcrossAllTypes({ diff --git a/src/renderer/src/hooks/ipc-tab-switch.ts b/src/renderer/src/hooks/ipc-tab-switch.ts index 18f4c4d4ff8..53ce184036f 100644 --- a/src/renderer/src/hooks/ipc-tab-switch.ts +++ b/src/renderer/src/hooks/ipc-tab-switch.ts @@ -87,7 +87,9 @@ export function activateCyclableTab(store: AppStoreState, next: TypeCyclableTab) } /** - * Handle Cmd/Ctrl+Shift+[ / ] direction switching within the active tab type. + * Handle direction switching within the active tab type (same-type cycle). + * The default chord is Cmd/Ctrl+Alt+[ / ] for fresh installs; pre-swap installs + * keep Cmd/Ctrl+Shift+[ / ] via the seed migration, and either is rebindable. * Extracted from useIpcEvents to keep file size under the max-lines lint threshold. * Returns true if a tab switch occurred, false otherwise. */ @@ -114,14 +116,13 @@ export function handleSwitchTab(direction: number): boolean { } /** - * Handle Cmd/Ctrl+Alt+Shift+[ / ] cycling across every visible tab, - * regardless of tab type. + * Handle cycling across every visible tab, regardless of tab type. * - * Why: companion chord to the type-scoped Cmd/Ctrl+Shift+[ / ] that ships as - * the default. The type-scoped chord is the VS Code-style per-pane cycle; - * this one gives users an escape hatch back to the pre-scope "cycle through - * everything" behavior without needing a settings toggle (see PR #1281 - * discussion). Returns true if a tab switch occurred, false otherwise. + * Why: companion chord to the type-scoped same-type cycle. Fresh installs bind + * this broad cycle to Cmd/Ctrl+Shift+[ / ] — the widespread "switch tab" chord — + * and the same-type cycle to Cmd/Ctrl+Alt+[ / ]; pre-swap installs keep the + * inverse via the seed migration (see PR #1281 for the original type-scope + * rationale). Returns true if a tab switch occurred, false otherwise. */ export function handleSwitchTabAcrossAllTypes(direction: number): boolean { const ctx = resolveCycleContext() diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 5b38914a75b..6c860897582 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -12,6 +12,7 @@ import { isDigitIndexActionId, isDoubleTapBinding, keybindingFromInput, + LEGACY_TAB_SWITCH_BINDINGS, keybindingFromInputForAction, keybindingMatchesAction, keybindingMatchesInput, @@ -443,6 +444,42 @@ describe('keybindings', () => { ]) }) + it('defaults tab-switch chords to the swapped convention for fresh installs', () => { + // New users get the widespread mapping: Shift+bracket cycles all tabs, + // Alt+bracket cycles within the active type. + expect(getEffectiveKeybindingsForAction('tab.nextAllTypes', 'darwin')).toEqual([ + 'Mod+Shift+BracketRight' + ]) + expect(getEffectiveKeybindingsForAction('tab.previousAllTypes', 'darwin')).toEqual([ + 'Mod+Shift+BracketLeft' + ]) + expect(getEffectiveKeybindingsForAction('tab.nextSameType', 'darwin')).toEqual([ + 'Mod+Alt+BracketRight' + ]) + expect(getEffectiveKeybindingsForAction('tab.previousSameType', 'darwin')).toEqual([ + 'Mod+Alt+BracketLeft' + ]) + }) + + it('pins the pre-swap chords via LEGACY_TAB_SWITCH_BINDINGS for upgrading installs', () => { + // These are what the seed migration writes so pre-existing users keep the + // shortcuts they learned; overriding an action with its legacy value must + // reproduce the old effective binding. + expect(LEGACY_TAB_SWITCH_BINDINGS).toEqual({ + 'tab.nextSameType': ['Mod+Shift+BracketRight'], + 'tab.previousSameType': ['Mod+Shift+BracketLeft'], + 'tab.nextAllTypes': ['Mod+Alt+BracketRight'], + 'tab.previousAllTypes': ['Mod+Alt+BracketLeft'] + }) + for (const [actionId, bindings] of Object.entries(LEGACY_TAB_SWITCH_BINDINGS)) { + expect( + getEffectiveKeybindingsForAction(actionId as KeybindingActionId, 'darwin', { + [actionId]: bindings + }) + ).toEqual(bindings) + } + }) + it('defines browser history shortcuts for Logitech side-button remaps', () => { expect(getEffectiveKeybindingsForAction('browser.back', 'darwin')).toEqual(['Mod+BracketLeft']) expect(getEffectiveKeybindingsForAction('browser.forward', 'darwin')).toEqual([ @@ -1216,29 +1253,30 @@ describe('keybindings', () => { expect(keybindingMatchesAction('terminal.focusNextPane', jisLeftBracket, 'darwin')).toBe(false) expect(keybindingMatchesAction('terminal.focusNextPane', jisRightBracket, 'darwin')).toBe(true) + // Alt+bracket is the fresh-install same-type default after the convention swap. expect( - keybindingMatchesAction('tab.previousAllTypes', { ...jisLeftBracket, alt: true }, 'darwin') + keybindingMatchesAction('tab.previousSameType', { ...jisLeftBracket, alt: true }, 'darwin') ).toBe(true) expect( - keybindingMatchesAction('tab.nextAllTypes', { ...jisRightBracket, alt: true }, 'darwin') + keybindingMatchesAction('tab.nextSameType', { ...jisRightBracket, alt: true }, 'darwin') ).toBe(true) expect( keybindingMatchesAction( - 'tab.previousAllTypes', + 'tab.previousSameType', { ...jisLeftBracket, control: true, meta: false, alt: true }, 'linux' ) ).toBe(true) expect( keybindingMatchesAction( - 'tab.nextAllTypes', + 'tab.nextSameType', { ...jisLeftBracket, control: true, meta: false, alt: true }, 'linux' ) ).toBe(false) expect( keybindingMatchesAction( - 'tab.nextAllTypes', + 'tab.nextSameType', { ...jisRightBracket, control: true, meta: false, alt: true }, 'linux' ) @@ -1268,7 +1306,7 @@ describe('keybindings', () => { expect( keybindingMatchesAction( - 'tab.previousAllTypes', + 'tab.previousSameType', { key: '[', code: 'Digit8', @@ -1282,7 +1320,7 @@ describe('keybindings', () => { ).toBe(false) expect( keybindingMatchesAction( - 'tab.previousAllTypes', + 'tab.previousSameType', { key: 'Dead', code: 'BracketLeft', @@ -1385,7 +1423,9 @@ describe('keybindings', () => { expect(formatKeybindingList(['DoubleTap+Shift'], 'linux')).toBe('Shift Shift') }) - it('matches macOS Option-composed bracket shortcuts for all-type tab switching', () => { + it('matches macOS Option-composed bracket shortcuts for same-type tab switching', () => { + // Cmd+Alt+bracket is the fresh-install same-type default after the swap, so + // Option-composed dead keys (\u2325[ -> "\u201c") must still resolve to that action. const macOptionLeftBracket = { key: '\u201c', code: 'BracketLeft', @@ -1403,12 +1443,12 @@ describe('keybindings', () => { shift: false } - expect(keybindingMatchesAction('tab.previousAllTypes', macOptionLeftBracket, 'darwin')).toBe( + expect(keybindingMatchesAction('tab.previousSameType', macOptionLeftBracket, 'darwin')).toBe( true ) - expect(keybindingMatchesAction('tab.nextAllTypes', macOptionLeftBracket, 'darwin')).toBe(false) - expect(keybindingMatchesAction('tab.nextAllTypes', macOptionRightBracket, 'darwin')).toBe(true) - expect(keybindingMatchesAction('tab.previousAllTypes', macOptionRightBracket, 'darwin')).toBe( + expect(keybindingMatchesAction('tab.nextSameType', macOptionLeftBracket, 'darwin')).toBe(false) + expect(keybindingMatchesAction('tab.nextSameType', macOptionRightBracket, 'darwin')).toBe(true) + expect(keybindingMatchesAction('tab.previousSameType', macOptionRightBracket, 'darwin')).toBe( false ) }) diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index f250c39848d..857446a8051 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -643,7 +643,10 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ group: 'Tab Navigation', scope: 'tabs', searchKeywords: ['shortcut', 'tab', 'next', 'switch', 'cycle'], - defaultBindings: platformBindings(['Mod+Shift+BracketRight']) + // Why: Mod+Shift+Bracket is the widespread "switch tab" chord, so it now + // drives the broad all-types cycle for new users; same-type moves to + // Mod+Alt. Pre-release installs keep the old mapping via the seed migration. + defaultBindings: platformBindings(['Mod+Alt+BracketRight']) }, { id: 'tab.previousSameType', @@ -651,7 +654,7 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ group: 'Tab Navigation', scope: 'tabs', searchKeywords: ['shortcut', 'tab', 'previous', 'switch', 'cycle'], - defaultBindings: platformBindings(['Mod+Shift+BracketLeft']) + defaultBindings: platformBindings(['Mod+Alt+BracketLeft']) }, { id: 'tab.nextAllTypes', @@ -659,7 +662,7 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ group: 'Tab Navigation', scope: 'tabs', searchKeywords: ['shortcut', 'tab', 'next', 'switch', 'cycle', 'all', 'any'], - defaultBindings: platformBindings(['Mod+Alt+BracketRight']) + defaultBindings: platformBindings(['Mod+Shift+BracketRight']) }, { id: 'tab.previousAllTypes', @@ -667,7 +670,7 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ group: 'Tab Navigation', scope: 'tabs', searchKeywords: ['shortcut', 'tab', 'previous', 'switch', 'cycle', 'all', 'any'], - defaultBindings: platformBindings(['Mod+Alt+BracketLeft']) + defaultBindings: platformBindings(['Mod+Shift+BracketLeft']) }, { id: 'tab.previousRecent', @@ -1070,6 +1073,18 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ ...buildAgentTabKeybindingDefinitions() ] +/** + * The tab-switch bindings as they shipped before the convention swap. A one-time + * migration pins these for pre-release installs so upgrading users keep the + * shortcuts they learned; fresh installs get the new registry defaults above. + */ +export const LEGACY_TAB_SWITCH_BINDINGS: Readonly>> = { + 'tab.nextSameType': ['Mod+Shift+BracketRight'], + 'tab.previousSameType': ['Mod+Shift+BracketLeft'], + 'tab.nextAllTypes': ['Mod+Alt+BracketRight'], + 'tab.previousAllTypes': ['Mod+Alt+BracketLeft'] +} + export function agentTabActionId(agent: TuiAgent): AgentTabActionId { return `tab.newAgent.${agent}` } diff --git a/src/shared/types.ts b/src/shared/types.ts index bbe49dfac81..9f3d62e6968 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3042,6 +3042,12 @@ export type GlobalSettings = { * false for fresh installs (no first-launch surface). */ existedBeforeTelemetryRelease: boolean } + /** One-shot cohort marker for the tab-switch keybinding convention swap. + * Absent before the migration runs. Set once on first boot after the swap: + * `'pending'` for pre-existing installs (a seed then pins the old chords in + * keybindings.json before flipping this to `'done'`), or `'done'` for fresh + * installs, which adopt the new registry defaults with no seed. */ + tabSwitchKeybindingSeed?: 'pending' | 'done' /** Local voice/dictation configuration (Phase 1 voice feature). Optional * because profiles created before voice landed won't have the key; * `getDefaultSettings()` hydrates `getDefaultVoiceSettings()` via the