Skip to content

feat(ui): add ⌘+K command palette for quick nav and actions#2159

Open
serhalp wants to merge 6 commits intomainfrom
serhalp/cmdk-palette
Open

feat(ui): add ⌘+K command palette for quick nav and actions#2159
serhalp wants to merge 6 commits intomainfrom
serhalp/cmdk-palette

Conversation

@serhalp
Copy link
Member

@serhalp serhalp commented Mar 20, 2026

🔗 Linked issue

closes #81

supersedes stale #470

🧭 Context

npmx already exposes a ton of useful capabilities (with way more to come), but they require quite a bit of precise clicking around. We always imagined npmx as a power tool for power users. The command palette is a familiar solution to provide discoverable, fast, efficient, repeatable access to an app's capabilities.

📚 Description

This PR adds a command palette with access to every page, every action, and every capability of npmx.

It can be opened from anywhere in the app by pressing ⌘+K on macOS / Ctrl+K on Windows/Linux, or by clicking the new "quick actions" nav item in the header.

The palette includes a set of "global" commands and a composable allowing a page or component to register specific commands that should be made available when that page/component is visible.

The palette supports multi-step flows, such as "change language" → languages are listed.

I should've maybe kept this PR small and added more commands later, but... oops, I believe I covered every single page and capability:

All commands

Global commands (always available)

  • navigation
    • search
    • home
    • compare
    • settings
    • my packages if npm is connected
    • my orgs if npm is connected
    • my profile if atproto is connected
  • connections
    • connect to npm cli if npm not connected
    • connect to atmosphere if atproto not connected
    • disconnect npm if npm is connected
    • disconnect atmosphere if atproto is connected
  • settings
    • language → second step lists all languages + "Help translate" CTA
      • note: there's some magic here to match user input on languages directly on the first screen too!
    • relative dates
    • use system theme
    • use light theme
    • use dark theme
    • accent colors → second step lists all accent colors
    • background shade -> second step lists all bg shades
  • help
    • keyboard shortcuts
    • docs
    • chat
  • npmx
    • about
    • blog
    • privacy
    • accessibility
    • docs
    • chat
    • builders
    • source
    • social

Package context

All pages with a package context also include:

  • package ()
    • copy package name
    • package page
    • docs
    • code
    • compare this package
    • download tarball
    • diff
  • versions of
    • <... every version of this package ...>

Package page

  • package
    • copy install command
    • copy run command if available
    • copy create command if available
    • go to types package if available
    • go to create package if available
    • open skills modal if available
  • links
    • fund if available
    • repo (): <owner/repo>
    • repo stars
    • repo forks
    • homepage
    • issues
    • npm
    • jsr if available
    • <... every playground ...>

Package code page

  • actions
    • copy link
    • copy file contents
    • preview
    • code

Package diff page

  • actions
    • merge modified lines on/off
    • word wrap on/off

Compare page

  • select all facets
  • deselect all facets
  • copy table
  • table
  • charts

Profile page

  • actions
    • edit profile if viewing profile
    • share invite
  • links
    • profile-website if profile has a website

There are two behaviours worth calling out separately:

  • When the user's query is a valid semver range specifier, while in a package context, the package versions listed in the palette are filtered to those that match the semver range.
  • A fallback item always shows up last in the palette results: Search for "<query>". Selecting this submits a search for the user's query.

The palette has full keyboard navigation support and screen reader support.

Screenshots

New header nav item (desktop) Screenshot 2026-03-22 at 14-41-06 npmx - Package Browser for the npm Registry
Global commands (desktop) Screenshot 2026-03-22 at 14 31 21 Screenshot 2026-03-22 at 14 32 58 Screenshot 2026-03-22 at 14 33 29
Global commands — logged in via atproto (desktop) Screenshot 2026-03-22 at 15 02 57
Global commands (mobile, light) Screen Shot 2026-03-22 at 14 37 02
Languages (desktop) Screenshot 2026-03-22 at 14 34 02
Accent colors (desktop) Screenshot 2026-03-22 at 14 34 58
Background shades (desktop, light) Screenshot 2026-03-22 at 14 35 54
New header nav item (desktop, non-homepage) Screenshot 2026-03-22 at 14-43-30 vite - npmx
Package page commands (desktop) Screenshot 2026-03-22 at 14 45 13 Screenshot 2026-03-22 at 14 45 38
Package page - input is valid semver (desktop) Screenshot 2026-03-22 at 14 46 29
Package code page (desktop) Screenshot 2026-03-22 at 14 57 03
Package diff page (desktop) Screenshot 2026-03-22 at 14 59 16
Compare page (desktop) Screenshot 2026-03-22 at 14 59 58
Profile page (desktop) Screenshot 2026-03-22 at 15 05 04
"Search for" fallback command (desktop) Screenshot 2026-03-22 at 14 38 50

Future work

  • This PR was huge enough as is, so I punted on adding hotkeys for quickly triggering commands within the command palette (e.g. typing r and hitting Enter to open the package's repo right away). We should probably do this eventually.
  • I didn't add any palette commands for package/user/org management stuff that users can do when logged in via npm CLI. We should probably do this eventually.

@vercel
Copy link

vercel bot commented Mar 20, 2026

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

Project Deployment Actions Updated (UTC)
npmx.dev Ready Ready Preview, Comment Mar 24, 2026 0:23am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
docs.npmx.dev Ignored Ignored Preview Mar 24, 2026 0:23am
npmx-lunaria Ignored Ignored Mar 24, 2026 0:23am

Request Review

@github-actions
Copy link

github-actions bot commented Mar 20, 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
i18n/locales/en.json Source changed, localizations will be marked as outdated.
i18n/locales/fr-FR.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.

@codecov
Copy link

codecov bot commented Mar 20, 2026

@trueberryless
Copy link
Contributor

I love it, super clean and useful! 🥳

@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from 3b1e01b to e280565 Compare March 21, 2026 22:25
@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from e280565 to 10fbdc8 Compare March 22, 2026 01:08
@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from 10fbdc8 to 15a4d07 Compare March 22, 2026 01:23
@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from 15a4d07 to f2b53bf Compare March 22, 2026 01:33
@serhalp serhalp changed the title feat(ui): add ⌘+K command palette for power user fast nav and actions feat(ui): add ⌘+K command palette for quick nav and actions Mar 22, 2026
@serhalp serhalp force-pushed the serhalp/cmdk-palette branch 2 times, most recently from 6777ee1 to 823c9cf Compare March 22, 2026 02:44
@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from 823c9cf to 2667fda Compare March 22, 2026 02:49
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 22, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a first-class command palette: a client component app/components/CommandPalette.client.vue, multiple composables and command builders under app/composables (global, package, version, queries, and platform key detection), new types in app/types/command-palette.ts, package tarball download utility app/utils/package-download.ts, UI triggers in AppHeader/AppFooter/MobileMenu and app layout, context command registrations across package/docs/diff/compare/profile pages and many components, i18n/docs updates, and extensive unit and accessibility tests.

Possibly related PRs

Suggested labels

needs review, ux

Suggested reviewers

  • graphieros
  • danielroe
  • alexdln
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The PR description provides comprehensive context covering the motivation, design, implementation details, all included commands, and screenshots demonstrating the feature across different states.
Linked Issues check ✅ Passed The PR fully addresses issue #81 by implementing a global command palette with ⌘+K shortcut, modal typeahead UI, keyboard navigation, and screen reader support across the entire app.
Out of Scope Changes check ✅ Passed All changes are directly aligned with implementing the command palette feature. Minor refactoring to settings composables (accent/background theme IDs) and download utility extraction support the core feature without introducing unrelated modifications.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch serhalp/cmdk-palette

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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: 12

🧹 Nitpick comments (8)
app/utils/package-download.ts (1)

32-41: Make link/object-URL cleanup unconditional with finally.

If appendChild/click throws, cleanup and revokeObjectURL are skipped. Wrapping the interaction in try/finally avoids leaks and keeps behaviour deterministic.

♻️ Suggested refactor
 export async function downloadPackageTarball(
   packageName: string,
   version: DownloadablePackageVersion,
 ) {
   const tarballUrl = version.dist.tarball
   if (!tarballUrl) return
 
   const downloadUrl = await getDownloadUrl(tarballUrl)
 
   const link = document.createElement('a')
-  link.href = downloadUrl ?? tarballUrl
-  link.download = `${packageName.replace(/\//g, '__')}-${version.version}.tgz`
-  document.body.appendChild(link)
-  link.click()
-  document.body.removeChild(link)
-
-  if (downloadUrl) {
-    URL.revokeObjectURL(downloadUrl)
-  }
+  try {
+    link.href = downloadUrl ?? tarballUrl
+    link.download = `${packageName.replace(/\//g, '__')}-${version.version}.tgz`
+    document.body.appendChild(link)
+    link.click()
+  } finally {
+    if (link.isConnected) {
+      document.body.removeChild(link)
+    }
+    if (downloadUrl) {
+      URL.revokeObjectURL(downloadUrl)
+    }
+  }
 }

As per coding guidelines: "Use error handling patterns consistently".

test/nuxt/app/utils/package-download.spec.ts (1)

78-116: Add one test for the explicit non-OK HTTP response branch.

getDownloadUrl has a dedicated !response.ok branch (Line 11 in the utility), but this spec currently covers only thrown-fetch failure for fallback.

🧪 Optional test addition
+  it('falls back to the remote tarball URL when fetch returns a non-OK response', async () => {
+    const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+    vi.stubGlobal(
+      'fetch',
+      vi.fn(async () => ({
+        ok: false,
+        status: 503,
+        blob: async () => new Blob(['ignored']),
+      })),
+    )
+    Object.defineProperty(URL, 'createObjectURL', {
+      configurable: true,
+      writable: true,
+      value: vi.fn(),
+    })
+    Object.defineProperty(URL, 'revokeObjectURL', {
+      configurable: true,
+      writable: true,
+      value: vi.fn(),
+    })
+
+    await downloadPackageTarball('vue', {
+      version: '3.5.0',
+      dist: {
+        tarball: 'https://registry.npmjs.org/vue/-/vue-3.5.0.tgz',
+      },
+    })
+
+    const anchor = appendSpy.mock.calls[0]?.[0]
+    expect(anchor).toBeInstanceOf(HTMLAnchorElement)
+    expect((anchor as HTMLAnchorElement).href).toBe(
+      'https://registry.npmjs.org/vue/-/vue-3.5.0.tgz',
+    )
+    expect(URL.createObjectURL).not.toHaveBeenCalled()
+    expect(URL.revokeObjectURL).not.toHaveBeenCalled()
+    expect(consoleError).toHaveBeenCalled()
+  })

As per coding guidelines: "Write unit tests for core functionality using vitest".

app/components/Settings/AccentColorPicker.vue (1)

17-19: Update the stale inline comment to reflect current default handling.

The comment still references a clear-button/value-empty default, but the code now resolves via defaultId.

Suggested wording tweak
-    // Remove checked from the server-default (clear button, value="")
+    // Remove checked from the server-default option to avoid dual checked radios
app/components/Settings/BgThemePicker.vue (1)

17-19: Refresh the inline comment to match the new default-option logic.

The current wording still implies an empty-value clear default, which is no longer what this block does.

Suggested wording tweak
-    // Remove checked from the server-default (clear button, value="")
+    // Remove checked from the server-default option to avoid dual checked radios
app/components/Package/ExternalLinks.vue (1)

109-117: Avoid chained non-null assertions in href assignment.

This branch is already guarded, so you can keep full type-safety without ! assertions.

♻️ Suggested typed simplification
     if (displayVersion.value?.bugs?.url) {
+      const bugsUrl = displayVersion.value.bugs.url
       commands.push({
         id: 'package-link-issues',
         group: 'links',
         label: $t('package.links.issues'),
         keywords: [...packageKeywords, $t('package.links.issues')],
         iconClass: 'i-lucide:circle-alert',
-        href: displayVersion.value!.bugs!.url!,
+        href: bugsUrl,
       })
     }
As per coding guidelines: "Ensure you write strictly type-safe code, for example by ensuring you always check when accessing an array value by index".
app/components/CommandPalette.client.vue (1)

319-323: Use the global button focus rule here.

button:focus-visible is already styled globally, so the inline focus-visible:outline-accent/70 classes duplicate that rule on the back button and on command rows that render as <button>. Please rely on the global rule for the button variant, or split the shared row class so only link variants carry extra focus treatment. Based on learnings, "focus-visible styling for button and select elements is implemented globally in app/assets/main.css ... Do not apply per-element inline utility classes."

Also applies to: 380-457

app/composables/useCommandPaletteGlobalCommands.ts (1)

212-604: Split the global command builder into smaller factories.

This computed now mixes root navigation, settings, connections, help links, and account-specific branches in one block, which makes it hard to review and easy to regress when new commands are added. Extracting per-section builders would make the command set much easier to audit and extend. As per coding guidelines, "Keep functions focused and manageable (generally under 50 lines)."

test/nuxt/composables/use-command-palette-commands.spec.ts (1)

44-115: Move wrapper disposal into afterEach.

Every test unmounts manually, but if an assertion fails earlier the scope-disposal cleanup never runs and the command-palette registries can leak into later cases. Tracking the mounted wrapper centrally, like the component spec does, would make teardown deterministic.

♻️ Suggested cleanup pattern
+let currentWrapper: Awaited<ReturnType<typeof mountSuspended>> | null = null
+
 async function captureCommandPalette(options?: {
   route?: string
   query?: string
   colorMode?: 'system' | 'light' | 'dark'
@@
-  const wrapper = await mountSuspended(WrapperComponent, {
+  currentWrapper = await mountSuspended(WrapperComponent, {
     route: options?.route ?? '/',
   })

   return {
-    wrapper,
+    wrapper: currentWrapper,
     groupedCommands,
     flatCommands,
     routePath,
     submitSearchQuery,
   }
 }

 afterEach(() => {
+  currentWrapper?.unmount()
+  currentWrapper = null
   const commandPalette = useCommandPalette()
   commandPalette.close()
   commandPalette.clearPackageContext()

Also applies to: 117-129


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ad281cb6-8c50-44bc-a99e-7668d57cdbd2

📥 Commits

Reviewing files that changed from the base of the PR and between 7f2fc1a and 9983364.

📒 Files selected for processing (43)
  • CONTRIBUTING.md
  • app/app.vue
  • app/components/AppFooter.vue
  • app/components/AppHeader.vue
  • app/components/CommandPalette.client.vue
  • app/components/Diff/ViewerPanel.vue
  • app/components/Header/MobileMenu.client.vue
  • app/components/Package/DownloadButton.vue
  • app/components/Package/ExternalLinks.vue
  • app/components/Package/Header.vue
  • app/components/Package/Playgrounds.vue
  • app/components/Package/SkillsCard.vue
  • app/components/Settings/AccentColorPicker.vue
  • app/components/Settings/BgThemePicker.vue
  • app/components/Terminal/Install.vue
  • app/composables/useCommandPalette.ts
  • app/composables/useCommandPaletteCommands.ts
  • app/composables/useCommandPaletteGlobalCommands.ts
  • app/composables/useCommandPalettePackageCommands.ts
  • app/composables/useCommandPalettePackageVersions.ts
  • app/composables/useCommandPaletteVersionCommands.ts
  • app/composables/usePlatformModifierKey.ts
  • app/composables/useSettings.ts
  • app/pages/compare.vue
  • app/pages/diff/[[org]]/[packageName]/v/[versionRange].vue
  • app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue
  • app/pages/package-docs/[...path].vue
  • app/pages/package/[[org]]/[name].vue
  • app/pages/profile/[identity]/index.vue
  • app/types/command-palette.ts
  • app/utils/package-download.ts
  • docs/content/2.guide/1.features.md
  • docs/content/2.guide/2.keyboard-shortcuts.md
  • docs/content/index.md
  • i18n/locales/en.json
  • i18n/locales/fr-FR.json
  • i18n/schema.json
  • test/nuxt/a11y.spec.ts
  • test/nuxt/app/utils/package-download.spec.ts
  • test/nuxt/components/CommandPalette.spec.ts
  • test/nuxt/composables/use-command-palette-commands.spec.ts
  • test/nuxt/composables/use-command-palette-package-versions.spec.ts
  • test/nuxt/composables/use-command-palette.spec.ts

Comment on lines +14 to +15
const queryOverrideRegistry = new Map<string, (query: string) => CommandPaletteCommand[] | null>()

Copy link
Contributor

@coderabbitai coderabbitai bot Mar 22, 2026

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

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Expected result: only "client-only" lines.
# Any "server-capable" match confirms that SSR can hit the process-wide registry.
rg -n '\buseCommandPaletteQueryOverride\s*\(' app | while IFS=: read -r file line _; do
  if [[ "$file" == *.client.* ]]; then
    scope="client-only"
  else
    scope="server-capable"
  fi
  printf '%s\t%s:%s\n' "$scope" "$file" "$line"
done

Repository: npmx-dev/npmx.dev

Length of output: 186


🏁 Script executed:

# Check useCommandPaletteVersionCommands.ts around line 50
cat -n app/composables/useCommandPaletteVersionCommands.ts | sed -n '40,60p'

Repository: npmx-dev/npmx.dev

Length of output: 906


🏁 Script executed:

# Check the broader context of useCommandPaletteQueryOverride definition and guards
cat -n app/composables/useCommandPalette.ts | sed -n '295,315p'

Repository: npmx-dev/npmx.dev

Length of output: 600


🏁 Script executed:

# Check if useCommandPaletteVersionCommands.ts has any import.meta.client guards
rg -n 'import\.meta\.client' app/composables/useCommandPaletteVersionCommands.ts

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Verify the Command Palette component/usage to see if it's client-only
fd -e vue -e ts app | xargs rg -l 'CommandPalette|useCommandPalette' | head -10

Repository: npmx-dev/npmx.dev

Length of output: 101


🏁 Script executed:

# Get full filename info and check if it's .client or not
fd 'useCommandPaletteVersionCommands'

Repository: npmx-dev/npmx.dev

Length of output: 112


🏁 Script executed:

# Search for all imports of useCommandPaletteVersionCommands
rg -n 'useCommandPaletteVersionCommands' app

Repository: npmx-dev/npmx.dev

Length of output: 719


🏁 Script executed:

# Check the actual defineComposable pattern and whether useCommandPalette has guards
cat -n app/composables/useCommandPalette.ts | head -80

Repository: npmx-dev/npmx.dev

Length of output: 2902


🏁 Script executed:

# Find the setQueryOverride function implementation
cat -n app/composables/useCommandPalette.ts | sed -n '150,250p'

Repository: npmx-dev/npmx.dev

Length of output: 3231


🏁 Script executed:

# Check what happens when resolveQueryOverride is called - is it server-side only?
rg -n 'resolveQueryOverride\|setQueryOverride\|clearQueryOverride' app/composables/useCommandPalette.ts

Repository: npmx-dev/npmx.dev

Length of output: 43


🏁 Script executed:

# Check if the pages that call useCommandPaletteVersionCommands are marked as client-only
fd -e vue 'package.*\.vue$|diff.*\.vue$' | xargs grep -l 'defineRouteRules\|ssr:' || echo "No SSR rules found"

Repository: npmx-dev/npmx.dev

Length of output: 79


Guard process-wide registry mutations from server-side execution.

queryOverrideRegistry is a module-level Map mutated by setQueryOverride() without an import.meta.client guard. Since useCommandPaletteVersionCommands (which calls useCommandPaletteQueryOverride) is imported from server-capable pages and Nuxt renders those pages server-side, concurrent requests can overwrite each other's {scopeId}:{group} entries. The related queryOverrides state is correctly per-request, but the registry persists across requests.

Guard the mutation with import.meta.client (as done in useCommandPalettePackageContext at line 231), or move the registry to per-request state.

Occurs at: lines 164–176, 300–311

Copy link
Member Author

Choose a reason for hiding this comment

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

I may be tripping, but I don't think this is a real issue

Copy link
Contributor

Choose a reason for hiding this comment

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

@serhalp You're right, and I apologize for the noise. After closer analysis: queryOverrideRegistry is only ever read inside useCommandPaletteCommands, which is consumed exclusively by CommandPalette.client.vue — a client-only component that never executes server-side. Even though the registry can be written during SSR page setup, no SSR code path ever reads it, so the cross-request contamination scenario I described simply cannot occur. The concern was invalid.


✏️ Learnings added
Learnt from: serhalp
Repo: npmx-dev/npmx.dev PR: 2159
File: app/composables/useCommandPalette.ts:14-15
Timestamp: 2026-03-24T12:14:10.506Z
Learning: In npmx-dev/npmx.dev (`app/composables/useCommandPalette.ts`), the module-level Maps (`queryOverrideRegistry`, `actionRegistry`, `actionIdsByScopeId`, `packageContextOpenRegistry`) are written to from server-capable pages during SSR setup, but they are **only read** by `useCommandPaletteCommands.ts`, which is consumed exclusively by `CommandPalette.client.vue`. Because the registry is never read server-side, there is no SSR cross-request contamination risk. Do not flag these registries for missing `import.meta.client` guards on write paths.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: serhalp
Repo: npmx-dev/npmx.dev PR: 2159
File: app/components/AppFooter.vue:8-8
Timestamp: 2026-03-24T02:08:02.618Z
Learning: In npmx.dev (app/components/AppFooter.vue), `shortcuts.command_palette_description` is a cross-platform description that hardcodes the macOS ⌘K shortcut inline and uses a `{ctrlKey}` interpolation parameter only for the Windows/Linux "Ctrl" label. `$t('shortcuts.ctrl_key')` always resolves to "Ctrl". Do not replace this with `primaryModifierKeyLabel` from `usePlatformModifierKey()`, as that would incorrectly render ⌘ for the Windows/Linux portion of the description on Apple devices.

Learnt from: danielroe
Repo: npmx-dev/npmx.dev PR: 1204
File: app/composables/npm/useUserPackages.ts:40-41
Timestamp: 2026-02-08T13:24:06.104Z
Learning: In Nuxt 3, when using useAsyncData or useLazyAsyncData, allow and rely on reactive keys by typing them as MaybeRefOrGetter<string>. Keys can be a string, a Ref<string>, a ComputedRef<string>, or a getter () => string. Reactive keys should automatically trigger refetches when their value changes. In reviews, verify that data scripts pass a MaybeRefOrGetter<string> (not a plain string-only key if reactivity is intended), and that the logic handles dynamic key changes without stale data.

Learnt from: MatteoGabriele
Repo: npmx-dev/npmx.dev PR: 1922
File: app/composables/usePackageListPreferences.ts:52-53
Timestamp: 2026-03-05T10:14:50.799Z
Learning: In Nuxt projects (here npmx-dev/npmx.dev), exports from shared/types/* (e.g., PAGE_SIZE_OPTIONS, DEFAULT_COLUMNS, DEFAULT_PREFERENCES, PageSize) are auto-imported by Nuxt for composables and components. Do not add explicit import statements for these constants/types when using files under shared/types/, and rely on the auto-imported bindings in files under app/composables (and similarly in components). This pattern applies to all TS files within app/composables that reference these shared/types exports.

@serhalp serhalp force-pushed the serhalp/cmdk-palette branch from 4d4ddfb to c9b5586 Compare March 24, 2026 12:08
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: 2

🧹 Nitpick comments (1)
app/composables/useCommandPaletteCommands.ts (1)

23-223: Split useCommandPaletteCommands() into smaller helpers.

This composable now owns localisation, command normalisation, root search composition, Fuse wiring, query overrides, grouping, and view metadata in one place. Extracting a few of those steps into dedicated helpers/composables would make the reactive flow much easier to follow and keep the function closer to the repo guideline.

As per coding guidelines, "Keep functions focused and manageable (generally under 50 lines)".


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 133f648f-7f8f-42cc-a51b-5f94084a592e

📥 Commits

Reviewing files that changed from the base of the PR and between 1036612 and 4d4ddfb.

📒 Files selected for processing (9)
  • app/composables/useCommandPaletteCommands.ts
  • app/composables/useCommandPalettePackageCommands.ts
  • app/composables/useCommandPalettePackageVersions.ts
  • app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue
  • app/pages/profile/[identity]/index.vue
  • shared/utils/url.ts
  • test/nuxt/a11y.spec.ts
  • test/nuxt/composables/use-command-palette-commands.spec.ts
  • test/nuxt/composables/use-command-palette-package-versions.spec.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • test/nuxt/composables/use-command-palette-package-versions.spec.ts
  • app/pages/profile/[identity]/index.vue
  • app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue
  • app/composables/useCommandPalettePackageVersions.ts

Comment on lines +156 to +164
const { results } = useFuse(query, commands, {
fuseOptions: {
keys: ['label', 'keywords'],
threshold: 0.3,
ignoreLocation: true,
ignoreDiacritics: true,
},
matchAllWhenSearchEmpty: true,
})
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

Use trimmedQuery for Fuse matching.

Line 156 still feeds Fuse the raw query, while the rest of this composable already branches on trimmedQuery. That makes whitespace-only input inconsistent: commands falls back to the default root list, but Fuse can still filter that list out by searching for spaces.

Suggested fix
-  const { results } = useFuse(query, commands, {
+  const { results } = useFuse(trimmedQuery, commands, {
📝 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
const { results } = useFuse(query, commands, {
fuseOptions: {
keys: ['label', 'keywords'],
threshold: 0.3,
ignoreLocation: true,
ignoreDiacritics: true,
},
matchAllWhenSearchEmpty: true,
})
const { results } = useFuse(trimmedQuery, commands, {
fuseOptions: {
keys: ['label', 'keywords'],
threshold: 0.3,
ignoreLocation: true,
ignoreDiacritics: true,
},
matchAllWhenSearchEmpty: true,
})

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: 2

🧹 Nitpick comments (1)
app/composables/useCommandPaletteCommands.ts (1)

150-154: Consider avoiding spread in map for efficiency.

The static analysis tool flags spreading to modify object properties in map calls as inefficient. For a command palette with a small number of commands this is unlikely to be a bottleneck, but if performance becomes a concern, you could pre-allocate the keywords array or use Object.assign.

Alternative approach
     return base.map(command => {
-      return {
-        ...command,
-        keywords: [...command.keywords, groupLabel(command.group)],
-      }
+      const keywords = command.keywords.slice()
+      keywords.push(groupLabel(command.group))
+      return Object.assign({}, command, { keywords })
     })

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8b42d3e1-fc6d-4a49-bfad-32c393ac7152

📥 Commits

Reviewing files that changed from the base of the PR and between 4d4ddfb and 651ba8a.

📒 Files selected for processing (10)
  • app/composables/useCommandPaletteCommands.ts
  • app/composables/useCommandPalettePackageCommands.ts
  • app/composables/useCommandPalettePackageVersions.ts
  • app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue
  • app/pages/profile/[identity]/index.vue
  • docs/content/index.md
  • shared/utils/url.ts
  • test/nuxt/a11y.spec.ts
  • test/nuxt/composables/use-command-palette-commands.spec.ts
  • test/nuxt/composables/use-command-palette-package-versions.spec.ts
✅ Files skipped from review due to trivial changes (2)
  • docs/content/index.md
  • test/nuxt/composables/use-command-palette-commands.spec.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • test/nuxt/composables/use-command-palette-package-versions.spec.ts
  • app/composables/useCommandPalettePackageVersions.ts

Comment on lines +525 to +531
const dialog = document.getElementById('command-palette-modal')
const announcer = document.getElementById('command-palette-modal-announcement')

expect(dialog).not.toBeNull()

const results = await runAxeElements([announcer, dialog])
expect(results.violations).toEqual([])
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

Assert announcer existence before running axe.

At Line 526, announcer may be null and is silently skipped by runAxeElements, so a missing live region would not fail this test.

Suggested fix
       const dialog = document.getElementById('command-palette-modal')
       const announcer = document.getElementById('command-palette-modal-announcement')

       expect(dialog).not.toBeNull()
+      expect(announcer).not.toBeNull()

       const results = await runAxeElements([announcer, dialog])
       expect(results.violations).toEqual([])
📝 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
const dialog = document.getElementById('command-palette-modal')
const announcer = document.getElementById('command-palette-modal-announcement')
expect(dialog).not.toBeNull()
const results = await runAxeElements([announcer, dialog])
expect(results.violations).toEqual([])
const dialog = document.getElementById('command-palette-modal')
const announcer = document.getElementById('command-palette-modal-announcement')
expect(dialog).not.toBeNull()
expect(announcer).not.toBeNull()
const results = await runAxeElements([announcer, dialog])
expect(results.violations).toEqual([])

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.

🧹 Nitpick comments (2)
app/composables/useCommandPalettePackageCommands.ts (2)

58-110: Consider extracting repeated conditions to local variables.

The same condition is evaluated twice for each command—once for active and again inside activeLabel. This could be simplified by computing each condition once:

const isPackagePage = ['package', 'package-version'].includes(`${route.name ?? ''}`)
const isDocsPage = route.name === 'docs'
const isCodePage = route.name === 'code'
const isComparePage = route.name === 'compare' && isCurrentPackageCompare(resolvedContext.packageName)

Then use these variables for both active and activeLabel properties. This reduces redundant evaluation and improves readability.


13-157: Consider extracting command builders to reduce function length.

The main function exceeds 140 lines. While the logic is cohesive, you could improve maintainability by extracting helper functions for building each command group (e.g., buildBaseCommands, buildDownloadCommand, buildDiffCommand). This would align better with the guideline to keep functions "generally under 50 lines".

That said, since this is a single computed block with clear structure, this is a nice-to-have rather than essential. As per coding guidelines: "Keep functions focused and manageable (generally under 50 lines)".


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 586e63cc-12af-44e9-9e55-e8503c257ccc

📥 Commits

Reviewing files that changed from the base of the PR and between 651ba8a and b515456.

📒 Files selected for processing (1)
  • app/composables/useCommandPalettePackageCommands.ts

Copy link
Member

@knowler knowler left a comment

Choose a reason for hiding this comment

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

This is a partial review, but this is looking good.

<section
v-for="group in groupedCommands"
:key="group.id"
:aria-labelledby="`${dialogId}-group-${group.id}`"
Copy link
Member

Choose a reason for hiding this comment

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

These already have headings, so I don’t see a need to make them implicit role=region as well.

Suggested change
:aria-labelledby="`${dialogId}-group-${group.id}`"

Comment on lines +349 to +350
:aria-label="$t('command_palette.results_label')"
role="region"
Copy link
Member

Choose a reason for hiding this comment

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

This one seems fine though

Comment on lines +384 to +388
:class="
activeIndex === (commandIndexMap.get(command.id) ?? -1)
? 'border-border/80 bg-bg'
: ''
"
Copy link
Member

Choose a reason for hiding this comment

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

Could these be applied with just CSS? I can see hover: ones, but could we add focus-visible: ones too?

:aria-current="command.active ? 'true' : undefined"
@click="handleCommandClick(command)"
@focus="activeIndex = commandIndexMap.get(command.id) ?? -1"
@mouseenter="activeIndex = commandIndexMap.get(command.id) ?? -1"
Copy link
Member

Choose a reason for hiding this comment

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

Why should the active index change on mouseenter?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cmd+K quick actions command bar

4 participants