Skip to content

feat: command bar#470

Draft
Gianthard-cyh wants to merge 23 commits intonpmx-dev:mainfrom
Gianthard-cyh:main
Draft

feat: command bar#470
Gianthard-cyh wants to merge 23 commits intonpmx-dev:mainfrom
Gianthard-cyh:main

Conversation

@Gianthard-cyh
Copy link

@Gianthard-cyh Gianthard-cyh commented Jan 31, 2026

closes #81.

API Overview

There are two ways to register commands depending on their lifecycle:

  1. registerGlobalCommand: Registers a command that persists globally. Use this for app-wide actions like "Search Packages", "Toggle Theme", or navigation. Usually registered in plugins/command.ts.

  2. registerScopedCommand: Registers a command that only exists while a component is mounted. Use this for context-aware actions like "Copy Install Command" (only relevant when viewing a package). It automatically unregisters when the component unmounts. You can safely use component variables (closures) inside the handler.

Interactive UI (ctx)

The handler receives a ctx object to interact with the Command Palette UI. Both methods return a Promise that resolves when the user finishes.

  • ctx.input({ prompt }): Opens a text input.
  • ctx.select({ prompt, items }): Opens a list for selection.

Example Usage

Here is a quick snippet showing how to use them:

// 1. Global Command (e.g. in a plugin)
registerGlobalCommand({
  id: 'app:hello',
  name: 'Say Hello',
  description: 'Global hello command',
  handler: async (ctx) => {
    // Open an input box
    const name = await ctx.input({ prompt: 'What is your name?' })
    if (name) {
      console.log(`Hello, ${name}!`)
    }
  }
})

// 2. Scoped Command (inside a Vue component)
const version = ref('1.0.0')

registerScopedCommand({
  id: 'pkg:switch-version',
  name: 'Switch Version',
  description: 'Switch package version',
  handler: async (ctx) => {
    // Open a selection list
    // You can access 'version.value' here because it's scoped!
    const selected = await ctx.select({
      prompt: `Current: ${version.value}. Choose new version:`,
      items: [
        { id: 'v1', name: 'v1.0.0' },
        { id: 'v2', name: 'v2.0.0' }
      ]
    })
    
    if (selected) {
      version.value = selected.name
    }
  }
})

@vercel
Copy link

vercel bot commented Jan 31, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Feb 6, 2026 3:13am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Feb 6, 2026 3:13am
npmx-lunaria Ignored Ignored Feb 6, 2026 3:13am

Request Review

Co-authored-by: Robin <robin.kehl@singular-it.de>
@github-actions
Copy link

github-actions bot commented Feb 1, 2026

Lunaria Status Overview

🌕 This pull request will trigger status changes.

Learn more

By default, every PR changing files present in the Lunaria configuration's files property will be considered and trigger status changes accordingly.

You can change this by adding one of the keywords present in the ignoreKeywords property in your Lunaria configuration file in the PR's title (ignoring all files) or by including a tracker directive in the merged commit's description.

Tracked Files

File Note
lunaria/files/en-GB.json Localization changed, will be marked as complete. 🔄️
lunaria/files/en-US.json Source changed, localizations will be marked as outdated.
lunaria/files/zh-CN.json Localization changed, will be marked as complete. 🔄️
Warnings reference
Icon Description
🔄️ The source for this localization has been updated since the creation of this pull request, make sure all changes in the source have been applied.

@Gianthard-cyh Gianthard-cyh marked this pull request as ready for review February 1, 2026 08:14
Copilot AI review requested due to automatic review settings February 1, 2026 08:14
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements a Cmd/Ctrl+K “command bar” (quick actions) feature and wires it into the app’s command registration and localization system.

Changes:

  • Added a new CommandBar modal component with filtering + keyboard navigation.
  • Introduced a global command registry composable and initial command registrations (search + install/run/create copy actions).
  • Added global keybinding (Cmd/Ctrl+K) and new i18n strings for command labels/descriptions.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
lunaria/files/zh-CN.json Adds Chinese translations for command bar placeholder and copy actions.
lunaria/files/en-US.json Adds English translations for command bar placeholder and copy actions.
i18n/locales/zh-CN.json Mirrors Chinese command bar/copy-action strings into app locale files.
i18n/locales/en.json Mirrors English command bar/copy-action strings into app locale files.
app/plugins/commands.ts Registers a global “search packages” command for the command bar.
app/composables/useCommandRegistry.ts Adds the command registry API and scoped/global registration helpers.
app/components/InstallCommandTerminal.vue Registers scoped commands for copying install/run/create commands.
app/components/CommandBar.vue New command bar UI (modal) + filtering, selection, triggering, and input/select flows.
app/app.vue Adds global Cmd/Ctrl+K handler and mounts CommandBar.

@serhalp
Copy link
Member

serhalp commented Feb 6, 2026

hey @Gianthard-cyh sorry this PR fell through the cracks a bit. would you still like to see this through or would you prefer for someone to take it over?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 6, 2026

📝 Walkthrough

Walkthrough

This change introduces a command bar system triggered by Ctrl/Cmd+K. A new CommandBar component provides a modal command palette with search input, keyboard navigation support, and command filtering. A composable-based command registry system enables both global and lifecycle-bound command registration. The root application integrates the CommandBar and keyboard shortcut handler. A packages:search command is registered that navigates to search or focuses existing search input. Package-related commands in Terminal/Install.vue register installation, run, and create actions. Translations are added across English and Chinese locales for command UI strings, and accessibility tests verify CommandBar compliance.

Suggested reviewers

  • danielroe
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The pull request description clearly documents the command bar feature with API overview, usage patterns, and practical examples directly related to the changeset.
Linked Issues check ✅ Passed The PR implements all core requirements from issue #81: keyboard shortcut (Ctrl/Cmd+K), modal with typeahead search, and quick action execution functionality.
Out of Scope Changes check ✅ Passed All changes directly support the command bar feature: CommandBar component, command registry, plugin integration, i18n translations, and accessibility tests are all within scope.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link

codecov bot commented Feb 6, 2026

Codecov Report

❌ Patch coverage is 42.03822% with 91 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
app/components/CommandBar.vue 34.90% 53 Missing and 16 partials ⚠️
app/components/Terminal/Install.vue 41.66% 7 Missing ⚠️
app/app.vue 0.00% 5 Missing and 1 partial ⚠️
app/plugins/commands.ts 50.00% 5 Missing and 1 partial ⚠️
app/composables/useCommandRegistry.ts 85.71% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (4)
app/components/Terminal/Install.vue (2)

99-106: Inconsistent command ID prefix: package:install vs packages:copy-run / packages:copy-create.

The first command uses the singular package: prefix while the other two use plural packages:. Pick one convention for consistency. The global command in the plugin also uses packages:search, so packages: (plural) seems to be the intended namespace.

♻️ Suggested fix
 registerScopedCommand({
-  id: 'package:install',
+  id: 'packages:copy-install',
   name: t('command.copy_install'),
   description: t('command.copy_install_desc'),

5-6: Minor: useI18n() vs auto-imported $t().

The rest of the codebase uses the auto-imported $t() global in <script setup> rather than destructuring useI18n(). This works but is a minor deviation from the established pattern. Based on learnings, in this Nuxt 4 project with nuxtjs/i18n v10, $t() is auto-imported and works correctly inside <script setup> callbacks, and this is the established pattern across pages.

app/plugins/commands.ts (1)

11-22: Minor duplication with the / keyboard shortcut handler in app.vue.

The search-input focus/navigate logic here is nearly identical to the / key handler in app.vue (lines 52–68). Not blocking, but if this pattern grows you might want to extract a shared focusOrNavigateToSearch() utility.

app/components/CommandBar.vue (1)

57-61: any types in ViewState weaken type safety.

items: any[] and resolve: (val: any) => void in the SELECT variant lose type information. While full generics on a union ref are awkward, at minimum you could use Command (or a dedicated SelectItem interface with { id: string; name: string }) for the items and resolve value to keep the contract explicit. As per coding guidelines, **/*.{ts,tsx,vue} files should ensure strictly type-safe code.

Comment on lines +4 to +7
<div
class="fixed inset-0 z-[1000] flex items-start justify-center bg-black/50 backdrop-blur-sm"
v-show="show"
>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clicking the backdrop does not close the modal.

Users expect that clicking outside a modal dialogue dismisses it. The overlay div has no click handler. Without this, keyboard-only close (Escape) is the only way out, and pointer users are left stranded.

Proposed fix
       class="fixed inset-0 z-[1000] flex items-start justify-center bg-black/50 backdrop-blur-sm"
       v-show="show"
+      `@click.self`="close"
     >

Using @click.self ensures only clicks directly on the backdrop (not bubbled from the inner container) trigger close.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
class="fixed inset-0 z-[1000] flex items-start justify-center bg-black/50 backdrop-blur-sm"
v-show="show"
>
<div
class="fixed inset-0 z-[1000] flex items-start justify-center bg-black/50 backdrop-blur-sm"
v-show="show"
`@click.self`="close"
>

Comment on lines +33 to +46
<div class="w-xl max-h-lg overflow-auto" v-if="view.type != 'INPUT'">
<div
v-for="item in filteredCmdList"
:key="item.id"
class="px-4 py-2 not-first:mt-2 hover:bg-bg-elevated select-none cursor-pointer rounded-md transition"
:class="{
'bg-bg-subtle': item.id === selected,
'trigger-anim': item.id === triggeringId,
}"
@click="onTrigger(item.id)"
>
<div class="text-fg">{{ item.name }}</div>
<div class="text-fg-subtle text-sm">{{ item.description }}</div>
</div>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Command list lacks ARIA listbox semantics.

The filtered command list is keyboard-navigable via ArrowUp/Down, but there is no role="listbox" on the container or role="option" on items, and no aria-activedescendant on the input to link the focused option. Screen readers will not announce the selected command.

Sketch of the fix
-          <div class="w-xl max-h-lg overflow-auto" v-if="view.type != 'INPUT'">
+          <div class="w-xl max-h-lg overflow-auto" role="listbox" id="command-listbox" v-if="view.type !== 'INPUT'">
             <div
               v-for="item in filteredCmdList"
               :key="item.id"
+              :id="`cmd-${item.id}`"
+              role="option"
+              :aria-selected="item.id === selected"
               class="px-4 py-2 not-first:mt-2 hover:bg-bg-elevated select-none cursor-pointer rounded-md transition"

Also add aria-activedescendant to the input, bound to `cmd-${selected}`, and aria-controls="command-listbox".

Comment on lines +93 to +110
const filteredCmdList = computed(() => {
if (view.value.type === 'INPUT') {
return []
}

const list = view.value.type === 'SELECT' ? view.value.items : commands.value

if (!inputVal.value) {
return list
}
const filter = inputVal.value.trim().toLowerCase()
return list.filter(
(item: any) =>
item.name.toLowerCase().includes(filter) ||
item.description?.toLowerCase().includes(filter) ||
item.id.includes(filter),
)
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

filteredCmdList accesses .name / .description on any items without guards.

In SELECT mode, view.value.items is typed any[], so item.name may not exist. If a select handler provides items without a name field, this will render undefined and toLowerCase() will throw at runtime (Line 106).

Either tighten the item type (preferred) or add a guard:

Defensive guard (if typing is deferred)
   return list.filter(
-    (item: any) =>
-      item.name.toLowerCase().includes(filter) ||
-      item.description?.toLowerCase().includes(filter) ||
+    (item: { id: string; name?: string; description?: string }) =>
+      item.name?.toLowerCase().includes(filter) ||
+      item.description?.toLowerCase().includes(filter) ||
       item.id.includes(filter),
   )

Comment on lines +147 to +167
function onTrigger(id: string) {
triggeringId.value = id

if (view.value.type === 'ROOT') {
const selectedItem = filteredCmdList.value.find((item: any) => item.id === id)
selectedItem?.handler?.(cmdCtx)
setTimeout(() => {
triggeringId.value = ''
if (view.value.type === 'ROOT') {
close()
}
}, 100)
} else if (view.value.type === 'INPUT') {
view.value.resolve(inputVal.value)
close()
} else if (view.value.type === 'SELECT') {
const selectedItem = filteredCmdList.value.find((item: any) => item.id === id)
view.value.resolve(selectedItem)
close()
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handler result is fire-and-forget — unhandled rejections will be silent.

selectedItem?.handler?.(cmdCtx) returns a Promise<void> but is neither awaited nor .catch()-ed. If a handler throws after the ctx.input() / ctx.select() call, the rejection is swallowed. At minimum, add a .catch() to log/surface the error.

Proposed fix
-    selectedItem?.handler?.(cmdCtx)
+    selectedItem?.handler?.(cmdCtx).catch((err: unknown) => {
+      console.error(`[CommandBar] handler "${id}" failed:`, err)
+    })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function onTrigger(id: string) {
triggeringId.value = id
if (view.value.type === 'ROOT') {
const selectedItem = filteredCmdList.value.find((item: any) => item.id === id)
selectedItem?.handler?.(cmdCtx)
setTimeout(() => {
triggeringId.value = ''
if (view.value.type === 'ROOT') {
close()
}
}, 100)
} else if (view.value.type === 'INPUT') {
view.value.resolve(inputVal.value)
close()
} else if (view.value.type === 'SELECT') {
const selectedItem = filteredCmdList.value.find((item: any) => item.id === id)
view.value.resolve(selectedItem)
close()
}
}
function onTrigger(id: string) {
triggeringId.value = id
if (view.value.type === 'ROOT') {
const selectedItem = filteredCmdList.value.find((item: any) => item.id === id)
selectedItem?.handler?.(cmdCtx)?.catch((err: unknown) => {
console.error(`[CommandBar] handler "${id}" failed:`, err)
})
setTimeout(() => {
triggeringId.value = ''
if (view.value.type === 'ROOT') {
close()
}
}, 100)
} else if (view.value.type === 'INPUT') {
view.value.resolve(inputVal.value)
close()
} else if (view.value.type === 'SELECT') {
const selectedItem = filteredCmdList.value.find((item: any) => item.id === id)
view.value.resolve(selectedItem)
close()
}
}

Comment on lines +169 to +199
const handleKeydown = useThrottleFn((e: KeyboardEvent) => {
if (view.value.type === 'INPUT' && e.key === 'Enter') {
e.preventDefault()
onTrigger('') // Trigger for input doesn't need ID
return
}

if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && !filteredCmdList.value.length) {
e.preventDefault()
return
}

const currentIndex = filteredCmdList.value.findIndex((item: any) => item.id === selected.value)

if (e.key === 'ArrowDown') {
e.preventDefault()
const nextIndex = (currentIndex + 1) % filteredCmdList.value.length
selected.value = filteredCmdList.value[nextIndex]?.id || ''
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const prevIndex =
(currentIndex - 1 + filteredCmdList.value.length) % filteredCmdList.value.length
selected.value = filteredCmdList.value[prevIndex]?.id || ''
} else if (e.key === 'Enter') {
e.preventDefault()
onTrigger(selected.value)
} else if (e.key === 'Escape') {
e.preventDefault()
close()
}
}, 50)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

useThrottleFn at 50 ms throttles all keystrokes, including typing.

Because handleKeydown is the sole @keydown handler on the text input, every character the user types is also throttled to 50 ms. On fast typists this may drop characters or feel laggy. The throttle is presumably intended only for arrow-key navigation. Consider either:

  • Moving the throttle inside the arrow/Enter branches only, or
  • Returning early for printable characters before the throttle takes effect.

Comment on lines +10 to +13
export interface CommandContext {
input: (options: CommandInputOptions) => Promise<string | undefined>
select: <T>(options: CommandSelectOptions<T>) => Promise<T | undefined>
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

CommandContext promises can dangle if the modal is closed without resolution.

input() and select() return Promise<string | undefined> / Promise<T | undefined>, but the CommandBar's close() does not resolve these promises — it simply resets the view state. Any handler await-ing ctx.input(...) will hang indefinitely if the user presses Escape or clicks the backdrop.

Consider resolving with undefined when the modal closes (e.g., store the pending resolve in a ref and call resolve(undefined) in close()).

Comment on lines +28 to +50
export const useCommandRegistry = () => {
const commands = useState<Map<string, Command>>('commands', () => new Map())

const register = (command: Command) => {
const serverCommand = {
...command,
handler: undefined,
}
if (import.meta.server) {
commands.value.set(command.id, serverCommand)
} else {
commands.value.set(command.id, command)
}
return () => {
commands.value.delete(command.id)
}
}

return {
register,
commands: computed(() => Array.from(commands.value.values())),
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Nuxt 3 useState Map serialization SSR hydration

💡 Result:

In Nuxt 3, useState() is persisted from SSR → client by serializing its value into the Nuxt payload. By default, this means you must store JSON-serializable data (plain objects/arrays/primitives). Putting a Map in useState can therefore fail (e.g., “Cannot stringify arbitrary non‑POJOs”) or hydrate inconsistently. [1][2]

Fix options

  1. Store a serializable shape, rebuild the Map
// SSR-safe
const entries = useState<[string, number][]>('myMapEntries', () => [])
const myMap = computed(() => new Map(entries.value))
  1. Enable rich payload serialization (supports Map/Set/Date/etc.)
    Nuxt added an experimental option that supports richer JS types in the SSR payload, including Map and Set: [3]
// nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    renderJsonPayloads: true
  }
})
  1. Custom payload serializer/deserializer
    If you need custom class-like types (or want explicit control), Nuxt recommends adding a custom payload serializer/deserializer via definePayloadPlugin. [1]

Sources: Nuxt state management + JSON-serialization constraint for useState [2], useState troubleshooting + payload plugin mention [1], Nuxt payload enhancements (renderJsonPayloads supports Map/Set) [3].

[1] https://nuxt.com/docs/4.x/api/composables/use-state
[2] https://nuxt.com/docs/getting-started/state-management/
[3] https://dev.nuxt.com/blog/v3-4


🏁 Script executed:

# Check the actual file structure and content
fd useCommandRegistry.ts

Repository: npmx-dev/npmx.dev

Length of output: 98


🏁 Script executed:

# Look for the full file to understand context
cat -n $(fd useCommandRegistry.ts)

Repository: npmx-dev/npmx.dev

Length of output: 2435


🏁 Script executed:

# Check for payload plugins or SSR-related configuration
fd "payload" --type f | head -20

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Search for useState usage with Map or non-serializable objects
rg "useState.*Map|useState.*Set" -t ts -t tsx

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

# Check for nuxt.config configuration
fd "nuxt.config" -t f

Repository: npmx-dev/npmx.dev

Length of output: 95


🏁 Script executed:

# Look at the nuxt config to see if experimental.renderJsonPayloads is enabled
cat -n $(fd "nuxt.config" -t f)

Repository: npmx-dev/npmx.dev

Length of output: 9998


🏁 Script executed:

# Check for any definePayloadPlugin usage
rg "definePayloadPlugin" -t ts -t tsx

Repository: npmx-dev/npmx.dev

Length of output: 88


🏁 Script executed:

# Check Nuxt version in package.json to understand compatibility
cat package.json | jq '.dependencies | {nuxt, "@nuxt/framework"}' 2>/dev/null || rg '"nuxt"' package.json -A 1 -B 1

Repository: npmx-dev/npmx.dev

Length of output: 109


🏁 Script executed:

# Check how commands are used/accessed in the codebase to understand impact
rg "\.commands" --type ts -A 2 -B 2 | head -50

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Check how the commands are actually used to understand the impact better
rg "useCommandRegistry\|registerGlobalCommand\|registerScopedCommand" --type ts -B 2 -A 5 | head -80

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Search more broadly for usage patterns
rg "commands" app/composables/ --type ts -B 3 -A 3

Repository: npmx-dev/npmx.dev

Length of output: 3055


🏁 Script executed:

# Check if there are any payload plugins at all
find . -name "*payload*" -o -name "*serializ*" 2>/dev/null | grep -v node_modules | head -20

Repository: npmx-dev/npmx.dev

Length of output: 43


Replace Map with Record<string, Command> for SSR serialisation safety.

Nuxt's useState() serialises its payload as JSON when transferring server state to the client. Map is not JSON-serialisable by default and will cause hydration issues. Since experimental.renderJsonPayloads is not enabled in your Nuxt config, use a plain object instead.

Recommended fix — use a plain object
-  const commands = useState<Map<string, Command>>('commands', () => new Map())
+  const commands = useState<Record<string, Command>>('commands', () => ({}))

   const register = (command: Command) => {
     const serverCommand = {
       ...command,
       handler: undefined,
     }
     if (import.meta.server) {
-      commands.value.set(command.id, serverCommand)
+      commands.value[command.id] = serverCommand
     } else {
-      commands.value.set(command.id, command)
+      commands.value[command.id] = command
     }
     return () => {
-      commands.value.delete(command.id)
+      delete commands.value[command.id]
     }
   }

   return {
     register,
-    commands: computed(() => Array.from(commands.value.values())),
+    commands: computed(() => Object.values(commands.value)),
   }

Alternatively, you can enable experimental.renderJsonPayloads: true in nuxt.config.ts to support native Map serialisation (Nuxt 4.3.0 supports this).

Comment on lines +70 to +80
export const registerScopedCommand = (command: Command) => {
const { register } = useCommandRegistry()
let unregister: () => void

onMounted(() => {
unregister = register(command)
})

onUnmounted(() => {
unregister()
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

unregister is used uninitialised if onUnmounted fires without a prior onMounted.

This is an edge case (e.g., SSR-only or <Suspense> fallback teardown), but calling unregister() when it is undefined would throw.

Defensive initialisation
   const { register } = useCommandRegistry()
-  let unregister: () => void
+  let unregister: (() => void) | undefined

   onMounted(() => {
     unregister = register(command)
   })

   onUnmounted(() => {
-    unregister()
+    unregister?.()
   })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const registerScopedCommand = (command: Command) => {
const { register } = useCommandRegistry()
let unregister: () => void
onMounted(() => {
unregister = register(command)
})
onUnmounted(() => {
unregister()
})
export const registerScopedCommand = (command: Command) => {
const { register } = useCommandRegistry()
let unregister: (() => void) | undefined
onMounted(() => {
unregister = register(command)
})
onUnmounted(() => {
unregister?.()
})

Comment on lines +7 to +23
register({
id: 'packages:search',
name: t('command.package_search'),
description: t('command.package_search_desc'),
handler: async () => {
const searchInput = document.querySelector<HTMLInputElement>(
'input[type="search"], input[name="q"]',
)

if (searchInput) {
searchInput.focus()
return
}

router.push('/search')
},
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for Command type definition and the command registry implementation
rg -n 'interface Command|type Command' --type=ts -A10

Repository: npmx-dev/npmx.dev

Length of output: 1803


🏁 Script executed:

#!/bin/bash
# Read the entire useCommandRegistry.ts file to see the register() implementation
cat -n app/composables/useCommandRegistry.ts

Repository: npmx-dev/npmx.dev

Length of output: 2435


🏁 Script executed:

#!/bin/bash
# Read the entire commands.ts plugin file
cat -n app/plugins/commands.ts

Repository: npmx-dev/npmx.dev

Length of output: 799


Command name and description won't update when the user changes locale.

The translation strings t('command.package_search') and t('command.package_search_desc') are evaluated once at plugin initialisation and stored as plain strings in the command registry. The Command interface defines name and description as static string types (not reactive), so if the user switches language, the command bar will continue displaying the original translations.

To fix this, either modify the Command interface to support ComputedRef<string> values for name and description, or add a watch listener to re-register the command when the locale changes.

@Gianthard-cyh
Copy link
Author

hey @Gianthard-cyh sorry this PR fell through the cracks a bit. would you still like to see this through or would you prefer for someone to take it over?

@serhalp Thanks for checking! I’ll continue working on this PR and keep it updated.

@serhalp serhalp added the stale This has become stale and may be closed soon label Mar 15, 2026
@serhalp serhalp marked this pull request as draft March 15, 2026 13:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale This has become stale and may be closed soon

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cmd+K quick actions command bar

6 participants