Skip to content

Renamed or recategorised commands keep their old stored metadata after a restart, and commands removed from the app still execute from old bindings #169

Description

@matt-edmondson

What's wrong

The typical app startup is await manager.InitializeAsync(); manager.RegisterCommands(appCommands);.

  • InitializeAsync (Keybinding/KeybindingManager.cs:~82-87) registers every command loaded from commands.json first.
  • CommandRegistry.RegisterCommand (Services/CommandRegistry.cs:23-28) is _commands.TryAdd(...), so each later registration from the app's own code returns false and is ignored.
  • SaveAsync (KeybindingManager.cs:~118-119) writes the whole registry back, so the stale copy is persisted again.

As a result, the persisted copy of a command always wins over the definition in code, and nothing ever removes a command the app stopped defining.

Reproduction

  1. v1 of the app: registers file.save (name "Save", category "File") and tools.legacy bound to Ctrl+L, then calls SaveAsync.
  2. v2 of the app: calls InitializeAsync, then registers file.save as name "Save File", category "Document", and no longer defines tools.legacy.

Observed in v2:

  • RegisterCommands(...) returns 0.
  • GetCommand("file.save") still returns name Save, category File.
  • GetCommandsByCategory("Document") returns no commands.
  • ExecuteChord(Ctrl+L) / FindCommandByChord still return tools.legacy, a command the app no longer has a handler for.

This effectively undoes the intent of #136, which skips unregistered commands: a command removed from code stays "registered" forever through persistence. Users also never see renamed commands or reorganised categories in a settings UI.

Suggested fix

Either:

  • Treat the command set as defined by code rather than as persisted state. Persist and restore only bindings and profiles. Loaded commands are used only to keep bindings for ids the app registers again. Or:
  • Make a registration from code an upsert that replaces the stored copy, and have SaveAsync drop commands that weren't registered by the app this session.

Acceptance criteria

  • After restart, RegisterCommand with changed name, description or category updates what GetCommand and GetCommandsByCategory return.
  • A command that the app doesn't register after InitializeAsync isn't returned by ExecuteChord / FindCommandByChord, and isn't written back by SaveAsync. Its binding may be retained or dropped, but the choice is documented.
  • A test covers the v1 → v2 sequence above.

Activity

  1. matt-edmondson commented on Oct 6, 2026

    @matt-edmondson
    ContributorAuthor

    Triage


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions