mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(desktop): resolve contributed keybinds through the fallback chain
$bindings is seeded at module init from the actions known then, so an action a plugin contributes later is absent from it. Both hotkey-hint call sites did a raw bindings[id] lookup, so a plugin command rendered with no combo in the palette and no hint on its tooltip even though the dispatcher (which goes through $comboIndex → bindingsFor) fired it fine. Route both through bindingsFor, the resolver that already falls back to the stored override and the action's shipped defaults, and subscribe the hint hook to the registry version so a late registration repaints. Covered by behavior tests over the contributed-action contract: dispatch, combo resolution, panel row, teardown, and the no-shadowing guard.
This commit is contained in:
parent
eaa61d2aa6
commit
6a67a4e952
3 changed files with 80 additions and 4 deletions
|
|
@ -67,7 +67,7 @@ import {
|
|||
closeCommandPalette,
|
||||
setCommandPaletteOpen
|
||||
} from '@/store/command-palette'
|
||||
import { $bindings } from '@/store/keybinds'
|
||||
import { $bindings, bindingsFor } from '@/store/keybinds'
|
||||
import { $dismissedAutoProjectIds, filterVisibleProjects } from '@/store/layout'
|
||||
import { openPetGenerate } from '@/store/pet-generate'
|
||||
import { $projectTree, goToProject, openFolderAsProject, requestStartWorkSession } from '@/store/projects'
|
||||
|
|
@ -328,7 +328,9 @@ const PaletteRow = memo(function PaletteRow({
|
|||
const Icon = item.icon
|
||||
// The row's live keybind, else a static modifier-variant hint (⌘↵). One slot,
|
||||
// so every downstream `ml-auto` fallback below keeps working unchanged.
|
||||
const combo = (item.action ? bindings[item.action]?.[0] : undefined) ?? item.comboHint
|
||||
// `bindingsFor`, not a raw lookup: a plugin's action is contributed after
|
||||
// $bindings was seeded, so its combo only resolves through the fallback chain.
|
||||
const combo = (item.action ? bindingsFor(item.action, bindings)[0] : undefined) ?? item.comboHint
|
||||
// While ⌘/⌃ is held, a row with a modifier variant previews it: the label
|
||||
// swaps to the variant's copy so Enter reads as what it will actually do.
|
||||
const modPreview = modHeld && Boolean(item.modLabel)
|
||||
|
|
|
|||
66
apps/desktop/src/lib/keybinds/contributed-actions.test.ts
Normal file
66
apps/desktop/src/lib/keybinds/contributed-actions.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { createPluginContext } from '@/contrib/plugin'
|
||||
import { allKeybindActions, contributedKeybindHandler, KEYBINDS_AREA } from '@/lib/keybinds/actions'
|
||||
import { bindingsFor } from '@/store/keybinds'
|
||||
|
||||
// The plugin-command contract: a plugin ships a hotkey through the `keybinds`
|
||||
// area and it behaves like a built-in — it dispatches, it resolves a combo for
|
||||
// the palette hint, and it survives the plugin being unloaded. These assert the
|
||||
// relationship between the pieces, not the specific chord any one plugin picks.
|
||||
describe('contributed keybind actions', () => {
|
||||
it('dispatches, resolves its default combo, and disappears on unload', () => {
|
||||
const ctx = createPluginContext('demo')
|
||||
let ran = 0
|
||||
|
||||
const dispose = ctx.register({
|
||||
id: 'new-thing',
|
||||
area: KEYBINDS_AREA,
|
||||
data: {
|
||||
id: 'demo.newThing',
|
||||
category: 'view',
|
||||
defaults: ['mod+alt+n'],
|
||||
label: 'Demo: New thing',
|
||||
run: () => void (ran += 1)
|
||||
}
|
||||
})
|
||||
|
||||
// Dispatch path: use-keybinds looks the handler up by action id.
|
||||
contributedKeybindHandler('demo.newThing')?.()
|
||||
expect(ran).toBe(1)
|
||||
|
||||
// Hint path: $bindings was seeded before this action existed, so only the
|
||||
// resolver (default fallback) finds the combo — a raw store lookup can't.
|
||||
expect(bindingsFor('demo.newThing')).toEqual(['mod+alt+n'])
|
||||
|
||||
// Panel path: it shows up as a rebindable row alongside the built-ins.
|
||||
expect(allKeybindActions().find(a => a.id === 'demo.newThing')?.label).toBe('Demo: New thing')
|
||||
|
||||
dispose()
|
||||
|
||||
expect(contributedKeybindHandler('demo.newThing')).toBeUndefined()
|
||||
expect(allKeybindActions().some(a => a.id === 'demo.newThing')).toBe(false)
|
||||
})
|
||||
|
||||
it('cannot shadow a built-in action id', () => {
|
||||
const ctx = createPluginContext('demo')
|
||||
|
||||
const dispose = ctx.register({
|
||||
id: 'steal-new-session',
|
||||
area: KEYBINDS_AREA,
|
||||
data: { id: 'session.new', defaults: ['mod+alt+n'], label: 'Demo: hijack', run: () => undefined }
|
||||
})
|
||||
|
||||
// The built-in keeps its own combo and its own (i18n) label — the
|
||||
// contribution is filtered out rather than overriding core.
|
||||
expect(bindingsFor('session.new')).toEqual(['mod+n', 'shift+n'])
|
||||
expect(allKeybindActions().filter(a => a.id === 'session.new')).toHaveLength(1)
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('leaves no registry residue between plugin loads', () => {
|
||||
expect(registry.getArea(KEYBINDS_AREA).filter(c => c.source === 'plugin:demo')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { $bindings } from '@/store/keybinds'
|
||||
import { $registryVersion } from '@/contrib/registry'
|
||||
import { $bindings, bindingsFor } from '@/store/keybinds'
|
||||
|
||||
import { KEYBIND_READONLY } from './actions'
|
||||
import { formatCombo } from './combo'
|
||||
|
|
@ -12,7 +13,14 @@ import { formatCombo } from './combo'
|
|||
export function useKeybindHint(actionId: string): string | null {
|
||||
const bindings = useStore($bindings)
|
||||
|
||||
const rebindable = bindings[actionId]?.[0]
|
||||
// `bindingsFor`, not a raw `bindings[id]`: $bindings is seeded at module init
|
||||
// from the actions known THEN, so a plugin action contributed later isn't in
|
||||
// it and a raw lookup renders no hint at all. The resolver falls through to
|
||||
// the stored override and the action's own defaults. Subscribing to the
|
||||
// registry version repaints the hint when that late registration lands.
|
||||
useStore($registryVersion)
|
||||
|
||||
const rebindable = bindingsFor(actionId, bindings)[0]
|
||||
|
||||
if (rebindable) {
|
||||
return formatCombo(rebindable)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue