mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
feat(keybindings): swap tab-switch chords to the common convention for new users
New installs now get the widespread mapping — Mod+Shift+[ / ] cycles across all tabs, Mod+Alt+[ / ] cycles within the active tab type. Pre-existing installs keep today's mapping: a one-time cohort seed (frozen on first launch via the fileExistedOnLoad signal, mirroring the telemetry migration) pins the legacy chords into keybindings.json, skipping any action the user already customized. - shared registry: swap the four tab.*SameType / tab.*AllTypes defaults; export LEGACY_TAB_SWITCH_BINDINGS for the seed - persistence: migrateTabSwitchKeybindings freezes the existing-vs-fresh cohort (tabSwitchKeybindingSeed = pending | done) - keybinding-file: seedLegacyTabSwitchBindings writes the legacy pins into the active-platform section so Settings reset still works - refresh stale default-chord comments - tests for the swapped defaults, the seed (fresh/existing/customized/idempotent), and the cohort migration
This commit is contained in:
+7
-1
@@ -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(() => {
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<Partial<Record<KeybindingActionId, string[]>>>
|
||||
): { 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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
+32
-1
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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<Partial<Record<KeybindingActionId, string[]>>> = {
|
||||
'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}`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user