feat(plugins): add commands and keybindings

This commit is contained in:
Neil
2026-07-13 03:33:44 -07:00
parent 0b0bed03f0
commit 7eef2bd8ed
52 changed files with 2149 additions and 325 deletions
+1
View File
@@ -1938,6 +1938,7 @@ app.whenReady().then(async () => {
getDisabledPlugins: () => normalizePluginIdList(store?.getSettings().disabledPlugins),
getPluginConsents: () => normalizePluginConsents(store?.getSettings().pluginConsents),
getDevPluginPaths: () => normalizePluginIdList(store?.getSettings().devPluginPaths),
getKeybindings: () => keybindings?.getOverrides() ?? {},
hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged)
})
// Why: headless `orca serve` clients reach plugins through the runtime RPC
+17
View File
@@ -75,6 +75,23 @@ describe('registerKeybindingHandlers', () => {
expect(authorizeExternalPathMock).toHaveBeenCalledWith(snapshot.path)
})
it('reconciles plugin command conflicts after a shortcut edit', () => {
const onChanged = vi.fn()
const setActionBindings = vi.fn(() => snapshot)
registerKeybindingHandlers({ setActionBindings } as never, onChanged)
expect(
getHandler('keybindings:setAction')(
{},
{
actionId: 'plugin:orca-samples.tasks/open',
bindings: ['Mod+Shift+T']
}
)
).toBe(snapshot)
expect(onChanged).toHaveBeenCalledOnce()
})
it('authorizes the keybindings file before opening it outside Orca', async () => {
openPathMock.mockResolvedValue('')
registerKeybindingHandlers({ ensureFile: vi.fn(() => snapshot) } as never)
+7 -1
View File
@@ -13,7 +13,10 @@ function broadcastKeybindingsChanged(snapshot: KeybindingFileSnapshot): void {
rebuildAppMenu()
}
export function registerKeybindingHandlers(service: KeybindingService): void {
export function registerKeybindingHandlers(
service: KeybindingService,
onChanged?: () => void
): void {
ipcMain.handle('keybindings:get', () => service.getSnapshot())
ipcMain.handle('keybindings:ensureFile', () => {
@@ -22,6 +25,7 @@ export function registerKeybindingHandlers(service: KeybindingService): void {
// workspace. Opening it in the editor still needs normal fs IPC access.
authorizeExternalPath(snapshot.path)
broadcastKeybindingsChanged(snapshot)
onChanged?.()
return snapshot
})
@@ -30,6 +34,7 @@ export function registerKeybindingHandlers(service: KeybindingService): void {
(_event, args: { actionId: KeybindingActionId; bindings: string[] | null }) => {
const snapshot = service.setActionBindings(args.actionId, args.bindings)
broadcastKeybindingsChanged(snapshot)
onChanged?.()
return snapshot
}
)
@@ -37,6 +42,7 @@ export function registerKeybindingHandlers(service: KeybindingService): void {
ipcMain.handle('keybindings:reload', () => {
const snapshot = service.reload()
broadcastKeybindingsChanged(snapshot)
onChanged?.()
return snapshot
})
+3 -1
View File
@@ -159,7 +159,9 @@ export function registerCoreHandlers(
registerAutomationHandlers(store, automations)
}
if (keybindings) {
registerKeybindingHandlers(keybindings)
registerKeybindingHandlers(keybindings, () => {
void pluginService?.reconcileActivationState()
})
}
if (pluginService) {
registerPluginHandlers(store, pluginService, runtime)
@@ -90,6 +90,32 @@ describe('keybinding-file', () => {
})
})
it('preserves valid plugin overrides while rejecting malformed plugin action IDs', () => {
writeFileSync(
filePath,
JSON.stringify({
keybindings: {
'plugin:orca-samples.tasks/open': 'Mod+Shift+T',
'plugin:tasks/open': 'Mod+Alt+T'
}
}),
'utf8'
)
const snapshot = readKeybindingFile(filePath, 'linux')
expect(snapshot.overrides).toEqual({
'plugin:orca-samples.tasks/open': ['Mod+Shift+T']
})
expect(snapshot.diagnostics).toMatchObject([
{ severity: 'warning', actionId: 'plugin:tasks/open' }
])
writeKeybindingOverride(filePath, 'linux', 'plugin:orca-samples.tasks/open', [])
expect(readKeybindingFile(filePath, 'linux').overrides).toEqual({
'plugin:orca-samples.tasks/open': []
})
})
it('ignores invalid, unknown, and conflicting manual edits', () => {
writeFileSync(
filePath,
@@ -0,0 +1,13 @@
import type { ValidDiscoveredPlugin } from './plugin-discovery'
export function assertPluginWorkerCommand(plugin: ValidDiscoveredPlugin, commandId: string): void {
const command = plugin.manifest.contributes.commands.find((entry) => entry.id === commandId)
if (!command) {
throw new Error(`plugin ${plugin.pluginKey} does not contribute command ${commandId}`)
}
// Declarative aliases are renderer-owned and must never cross the worker
// activation boundary, even if a compromised renderer invokes IPC directly.
if (command.action !== undefined) {
throw new Error(`plugin ${plugin.pluginKey} command ${commandId} is a built-in action alias`)
}
}
@@ -0,0 +1,180 @@
import { describe, expect, it } from 'vitest'
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest'
import type { ValidDiscoveredPlugin } from './plugin-discovery'
import { PluginCommandRegistry } from './plugin-command-registry'
function commandPlugin(
id: string,
contributes: {
commands: Record<string, unknown>[]
keybindings?: Record<string, unknown>[]
}
): ValidDiscoveredPlugin {
const manifest = pluginManifestSchema.parse({
manifestVersion: 1,
id,
publisher: 'orca-samples',
name: id,
version: '1.0.0',
engines: { orca: '>=1.0.0' },
pluginApi: 1,
...(contributes.commands.some((command) => command.action === undefined)
? { main: 'worker.js' }
: {}),
contributes,
capabilities: []
})
return {
pluginKey: `orca-samples.${id}`,
rootDir: `/plugins/${id}`,
manifest,
consentFingerprint: fingerprintPluginConsent(manifest, `content-${id}`),
consentContentHash: `content-${id}`,
contentHash: `content-${id}`,
isDev: false
}
}
describe('PluginCommandRegistry', () => {
it('retains pending previews and exposes only approved commands', () => {
const plugin = commandPlugin('aliases', {
commands: [{ id: 'tasks', title: 'Open Tasks', action: 'view.tasks' }],
keybindings: [{ command: 'tasks', key: 'mod+alt+t' }]
})
const registry = new PluginCommandRegistry()
registry.reconcile([plugin], () => false)
expect(registry.list()).toEqual([])
expect(registry.preview(plugin.pluginKey)).toEqual([
{
pluginKey: plugin.pluginKey,
id: 'tasks',
title: 'Open Tasks',
context: 'global',
handler: { type: 'built-in', action: 'view.tasks' },
keybindings: [{ key: 'Mod+Alt+T', when: 'global' }]
}
])
registry.reconcile([plugin], () => true)
expect(registry.list()).toHaveLength(1)
})
it('projects worker commands and inherited worktree keybinding context', () => {
const plugin = commandPlugin('worker', {
commands: [{ id: 'create', title: 'Create Task', context: 'worktree' }],
keybindings: [{ command: 'create', key: 'Mod+Shift+A' }]
})
const registry = new PluginCommandRegistry()
registry.reconcile([plugin], () => true)
expect(registry.list()).toMatchObject([
{
context: 'worktree',
handler: { type: 'worker' },
keybindings: [{ key: 'Mod+Shift+A', when: 'worktree' }]
}
])
})
it('errors approved plugins whose keybindings overlap', () => {
const global = commandPlugin('global', {
commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }],
keybindings: [{ command: 'tasks', key: 'Mod+Alt+T', when: 'global' }]
})
const worktree = commandPlugin('worktree', {
commands: [{ id: 'tasks', title: 'Tasks', context: 'worktree', action: 'view.tasks' }],
keybindings: [{ command: 'tasks', key: 'Mod+Alt+T', when: 'worktree' }]
})
const registry = new PluginCommandRegistry()
registry.reconcile([global, worktree], () => true)
expect(registry.list()).toEqual([])
expect(registry.error(global.pluginKey)).toContain('conflicts')
expect(registry.error(worktree.pluginKey)).toContain('conflicts')
})
it('uses saved effective bindings to recover conflicting plugins', () => {
const first = commandPlugin('first', {
commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }],
keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }]
})
const second = commandPlugin('second', {
commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }],
keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }]
})
const registry = new PluginCommandRegistry()
registry.reconcile(
[first, second],
() => true,
{ 'plugin:orca-samples.first/tasks': ['Mod+Shift+T'] },
'linux'
)
expect(registry.list()).toHaveLength(2)
expect(registry.error(first.pluginKey)).toBeNull()
expect(registry.error(second.pluginKey)).toBeNull()
})
it('rejects conflicting saved bindings within one plugin', () => {
const plugin = commandPlugin('aliases', {
commands: [
{ id: 'tasks', title: 'Tasks', action: 'view.tasks' },
{ id: 'sidebar', title: 'Sidebar', action: 'sidebar.left.toggle' }
]
})
const registry = new PluginCommandRegistry()
registry.reconcile(
[plugin],
() => true,
{
'plugin:orca-samples.aliases/tasks': ['Mod+Alt+T'],
'plugin:orca-samples.aliases/sidebar': ['Mod+Alt+T']
},
'linux'
)
expect(registry.list()).toEqual([])
expect(registry.error(plugin.pluginKey)).toContain('conflicts')
})
it('detects cross-platform Mod and physical Ctrl conflicts', () => {
const portable = commandPlugin('portable', {
commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }],
keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }]
})
const physical = commandPlugin('physical', {
commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }],
keybindings: [{ command: 'tasks', key: 'Ctrl+Alt+T' }]
})
const registry = new PluginCommandRegistry()
registry.reconcile([portable, physical], () => true, {}, 'linux')
expect(registry.list()).toEqual([])
expect(registry.error(portable.pluginKey)).toContain('conflicts')
expect(registry.error(physical.pluginKey)).toContain('conflicts')
})
it('allows the same worktree-only chord after one plugin is disabled', () => {
const first = commandPlugin('first', {
commands: [{ id: 'tasks', title: 'Tasks', context: 'worktree', action: 'view.tasks' }],
keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }]
})
const second = commandPlugin('second', {
commands: [{ id: 'tasks', title: 'Tasks', context: 'worktree', action: 'view.tasks' }],
keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }]
})
const registry = new PluginCommandRegistry()
registry.reconcile([first, second], (plugin) => plugin === first)
expect(registry.list()).toHaveLength(1)
expect(registry.error(first.pluginKey)).toBeNull()
})
})
+167
View File
@@ -0,0 +1,167 @@
import {
pluginCommandKeybindingActionId,
type PluginCommandAliasActionId
} from '../../shared/plugins/plugin-command-actions'
import { getKeybindingConflictIdentity, type KeybindingOverrides } from '../../shared/keybindings'
import type {
PluginCommandContribution,
PluginManifest
} from '../../shared/plugins/plugin-manifest'
import type { PluginKeybindingContribution } from '../../shared/plugins/plugin-content-pack-contributions'
import {
isInvalidDiscoveredPlugin,
type DiscoveredPlugin,
type ValidDiscoveredPlugin
} from './plugin-discovery'
export type PluginCommandKeybinding = {
key: string
when: 'global' | 'worktree'
}
export type PluginCommandRegistration = {
pluginKey: string
id: string
title: string
context: 'global' | 'worktree'
handler: { type: 'built-in'; action: PluginCommandAliasActionId } | { type: 'worker' }
keybindings: PluginCommandKeybinding[]
}
type CommandOwner = {
pluginKey: string
context: PluginCommandKeybinding['when']
key: string
}
export class PluginCommandRegistry {
private active: PluginCommandRegistration[] = []
private readonly previews = new Map<string, PluginCommandRegistration[]>()
private readonly errors = new Map<string, string>()
list(): readonly PluginCommandRegistration[] {
return this.active
}
preview(pluginKey: string): readonly PluginCommandRegistration[] {
return this.previews.get(pluginKey) ?? []
}
error(pluginKey: string): string | null {
return this.errors.get(pluginKey) ?? null
}
reconcile(
discovered: readonly DiscoveredPlugin[],
isApproved: (plugin: ValidDiscoveredPlugin) => boolean,
overrides: KeybindingOverrides = {},
platform: NodeJS.Platform = process.platform
): void {
const candidates = discovered.filter(
(plugin): plugin is ValidDiscoveredPlugin =>
!isInvalidDiscoveredPlugin(plugin) && plugin.manifest.contributes.commands.length > 0
)
const registrations = candidates.map((plugin) => ({
pluginKey: plugin.pluginKey,
approved: isApproved(plugin),
commands: registrationsForManifest(plugin.pluginKey, plugin.manifest)
}))
this.previews.clear()
this.errors.clear()
for (const plugin of registrations) {
this.previews.set(plugin.pluginKey, plugin.commands)
}
const approved = registrations.filter((plugin) => plugin.approved)
const chordOwners = new Map<string, CommandOwner[]>()
for (const plugin of approved) {
for (const command of plugin.commands) {
for (const keybinding of effectiveCommandKeybindings(command, overrides)) {
const identity = getKeybindingConflictIdentity(keybinding.key, platform)
const owners = chordOwners.get(identity) ?? []
owners.push({
pluginKey: plugin.pluginKey,
context: keybinding.when,
key: keybinding.key
})
chordOwners.set(identity, owners)
}
}
}
const conflicted = new Set<string>()
for (const owners of chordOwners.values()) {
for (let index = 0; index < owners.length; index += 1) {
for (let compared = index + 1; compared < owners.length; compared += 1) {
const first = owners[index]!
const second = owners[compared]!
if (!contextsOverlap(first.context, second.context)) {
continue
}
conflicted.add(first.pluginKey)
conflicted.add(second.pluginKey)
this.errors.set(
first.pluginKey,
`plugin keybinding ${first.key} conflicts with another plugin`
)
this.errors.set(
second.pluginKey,
`plugin keybinding ${second.key} conflicts with another plugin`
)
}
}
}
this.active = approved
.filter((plugin) => !conflicted.has(plugin.pluginKey))
.flatMap((plugin) => plugin.commands)
}
}
function effectiveCommandKeybindings(
command: PluginCommandRegistration,
overrides: KeybindingOverrides
): PluginCommandKeybinding[] {
const override = overrides[pluginCommandKeybindingActionId(command.pluginKey, command.id)]
if (!Array.isArray(override)) {
return command.keybindings
}
return override.map((key) => ({ key, when: command.context }))
}
function registrationsForManifest(
pluginKey: string,
manifest: PluginManifest
): PluginCommandRegistration[] {
return manifest.contributes.commands.map((command) => ({
pluginKey,
id: command.id,
title: command.title,
context: command.context ?? 'global',
handler:
command.action === undefined
? { type: 'worker' as const }
: { type: 'built-in' as const, action: command.action as PluginCommandAliasActionId },
keybindings: keybindingsForCommand(command, manifest.contributes.keybindings)
}))
}
function keybindingsForCommand(
command: PluginCommandContribution,
keybindings: readonly PluginKeybindingContribution[]
): PluginCommandKeybinding[] {
return keybindings
.filter((keybinding) => keybinding.command === command.id)
.map((keybinding) => ({
key: keybinding.key,
when: keybinding.when ?? command.context ?? 'global'
}))
}
function contextsOverlap(
first: PluginCommandKeybinding['when'],
second: PluginCommandKeybinding['when']
): boolean {
return first === 'global' || second === 'global' || first === second
}
@@ -10,7 +10,9 @@ import { PluginSkillRegistry } from './plugin-skill-registry'
import { PluginThemeRegistry } from './plugin-theme-registry'
import { PluginTerminalThemeRegistry } from './plugin-terminal-theme-registry'
import { PluginVmRecipeRegistry } from './plugin-vm-recipe-registry'
import { PluginCommandRegistry } from './plugin-command-registry'
import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity'
import type { KeybindingOverrides } from '../../shared/keybindings'
export class PluginContentPackRegistry {
readonly themes: PluginThemeRegistry
@@ -19,6 +21,7 @@ export class PluginContentPackRegistry {
readonly terminalThemes: PluginTerminalThemeRegistry
readonly skills: PluginSkillRegistry
readonly vmRecipes: PluginVmRecipeRegistry
readonly commands: PluginCommandRegistry
private readonly activationErrors = new Map<string, string>()
constructor(
@@ -35,11 +38,13 @@ export class PluginContentPackRegistry {
options.homeDirectory
)
this.vmRecipes = new PluginVmRecipeRegistry()
this.commands = new PluginCommandRegistry()
}
async reconcile(
discovered: readonly DiscoveredPlugin[],
isApproved: (plugin: ValidDiscoveredPlugin) => boolean
isApproved: (plugin: ValidDiscoveredPlugin) => boolean,
keybindings: KeybindingOverrides = {}
): Promise<void> {
const approvedKeys = new Set(
discovered
@@ -80,7 +85,8 @@ export class PluginContentPackRegistry {
this.iconThemes.reconcile(discovered, approveAtomically),
this.terminalThemes.reconcile(discovered, approveAtomically),
this.skills.reconcile(discovered, approveAtomically),
this.vmRecipes.reconcile(discovered, approveAtomically)
this.vmRecipes.reconcile(discovered, approveAtomically),
this.commands.reconcile(discovered, approveAtomically, keybindings)
])
let foundNewError = false
@@ -109,7 +115,8 @@ export class PluginContentPackRegistry {
this.iconThemes.error(pluginKey) ??
this.terminalThemes.error(pluginKey) ??
this.skills.error(pluginKey) ??
this.vmRecipes.error(pluginKey)
this.vmRecipes.error(pluginKey) ??
this.commands.error(pluginKey)
)
}
}
+48
View File
@@ -0,0 +1,48 @@
import type { PluginEventName } from '../../shared/plugins/plugin-manifest'
import {
isInvalidDiscoveredPlugin,
type DiscoveredPlugin,
type ValidDiscoveredPlugin
} from './plugin-discovery'
import type { PluginEventBus } from './plugin-event-bus'
import type { PluginWorkerController } from './plugin-worker-controller'
export function deliverPluginEvent(options: {
event: PluginEventName
payload: unknown
plugins: readonly DiscoveredPlugin[]
eventBus: PluginEventBus
workerController: PluginWorkerController
isRuntimeApproved: (plugin: ValidDiscoveredPlugin) => boolean
logWarning: (pluginKey: string, line: string) => void
}): void {
const projected = options.eventBus.projectPayload(options.event, options.payload)
if (!projected.ok) {
return
}
for (const plugin of options.plugins) {
if (isInvalidDiscoveredPlugin(plugin) || !options.isRuntimeApproved(plugin)) {
continue
}
const manifestSubscribed = plugin.manifest.contributes.events.some(
(subscription) => subscription.on === options.event
)
if (manifestSubscribed && plugin.manifest.main) {
void options.workerController
.ensure(plugin)
.then((handle) => handle.deliverEvent(options.event, projected.payload))
.catch((error) => {
options.logWarning(
plugin.pluginKey,
`event ${options.event} dropped: ${error instanceof Error ? error.message : String(error)}`
)
})
} else if (options.eventBus.isDynamicallySubscribed(plugin.pluginKey, options.event)) {
options.workerController.deliverEventIfRunning(
plugin.pluginKey,
options.event,
projected.payload
)
}
}
}
@@ -25,6 +25,7 @@ function serviceWith(
activation?: ReturnType<PluginService['activationState']>
worker?: ReturnType<PluginService['workerState']>
vmRecipes?: ReturnType<PluginService['contentPacks']['vmRecipes']['preview']>
commands?: ReturnType<PluginService['contentPacks']['commands']['preview']>
} = {}
): PluginService {
return {
@@ -37,7 +38,8 @@ function serviceWith(
workerState: () => options.worker ?? { state: 'inactive', restarts: 0 },
activationError: () => null,
contentPacks: {
vmRecipes: { preview: () => options.vmRecipes ?? [] }
vmRecipes: { preview: () => options.vmRecipes ?? [] },
commands: { preview: () => options.commands ?? [] }
}
} as unknown as PluginService
}
@@ -168,4 +170,49 @@ describe('buildPluginList consent identity', () => {
}
])
})
it('projects command handlers and normalized keybindings for consent and dispatch', () => {
const commandManifest = pluginManifestSchema.parse({
...manifest,
contributes: {
commands: [{ id: 'tasks', title: 'Open Tasks', context: 'worktree', action: 'view.tasks' }],
keybindings: [{ command: 'tasks', key: 'mod+alt+t' }]
}
})
const plugin: ValidDiscoveredPlugin = {
pluginKey: 'orca-samples.demo',
rootDir: join(tmpdir(), 'plugins', 'demo'),
manifest: commandManifest,
consentFingerprint: 'sha256-current',
consentContentHash: 'a'.repeat(64),
contentHash: null,
isDev: true
}
expect(
buildPluginList(
serviceWith(plugin, {
commands: [
{
pluginKey: plugin.pluginKey,
id: 'tasks',
title: 'Open Tasks',
context: 'worktree',
handler: { type: 'built-in', action: 'view.tasks' },
keybindings: [{ key: 'Mod+Alt+T', when: 'worktree' }]
}
]
}),
emptyPluginLockfile()
)[0]?.commands
).toEqual([
{
id: 'tasks',
title: 'Open Tasks',
context: 'worktree',
handler: { type: 'built-in', action: 'view.tasks' },
keybindings: [{ key: 'Mod+Alt+T', when: 'worktree' }]
}
])
})
})
+13 -3
View File
@@ -8,6 +8,7 @@ import type { PluginLockfile } from '../../shared/plugins/plugin-install-lockfil
import { isInvalidDiscoveredPlugin } from './plugin-discovery'
import type { PluginService } from './plugin-service'
import { listPluginVmRecipeCommands } from '../../shared/plugins/plugin-vm-recipe-artifact'
import type { PluginCommandAliasActionId } from '../../shared/plugins/plugin-command-actions'
/**
* Wire projection of installed plugins for the renderer and serve RPC.
@@ -46,7 +47,13 @@ export type PluginListEntry = {
isDev: boolean
capabilities: { kind: PluginCapabilityKind; description: string }[]
panels: PluginListPanelEntry[]
commands: { id: string; title: string }[]
commands: {
id: string
title: string
context: 'global' | 'worktree'
handler: { type: 'built-in'; action: PluginCommandAliasActionId } | { type: 'worker' }
keybindings: { key: string; when: 'global' | 'worktree' }[]
}[]
hasWorker: boolean
hasSkills: boolean
vmRecipes: {
@@ -142,9 +149,12 @@ export function buildPluginList(service: PluginService, lock: PluginLockfile): P
...(panel.icon ? { icon: panel.icon } : {}),
tabKey: pluginPanelTabKey(plugin.pluginKey, panel.id)
})),
commands: plugin.manifest.contributes.commands.map((command) => ({
commands: service.contentPacks.commands.preview(plugin.pluginKey).map((command) => ({
id: command.id,
title: command.title
title: command.title,
context: command.context,
handler: command.handler,
keybindings: command.keybindings
})),
hasWorker: Boolean(plugin.manifest.main),
hasSkills: plugin.manifest.contributes.skills.length > 0,
@@ -1,4 +1,5 @@
import type { PluginWorkerFactory } from './plugin-worker-manager'
import type { KeybindingOverrides } from '../../shared/keybindings'
export type PluginServiceOptions = {
userDataPath: string
@@ -7,6 +8,7 @@ export type PluginServiceOptions = {
getDisabledPlugins: () => string[]
getPluginConsents: () => Record<string, string>
getDevPluginPaths: () => string[]
getKeybindings?: () => KeybindingOverrides
hostEntryPath?: string
workerFactory?: PluginWorkerFactory
maxActiveWorkers?: number
@@ -2,11 +2,13 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { KeybindingOverrides } from '../../shared/keybindings'
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest'
import type { PluginWorkerHandle } from './plugin-host-process'
import { PluginService } from './plugin-service'
import type { PluginWorkerFactory } from './plugin-worker-manager'
import { hashPluginTree } from './plugin-content-hash'
const roots: string[] = []
const services: PluginService[] = []
@@ -103,6 +105,111 @@ afterEach(async () => {
})
describe('PluginService worker reconciliation', () => {
it('blocks every runtime surface until a saved override resolves a content conflict', async () => {
const conflictingManifest = (id: string): PluginManifest =>
pluginManifestSchema.parse({
manifestVersion: 1,
id,
publisher: 'orca-samples',
name: id,
version: '1.0.0',
engines: { orca: '>=1.0.0' },
pluginApi: 1,
main: 'worker.js',
contributes: {
panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }],
commands: [{ id: 'run', title: 'Run' }],
keybindings: [{ command: 'run', key: 'Mod+Alt+T' }],
events: [{ on: 'worktree.created' }]
},
capabilities: [{ kind: 'events:subscribe' }]
})
const firstManifest = conflictingManifest('first')
const secondManifest = conflictingManifest('second')
const firstRoot = await pluginRoot(firstManifest)
const secondRoot = await pluginRoot(secondManifest)
const firstHash = await hashPluginTree(firstRoot)
const secondHash = await hashPluginTree(secondRoot)
if (!firstHash.ok || !secondHash.ok) {
throw new Error('could not hash conflict fixtures')
}
let keybindings: KeybindingOverrides = {}
const factory = vi.fn<PluginWorkerFactory>(async () => testWorker())
const service = new PluginService({
userDataPath: firstRoot,
hostVersion: '1.4.0',
isPluginSystemEnabled: () => true,
getDisabledPlugins: () => [],
getPluginConsents: () => ({
'orca-samples.first': fingerprintPluginConsent(firstManifest, firstHash.hash),
'orca-samples.second': fingerprintPluginConsent(secondManifest, secondHash.hash)
}),
getDevPluginPaths: () => [firstRoot, secondRoot],
getKeybindings: () => keybindings,
workerFactory: factory
})
services.push(service)
await service.initialize()
expect(service.activationError('orca-samples.first')).toContain('conflicts')
expect(service.getGrantedCapabilities('orca-samples.first')).toBeNull()
await expect(service.invokeCommand('orca-samples.first', 'run')).rejects.toThrow('not enabled')
await expect(service.panels.readEntry('orca-samples.first', 'panel')).resolves.toBeNull()
service.emitEvent('worktree.created', {
worktreeId: 'worktree-1',
path: '/repo',
branch: 'feature'
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(factory).not.toHaveBeenCalled()
keybindings = { 'plugin:orca-samples.first/run': ['Mod+Shift+T'] }
await service.reconcileActivationState()
expect(service.activationError('orca-samples.first')).toBeNull()
await expect(service.panels.readEntry('orca-samples.first', 'panel')).resolves.toMatchObject({
html: expect.stringContaining('<h1>Panel</h1>')
})
await expect(service.invokeCommand('orca-samples.first', 'run')).resolves.toBeNull()
expect(factory).toHaveBeenCalledOnce()
})
it('rejects declarative aliases at the worker-command boundary without activating code', async () => {
const aliasManifest = pluginManifestSchema.parse({
manifestVersion: 1,
id: 'demo',
publisher: 'orca-samples',
name: 'Demo',
version: '1.0.0',
engines: { orca: '>=1.0.0' },
pluginApi: 1,
contributes: {
commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }]
},
capabilities: []
})
const root = await pluginRoot(aliasManifest)
const factory = vi.fn<PluginWorkerFactory>()
const service = new PluginService({
userDataPath: root,
hostVersion: '1.4.0',
isPluginSystemEnabled: () => true,
getDisabledPlugins: () => [],
getPluginConsents: () => ({ [pluginKey]: fingerprintPluginConsent(aliasManifest) }),
getDevPluginPaths: () => [root],
workerFactory: factory
})
services.push(service)
await service.initialize()
await expect(service.invokeCommand(pluginKey, 'tasks')).rejects.toThrow(
'is a built-in action alias'
)
expect(factory).not.toHaveBeenCalled()
})
it('denies every authority boundary immediately when the feature flag turns off', async () => {
const root = await pluginRoot()
const harness = createHarness(root)
+40 -44
View File
@@ -36,6 +36,8 @@ import { PluginContentPackRegistry } from './plugin-content-pack-registry'
import type { PluginServiceOptions } from './plugin-service-options'
import type { PluginChangeEvent } from '../../shared/plugins/plugin-change-event'
import { waitForPluginRefreshSettlement } from './plugin-refresh-settlement'
import { assertPluginWorkerCommand } from './plugin-command-invocation'
import { deliverPluginEvent } from './plugin-event-delivery'
export type { PluginRuntimeDelegate } from './plugin-host-service-bindings'
export type { PluginLogLine } from './plugin-log-buffer'
@@ -57,6 +59,7 @@ export class PluginService {
private runtimeDelegate: PluginRuntimeDelegate | null = null
private initPromise: Promise<void> | null = null
private refreshChain: Promise<void> = Promise.resolve()
private contentPacksReady = false
private disposed = false
constructor(options: PluginServiceOptions) {
@@ -69,7 +72,7 @@ export class PluginService {
this.panels = new PluginPanelController({
resolveApprovedPlugin: (pluginKey) => {
const plugin = this.findValidPlugin(pluginKey)
return plugin && this.activationState(plugin) === 'approved' ? plugin : null
return plugin && this.isRuntimeApproved(plugin) ? plugin : null
},
contentVerifier: this.contentVerifier,
executeHostCall: (pluginKey, method, params) =>
@@ -85,8 +88,7 @@ export class PluginService {
contentVerifier: this.contentVerifier,
capabilities: (pluginKey) => this.getGrantedCapabilities(pluginKey),
isCurrentApproved: (plugin) =>
this.findValidPlugin(plugin.pluginKey) === plugin &&
this.activationState(plugin) === 'approved',
this.findValidPlugin(plugin.pluginKey) === plugin && this.isRuntimeApproved(plugin),
invokeCommand: (pluginKey, commandId, args) => this.invokeCommand(pluginKey, commandId, args),
executeHostCall: (pluginKey, method, params) =>
this.executeHostCall(pluginKey, method, params, { viaPanel: false }),
@@ -144,6 +146,7 @@ export class PluginService {
if (this.disposed) {
return
}
this.contentPacksReady = false
this.contentVerifier.clear()
if (!enabled) {
this.panels.revokeAll()
@@ -158,14 +161,15 @@ export class PluginService {
if (this.disposed) {
return
}
const nextSpecs = collectApprovedWorkerSpecs(next, (plugin) =>
isPluginApproved(enabled, plugin, consentLists)
)
// Publish identity before shutdown so triggers cannot restart old code.
this.discovered = next
await this.contentPacks.reconcile(next, (plugin) =>
isPluginApproved(enabled, plugin, consentLists)
await this.contentPacks.reconcile(
next,
(plugin) => isPluginApproved(enabled, plugin, consentLists),
this.options.getKeybindings?.()
)
this.contentPacksReady = true
const nextSpecs = collectApprovedWorkerSpecs(next, (plugin) => this.isRuntimeApproved(plugin))
// Notify before slow shutdown so feature-off unmounts panels immediately.
this.notifyChanged(true)
await this.workerController.reconcile(nextSpecs)
@@ -210,6 +214,14 @@ export class PluginService {
})
}
private isRuntimeApproved(plugin: ValidDiscoveredPlugin): boolean {
return (
this.contentPacksReady &&
this.activationState(plugin) === 'approved' &&
!this.contentPacks.error(plugin.pluginKey)
)
}
workerState(pluginKey: string): { state: PluginRunState; restarts: number } {
return this.workerController.state(pluginKey)
}
@@ -222,7 +234,7 @@ export class PluginService {
* callers deny uniformly (no probe-able distinction). */
getGrantedCapabilities(pluginKey: string): PluginCapabilityKind[] | null {
const plugin = this.findValidPlugin(pluginKey)
if (!plugin || this.activationState(plugin) !== 'approved') {
if (!plugin || !this.isRuntimeApproved(plugin)) {
return null
}
return capabilityKinds(plugin.manifest.capabilities)
@@ -256,12 +268,10 @@ export class PluginService {
async invokeCommand(pluginKey: string, commandId: string, args?: unknown): Promise<unknown> {
const plugin = this.findValidPlugin(pluginKey)
if (!plugin || this.activationState(plugin) !== 'approved') {
if (!plugin || !this.isRuntimeApproved(plugin)) {
throw new Error(`plugin ${pluginKey} is not enabled`)
}
if (!plugin.manifest.contributes.commands.some((command) => command.id === commandId)) {
throw new Error(`plugin ${pluginKey} does not contribute command ${commandId}`)
}
assertPluginWorkerCommand(plugin, commandId)
const handle = await this.workerController.ensure(plugin)
if (!handle.commands.includes(commandId)) {
throw new Error(`plugin ${pluginKey} registered no handler for ${commandId}`)
@@ -273,32 +283,15 @@ export class PluginService {
if (!this.options.isPluginSystemEnabled() || this.disposed) {
return
}
const projected = this.eventBus.projectPayload(event, payload)
if (!projected.ok) {
return
}
for (const plugin of this.discovered) {
if (isInvalidDiscoveredPlugin(plugin) || this.activationState(plugin) !== 'approved') {
continue
}
const manifestSubscribed = plugin.manifest.contributes.events.some(
(subscription) => subscription.on === event
)
if (manifestSubscribed && plugin.manifest.main) {
void this.workerController
.ensure(plugin)
.then((handle) => handle.deliverEvent(event, projected.payload))
.catch((error) => {
this.logBuffer.append(
plugin.pluginKey,
'warn',
`event ${event} dropped: ${error instanceof Error ? error.message : String(error)}`
)
})
} else if (this.eventBus.isDynamicallySubscribed(plugin.pluginKey, event)) {
this.workerController.deliverEventIfRunning(plugin.pluginKey, event, projected.payload)
}
}
deliverPluginEvent({
event,
payload,
plugins: this.discovered,
eventBus: this.eventBus,
workerController: this.workerController,
isRuntimeApproved: (plugin) => this.isRuntimeApproved(plugin),
logWarning: (pluginKey, line) => this.logBuffer.append(pluginKey, 'warn', line)
})
}
async deactivatePlugin(pluginKey: string): Promise<void> {
@@ -315,12 +308,15 @@ export class PluginService {
}
private async performActivationStateReconciliation(): Promise<void> {
await this.contentPacks.reconcile(this.discovered, (plugin) => {
return this.activationState(plugin) === 'approved'
})
const nextSpecs = collectApprovedWorkerSpecs(
this.contentPacksReady = false
await this.contentPacks.reconcile(
this.discovered,
(plugin) => this.activationState(plugin) === 'approved'
(plugin) => this.activationState(plugin) === 'approved',
this.options.getKeybindings?.()
)
this.contentPacksReady = true
const nextSpecs = collectApprovedWorkerSpecs(this.discovered, (plugin) =>
this.isRuntimeApproved(plugin)
)
await this.workerController.reconcile(nextSpecs)
this.notifyChanged(true)
+26 -5
View File
@@ -22,6 +22,7 @@ let markerPaths: string[] = []
let consents: Record<string, string> = {}
function dummyManifest(index: number): PluginManifest {
const key = String.fromCharCode('A'.charCodeAt(0) + index)
return pluginManifestSchema.parse({
manifestVersion: 1,
id: `dummy-${index}`,
@@ -30,8 +31,25 @@ function dummyManifest(index: number): PluginManifest {
version: '1.0.0',
engines: { orca: '>=1.0.0' },
pluginApi: 1,
main: 'main.mjs',
contributes: { panels: [], commands: [], events: [] },
contributes: {
panels: [],
commands: [
{
id: 'open',
title: `Open Startup Dummy ${index}`,
action: 'view.tasks'
}
],
events: [],
keybindings: [{ command: 'open', key: `Mod+Alt+${key}` }],
themes: [
{
id: `startup-${index}`,
label: `Startup Theme ${index}`,
path: 'theme.json'
}
]
},
capabilities: []
})
}
@@ -48,8 +66,11 @@ async function installDummy(index: number): Promise<{ pluginKey: string; markerP
writeFile(join(pluginDir, 'current'), contentHash),
writeFile(join(versionDir, PLUGIN_MANIFEST_FILENAME), JSON.stringify(manifest)),
writeFile(
join(versionDir, 'main.mjs'),
`import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(markerPath)}, 'executed'); export default async function activate() { return {}; }`
join(versionDir, 'theme.json'),
JSON.stringify({
base: index % 2 === 0 ? 'dark' : 'light',
tokens: { '--background': index % 2 === 0 ? '#111111' : '#eeeeee' }
})
)
])
consents[pluginKey] = fingerprintPluginConsent(manifest)
@@ -74,7 +95,7 @@ describe('plugin startup budget', () => {
await rm(userDataPath, { recursive: true, force: true })
})
it('stays below 50ms P95 with 20 approved plugins and executes no plugin code', async () => {
it('stays below 50ms P95 with 20 approved content packs and executes no plugin code', async () => {
const workerFactory = vi.fn<PluginWorkerFactory>(async () => {
throw new Error('startup must not create a plugin worker')
})
@@ -137,4 +137,30 @@ describe('PluginWorkerController activation authority', () => {
expect(startedWorker.dispose).toHaveBeenCalledOnce()
await subject.dispose()
})
it('rejects workers that register declarative action aliases', async () => {
const base = await plugin()
const subjectPlugin: ValidDiscoveredPlugin = {
...base,
manifest: pluginManifestSchema.parse({
...base.manifest,
contributes: {
...base.manifest.contributes,
commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }]
}
})
}
const startedWorker = worker(['tasks'])
const subject = controller({
factory: vi.fn<PluginWorkerFactory>().mockResolvedValue(startedWorker),
verify: async () => undefined,
isApproved: () => true
})
await expect(subject.ensure(subjectPlugin)).rejects.toThrow(
'registered undeclared command tasks'
)
expect(startedWorker.dispose).toHaveBeenCalledOnce()
await subject.dispose()
})
})
+3 -1
View File
@@ -86,7 +86,9 @@ export class PluginWorkerController {
throw new Error(`plugin ${plugin.pluginKey} changed or was disabled during activation`)
}
const declaredCommands = new Set(
plugin.manifest.contributes.commands.map((command) => command.id)
plugin.manifest.contributes.commands
.filter((command) => command.action === undefined)
.map((command) => command.id)
)
const undeclaredCommand = handle.commands.find((command) => !declaredCommands.has(command))
if (undeclaredCommand) {
+7 -1
View File
@@ -949,7 +949,13 @@ export type PluginHostListEntry = {
isDev: boolean
capabilities: { kind: string; description: string }[]
panels: PluginHostPanel[]
commands: { id: string; title: string }[]
commands: {
id: string
title: string
context: 'global' | 'worktree'
handler: { type: 'built-in'; action: string } | { type: 'worker' }
keybindings: { key: string; when: 'global' | 'worktree' }[]
}[]
hasWorker: boolean
hasSkills?: boolean
vmRecipes?: {
+220 -154
View File
@@ -166,6 +166,11 @@ import {
type KeybindingContext,
type PhysicalModifierToken
} from '../../shared/keybindings'
import { PLUGIN_COMMAND_ALIAS_ACTION_IDS } from '../../shared/plugins/plugin-command-actions'
import { registerAppCommandDispatcher } from '@/lib/app-command-dispatch'
import { executePluginCommand } from '@/lib/plugin-command-execution'
import { findPluginCommandForKeybinding } from '@/lib/plugin-command-keybindings'
import { usePluginCommands } from '@/store/plugin-panels'
import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import {
ModifierDoubleTapDetector,
@@ -502,6 +507,7 @@ function App(): React.JSX.Element {
hasRequestedBackgroundTerminalWorktreeMount
)
const keybindings = useAppStore((s) => s.keybindings)
const pluginCommands = usePluginCommands()
const updateStatus = useAppStore((s) => s.updateStatus)
const activeContextualTourId = useAppStore((s) => s.activeContextualTourId)
const leftSidebarShortcutLabel = useShortcutLabel('sidebar.left.toggle')
@@ -1594,6 +1600,7 @@ function App(): React.JSX.Element {
floatingTerminalOpen,
floatingVisibleTabCount,
keybindings,
pluginCommands,
terminalShortcutPolicy: settings?.terminalShortcutPolicy,
setFloatingTerminalOpenWithFocus,
workspaceChromeActive,
@@ -1609,6 +1616,7 @@ function App(): React.JSX.Element {
floatingTerminalOpen,
floatingVisibleTabCount,
keybindings,
pluginCommands,
terminalShortcutPolicy: settings?.terminalShortcutPolicy,
setFloatingTerminalOpenWithFocus,
workspaceChromeActive,
@@ -1618,6 +1626,196 @@ function App(): React.JSX.Element {
useEffect(() => {
const doubleTapDetector = new ModifierDoubleTapDetector()
const createRegisteredCommandHandlers = (
input?: ShortcutDispatchInput,
keybindingContext: KeybindingContext = 'app'
): Map<KeybindingActionId, () => boolean> => {
const {
activeView,
activeWorktreeId,
actions,
floatingTerminalEnabled,
floatingTerminalOpen,
terminalShortcutPolicy,
keybindings,
setFloatingTerminalOpenWithFocus,
workspaceChromeActive,
creationLayoutActive
} = globalShortcutStateRef.current
const floatingWorkspaceFocused = isFloatingWorkspacePanelFocused()
const canRevealRightSidebar = !creationLayoutActive && canShowRightSidebarForView(activeView)
const claim = (actionId: KeybindingActionId, run: () => void): boolean => {
input?.preventDefault()
if (
input &&
keybindingContext === 'terminal' &&
(terminalShortcutPolicy ?? 'orca-first') === 'orca-first'
) {
showTerminalShortcutCaptureNotification({
actionId,
platform: shortcutPlatform,
keybindings
})
}
run()
return true
}
return new Map<KeybindingActionId, () => boolean>([
[
'worktree.history.back',
() => {
if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) {
return false
}
return claim('worktree.history.back', () => useAppStore.getState().goBackWorktree())
}
],
[
'worktree.history.forward',
() => {
if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) {
return false
}
return claim('worktree.history.forward', () =>
useAppStore.getState().goForwardWorktree()
)
}
],
['sidebar.left.toggle', () => claim('sidebar.left.toggle', () => actions.toggleSidebar())],
[
'sidebar.sleepingWorkspaces.toggle',
() =>
claim('sidebar.sleepingWorkspaces.toggle', () => {
const store = useAppStore.getState()
const nextShowSleeping = !store.showSleepingWorkspaces
store.setShowSleepingWorkspaces(nextShowSleeping)
if (nextShowSleeping) {
store.setSidebarOpen(true)
}
})
],
[
'floatingWorkspace.maximize',
() => {
if (floatingTerminalOpen || !floatingTerminalEnabled) {
return false
}
return claim('floatingWorkspace.maximize', () => {
requestFloatingTerminalOpenMaximized()
setFloatingTerminalOpenWithFocus(true)
})
}
],
[
'tab.rename',
() => {
const store = useAppStore.getState()
if (
!workspaceChromeActive ||
floatingWorkspaceFocused ||
store.activeTabType !== 'terminal' ||
!store.activeTabId
) {
return false
}
return claim('tab.rename', () => store.setRenamingTabId(store.activeTabId!))
}
],
[
'workspace.rename',
() => {
if (!workspaceChromeActive || floatingWorkspaceFocused || !activeWorktreeId) {
return false
}
return claim('workspace.rename', () => {
useAppStore.getState().setSidebarOpen(true)
requestScrollToCurrentWorkspaceRevealAndRename()
})
}
],
[
'workspace.openBoard',
() => {
if (activeView === 'settings') {
return false
}
return claim('workspace.openBoard', () => {
useAppStore.getState().setSidebarOpen(true)
window.dispatchEvent(new CustomEvent(OPEN_WORKSPACE_BOARD_EVENT))
})
}
],
[
'view.tasks',
() => {
const store = useAppStore.getState()
if (activeView === 'settings' || !store.repos.some((repo) => isGitRepoKind(repo))) {
return false
}
return claim('view.tasks', () => store.openTaskPage())
}
],
[
'sidebar.right.toggle',
() =>
canRevealRightSidebar
? claim('sidebar.right.toggle', () => actions.toggleRightSidebar())
: false
],
[
'sidebar.explorer.toggle',
() =>
canRevealRightSidebar
? claim('sidebar.explorer.toggle', () => actions.showRightSidebarFiles())
: false
],
[
'sidebar.search.toggle',
() =>
canRevealRightSidebar
? claim('sidebar.search.toggle', () => actions.showRightSidebarSearch())
: false
],
[
'sidebar.sourceControl.toggle',
() => {
if (!canRevealRightSidebar || document.querySelector('[data-terminal-search-root]')) {
return false
}
return claim('sidebar.sourceControl.toggle', () => {
actions.setRightSidebarTab('source-control')
actions.setRightSidebarOpen(true)
})
}
],
[
'sidebar.checks.toggle',
() =>
canRevealRightSidebar
? claim('sidebar.checks.toggle', () => {
actions.setRightSidebarTab('checks')
actions.setRightSidebarOpen(true)
})
: false
],
[
'sidebar.ports.toggle',
() =>
canRevealRightSidebar
? claim('sidebar.ports.toggle', () => {
actions.setRightSidebarTab('ports')
actions.setRightSidebarOpen(true)
})
: false
]
])
}
const unregisterAppCommandDispatcher = registerAppCommandDispatcher((actionId) =>
(createRegisteredCommandHandlers().get(actionId) ?? (() => false))()
)
const dispatchShortcutInput = (input: ShortcutDispatchInput): void => {
const {
activeView,
@@ -1627,9 +1825,9 @@ function App(): React.JSX.Element {
floatingTerminalOpen,
floatingVisibleTabCount,
keybindings,
pluginCommands,
terminalShortcutPolicy,
setFloatingTerminalOpenWithFocus,
workspaceChromeActive,
creationLayoutActive
} = globalShortcutStateRef.current
@@ -1752,25 +1950,6 @@ function App(): React.JSX.Element {
return
}
// Cmd/Ctrl+Alt+Arrow — worktree history back/forward. This stays before
// right-sidebar shortcuts because it is navigation, not sidebar reveal.
if (matchShortcut('worktree.history.back') || matchShortcut('worktree.history.forward')) {
// Why: Back/Forward traverse mixed worktree + page visits, so the
// shortcut is active wherever the titlebar button cluster is (terminal
// or stack-backed pages). Still suppressed in Settings.
if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) {
return
}
input.preventDefault()
const store = useAppStore.getState()
if (matchShortcut('worktree.history.back')) {
store.goBackWorktree()
} else {
store.goForwardWorktree()
}
return
}
// Why: only short-circuit chords the floating panel's own keydown
// handler claims (Cmd/Ctrl+T, Cmd/Ctrl+W, Cmd/Ctrl+Shift+B/M). Other
// app-level mod shortcuts (B, L, Shift+E/F/G) have no panel-level
@@ -1788,147 +1967,33 @@ function App(): React.JSX.Element {
}
}
// Cmd/Ctrl+B — toggle left sidebar
if (matchShortcut('sidebar.left.toggle')) {
input.preventDefault()
notifyTerminalCapture('sidebar.left.toggle')
actions.toggleSidebar()
return
}
// Toggle the "show sleeping workspaces" sidebar filter without opening the
// filters menu (issue #5209). When revealing them, open the left sidebar
// so the now-visible sleeping worktrees are actually reachable.
if (matchShortcut('sidebar.sleepingWorkspaces.toggle')) {
input.preventDefault()
notifyTerminalCapture('sidebar.sleepingWorkspaces.toggle')
const store = useAppStore.getState()
const nextShowSleeping = !store.showSleepingWorkspaces
store.setShowSleepingWorkspaces(nextShowSleeping)
if (nextShowSleeping) {
store.setSidebarOpen(true)
}
return
}
// Why: rename the active terminal tab. Cmd+R is free in the app/terminal
// focus zone because the browser pane owns its own Cmd+R reload and that
// focus never reaches this renderer-window handler. Only terminal tabs
// have an inline title editor, so other active tab types fall through.
if (workspaceChromeActive && !floatingWorkspaceFocused && matchShortcut('tab.rename')) {
const store = useAppStore.getState()
if (store.activeTabType === 'terminal' && store.activeTabId) {
// Plugin chords are user-reviewed instructional content. They win over
// built-in defaults only in app focus; terminal/editor/browser handlers
// retain their own shortcut authority.
if (context === 'app') {
const pluginCommand = findPluginCommandForKeybinding(
pluginCommands,
input,
shortcutPlatform,
keybindings,
Boolean(activeWorktreeId)
)
if (pluginCommand) {
input.preventDefault()
notifyTerminalCapture('tab.rename')
store.setRenamingTabId(store.activeTabId)
void executePluginCommand(pluginCommand, 'plugin-keybinding').catch(() => {
toast.error(
translate('auto.App.pluginCommandFailed', 'Could not run the plugin command.')
)
})
return
}
}
// Why: open the active worktree's inline title editor. Open/reveal it
// first so the card is mounted and visible even when sidebar filters or
// collapse state would otherwise hide it.
if (
workspaceChromeActive &&
!floatingWorkspaceFocused &&
matchShortcut('workspace.rename') &&
activeWorktreeId
) {
input.preventDefault()
notifyTerminalCapture('workspace.rename')
const store = useAppStore.getState()
store.setSidebarOpen(true)
requestScrollToCurrentWorkspaceRevealAndRename()
return
}
if (matchShortcut('workspace.openBoard') && activeView !== 'settings') {
input.preventDefault()
notifyTerminalCapture('workspace.openBoard')
const store = useAppStore.getState()
store.setSidebarOpen(true)
window.dispatchEvent(new CustomEvent(OPEN_WORKSPACE_BOARD_EVENT))
return
}
// Why: Cmd/Ctrl+N is handled via the main-process before-input-event
// allowlist (see window-shortcut-policy.ts / useIpcEvents.ts) so it works
// globally — including when focus lives inside the markdown rich editor
// (contentEditable) or a browser guest webContents, both of which bypass
// this renderer-side window keydown listener.
// Why: full-page navigation surfaces should not reveal the right sidebar;
// they are designed as distraction-free content areas.
if (matchShortcut('view.tasks') && activeView !== 'settings') {
const store = useAppStore.getState()
if (store.repos.some((repo) => isGitRepoKind(repo))) {
input.preventDefault()
notifyTerminalCapture('view.tasks')
store.openTaskPage()
}
return
}
if (!canRevealRightSidebar) {
return
}
// Cmd/Ctrl+L — toggle right sidebar
if (matchShortcut('sidebar.right.toggle')) {
input.preventDefault()
notifyTerminalCapture('sidebar.right.toggle')
actions.toggleRightSidebar()
return
}
// Cmd/Ctrl+Shift+E — toggle right sidebar / explorer tab
if (matchShortcut('sidebar.explorer.toggle')) {
input.preventDefault()
notifyTerminalCapture('sidebar.explorer.toggle')
actions.showRightSidebarFiles()
return
}
// Cmd/Ctrl+Shift+F — toggle right sidebar / search tab
if (matchShortcut('sidebar.search.toggle')) {
input.preventDefault()
notifyTerminalCapture('sidebar.search.toggle')
openSearchSidebar(null)
return
}
// Cmd/Ctrl+Shift+G — toggle right sidebar / source control tab.
// Skip when terminal search is open — Cmd+Shift+G means "find previous"
// in that context (handled by keyboard-handlers.ts). Both listeners share
// the window capture phase and registration order can vary with React
// effect re-runs, so a DOM check is the reliable coordination mechanism.
if (matchShortcut('sidebar.sourceControl.toggle')) {
if (document.querySelector('[data-terminal-search-root]')) {
const handlers = createRegisteredCommandHandlers(input, context)
for (const actionId of PLUGIN_COMMAND_ALIAS_ACTION_IDS) {
if (matchShortcut(actionId) && handlers.get(actionId)?.()) {
return
}
input.preventDefault()
notifyTerminalCapture('sidebar.sourceControl.toggle')
actions.setRightSidebarTab('source-control')
actions.setRightSidebarOpen(true)
return
}
if (matchShortcut('sidebar.checks.toggle')) {
input.preventDefault()
notifyTerminalCapture('sidebar.checks.toggle')
actions.setRightSidebarTab('checks')
actions.setRightSidebarOpen(true)
return
}
// Cmd+Shift+I — toggle right sidebar / ports tab (macOS only).
// Why: Ctrl+Shift+I is the built-in DevTools accelerator on Windows/Linux;
// intercepting it would break an essential developer tool.
if (matchShortcut('sidebar.ports.toggle')) {
input.preventDefault()
notifyTerminalCapture('sidebar.ports.toggle')
actions.setRightSidebarTab('ports')
actions.setRightSidebarOpen(true)
}
}
@@ -1994,6 +2059,7 @@ function App(): React.JSX.Element {
window.addEventListener('keyup', onKeyUp, { capture: true })
window.addEventListener('blur', onBlur)
return () => {
unregisterAppCommandDispatcher()
window.removeEventListener('keydown', onKeyDown, { capture: true })
window.removeEventListener('keyup', onKeyUp, { capture: true })
window.removeEventListener('blur', onBlur)
@@ -104,6 +104,8 @@ import {
getCmdJQuickActions,
CREATE_WORKSPACE_QUICK_ACTION_ID
} from '@/components/cmd-j/quick-actions'
import { buildPluginQuickActions } from '@/components/cmd-j/plugin-quick-actions'
import { usePluginCommands } from '@/store/plugin-panels'
import {
getComposerEligibleRepos,
resolveComposerGitRepoId
@@ -343,6 +345,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
const projectHostSetups = useAppStore((s) => s.projectHostSetups)
const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo)
const pendingWorktreeCreations = useAppStore((s) => s.pendingWorktreeCreations)
const pluginCommands = usePluginCommands()
// Why: keep the (very hot) status maps subscribed through the dialog's close
// animation. `visible` flips false synchronously on close, but the CommandDialog
// content stays mounted while it fades/zooms out — dropping the maps to empty
@@ -820,7 +823,14 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
() => buildCmdJSettingsResults(settingsSections),
[settingsSections]
)
const actionResults = useMemo(() => buildCmdJActionResults(getCmdJQuickActions()), [])
const actionResults = useMemo(
() =>
buildCmdJActionResults([
...getCmdJQuickActions(),
...buildPluginQuickActions(pluginCommands)
]),
[pluginCommands]
)
// Why: Cmd+J should only offer project jumps the sidebar can actually reveal;
// archived-only repos are intentionally left out of this navigation surface.
const renderableProjectRepoIds = useMemo(() => {
@@ -1412,17 +1422,30 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
closeModal()
setSelectedItemId('')
const ctx = buildQuickActionContext()
void action.run(ctx).then((result) => {
if (result.status === 'unavailable') {
toast.error(getUnavailableQuickActionMessage(action.title, result.reason))
return
}
if (action.id === 'create-workspace') {
recordFeatureInteraction('cmd-j-create-workspace')
return
}
recordFeatureInteraction('cmd-j-quick-action')
})
void action
.run(ctx)
.then((result) => {
if (result.status === 'unavailable') {
toast.error(getUnavailableQuickActionMessage(action.title, result.reason))
return
}
if (action.id === 'create-workspace') {
recordFeatureInteraction('cmd-j-create-workspace')
return
}
recordFeatureInteraction('cmd-j-quick-action')
})
.catch((error: unknown) => {
if (!action.id.startsWith('plugin:')) {
throw error
}
toast.error(
translate(
'auto.components.WorktreeJumpPalette.pluginCommandFailed',
'Could not run the plugin command.'
)
)
})
},
[buildQuickActionContext, closeModal, recordFeatureInteraction]
)
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from 'vitest'
import type { ActivePluginCommand } from '@/store/plugin-panels'
import { buildPluginQuickActions } from './plugin-quick-actions'
vi.mock('@/lib/plugin-command-execution', () => ({ executePluginCommand: vi.fn() }))
function command(context: 'global' | 'worktree'): ActivePluginCommand {
return {
pluginKey: 'orca-samples.tasks',
pluginName: 'Tasks Pack',
id: 'open',
title: 'Open Tasks',
context,
handler: { type: 'built-in', action: 'view.tasks' },
keybindings: []
}
}
describe('plugin Cmd+J actions', () => {
it('adds enabled plugin commands with plugin attribution', () => {
const action = buildPluginQuickActions([command('global')])[0]!
expect(action).toMatchObject({
id: 'plugin:orca-samples.tasks/open',
title: 'Open Tasks',
description: 'Tasks Pack plugin command'
})
expect(action.isAvailable({ activeWorktreeId: null } as never)).toEqual({ available: true })
})
it('requires an active workspace for worktree commands', () => {
const action = buildPluginQuickActions([command('worktree')])[0]!
expect(action.isAvailable({ activeWorktreeId: null } as never)).toEqual({
available: false,
reason: 'no-active-workspace'
})
expect(action.isAvailable({ activeWorktreeId: 'worktree-1' } as never)).toEqual({
available: true
})
})
})
@@ -0,0 +1,34 @@
import { Blocks } from 'lucide-react'
import type { ActivePluginCommand } from '@/store/plugin-panels'
import { executePluginCommand } from '@/lib/plugin-command-execution'
import { translate } from '@/i18n/i18n'
import type { CmdJQuickAction } from './quick-actions'
export function buildPluginQuickActions(
commands: readonly ActivePluginCommand[]
): CmdJQuickAction[] {
return commands.map((command) => ({
id: `plugin:${command.pluginKey}/${command.id}`,
kind: 'action',
title: command.title,
description: translate(
'auto.components.cmd.j.pluginQuickActions.description',
'{{value0}} plugin command',
{ value0: command.pluginName }
),
icon: Blocks,
verbKeywords: [
command.title,
command.pluginName,
translate('auto.components.cmd.j.pluginQuickActions.keyword', 'plugin command')
],
isAvailable: (context) =>
command.context === 'worktree' && !context.activeWorktreeId
? { available: false, reason: 'no-active-workspace' }
: { available: true },
run: async () => {
await executePluginCommand(command, 'plugin-palette')
return { status: 'ok' }
}
}))
}
@@ -167,6 +167,31 @@ describe('PluginConsentDialog', () => {
expect(commands[0]?.getAttribute('aria-label')).toBe('Cloud Sandbox · Create command')
})
it('shows plugin shortcuts and names built-in chords they replace', async () => {
await renderConsent(
{
...plugin,
hasWorker: false,
capabilities: [],
commands: [
{
id: 'tasks',
title: 'Open Tasks',
context: 'global',
handler: { type: 'built-in', action: 'view.tasks' },
keybindings: [{ key: 'Mod+P', when: 'global' }]
}
]
},
vi.fn().mockResolvedValue(undefined)
)
expect(document.body.textContent).toContain('Review plugin content')
expect(document.body.textContent).toContain('Keyboard shortcuts')
expect(document.body.textContent).toContain('Open Tasks')
expect(document.body.textContent).toContain('Replaces: Go to File')
})
it('records Keep Disabled when Escape dismisses the dialog', async () => {
const onDecision = vi.fn().mockResolvedValue(undefined)
await renderConsent(plugin, onDecision)
@@ -5,6 +5,7 @@ import { translate } from '@/i18n/i18n'
import { pluginConsentErrorMessage } from './plugin-error-presentation'
import { Button } from '../ui/button'
import { PluginVmRecipeConsentPreview } from './PluginVmRecipeConsentPreview'
import { PluginKeybindingConsentPreview } from './PluginKeybindingConsentPreview'
import {
Dialog,
DialogContent,
@@ -30,7 +31,7 @@ function trustTier(plugin: PluginHostListEntry): string {
'Background worker — runs its own process'
)
}
if (plugin.hasSkills || (plugin.vmRecipes?.length ?? 0) > 0) {
if (hasInstructionalContent(plugin)) {
return translate(
'auto.components.settings.PluginConsentDialog.instructionalTrust',
'Instructional content — runs later under user or agent authority'
@@ -85,7 +86,11 @@ function capabilityDescription(kind: string, fallback: string): string {
}
function hasInstructionalContent(plugin: PluginHostListEntry): boolean {
return plugin.hasSkills || (plugin.vmRecipes?.length ?? 0) > 0
return (
plugin.hasSkills ||
(plugin.vmRecipes?.length ?? 0) > 0 ||
plugin.commands.some((command) => command.keybindings.length > 0)
)
}
function consentTitle(plugin: PluginHostListEntry): string {
@@ -250,6 +255,7 @@ export function PluginConsentDialog({
</span>
</div>
) : null}
<PluginKeybindingConsentPreview commands={plugin.commands} />
<PluginVmRecipeConsentPreview recipes={plugin.vmRecipes ?? []} />
{error ? <p className="text-xs text-destructive">{error}</p> : null}
<DialogFooter>
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { shadowedKeybindingTitles } from './PluginKeybindingConsentPreview'
describe('plugin keybinding consent preview', () => {
it('names built-in shortcuts shadowed by plugin chords and user overrides', () => {
expect(shadowedKeybindingTitles('Mod+P', 'darwin')).toContain('Go to File')
expect(
shadowedKeybindingTitles('Mod+Alt+T', 'linux', { 'view.tasks': ['Mod+Alt+T'] })
).toContain('Open Tasks')
})
})
@@ -0,0 +1,108 @@
import { AlertTriangle, Keyboard } from 'lucide-react'
import type { PluginHostListEntry } from '../../../../preload/api-types'
import {
findKeybindingActionsForBinding,
formatKeybindingList,
getKeybindingDefinition,
type KeybindingOverrides
} from '../../../../shared/keybindings'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
type CommandPreview = PluginHostListEntry['commands'][number]
function shortcutPlatform(): NodeJS.Platform {
if (navigator.userAgent.includes('Mac')) {
return 'darwin'
}
return navigator.userAgent.includes('Windows') ? 'win32' : 'linux'
}
export function shadowedKeybindingTitles(
binding: string,
platform: NodeJS.Platform,
overrides?: KeybindingOverrides
): string[] {
return findKeybindingActionsForBinding(binding, platform, overrides).map(
(actionId) => getKeybindingDefinition(actionId)?.title ?? actionId
)
}
export function PluginKeybindingConsentPreview({
commands
}: {
commands: readonly CommandPreview[]
}): React.JSX.Element | null {
const overrides = useAppStore((state) => state.keybindings)
const platform = shortcutPlatform()
const bindings = commands.flatMap((command) =>
command.keybindings.map((keybinding) => ({ command, keybinding }))
)
if (bindings.length === 0) {
return null
}
return (
<section className="space-y-3" aria-labelledby="plugin-keybinding-consent-heading">
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/50 px-3.5 py-3 text-sm leading-6">
<Keyboard className="mt-1 size-4 shrink-0" />
<span>
{translate(
'auto.components.settings.PluginKeybindingConsentPreview.warning',
'This plugin adds keyboard shortcuts. Plugin shortcuts take priority over matching Orca shortcuts while the plugin is enabled.'
)}
</span>
</div>
<h3
id="plugin-keybinding-consent-heading"
className="text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground"
>
{translate(
'auto.components.settings.PluginKeybindingConsentPreview.heading',
'Keyboard shortcuts'
)}
</h3>
<div className="space-y-2">
{bindings.map(({ command, keybinding }) => {
const shadowed = shadowedKeybindingTitles(keybinding.key, platform, overrides)
return (
<div
key={`${command.id}:${keybinding.key}`}
className="space-y-1 rounded-md border border-border p-3"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-medium">{command.title}</span>
<kbd className="rounded border border-border bg-muted px-2 py-0.5 font-mono text-xs text-foreground">
{formatKeybindingList([keybinding.key], platform)}
</kbd>
</div>
<p className="text-xs text-muted-foreground">
{keybinding.when === 'worktree'
? translate(
'auto.components.settings.PluginKeybindingConsentPreview.worktree',
'Runs only while a workspace is active.'
)
: translate(
'auto.components.settings.PluginKeybindingConsentPreview.global',
'Runs in the app without requiring an active workspace.'
)}
</p>
{shadowed.length > 0 ? (
<p className="flex items-start gap-1.5 text-xs leading-5 text-foreground">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>
{translate(
'auto.components.settings.PluginKeybindingConsentPreview.shadows',
'Replaces: {{value0}}',
{ value0: shadowed.join(', ') }
)}
</span>
</p>
) : null}
</div>
)
})}
</div>
</section>
)
}
@@ -1,19 +1,16 @@
import React, { useEffect, useMemo, useState } from 'react'
import {
findKeybindingConflicts,
findKeybindingConflictsForDefinitions,
formatKeybindingList,
getEffectiveKeybindingsForAction,
getEffectiveKeybindingsForDefinition,
getKeybindingDefinition,
keybindingFromInputForAction,
normalizeKeybindingListForAction,
type KeybindingActionId,
type KeybindingDefinition,
type KeybindingInput
} from '../../../../shared/keybindings'
import {
EMPTY_DISABLED_TUI_AGENTS,
disabledAgentTabActionIds,
groupDefinitions
} from './shortcut-groups'
import { EMPTY_DISABLED_TUI_AGENTS } from './shortcut-groups'
import { useAppStore } from '../../store'
import { KeybindingsFileActions } from './KeybindingsFileActions'
import { SettingsSubsectionHeader } from './SettingsFormControls'
@@ -46,6 +43,8 @@ import {
} from './shortcut-binding-list-mutations'
import { useMountedRef } from '@/hooks/useMountedRef'
import { translate } from '@/i18n/i18n'
import { useEditablePluginCommands } from '@/store/plugin-panels'
import { buildShortcutDefinitionCatalog } from './shortcut-definition-catalog'
const isMac = navigator.userAgent.includes('Mac')
const platform: NodeJS.Platform = isMac
@@ -68,6 +67,7 @@ export function ShortcutsPane(): React.JSX.Element {
const setKeybindingOverride = useAppStore((state) => state.setKeybindingOverride)
const resetKeybindingOverride = useAppStore((state) => state.resetKeybindingOverride)
const disableKeybindingAction = useAppStore((state) => state.disableKeybindingAction)
const pluginCommands = useEditablePluginCommands()
const mountedRef = useMountedRef()
const [errors, setErrors] = useState<Partial<Record<KeybindingActionId, string>>>({})
const [recordingActionId, setRecordingActionId] = useState<KeybindingActionId | null>(null)
@@ -90,34 +90,32 @@ export function ShortcutsPane(): React.JSX.Element {
return () => window.api.ui.setShortcutRecorderFocused(false)
}, [recordingActionId])
const groups = useMemo(() => groupDefinitions(disabledTuiAgents), [disabledTuiAgents])
const ignoredConflictActionIds = useMemo(
() => disabledAgentTabActionIds(disabledTuiAgents),
[disabledTuiAgents]
)
const conflictByAction = useMemo(() => {
const result = new Map<KeybindingActionId, string[]>()
for (const conflict of findKeybindingConflicts(platform, keybindings, {
ignoredActionIds: ignoredConflictActionIds
})) {
const labels = conflict.actionIds
.map((id) => getKeybindingDefinition(id)?.title ?? id)
.join(', ')
for (const actionId of conflict.actionIds) {
result.set(actionId, [
...(result.get(actionId) ?? []),
`${formatKeybindingList([conflict.binding], platform)} conflicts with ${labels}.`
])
}
}
return result
}, [ignoredConflictActionIds, keybindings])
const { groups, definitions, definitionsByAction, ignoredConflictActionIds, conflictByAction } =
useMemo(
() =>
buildShortcutDefinitionCatalog({
disabledTuiAgents,
pluginCommands,
keybindings,
platform
}),
[disabledTuiAgents, keybindings, pluginCommands]
)
const definitionForAction = (actionId: KeybindingActionId): KeybindingDefinition | null =>
definitionsByAction.get(actionId) ?? getKeybindingDefinition(actionId)
const effectiveBindingsForAction = (
actionId: KeybindingActionId,
overrides = keybindings
): string[] => {
const definition = definitionForAction(actionId)
return definition ? getEffectiveKeybindingsForDefinition(definition, platform, overrides) : []
}
const shortcutGroups = useMemo<ShortcutRowsByGroup[]>(
() =>
groups.map((group) => ({
title: group.title,
rows: group.items.map((item) => {
const effective = getEffectiveKeybindingsForAction(item.id, platform, keybindings)
const effective = getEffectiveKeybindingsForDefinition(item, platform, keybindings)
const modified = hasOwnBindingOverride(keybindings, item.id)
const warnings = conflictByAction.get(item.id) ?? []
return {
@@ -175,19 +173,30 @@ export function ShortcutsPane(): React.JSX.Element {
return false
}
const defaults = getEffectiveKeybindingsForAction(actionId, platform, {})
const definition = definitionForAction(actionId)
if (!definition) {
setErrors((prev) => ({
...prev,
[actionId]: translate(
'auto.components.settings.ShortcutsPane.shortcutUnavailable',
'Shortcut is no longer available.'
)
}))
return false
}
const defaults = getEffectiveKeybindingsForDefinition(definition, platform, {})
const next =
sameBindings(normalizedResult, defaults) ||
(normalizedResult.length === 0 && defaults.length === 0)
? removeBindingOverride(keybindings, actionId)
: { ...keybindings, [actionId]: normalizedResult }
const blockingConflict = findKeybindingConflicts(platform, next, {
const blockingConflict = findKeybindingConflictsForDefinitions(definitions, platform, next, {
ignoredActionIds: ignoredConflictActionIds
}).find((conflict) => conflict.actionIds.includes(actionId))
if (blockingConflict) {
const labels = blockingConflict.actionIds
.filter((id) => id !== actionId)
.map((id) => getKeybindingDefinition(id)?.title ?? id)
.map((id) => definitionsByAction.get(id)?.title ?? id)
.join(', ')
setErrors((prev) => ({
...prev,
@@ -228,7 +237,7 @@ export function ShortcutsPane(): React.JSX.Element {
// Edit just the targeted binding (or append a new one) instead of replacing
// the whole list, so an action's other bindings survive the capture.
const current = getEffectiveKeybindingsForAction(actionId, platform, keybindings)
const current = effectiveBindingsForAction(actionId)
const next =
recordingBindingIndex === null || recordingBindingIndex >= current.length
? appendBinding(current, captured.value)
@@ -241,7 +250,7 @@ export function ShortcutsPane(): React.JSX.Element {
const removeBinding = async (actionId: KeybindingActionId, index: number): Promise<void> => {
setErrors((prev) => ({ ...prev, [actionId]: undefined }))
const current = getEffectiveKeybindingsForAction(actionId, platform, keybindings)
const current = effectiveBindingsForAction(actionId)
await saveBindings(actionId, removeBindingAt(current, index))
}
@@ -249,7 +258,7 @@ export function ShortcutsPane(): React.JSX.Element {
setErrors((prev) => ({ ...prev, [actionId]: undefined }))
try {
await (hasCommonBindingOverride(keybindingSnapshot, actionId)
? setKeybindingOverride(actionId, getEffectiveKeybindingsForAction(actionId, platform, {}))
? setKeybindingOverride(actionId, effectiveBindingsForAction(actionId, {}))
: resetKeybindingOverride(actionId))
} catch (error) {
if (mountedRef.current) {
@@ -370,7 +379,7 @@ export function ShortcutsPane(): React.JSX.Element {
clearError(actionId)
}}
onAppendBinding={(actionId) => {
const current = getEffectiveKeybindingsForAction(actionId, platform, keybindings)
const current = effectiveBindingsForAction(actionId)
setRecordingActionId(actionId)
setRecordingBindingIndex(current.length)
clearError(actionId)
@@ -399,7 +408,7 @@ export function ShortcutsPane(): React.JSX.Element {
}}
onDisableAction={(actionId) => {
// Remember the current bindings first so "Enable" can restore them.
const current = getEffectiveKeybindingsForAction(actionId, platform, keybindings)
const current = effectiveBindingsForAction(actionId)
setDisableMemory((memory) => ({ ...memory, [actionId]: current }))
clearRecordingForAction(actionId)
void disableBinding(actionId)
@@ -0,0 +1,62 @@
import {
findKeybindingConflictsForDefinitions,
formatKeybindingList,
type KeybindingActionId,
type KeybindingDefinition,
type KeybindingOverrides
} from '../../../../shared/keybindings'
import type { TuiAgent } from '../../../../shared/types'
import type { ActivePluginCommand } from '@/store/plugin-panels'
import { buildPluginCommandKeybindingDefinitions } from '@/lib/plugin-command-keybindings'
import { disabledAgentTabActionIds, groupDefinitions, type ShortcutGroup } from './shortcut-groups'
export type ShortcutDefinitionCatalog = {
groups: ShortcutGroup[]
definitions: KeybindingDefinition[]
definitionsByAction: Map<KeybindingActionId, KeybindingDefinition>
ignoredConflictActionIds: KeybindingActionId[]
conflictByAction: Map<KeybindingActionId, string[]>
}
export function buildShortcutDefinitionCatalog(options: {
disabledTuiAgents: readonly TuiAgent[]
pluginCommands: readonly ActivePluginCommand[]
keybindings: KeybindingOverrides
platform: NodeJS.Platform
}): ShortcutDefinitionCatalog {
const pluginDefinitions = buildPluginCommandKeybindingDefinitions(options.pluginCommands)
const groups = groupDefinitions(options.disabledTuiAgents, pluginDefinitions)
const definitions = groups.flatMap((group) => group.items)
const definitionsByAction = new Map(definitions.map((definition) => [definition.id, definition]))
const ignoredConflictActionIds = disabledAgentTabActionIds(options.disabledTuiAgents)
const conflictByAction = new Map<KeybindingActionId, string[]>()
const conflicts = findKeybindingConflictsForDefinitions(
definitions,
options.platform,
options.keybindings,
{
ignoredActionIds: ignoredConflictActionIds,
// Plugin defaults are external additions to Orca's conflict-free static
// registry, so surface their collisions even before the user customizes one.
relevantActionIds: pluginDefinitions.map((definition) => definition.id)
}
)
for (const conflict of conflicts) {
const labels = conflict.actionIds
.map((id) => definitionsByAction.get(id)?.title ?? id)
.join(', ')
for (const actionId of conflict.actionIds) {
conflictByAction.set(actionId, [
...(conflictByAction.get(actionId) ?? []),
`${formatKeybindingList([conflict.binding], options.platform)} conflicts with ${labels}.`
])
}
}
return {
groups,
definitions,
definitionsByAction,
ignoredConflictActionIds,
conflictByAction
}
}
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import type { KeybindingDefinition } from '../../../../shared/keybindings'
import type { ActivePluginCommand } from '@/store/plugin-panels'
import { buildShortcutDefinitionCatalog } from './shortcut-definition-catalog'
import { groupDefinitions } from './shortcut-groups'
const pluginDefinition: KeybindingDefinition = {
id: 'plugin:orca-samples.tasks/open',
title: 'Open Tasks — Tasks',
group: 'Plugins',
scope: 'global',
searchKeywords: ['plugin', 'tasks'],
defaultBindings: {
darwin: ['Mod+Alt+T'],
linux: ['Mod+Alt+T'],
win32: ['Mod+Alt+T']
}
}
describe('shortcut groups', () => {
it('includes dynamic plugin command definitions in Settings', () => {
expect(groupDefinitions([], [pluginDefinition])).toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Plugins',
items: [pluginDefinition]
})
])
)
})
it('reports a plugin default that shadows a built-in shortcut', () => {
const command: ActivePluginCommand = {
pluginKey: 'orca-samples.tasks',
pluginName: 'Tasks',
id: 'open',
title: 'Open Tasks',
context: 'global',
handler: { type: 'built-in', action: 'view.tasks' },
keybindings: [{ key: 'Mod+P', when: 'global' }]
}
const catalog = buildShortcutDefinitionCatalog({
disabledTuiAgents: [],
pluginCommands: [command],
keybindings: {},
platform: 'darwin'
})
expect(catalog.conflictByAction.get('plugin:orca-samples.tasks/open')).toEqual([
expect.stringContaining('Go to File')
])
})
})
@@ -20,7 +20,10 @@ export function disabledAgentTabActionIds(
return normalizeDisabledTuiAgents(disabledTuiAgents).map((agent) => agentTabActionId(agent))
}
export function groupDefinitions(disabledTuiAgents: readonly TuiAgent[]): ShortcutGroup[] {
export function groupDefinitions(
disabledTuiAgents: readonly TuiAgent[],
additionalDefinitions: readonly KeybindingDefinition[] = []
): ShortcutGroup[] {
// Why: per-agent launch rows only make sense for agents the user keeps
// enabled in Settings → Agents; hiding disabled ones keeps the Agents group
// scoped to what the chord could actually launch.
@@ -28,7 +31,7 @@ export function groupDefinitions(disabledTuiAgents: readonly TuiAgent[]): Shortc
disabledAgentTabActionIds(disabledTuiAgents)
)
const groups = new Map<string, KeybindingDefinition[]>()
for (const definition of KEYBINDING_DEFINITIONS) {
for (const definition of [...KEYBINDING_DEFINITIONS, ...additionalDefinitions]) {
if (hiddenAgentActionIds.has(definition.id)) {
continue
}
+20 -3
View File
@@ -125,7 +125,8 @@
"fe21e8f6f5": "Go back ({{value0}})",
"064bd07810": "Go back",
"ce37cf5279": "Toggle sidebar ({{value0}})",
"e4b9e7dff7": "Toggle sidebar"
"e4b9e7dff7": "Toggle sidebar",
"pluginCommandFailed": "Could not run the plugin command."
},
"web": {
"WebConnect": {
@@ -582,6 +583,9 @@
"de50bef680": "macOS is blocking Orca notifications"
}
}
},
"pluginCommandKeybindings": {
"group": "Plugins"
}
},
"hooks": {
@@ -1857,7 +1861,8 @@
"workspaceTabMissing": "Tab no longer exists",
"projectsGroupsHeader": "Projects & Groups",
"projectBadge": "Project",
"repoGroupBadge": "Repo group"
"repoGroupBadge": "Repo group",
"pluginCommandFailed": "Could not run the plugin command."
},
"github": {
"pr": {
@@ -6489,7 +6494,8 @@
"25b0004fbf": "Terminal active",
"781cb74d22": "Runs from terminal panes.",
"cb02e00202": "Terminal",
"d8c988dab4": "~/.orca/keybindings.json"
"d8c988dab4": "~/.orca/keybindings.json",
"shortcutUnavailable": "Shortcut is no longer available."
},
"SourceControlAiActionRecipeDefaults": {
"2576299196": "args",
@@ -9204,6 +9210,13 @@
"warning": "This plugin installs VM recipes. Orca runs the commands below on your local desktop host when you use the matching lifecycle action.",
"commandLabel": "{{value0}} · {{value1}} command",
"heading": "VM recipe commands"
},
"PluginKeybindingConsentPreview": {
"warning": "This plugin adds keyboard shortcuts. Plugin shortcuts take priority over matching Orca shortcuts while the plugin is enabled.",
"heading": "Keyboard shortcuts",
"worktree": "Runs only while a workspace is active.",
"global": "Runs in the app without requiring an active workspace.",
"shadows": "Replaces: {{value0}}"
}
},
"right": {
@@ -12262,6 +12275,10 @@
"project": "Project"
}
}
},
"pluginQuickActions": {
"description": "{{value0}} plugin command",
"keyword": "plugin command"
}
}
},
+20 -3
View File
@@ -125,7 +125,8 @@
"fe21e8f6f5": "Volver ({{value0}})",
"064bd07810": "Volver",
"ce37cf5279": "Alternar barra lateral ({{value0}})",
"e4b9e7dff7": "Alternar barra lateral"
"e4b9e7dff7": "Alternar barra lateral",
"pluginCommandFailed": "No se pudo ejecutar el comando del plugin."
},
"web": {
"WebConnect": {
@@ -582,6 +583,9 @@
"de50bef680": "macOS está bloqueando las notificaciones de Orca"
}
}
},
"pluginCommandKeybindings": {
"group": "Plugins"
}
},
"hooks": {
@@ -1857,7 +1861,8 @@
"workspaceTabMissing": "La pestaña ya no existe",
"projectsGroupsHeader": "Proyectos y grupos",
"projectBadge": "Proyecto",
"repoGroupBadge": "Grupo de repositorios"
"repoGroupBadge": "Grupo de repositorios",
"pluginCommandFailed": "No se pudo ejecutar el comando del plugin."
},
"github": {
"pr": {
@@ -6452,7 +6457,8 @@
"25b0004fbf": "Terminal activa",
"781cb74d22": "Se ejecuta desde paneles de terminal.",
"cb02e00202": "Terminal",
"d8c988dab4": "~/.orca/keybindings.json"
"d8c988dab4": "~/.orca/keybindings.json",
"shortcutUnavailable": "El atajo ya no está disponible."
},
"SourceControlAiActionRecipeDefaults": {
"2576299196": "argumentos",
@@ -9204,6 +9210,13 @@
"warning": "Este plugin instala recetas de VM. Orca ejecuta los comandos siguientes en el equipo local cuando usas la acción de ciclo de vida correspondiente.",
"commandLabel": "{{value0}} · comando de {{value1}}",
"heading": "Comandos de recetas de VM"
},
"PluginKeybindingConsentPreview": {
"warning": "Este plugin añade atajos de teclado. Los atajos del plugin tienen prioridad sobre los atajos coincidentes de Orca mientras el plugin está activado.",
"heading": "Atajos de teclado",
"worktree": "Solo se ejecuta mientras hay un espacio de trabajo activo.",
"global": "Se ejecuta en la aplicación sin requerir un espacio de trabajo activo.",
"shadows": "Reemplaza: {{value0}}"
}
},
"right": {
@@ -12262,6 +12275,10 @@
"project": "Proyecto"
}
}
},
"pluginQuickActions": {
"description": "Comando del plugin {{value0}}",
"keyword": "comando del plugin"
}
}
},
+20 -3
View File
@@ -125,7 +125,8 @@
"fe21e8f6f5": "戻る ({{value0}})",
"064bd07810": "戻る",
"ce37cf5279": "サイドバーの切り替え ({{value0}})",
"e4b9e7dff7": "サイドバーの切り替え"
"e4b9e7dff7": "サイドバーの切り替え",
"pluginCommandFailed": "プラグインのコマンドを実行できませんでした。"
},
"web": {
"WebConnect": {
@@ -582,6 +583,9 @@
"de50bef680": "macOS が Orca の通知をブロックしています"
}
}
},
"pluginCommandKeybindings": {
"group": "Plugins"
}
},
"hooks": {
@@ -1857,7 +1861,8 @@
"workspaceTabMissing": "タブはもう存在しません",
"projectsGroupsHeader": "プロジェクトとグループ",
"projectBadge": "プロジェクト",
"repoGroupBadge": "リポジトリグループ"
"repoGroupBadge": "リポジトリグループ",
"pluginCommandFailed": "プラグインのコマンドを実行できませんでした。"
},
"github": {
"pr": {
@@ -6474,7 +6479,8 @@
"25b0004fbf": "Terminal がアクティブです",
"781cb74d22": "terminal ペインから実行します。",
"cb02e00202": "ターミナル",
"d8c988dab4": "~/.orca/keybindings.json"
"d8c988dab4": "~/.orca/keybindings.json",
"shortcutUnavailable": "ショートカットは利用できなくなりました。"
},
"SourceControlAiActionRecipeDefaults": {
"2576299196": "引数",
@@ -9204,6 +9210,13 @@
"warning": "このプラグインは VM レシピをインストールします。対応するライフサイクル操作を使用すると、Orca は以下のコマンドをローカルのデスクトップホストで実行します。",
"commandLabel": "{{value0}} · {{value1}}コマンド",
"heading": "VM レシピのコマンド"
},
"PluginKeybindingConsentPreview": {
"warning": "このプラグインはキーボードショートカットを追加します。有効な間は、一致する Orca のショートカットよりプラグインのショートカットが優先されます。",
"heading": "キーボードショートカット",
"worktree": "ワークスペースがアクティブな場合のみ実行されます。",
"global": "アクティブなワークスペースがなくてもアプリで実行されます。",
"shadows": "置き換える操作: {{value0}}"
}
},
"right": {
@@ -12262,6 +12275,10 @@
"project": "プロジェクト"
}
}
},
"pluginQuickActions": {
"description": "{{value0}} プラグインのコマンド",
"keyword": "プラグインのコマンド"
}
}
},
+20 -3
View File
@@ -125,7 +125,8 @@
"fe21e8f6f5": "돌아가기({{value0}})",
"064bd07810": "돌아가기",
"ce37cf5279": "사이드바 표시/숨기기({{value0}})",
"e4b9e7dff7": "사이드바 표시/숨기기"
"e4b9e7dff7": "사이드바 표시/숨기기",
"pluginCommandFailed": "플러그인 명령을 실행할 수 없습니다."
},
"web": {
"WebConnect": {
@@ -582,6 +583,9 @@
"de50bef680": "macOS가 Orca 알림을 차단하고 있습니다"
}
}
},
"pluginCommandKeybindings": {
"group": "Plugins"
}
},
"hooks": {
@@ -1857,7 +1861,8 @@
"workspaceTabMissing": "탭이 더 이상 존재하지 않습니다",
"projectsGroupsHeader": "프로젝트 및 그룹",
"projectBadge": "프로젝트",
"repoGroupBadge": "리포지토리 그룹"
"repoGroupBadge": "리포지토리 그룹",
"pluginCommandFailed": "플러그인 명령을 실행할 수 없습니다."
},
"github": {
"pr": {
@@ -6437,7 +6442,8 @@
"25b0004fbf": "Terminal 활성",
"781cb74d22": "terminal 패널에서 실행됩니다.",
"cb02e00202": "터미널",
"d8c988dab4": "~/.orca/keybindings.json"
"d8c988dab4": "~/.orca/keybindings.json",
"shortcutUnavailable": "단축키를 더 이상 사용할 수 없습니다."
},
"SourceControlAiActionRecipeDefaults": {
"2576299196": "인수",
@@ -9204,6 +9210,13 @@
"warning": "이 플러그인은 VM 레시피를 설치합니다. 해당 수명 주기 작업을 사용하면 Orca가 아래 명령을 로컬 데스크톱 호스트에서 실행합니다.",
"commandLabel": "{{value0}} · {{value1}} 명령",
"heading": "VM 레시피 명령"
},
"PluginKeybindingConsentPreview": {
"warning": "이 플러그인은 키보드 단축키를 추가합니다. 플러그인이 활성화된 동안 일치하는 Orca 단축키보다 플러그인 단축키가 우선합니다.",
"heading": "키보드 단축키",
"worktree": "워크스페이스가 활성화된 동안에만 실행됩니다.",
"global": "활성 워크스페이스 없이 앱에서 실행됩니다.",
"shadows": "대체 항목: {{value0}}"
}
},
"right": {
@@ -12262,6 +12275,10 @@
"project": "프로젝트"
}
}
},
"pluginQuickActions": {
"description": "{{value0}} 플러그인 명령",
"keyword": "플러그인 명령"
}
}
},
+20 -3
View File
@@ -125,7 +125,8 @@
"fe21e8f6f5": "返回 ({{value0}})",
"064bd07810": "返回",
"ce37cf5279": "切换侧边栏 ({{value0}})",
"e4b9e7dff7": "切换侧边栏"
"e4b9e7dff7": "切换侧边栏",
"pluginCommandFailed": "无法运行插件命令。"
},
"web": {
"WebConnect": {
@@ -582,6 +583,9 @@
"de50bef680": "macOS 正在阻止 Orca 通知"
}
}
},
"pluginCommandKeybindings": {
"group": "Plugins"
}
},
"hooks": {
@@ -1857,7 +1861,8 @@
"workspaceTabMissing": "标签页不再存在",
"projectsGroupsHeader": "项目和分组",
"projectBadge": "项目",
"repoGroupBadge": "仓库组"
"repoGroupBadge": "仓库组",
"pluginCommandFailed": "无法运行插件命令。"
},
"github": {
"pr": {
@@ -6437,7 +6442,8 @@
"25b0004fbf": "终端活跃",
"781cb74d22": "从终端窗格运行。",
"cb02e00202": "终端",
"d8c988dab4": "~/.orca/keybindings.json"
"d8c988dab4": "~/.orca/keybindings.json",
"shortcutUnavailable": "此快捷键已不再可用。"
},
"SourceControlAiActionRecipeDefaults": {
"2576299196": "参数",
@@ -9204,6 +9210,13 @@
"warning": "此插件会安装 VM 配方。当你使用对应的生命周期操作时,Orca 会在本地桌面主机上运行以下命令。",
"commandLabel": "{{value0}} · {{value1}}命令",
"heading": "VM 配方命令"
},
"PluginKeybindingConsentPreview": {
"warning": "此插件会添加键盘快捷键。插件启用期间,插件快捷键优先于相同的 Orca 快捷键。",
"heading": "键盘快捷键",
"worktree": "仅在有活动工作区时运行。",
"global": "无需活动工作区即可在应用中运行。",
"shadows": "替代:{{value0}}"
}
},
"right": {
@@ -12262,6 +12275,10 @@
"project": "项目"
}
}
},
"pluginQuickActions": {
"description": "{{value0}} 插件命令",
"keyword": "插件命令"
}
}
},
@@ -0,0 +1,29 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { dispatchAppCommand, registerAppCommandDispatcher } from './app-command-dispatch'
let cleanup: (() => void) | null = null
afterEach(() => cleanup?.())
describe('app command dispatch', () => {
it('routes known action ids through the current handler registry', () => {
const dispatcher = vi.fn(() => true)
cleanup = registerAppCommandDispatcher(dispatcher)
expect(dispatchAppCommand('view.tasks', 'plugin-palette')).toBe(true)
expect(dispatcher).toHaveBeenCalledWith('view.tasks', 'plugin-palette')
})
it('rejects unknown actions and unregisters only the current dispatcher', () => {
const staleCleanup = registerAppCommandDispatcher(() => true)
const current = vi.fn(() => true)
cleanup = registerAppCommandDispatcher(current)
staleCleanup()
expect(dispatchAppCommand('missing.action', 'plugin-keybinding')).toBe(false)
expect(dispatchAppCommand('view.tasks', 'plugin-keybinding')).toBe(true)
cleanup()
cleanup = null
expect(dispatchAppCommand('view.tasks', 'plugin-keybinding')).toBe(false)
})
})
@@ -0,0 +1,24 @@
import { isKeybindingActionId, type KeybindingActionId } from '../../../shared/keybindings'
export type AppCommandSource = 'plugin-keybinding' | 'plugin-palette'
export type AppCommandDispatcher = (
actionId: KeybindingActionId,
source: AppCommandSource
) => boolean
let currentDispatcher: AppCommandDispatcher | null = null
export function registerAppCommandDispatcher(dispatcher: AppCommandDispatcher): () => void {
currentDispatcher = dispatcher
return () => {
if (currentDispatcher === dispatcher) {
currentDispatcher = null
}
}
}
export function dispatchAppCommand(actionId: string, source: AppCommandSource): boolean {
return isKeybindingActionId(actionId) && currentDispatcher !== null
? currentDispatcher(actionId, source)
: false
}
@@ -0,0 +1,54 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ActivePluginCommand } from '@/store/plugin-panels'
import { registerAppCommandDispatcher } from './app-command-dispatch'
import { executePluginCommand } from './plugin-command-execution'
let unregister: (() => void) | null = null
afterEach(() => {
unregister?.()
unregister = null
})
function command(handler: ActivePluginCommand['handler']): ActivePluginCommand {
return {
pluginKey: 'orca-samples.tasks',
pluginName: 'Tasks',
id: 'open',
title: 'Open Tasks',
context: 'global',
handler,
keybindings: []
}
}
describe('plugin command execution', () => {
it('dispatches aliases without invoking a plugin worker', async () => {
const dispatcher = vi.fn(() => true)
const invokeCommand = vi.fn()
unregister = registerAppCommandDispatcher(dispatcher)
Object.assign(window, { api: { plugins: { invokeCommand } } })
await executePluginCommand(
command({ type: 'built-in', action: 'view.tasks' }),
'plugin-palette'
)
expect(dispatcher).toHaveBeenCalledWith('view.tasks', 'plugin-palette')
expect(invokeCommand).not.toHaveBeenCalled()
})
it('routes worker commands through the authenticated preload binding', async () => {
const invokeCommand = vi.fn().mockResolvedValue({ ok: true })
Object.assign(window, { api: { plugins: { invokeCommand } } })
await executePluginCommand(command({ type: 'worker' }), 'plugin-keybinding')
expect(invokeCommand).toHaveBeenCalledWith({
pluginKey: 'orca-samples.tasks',
commandId: 'open'
})
})
})
@@ -0,0 +1,18 @@
import type { ActivePluginCommand } from '@/store/plugin-panels'
import { dispatchAppCommand, type AppCommandSource } from './app-command-dispatch'
export async function executePluginCommand(
command: ActivePluginCommand,
source: AppCommandSource
): Promise<void> {
if (command.handler.type === 'built-in') {
if (!dispatchAppCommand(command.handler.action, source)) {
throw new Error('built-in action is unavailable in the current context')
}
return
}
await window.api.plugins.invokeCommand({
pluginKey: command.pluginKey,
commandId: command.id
})
}
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import {
findKeybindingConflictsForDefinitions,
KEYBINDING_DEFINITIONS
} from '../../../shared/keybindings'
import type { ActivePluginCommand } from '@/store/plugin-panels'
import {
buildPluginCommandKeybindingDefinitions,
findPluginCommandForKeybinding,
getEffectivePluginCommandKeybindings,
pluginCommandKeybindingActionId,
pluginCommandKeybindingDefinition
} from './plugin-command-keybindings'
const command: ActivePluginCommand = {
pluginKey: 'orca-samples.tasks',
pluginName: 'Tasks',
id: 'open',
title: 'Open Tasks',
context: 'global',
handler: { type: 'built-in', action: 'view.tasks' },
keybindings: [{ key: 'Mod+Alt+T', when: 'global' }]
}
describe('plugin command keybindings', () => {
it('builds a stable dynamic action definition', () => {
expect(pluginCommandKeybindingActionId(command)).toBe('plugin:orca-samples.tasks/open')
expect(pluginCommandKeybindingDefinition(command)).toMatchObject({
title: 'Open Tasks — Tasks',
group: 'Plugins',
defaultBindings: { darwin: ['Mod+Alt+T'] }
})
})
it('lets user overrides replace or disable contributed defaults', () => {
const actionId = pluginCommandKeybindingActionId(command)
expect(getEffectivePluginCommandKeybindings(command, 'linux')).toEqual(['Mod+Alt+T'])
expect(
getEffectivePluginCommandKeybindings(command, 'linux', { [actionId]: ['Mod+Shift+T'] })
).toEqual(['Mod+Shift+T'])
expect(getEffectivePluginCommandKeybindings(command, 'linux', { [actionId]: [] })).toEqual([])
})
it('keeps removed-plugin overrides inert and restores them on reinstall', () => {
const actionId = pluginCommandKeybindingActionId(command)
const overrides = { [actionId]: ['Mod+Shift+T'] }
expect(buildPluginCommandKeybindingDefinitions([])).toEqual([])
expect(getEffectivePluginCommandKeybindings(command, 'linux', overrides)).toEqual([
'Mod+Shift+T'
])
})
it('matches effective overrides and honors explicit disablement', () => {
const actionId = pluginCommandKeybindingActionId(command)
const input = {
key: 't',
code: 'KeyT',
control: true,
meta: false,
alt: false,
shift: true
}
expect(findPluginCommandForKeybinding([command], input, 'linux', undefined, true)).toBeNull()
expect(
findPluginCommandForKeybinding(
[command],
input,
'linux',
{ [actionId]: ['Mod+Shift+T'] },
true
)
).toBe(command)
expect(
findPluginCommandForKeybinding([command], input, 'linux', { [actionId]: [] }, true)
).toBeNull()
})
it('reports conflicts between plugin and built-in definitions', () => {
const actionId = pluginCommandKeybindingActionId(command)
const definitions = [...KEYBINDING_DEFINITIONS, pluginCommandKeybindingDefinition(command)]
expect(
findKeybindingConflictsForDefinitions(definitions, 'darwin', {
[actionId]: ['Mod+P']
})
).toEqual(
expect.arrayContaining([
expect.objectContaining({
actionIds: expect.arrayContaining([actionId, 'worktree.quickOpen'])
})
])
)
})
it('does not match worktree commands without an active worktree', () => {
const worktreeCommand = { ...command, context: 'worktree' as const }
const input = {
key: 't',
code: 'KeyT',
control: true,
meta: false,
alt: true,
shift: false
}
expect(
findPluginCommandForKeybinding([worktreeCommand], input, 'linux', undefined, false)
).toBeNull()
expect(findPluginCommandForKeybinding([worktreeCommand], input, 'linux', undefined, true)).toBe(
worktreeCommand
)
})
})
@@ -0,0 +1,75 @@
import type { ActivePluginCommand } from '@/store/plugin-panels'
import {
getEffectiveKeybindingsForDefinition,
keybindingMatchesInput,
type KeybindingDefinition,
type KeybindingInput,
type KeybindingOverrides,
type PluginKeybindingActionId
} from '../../../shared/keybindings'
import { translate } from '@/i18n/i18n'
import { pluginCommandKeybindingActionId as sharedPluginCommandKeybindingActionId } from '../../../shared/plugins/plugin-command-actions'
export function pluginCommandKeybindingActionId(
command: Pick<ActivePluginCommand, 'pluginKey' | 'id'>
): PluginKeybindingActionId {
return sharedPluginCommandKeybindingActionId(command.pluginKey, command.id)
}
export function pluginCommandKeybindingDefinition(
command: ActivePluginCommand
): KeybindingDefinition {
const defaults = command.keybindings.map((keybinding) => keybinding.key)
return {
id: pluginCommandKeybindingActionId(command),
title: `${command.title} — ${command.pluginName}`,
group: translate('auto.lib.pluginCommandKeybindings.group', 'Plugins'),
scope: 'global',
searchKeywords: [
'plugin',
'shortcut',
command.title.toLowerCase(),
command.pluginName.toLowerCase()
],
defaultBindings: { darwin: defaults, linux: defaults, win32: defaults }
}
}
export function buildPluginCommandKeybindingDefinitions(
commands: readonly ActivePluginCommand[]
): KeybindingDefinition[] {
return commands.map(pluginCommandKeybindingDefinition)
}
export function getEffectivePluginCommandKeybindings(
command: ActivePluginCommand,
platform: NodeJS.Platform,
overrides?: KeybindingOverrides
): string[] {
return getEffectiveKeybindingsForDefinition(
pluginCommandKeybindingDefinition(command),
platform,
overrides
)
}
export function findPluginCommandForKeybinding(
commands: readonly ActivePluginCommand[],
input: KeybindingInput,
platform: NodeJS.Platform,
overrides: KeybindingOverrides | undefined,
hasActiveWorktree: boolean
): ActivePluginCommand | null {
for (const command of commands) {
if (command.context === 'worktree' && !hasActiveWorktree) {
continue
}
const matches = getEffectivePluginCommandKeybindings(command, platform, overrides).some(
(binding) => keybindingMatchesInput(binding, input, platform)
)
if (matches) {
return command
}
}
return null
}
+36 -1
View File
@@ -2,7 +2,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { PluginHostListEntry } from '../../../preload/api-types'
import { usePluginPanelsStore } from './plugin-panels'
import {
collectActivePluginCommands,
collectEditablePluginCommands,
usePluginPanelsStore
} from './plugin-panels'
function plugin(pluginKey: string): PluginHostListEntry {
return {
@@ -30,6 +34,37 @@ afterEach(() => {
})
describe('plugin panel list loading', () => {
it('collects commands only from enabled plugin states', () => {
const enabled = {
...plugin('orca-samples.enabled'),
commands: [
{
id: 'tasks',
title: 'Tasks',
context: 'global' as const,
handler: { type: 'built-in' as const, action: 'view.tasks' },
keybindings: [{ key: 'Mod+Alt+T', when: 'global' as const }]
}
]
}
const pending = { ...enabled, pluginKey: 'orca-samples.pending', status: 'pending' as const }
expect(collectActivePluginCommands([enabled, pending])).toEqual([
expect.objectContaining({
pluginKey: enabled.pluginKey,
pluginName: enabled.name,
id: 'tasks'
})
])
expect(
collectEditablePluginCommands([
{ ...enabled, status: 'errored' },
pending,
{ ...enabled, status: 'disabled' }
])
).toEqual([expect.objectContaining({ pluginKey: enabled.pluginKey, id: 'tasks' })])
})
it('bounds watchdog errors to installed panels and clears them on recovery', () => {
const installed = {
...plugin('orca-samples.current'),
+55
View File
@@ -8,6 +8,11 @@ export type ActivePluginPanel = PluginHostPanel & {
pluginName: string
}
export type ActivePluginCommand = PluginHostListEntry['commands'][number] & {
pluginKey: string
pluginName: string
}
export type PluginPanelsFetchStatus = 'idle' | 'loading' | 'ready' | 'error'
export type PluginPanelHealth = 'healthy' | 'error'
@@ -157,6 +162,37 @@ export function collectInstalledPluginTabKeys(
return new Set(plugins.flatMap((plugin) => plugin.panels.map((panel) => panel.tabKey)))
}
export function collectActivePluginCommands(
plugins: readonly PluginHostListEntry[]
): ActivePluginCommand[] {
return plugins
.filter(
(plugin) =>
plugin.status === 'running' || plugin.status === 'restarting' || plugin.status === 'idle'
)
.flatMap((plugin) =>
plugin.commands.map((command) => ({
...command,
pluginKey: plugin.pluginKey,
pluginName: plugin.name
}))
)
}
export function collectEditablePluginCommands(
plugins: readonly PluginHostListEntry[]
): ActivePluginCommand[] {
return plugins
.filter((plugin) => ['running', 'restarting', 'idle', 'errored'].includes(plugin.status))
.flatMap((plugin) =>
plugin.commands.map((command) => ({
...command,
pluginKey: plugin.pluginKey,
pluginName: plugin.name
}))
)
}
/** Panel contributions of enabled plugins, loading the list on first use. */
export function usePluginPanels(): ActivePluginPanel[] {
const plugins = usePluginPanelsStore((s) => s.plugins)
@@ -167,3 +203,22 @@ export function usePluginPanels(): ActivePluginPanel[] {
// referentially stable and doesn't retrigger useSyncExternalStore loops.
return useMemo(() => collectActivePluginPanels(plugins), [plugins])
}
/** Commands of enabled plugins, sharing the authoritative plugin-list refresh. */
export function usePluginCommands(): ActivePluginCommand[] {
const plugins = usePluginPanelsStore((state) => state.plugins)
useEffect(() => {
ensurePluginPanelsLoaded()
}, [])
return useMemo(() => collectActivePluginCommands(plugins), [plugins])
}
/** Enabled commands remain editable while a chord conflict has errored the
* plugin, so a saved override can recover both conflicting plugins. */
export function useEditablePluginCommands(): ActivePluginCommand[] {
const plugins = usePluginPanelsStore((state) => state.plugins)
useEffect(() => {
ensurePluginPanelsLoaded()
}, [])
return useMemo(() => collectEditablePluginCommands(plugins), [plugins])
}
+20
View File
@@ -4,11 +4,13 @@
import { describe, expect, it } from 'vitest'
import {
agentTabActionId,
findKeybindingActionsForBinding,
getKeybindingDefinition,
findKeybindingConflicts,
formatKeybinding,
formatKeybindingList,
getEffectiveKeybindingsForAction,
isKeybindingActionId,
isDigitIndexActionId,
isDoubleTapBinding,
keybindingFromInput,
@@ -25,6 +27,14 @@ import type { KeybindingActionId, KeybindingPlatform } from './keybindings'
import { ALL_TUI_AGENTS } from './tui-agent-display-names'
describe('keybindings', () => {
it('accepts bounded plugin command action IDs and rejects malformed variants', () => {
expect(isKeybindingActionId('plugin:orca-samples.tasks/open')).toBe(true)
expect(isKeybindingActionId('plugin:orca-samples.tasks/task.open-latest')).toBe(true)
expect(isKeybindingActionId('plugin:tasks/open')).toBe(false)
expect(isKeybindingActionId('plugin:orca-samples.tasks/../open')).toBe(false)
expect(isKeybindingActionId(`plugin:orca-samples.tasks/${'a'.repeat(401)}`)).toBe(false)
})
it('normalizes editable shortcut input and rejects unsafe bindings', () => {
expect(normalizeKeybinding(' ctrl + shift + p ')).toEqual({
ok: true,
@@ -242,6 +252,16 @@ describe('keybindings', () => {
})
})
it('finds app-level owners of a prospective plugin chord with overrides', () => {
expect(findKeybindingActionsForBinding('Mod+P', 'darwin')).toContain('worktree.quickOpen')
expect(
findKeybindingActionsForBinding('Mod+Alt+T', 'linux', {
'view.tasks': ['Mod+Alt+T']
})
).toContain('view.tasks')
expect(findKeybindingActionsForBinding('Mod+F', 'darwin')).not.toContain('editor.find')
})
it('reports quick-command menu conflicts with global shortcuts and digit ranges', () => {
expect(
findKeybindingConflicts('darwin', {
+60 -8
View File
@@ -26,6 +26,7 @@ export type KeybindingMatchOptions = {
}
export type AgentTabActionId = `tab.newAgent.${TuiAgent}`
export type PluginKeybindingActionId = `plugin:${string}`
export type KeybindingActionId =
| 'worktree.quickOpen'
@@ -111,6 +112,7 @@ export type KeybindingActionId =
| 'terminal.closePane'
| 'terminal.splitRight'
| 'terminal.splitDown'
| PluginKeybindingActionId
export type KeybindingOverrides = Partial<Record<KeybindingActionId, string[]>>
@@ -190,6 +192,7 @@ export type KeybindingConflict = {
export type FindKeybindingConflictOptions = {
ignoredActionIds?: Iterable<KeybindingActionId>
relevantActionIds?: Iterable<KeybindingActionId>
}
export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [
@@ -1092,7 +1095,16 @@ export function getKeybindingPlatform(platform: NodeJS.Platform): KeybindingPlat
}
export function isKeybindingActionId(value: string): value is KeybindingActionId {
return DEFINITION_IDS.has(value as KeybindingActionId)
return DEFINITION_IDS.has(value as KeybindingActionId) || isPluginKeybindingActionId(value)
}
export function isPluginKeybindingActionId(value: string): value is PluginKeybindingActionId {
return (
value.length <= 400 &&
/^plugin:[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+(?:-[a-z0-9]+)*\/[A-Za-z0-9]+(?:[._-][A-Za-z0-9]+)*$/.test(
value
)
)
}
function hasModifier(
@@ -1764,9 +1776,6 @@ export function getEffectiveKeybindingsForAction(
overrides?: KeybindingOverrides
): string[] {
const definition = DEFINITIONS_BY_ID.get(actionId)
if (!definition) {
return []
}
const override = overrides?.[actionId]
if (Array.isArray(override)) {
// Why: digit-index overrides resolve to their canonical <mods>+1 representative
@@ -1790,6 +1799,18 @@ export function getEffectiveKeybindingsForAction(
return normalized.ok ? [normalized.value] : []
})
}
return definition ? getDefaultBindings(definition, platform) : []
}
export function getEffectiveKeybindingsForDefinition(
definition: KeybindingDefinition,
platform: NodeJS.Platform,
overrides?: KeybindingOverrides
): string[] {
const override = overrides?.[definition.id]
if (Array.isArray(override)) {
return getEffectiveKeybindingsForAction(definition.id, platform, overrides)
}
return getDefaultBindings(definition, platform)
}
@@ -2054,7 +2075,7 @@ function keybindingConflictIdentityForParsed(
].join('+')
}
function keybindingConflictIdentity(binding: string, platform: NodeJS.Platform): string {
export function getKeybindingConflictIdentity(binding: string, platform: NodeJS.Platform): string {
const parsed = parseKeybinding(binding)
return parsed ? keybindingConflictIdentityForParsed(parsed, platform) : binding
}
@@ -2064,7 +2085,7 @@ function keybindingConflictIdentities(
binding: string,
platform: NodeJS.Platform
): readonly string[] {
const exact = keybindingConflictIdentity(binding, platform)
const exact = getKeybindingConflictIdentity(binding, platform)
if (!isDigitIndexActionId(actionId)) {
return [exact]
}
@@ -2199,6 +2220,23 @@ export function formatKeybindingList(
.join(', ')
}
export function findKeybindingActionsForBinding(
binding: string,
platform: NodeJS.Platform,
overrides?: KeybindingOverrides,
scopes: readonly KeybindingScope[] = ['global', 'tabs']
): KeybindingActionId[] {
const identity = getKeybindingConflictIdentity(binding, platform)
const allowedScopes = new Set(scopes)
return KEYBINDING_DEFINITIONS.filter(
(definition) =>
allowedScopes.has(definition.scope) &&
getEffectiveKeybindingsForAction(definition.id, platform, overrides).some((candidate) =>
keybindingConflictIdentities(definition.id, candidate, platform).includes(identity)
)
).map((definition) => definition.id)
}
function formatKeyToken(token: string): string {
const labels: Record<string, string> = {
BracketLeft: '[',
@@ -2237,6 +2275,15 @@ export function findKeybindingConflicts(
platform: NodeJS.Platform,
overrides?: KeybindingOverrides,
options: FindKeybindingConflictOptions = {}
): KeybindingConflict[] {
return findKeybindingConflictsForDefinitions(KEYBINDING_DEFINITIONS, platform, overrides, options)
}
export function findKeybindingConflictsForDefinitions(
definitions: readonly KeybindingDefinition[],
platform: NodeJS.Platform,
overrides?: KeybindingOverrides,
options: FindKeybindingConflictOptions = {}
): KeybindingConflict[] {
const owners = new Map<string, { binding: string; actionIds: Set<KeybindingActionId> }>()
const ignoredActionIds = new Set(options.ignoredActionIds ?? [])
@@ -2246,11 +2293,16 @@ export function findKeybindingConflicts(
isKeybindingActionId(actionId) && !ignoredActionIds.has(actionId)
)
)
for (const definition of KEYBINDING_DEFINITIONS) {
for (const actionId of options.relevantActionIds ?? []) {
if (!ignoredActionIds.has(actionId)) {
customizedActions.add(actionId)
}
}
for (const definition of definitions) {
if (ignoredActionIds.has(definition.id)) {
continue
}
for (const binding of getEffectiveKeybindingsForAction(definition.id, platform, overrides)) {
for (const binding of getEffectiveKeybindingsForDefinition(definition, platform, overrides)) {
const groups = new Set([definition.conflictGroup ?? definition.scope])
if (definition.conflictGroup) {
// Why: native menu accelerators can still consume global chords, so custom
@@ -0,0 +1,36 @@
import type { KeybindingActionId, PluginKeybindingActionId } from '../keybindings'
/** Built-in actions with a renderer-owned command handler. Keep this closed so
* declarative aliases cannot target component-private shortcut implementations. */
export const PLUGIN_COMMAND_ALIAS_ACTION_IDS = [
'worktree.history.back',
'worktree.history.forward',
'sidebar.left.toggle',
'sidebar.sleepingWorkspaces.toggle',
'floatingWorkspace.maximize',
'tab.rename',
'workspace.rename',
'workspace.openBoard',
'view.tasks',
'sidebar.right.toggle',
'sidebar.explorer.toggle',
'sidebar.search.toggle',
'sidebar.sourceControl.toggle',
'sidebar.checks.toggle',
'sidebar.ports.toggle'
] as const satisfies readonly KeybindingActionId[]
export type PluginCommandAliasActionId = (typeof PLUGIN_COMMAND_ALIAS_ACTION_IDS)[number]
const PLUGIN_COMMAND_ALIAS_ACTION_ID_SET = new Set<string>(PLUGIN_COMMAND_ALIAS_ACTION_IDS)
export function isPluginCommandAliasActionId(value: string): value is PluginCommandAliasActionId {
return PLUGIN_COMMAND_ALIAS_ACTION_ID_SET.has(value)
}
export function pluginCommandKeybindingActionId(
pluginKey: string,
commandId: string
): PluginKeybindingActionId {
return `plugin:${pluginKey}/${commandId}`
}
@@ -43,6 +43,7 @@ describe('content-pack manifest contributions', () => {
expect(parsed.main).toBeUndefined()
expect(parsed.contributes.skills[0]?.path).toBe('skills')
expect(parsed.contributes.languagePacks[0]?.locale).toBe('pt-BR')
expect(parsed.contributes.keybindings[0]?.key).toBe('Mod+Alt+T')
})
it('defaults every contribution registry to an empty array', () => {
@@ -71,6 +72,56 @@ describe('content-pack manifest contributions', () => {
).toMatchObject({ ok: false, error: expect.stringContaining('worker command') })
})
it.each([
[
'unknown alias target',
{
commands: [{ id: 'open', title: 'Open', action: 'missing.action' }]
},
'unknown built-in action'
],
[
'unknown command reference',
{ keybindings: [{ command: 'missing', key: 'Mod+K' }] },
'unknown contributed command'
],
[
'invalid chord',
{
commands: [{ id: 'open', title: 'Open', action: 'view.tasks' }],
keybindings: [{ command: 'open', key: 'Mod+NotAKey' }]
},
'key'
],
[
'global binding for a worktree command',
{
commands: [{ id: 'open', title: 'Open', context: 'worktree', action: 'view.tasks' }],
keybindings: [{ command: 'open', key: 'Mod+K', when: 'global' }]
},
'command context'
],
[
'platform-equivalent duplicate chords',
{
commands: [
{ id: 'first', title: 'First', action: 'view.tasks' },
{ id: 'second', title: 'Second', action: 'sidebar.left.toggle' }
],
keybindings: [
{ command: 'first', key: 'Mod+K' },
{ command: 'second', key: 'Ctrl+K' }
]
},
'duplicate keybinding'
]
])('rejects %s', (_label, contributes, error) => {
expect(parsePluginManifest(manifest(contributes))).toMatchObject({
ok: false,
error: expect.stringContaining(error)
})
})
it.each([
['theme', { themes: [{ id: 'bad', label: 'Bad', path: '../outside.json' }] }],
['icon theme', { iconThemes: [{ id: 'bad', path: '/tmp/icons.json' }] }],
@@ -108,12 +159,10 @@ describe('content-pack manifest contributions', () => {
expect.arrayContaining([
'duplicate themes id: same',
'duplicate language pack locale: pt-br',
'duplicate skills path: skills'
'duplicate skills path: skills',
'duplicate keybinding: mod+t'
])
)
expect(
parsed.error.issues.some((issue) => issue.message.startsWith('duplicate keybinding:'))
).toBe(true)
}
})
})
@@ -5,6 +5,7 @@ import {
pluginRelativeDirectorySchema,
pluginRelativePathSchema
} from './plugin-manifest-fields'
import { normalizeKeybinding } from '../keybindings'
export const PLUGIN_THEME_LIMIT = 64
export const PLUGIN_ICON_THEME_LIMIT = 32
@@ -70,8 +71,19 @@ export const pluginSkillContributionSchema = z
export const pluginKeybindingContributionSchema = z
.object({
command: pluginCommandIdSchema,
key: z.string().min(1).max(128),
when: z.string().min(1).max(256).optional()
key: z
.string()
.min(1)
.max(128)
.transform((value, ctx) => {
const normalized = normalizeKeybinding(value)
if (!normalized.ok) {
ctx.addIssue({ code: 'custom', message: normalized.error })
return z.NEVER
}
return normalized.value
}),
when: z.enum(['global', 'worktree']).optional()
})
.strict()
@@ -33,22 +33,23 @@ describe('plugin language-pack artifacts', () => {
})
})
it.each(['PluginConsentDialog', 'PluginVmRecipeConsentPreview'])(
'prevents language packs from rewriting %s security copy',
(component) => {
expect(
parsePluginLanguagePackArtifact(
JSON.stringify({
auto: {
components: {
settings: { [component]: { disclaimer: 'This plugin is perfectly safe.' } }
}
it.each([
'PluginConsentDialog',
'PluginKeybindingConsentPreview',
'PluginVmRecipeConsentPreview'
])('prevents language packs from rewriting %s security copy', (component) => {
expect(
parsePluginLanguagePackArtifact(
JSON.stringify({
auto: {
components: {
settings: { [component]: { disclaimer: 'This plugin is perfectly safe.' } }
}
})
)
).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') })
}
)
}
})
)
).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') })
})
it.each([
['array leaf', { settings: { choices: ['one'] } }],
@@ -5,6 +5,7 @@ const DANGEROUS_CATALOG_KEYS = new Set(['__proto__', 'prototype', 'constructor']
const PROTECTED_TRANSLATION_PREFIXES = [
'auto.components.settings.PluginConsentDialog',
'auto.components.settings.PluginInstallDialog',
'auto.components.settings.PluginKeybindingConsentPreview',
'auto.components.settings.PluginRemoveDialog',
'auto.components.settings.PluginSettingsRow',
'auto.components.settings.PluginVmRecipeConsentPreview',
@@ -1,4 +1,6 @@
import type { RefinementCtx } from 'zod'
import { isPluginCommandAliasActionId } from './plugin-command-actions'
import { getKeybindingConflictIdentity } from '../keybindings'
type IdentifiedContribution = { id: string }
type PathContribution = { path: string }
@@ -7,14 +9,14 @@ type ContributionValidationManifest = {
main?: string
contributes: {
panels: IdentifiedContribution[]
commands: (IdentifiedContribution & { action?: string })[]
commands: (IdentifiedContribution & { action?: string; context?: 'global' | 'worktree' })[]
events: { on: string }[]
themes: IdentifiedContribution[]
iconThemes: IdentifiedContribution[]
terminalThemes: IdentifiedContribution[]
languagePacks: { locale: string }[]
skills: PathContribution[]
keybindings: { command: string; key: string }[]
keybindings: { command: string; key: string; when?: 'global' | 'worktree' }[]
vmRecipes: PathContribution[]
agents: PathContribution[]
}
@@ -71,16 +73,50 @@ export function validatePluginManifestContributions(
ctx
)
}
rejectDuplicateValues(
manifest.contributes.keybindings,
(entry) => {
const keybinding = entry as { command: string; key: string }
return `${keybinding.command}\u0000${keybinding.key.toLowerCase()}`
},
'keybindings',
'keybinding',
ctx
)
const keybindingIdentities = new Set<string>()
for (const [index, keybinding] of manifest.contributes.keybindings.entries()) {
const identities = (['darwin', 'linux', 'win32'] as const).map((platform) =>
getKeybindingConflictIdentity(keybinding.key, platform)
)
if (identities.some((identity) => keybindingIdentities.has(identity))) {
ctx.addIssue({
code: 'custom',
path: ['contributes', 'keybindings', index],
message: `duplicate keybinding: ${keybinding.key.toLowerCase()}`
})
}
identities.forEach((identity) => keybindingIdentities.add(identity))
}
const commands = new Map(manifest.contributes.commands.map((command) => [command.id, command]))
for (const [index, command] of manifest.contributes.commands.entries()) {
if (command.action !== undefined && !isPluginCommandAliasActionId(command.action)) {
ctx.addIssue({
code: 'custom',
path: ['contributes', 'commands', index, 'action'],
message: `unknown built-in action: ${command.action}`
})
}
}
for (const [index, keybinding] of manifest.contributes.keybindings.entries()) {
const command = commands.get(keybinding.command)
if (!command) {
ctx.addIssue({
code: 'custom',
path: ['contributes', 'keybindings', index, 'command'],
message: `unknown contributed command: ${keybinding.command}`
})
continue
}
const commandContext = command.context ?? 'global'
if (keybinding.when !== undefined && keybinding.when !== commandContext) {
ctx.addIssue({
code: 'custom',
path: ['contributes', 'keybindings', index, 'when'],
message: 'keybinding context must match its command context'
})
}
}
if (
!manifest.main &&