modern-cmdk 1.2.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,18 +9,19 @@
9
9
 
10
10
  <p align="center">
11
11
  <a href="https://www.npmjs.com/package/modern-cmdk"><img alt="npm version" src="https://img.shields.io/npm/v/modern-cmdk?style=flat-square&color=dc2626&labelColor=0a0e27"/></a>
12
- <a href="https://bundlephobia.com/package/modern-cmdk"><img alt="bundle size" src="https://img.shields.io/bundlephobia/minzip/modern-cmdk?style=flat-square&color=dc2626&labelColor=0a0e27&label=core"/></a>
12
+ <a href="https://github.com/ABCrimson/modern-cmdk/blob/main/package.json"><img alt="core size budget" src="https://img.shields.io/badge/core-%3C%206.5%20kB%20gzip-dc2626?style=flat-square&labelColor=0a0e27"/></a>
13
+ <a href="https://github.com/ABCrimson/modern-cmdk/blob/main/package.json"><img alt="react size budget" src="https://img.shields.io/badge/react-%3C%2012%20kB%20gzip-dc2626?style=flat-square&labelColor=0a0e27"/></a>
13
14
  <a href="https://github.com/ABCrimson/modern-cmdk/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/ABCrimson/modern-cmdk/ci.yml?style=flat-square&color=dc2626&labelColor=0a0e27&label=CI"/></a>
14
- <a href="https://www.typescriptlang.org/"><img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-7.0-dc2626?style=flat-square&labelColor=0a0e27"/></a>
15
+ <a href="https://www.typescriptlang.org/"><img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-7-dc2626?style=flat-square&labelColor=0a0e27"/></a>
15
16
  <a href="https://react.dev/"><img alt="React" src="https://img.shields.io/badge/React-19-dc2626?style=flat-square&labelColor=0a0e27"/></a>
16
17
  <a href="https://github.com/ABCrimson/modern-cmdk/blob/main/LICENSE"><img alt="MIT License" src="https://img.shields.io/npm/l/modern-cmdk?style=flat-square&color=dc2626&labelColor=0a0e27"/></a>
17
18
  </p>
18
19
 
19
20
  <p align="center">
20
- <a href="https://command.crimson.dev">Documentation</a> &middot;
21
- <a href="https://command.crimson.dev/guide/getting-started">Getting Started</a> &middot;
22
- <a href="https://command.crimson.dev/api/command">API Reference</a> &middot;
23
- <a href="https://command.crimson.dev/examples/basic">Examples</a>
21
+ <a href="https://abcrimson.github.io/modern-cmdk/">Documentation</a> &middot;
22
+ <a href="https://abcrimson.github.io/modern-cmdk/guide/getting-started.html">Getting Started</a> &middot;
23
+ <a href="https://abcrimson.github.io/modern-cmdk/api/command.html">API Reference</a> &middot;
24
+ <a href="https://abcrimson.github.io/modern-cmdk/examples/basic.html">Examples</a>
24
25
  </p>
25
26
 
26
27
  ---
@@ -33,7 +34,8 @@ npm install modern-cmdk
33
34
 
34
35
  > [!NOTE]
35
36
  > `modern-cmdk` is ESM-only and declares `engines.node >= 26.4.0`. The optional WASM search
36
- > engine (`modern-cmdk-search-wasm`) is experimental and not yet published to npm.
37
+ > engine (`modern-cmdk-search-wasm`) is a private, unpublished workspace package (0.9.0) that you
38
+ > build from source; it is not on npm.
37
39
 
38
40
  The package exports three entry points:
39
41
 
@@ -43,7 +45,11 @@ import { Command } from 'modern-cmdk/react'; // React 19 component
43
45
  import 'modern-cmdk/styles.css'; // Default styles
44
46
  ```
45
47
 
46
- React is an **optional** peer dependency — use the core engine standalone in any framework, or pair it with the React adapter for a batteries-included experience.
48
+ React is an **optional** peer dependency — use the core engine standalone in any framework, or pair it with the React adapter. `modern-cmdk/react` imports `react`, `react-dom` and `radix-ui`, which are optional peers that npm and pnpm do not install for you, so add them yourself (the Quick Start below needs all three):
49
+
50
+ ```bash
51
+ npm install modern-cmdk react react-dom radix-ui
52
+ ```
47
53
 
48
54
  ---
49
55
 
@@ -54,62 +60,78 @@ React is an **optional** peer dependency — use the core engine standalone in a
54
60
 
55
61
  import { Command } from 'modern-cmdk/react';
56
62
  import 'modern-cmdk/styles.css';
63
+ import { useEffect, useState } from 'react';
57
64
 
58
65
  function CommandPalette() {
66
+ const [open, setOpen] = useState(false);
67
+
68
+ // Nothing opens the dialog for you: toggle it from a Mod+K listener (Cmd+K / Ctrl+K).
69
+ useEffect(() => {
70
+ const onKeyDown = (e: KeyboardEvent) => {
71
+ if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
72
+ e.preventDefault();
73
+ setOpen((o) => !o);
74
+ }
75
+ };
76
+ document.addEventListener('keydown', onKeyDown);
77
+ return () => document.removeEventListener('keydown', onKeyDown);
78
+ }, []);
79
+
59
80
  return (
60
- <Command.Dialog>
81
+ <Command.Dialog open={open} onOpenChange={setOpen} label="Command Menu">
61
82
  <Command.Input placeholder="Type a command..." />
62
83
  <Command.List>
63
84
  <Command.Empty>No results found.</Command.Empty>
64
85
 
65
86
  <Command.Group heading="Actions">
66
- <Command.Item value="copy" onSelect={() => copyToClipboard()}>
67
- <Command.Highlight>Copy to Clipboard</Command.Highlight>
68
- <Command.Shortcut>Mod+C</Command.Shortcut>
87
+ <Command.Item value="copy" onSelect={() => setOpen(false)}>
88
+ Copy to Clipboard
89
+ <Command.Shortcut shortcut="Mod+C" />
69
90
  </Command.Item>
70
- <Command.Item value="paste" onSelect={() => paste()}>
71
- <Command.Highlight>Paste</Command.Highlight>
72
- <Command.Shortcut>Mod+V</Command.Shortcut>
91
+ <Command.Item value="paste" onSelect={() => setOpen(false)}>
92
+ Paste
93
+ <Command.Shortcut shortcut="Mod+V" />
73
94
  </Command.Item>
74
95
  </Command.Group>
75
96
 
76
97
  <Command.Group heading="Navigation">
77
- <Command.Item value="settings" onSelect={() => navigate('/settings')}>
98
+ <Command.Item value="settings" onSelect={() => setOpen(false)}>
78
99
  Settings
79
100
  <Command.Badge>New</Command.Badge>
80
101
  </Command.Item>
81
102
  </Command.Group>
82
103
  </Command.List>
83
- <Command.Loading>Searching...</Command.Loading>
84
104
  </Command.Dialog>
85
105
  );
86
106
  }
87
107
  ```
88
108
 
89
- Or scaffold a new project instantly:
109
+ `<Command label="Command menu">` takes the same children inline, with no dialog; `<Command.Highlight text={...} ranges={...} />` renders match highlighting from scorer ranges.
110
+
111
+ Or scaffold a new project:
90
112
 
91
113
  ```bash
92
- npm create modern-cmdk
114
+ npm create modern-cmdk@latest my-palette -- --template=react-basic
93
115
  ```
94
116
 
95
117
  ---
96
118
 
97
119
  ## Why modern-cmdk?
98
120
 
99
- A ground-up rewrite of `cmdk` for **React 19**, **ES2026**, and **TypeScript 7**. Framework-agnostic core. Zero compromise on performance, accessibility, or developer experience.
121
+ A ground-up rewrite of `cmdk` for **React 19** and **TypeScript 7**, written in modern ECMAScript. Framework-agnostic core. Zero compromise on performance, accessibility, or developer experience.
100
122
 
101
123
  | | cmdk | modern-cmdk |
102
124
  |---|---|---|
103
125
  | **Architecture** | React-only, tightly coupled | Framework-agnostic core + thin adapters |
104
- | **React** | 18 | 19 (`use()`, `useOptimistic`, `useId`, `ref` as prop) |
105
- | **Search** | Basic substring | Fuzzy scoring + optional WASM (sub-1ms on 100K items) |
106
- | **Ranking** | Static order | Frecency with time-based exponential decay |
126
+ | **React** | 18 / 19 | 19 (`use()`, `useOptimistic`, `useId`, `ref` as prop) |
127
+ | **Search** | Fuzzy (`command-score`) | Fuzzy scoring with match ranges; an experimental WASM engine (unpublished, does not work yet) |
128
+ | **Ranking** | Static order | Frecency: frequency x bucketed recency weights |
107
129
  | **Virtualization** | None | Automatic variable-height, `content-visibility: auto` |
108
130
  | **Animations** | CSS transitions | GPU-composited: `@starting-style`, `scroll-timeline`, spring easing |
109
131
  | **Keyboard** | External | Built-in registry, `Mod` key, conflict detection |
110
132
  | **Accessibility** | Partial ARIA | Full WAI-ARIA combobox, `forced-colors`, `prefers-contrast` |
111
- | **Bundle** | ~6 KB | Core ~6.4 KB, React ~9.8 KB |
112
- | **TypeScript** | 4.x/5.x | 7.0, isolated declarations, branded types |
133
+ | **Bundle** | not compared | Core ~6.4 kB, React ~9.8 kB gzip (budgets: < 6.5 kB, < 12 kB) |
134
+ | **TypeScript** | 4.x/5.x | 7, isolated declarations, branded types |
113
135
  | **Cleanup** | Manual | `using`/`await using` (Explicit Resource Management) |
114
136
  | **Error handling** | None | `CommandErrorBoundary` with fallback UI |
115
137
  | **DevTools** | None | Built-in devtools hook for browser inspection |
@@ -118,12 +140,12 @@ A ground-up rewrite of `cmdk` for **React 19**, **ES2026**, and **TypeScript 7**
118
140
 
119
141
  ## Features
120
142
 
121
- - **Framework-agnostic core** -- Pure TypeScript state machine. No DOM. No React. Portable to any runtime. The sole runtime dependency (`idb-keyval`) is lazy-loaded only if you opt into IndexedDB frecency persistence.
122
- - **React 19 adapter** -- 14 compound components. `useSyncExternalStore`, `useTransition`, `useOptimistic`, `use()` for Suspense. React Compiler compatible.
123
- - **Automatic virtualization** -- Variable-height virtual scrolling kicks in at a configurable threshold (`virtualizeThreshold`, default 100). Handles 100K+ items smoothly with `content-visibility: auto`.
124
- - **Fuzzy search** -- Built-in TypeScript scorer with incremental filtering. Optional Rust/WASM engine for sub-1ms on 100K items with graceful TS fallback _(experimental, not yet published to npm)_.
125
- - **Frecency ranking** -- Frequency x recency with time-based exponential decay. Pluggable persistence (memory or IndexedDB).
126
- - **Keyboard shortcuts** -- Built-in registry with cross-platform `Mod` key, conflict detection, and `RegExp.escape` parsing. Cross-browser helper functions for grouping and set operations.
143
+ - **Framework-agnostic core** -- Pure TypeScript state machine. No React. No hard DOM dependency; DOM APIs are feature-detected and SSR-safe. Portable to any runtime. The sole runtime dependency (`idb-keyval`) is lazy-loaded only if you opt into IndexedDB frecency persistence.
144
+ - **React 19 adapter** -- a `Command` root with 14 compound sub-components (`Command.Input` through `Command.Activity`). `useSyncExternalStore`, `useTransition`, `useOptimistic`, `use()` for Suspense. React Compiler compatible.
145
+ - **Automatic virtualization** -- Variable-height virtual scrolling turns on when more than 100 items match and off again at 50 or fewer (fixed thresholds; force it with `<Command.List virtualize>`). Off-screen items use `content-visibility: auto`. The `virtualizeThreshold` option is accepted but not read yet.
146
+ - **Fuzzy search** -- Built-in TypeScript scorer with incremental filtering. Experimental Rust/WASM engine _(private, not published to npm)_; it does not work yet and always falls back to the TS scorer.
147
+ - **Frecency ranking** -- Frequency x recency, weighted by hour/day/week/month buckets. Pluggable persistence (memory or IndexedDB).
148
+ - **Keyboard shortcuts** -- Built-in registry with cross-platform `Mod` key, and conflict detection. Cross-browser helper functions for grouping and set operations.
127
149
  - **Full accessibility** -- WAI-ARIA combobox pattern, `aria-live` announcements, `forced-colors`, `prefers-contrast`, `prefers-reduced-motion`.
128
150
  - **GPU-composited animations** -- `@starting-style` entry, `scroll-timeline` progress, spring `linear()` easing. All customizable via CSS custom properties.
129
151
  - **Error boundary** -- `CommandErrorBoundary` with static or render-function fallback.
@@ -140,10 +162,10 @@ A ground-up rewrite of `cmdk` for **React 19**, **ES2026**, and **TypeScript 7**
140
162
  <Command.Input /> {/* Search input */}
141
163
  <Command.List> {/* Scrollable list with auto-virtualization */}
142
164
  <Command.Empty /> {/* Shown when filteredCount === 0 */}
143
- <Command.Loading /> {/* Shown when loading */}
165
+ <Command.Loading /> {/* renders when the `loading` prop is true (or while the active-item transition is pending) */}
144
166
  <Command.Group> {/* Labeled group of items */}
145
167
  <Command.Item> {/* Selectable item */}
146
- <Command.Highlight /> {/* Fuzzy match highlighting */}
168
+ <Command.Highlight text={label} ranges={ranges} /> {/* Fuzzy match highlighting */}
147
169
  <Command.Badge /> {/* Status badge */}
148
170
  <Command.Shortcut /> {/* Keyboard shortcut display */}
149
171
  </Command.Item>
@@ -189,16 +211,17 @@ machine.subscribe(() => {
189
211
 
190
212
  ## Performance
191
213
 
192
- | Benchmark | Target | Measured |
214
+ | Benchmark | Target | Measured (CI runner) |
193
215
  |---|---|---|
194
- | Search 10K items (TS scorer) | < 16 ms | ~8.2 ms |
195
- | Search 100K items (WASM) | < 1 ms | ~0.7 ms |
196
- | Filter 10K (incremental) | < 2 ms | ~1.1 ms |
197
- | State update cycle | < 4 ms | ~2.3 ms |
216
+ | Search 10K items (TS scorer) | < 16 ms | ~3.5 ms median |
217
+ | State update: `SEARCH_CHANGE` | < 4 ms | ~0.37 ms median |
218
+ | State update: `NAVIGATE` | < 1 ms | ~0.00075 ms median |
198
219
  | Core bundle (gzipped) | <= 6.5 KB | ~6.4 KB |
199
220
  | React bundle (gzipped) | <= 12 KB | ~9.8 KB |
200
221
 
201
- CI enforces 5% warning / 15% failure regression thresholds with 3-run averaging.
222
+ Timings are medians from `benchmarks/baseline.json`: one CI run of the standalone harness, three runs of 1,000 iterations averaged, measured on a shared GitHub Actions
223
+ runner (noise is +/-20-40 %). CI compares new runs against that baseline with 5 % warning / 15 % failure
224
+ thresholds as a coarse tripwire, not a precise regression gate; the `NAVIGATE` baseline sits below the 0.01 ms noise floor and is reported, not compared, and a median above its target fails. There is no published WASM benchmark.
202
225
 
203
226
  ---
204
227
 
@@ -216,37 +239,69 @@ All animations and dimensions are customizable via CSS custom properties:
216
239
  --command-dialog-max-width: 640px;
217
240
  --command-item-height: 44px;
218
241
  --command-highlight-color: oklch(0.85 0.15 90 / 0.3);
242
+ --command-muted-color: light-dark(oklch(0.45 0.015 260), oklch(0.75 0.015 260));
219
243
  }
220
244
  ```
221
245
 
246
+ `--command-muted-color` colours secondary text (group headings, shortcuts, the empty state, the
247
+ placeholder). Its default uses `light-dark()`, which follows the used `color-scheme`: set
248
+ `color-scheme` on `:root` or a parent (for example `color-scheme: dark` on a dark theme), or override
249
+ `--command-muted-color`. A dark-themed page that sets no `color-scheme` gets the light value on its dark
250
+ surface (about 2.4:1 contrast).
251
+
222
252
  All styles use `@layer command` for zero-specificity conflicts and CSS logical properties for full RTL support.
223
253
 
224
254
  ---
225
255
 
226
256
  ## Migration from cmdk
227
257
 
228
- Automated codemods handle the most common migration patterns:
258
+ Four codemods ship as the `modern-cmdk` bin. Pass a transform name and a glob (quote the glob so your
259
+ shell does not expand it):
229
260
 
230
261
  ```bash
231
- npx modern-cmdk import-rewrite ./src
232
- npx modern-cmdk data-attrs ./src
233
- npx modern-cmdk forward-ref ./src
234
- npx modern-cmdk should-filter ./src
262
+ npx modern-cmdk --help
263
+ npx modern-cmdk import-rewrite "./src/**/*.tsx" --dry-run
264
+ npx modern-cmdk import-rewrite "./src/**/*.tsx"
235
265
  ```
236
266
 
237
- See the [full migration guide](https://command.crimson.dev/guide/migration-from-cmdk).
267
+ | Transform | What it does |
268
+ |---|---|
269
+ | `import-rewrite` | Rewrites `cmdk` imports to `modern-cmdk/react` |
270
+ | `data-attrs` | Converts `[cmdk-*]` selectors to `[data-command-*]` |
271
+ | `forward-ref` | Removes `React.forwardRef` wrappers (React 19) |
272
+ | `should-filter` | Converts the `shouldFilter` prop to `filter` |
273
+
274
+ Options: `--dry-run` lists the files that would change and writes nothing; `--concurrency=N` sets how
275
+ many files are processed at once (default 8); `--strict` is described below.
276
+
277
+ A run prints one `changed <file>` line per rewritten file and a summary. With `--dry-run` the lines read
278
+ `would change <file>` and the summary ends in `(dry run, nothing written)`:
279
+
280
+ ```text
281
+ would change src/a.tsx
282
+ 1 changed, 0 unchanged, 0 errors (dry run, nothing written)
283
+ ```
284
+
285
+ Exit codes in 1.x are unchanged by default: `0` for `--help`, missing arguments and per-file errors, and
286
+ `1` for an unknown transform, a glob that matches no files, or an uncaught error. An unknown option is
287
+ warned about on stderr and ignored when the transform and glob are present. A malformed known option
288
+ (`--concurrency` with no value, `--dry-run=yes`) is a usage error that writes nothing and exits `0`.
289
+ With `--strict`, every usage error (including an unknown option, an unknown transform and a malformed
290
+ option) exits `2`, and any file error exits `1`.
291
+
292
+ See the [full migration guide](https://abcrimson.github.io/modern-cmdk/guide/migration-from-cmdk.html).
238
293
 
239
294
  ---
240
295
 
241
296
  ## Documentation
242
297
 
243
- **[command.crimson.dev](https://command.crimson.dev)**
298
+ **[abcrimson.github.io/modern-cmdk](https://abcrimson.github.io/modern-cmdk/)**
244
299
 
245
- **Guides:** [Getting Started](https://command.crimson.dev/guide/getting-started) | [Installation](https://command.crimson.dev/guide/installation) | [Basic Usage](https://command.crimson.dev/guide/basic-usage) | [Async Items](https://command.crimson.dev/guide/async-items) | [WASM Search](https://command.crimson.dev/guide/wasm-search) | [Frecency](https://command.crimson.dev/guide/frecency) | [Shortcuts](https://command.crimson.dev/guide/shortcuts) | [Virtualization](https://command.crimson.dev/guide/virtualization) | [SSR / Next.js](https://command.crimson.dev/guide/ssr) | [Theming](https://command.crimson.dev/guide/theming) | [Accessibility](https://command.crimson.dev/guide/accessibility)
300
+ **Guides:** [Getting Started](https://abcrimson.github.io/modern-cmdk/guide/getting-started.html) | [Installation](https://abcrimson.github.io/modern-cmdk/guide/installation.html) | [Basic Usage](https://abcrimson.github.io/modern-cmdk/guide/basic-usage.html) | [Async Items](https://abcrimson.github.io/modern-cmdk/guide/async-items.html) | [WASM Search](https://abcrimson.github.io/modern-cmdk/guide/wasm-search.html) | [Frecency](https://abcrimson.github.io/modern-cmdk/guide/frecency.html) | [Shortcuts](https://abcrimson.github.io/modern-cmdk/guide/shortcuts.html) | [Virtualization](https://abcrimson.github.io/modern-cmdk/guide/virtualization.html) | [SSR / Next.js](https://abcrimson.github.io/modern-cmdk/guide/ssr.html) | [Theming](https://abcrimson.github.io/modern-cmdk/guide/theming.html) | [Accessibility](https://abcrimson.github.io/modern-cmdk/guide/accessibility.html)
246
301
 
247
- **Recipes:** [File Picker](https://command.crimson.dev/recipes/file-picker) | [Emoji Picker](https://command.crimson.dev/recipes/emoji-picker) | [AI Chat Commands](https://command.crimson.dev/recipes/ai-chat-commands) | [Nested Commands](https://command.crimson.dev/recipes/nested-commands) | [Spotlight Search](https://command.crimson.dev/recipes/spotlight-search)
302
+ **Recipes:** [File Picker](https://abcrimson.github.io/modern-cmdk/recipes/file-picker.html) | [Emoji Picker](https://abcrimson.github.io/modern-cmdk/recipes/emoji-picker.html) | [AI Chat Commands](https://abcrimson.github.io/modern-cmdk/recipes/ai-chat-commands.html) | [Nested Commands](https://abcrimson.github.io/modern-cmdk/recipes/nested-commands.html) | [Spotlight Search](https://abcrimson.github.io/modern-cmdk/recipes/spotlight-search.html)
248
303
 
249
- **API:** [Core Engine](https://command.crimson.dev/api/command) | [React Adapter](https://command.crimson.dev/api/command-react) | [WASM Search](https://command.crimson.dev/api/command-search-wasm)
304
+ **API:** [Core Engine](https://abcrimson.github.io/modern-cmdk/api/command.html) | [React Adapter](https://abcrimson.github.io/modern-cmdk/api/command-react.html) | [WASM Search](https://abcrimson.github.io/modern-cmdk/api/command-search-wasm.html)
250
305
 
251
306
  ---
252
307
 
@@ -254,9 +309,9 @@ See the [full migration guide](https://command.crimson.dev/guide/migration-from-
254
309
 
255
310
  | Package | Description |
256
311
  |---|---|
257
- | [`create-modern-cmdk`](https://www.npmjs.com/package/create-modern-cmdk) | `npm create modern-cmdk` -- scaffold a new project |
258
- | `modern-cmdk-search-wasm` _(experimental, not yet published)_ | Rust/WASM fuzzy search -- sub-1ms on 100K items |
259
- | [`vscode-modern-cmdk`](https://marketplace.visualstudio.com/items?itemName=crimson-dev.vscode-modern-cmdk) | VS Code snippets extension |
312
+ | [`create-modern-cmdk`](https://www.npmjs.com/package/create-modern-cmdk) | `npm create modern-cmdk@latest <name>` -- scaffold a new project |
313
+ | `modern-cmdk-search-wasm` _(private 0.9.0, not published; build from source)_ | Experimental Rust/WASM fuzzy search |
314
+ | `vscode-modern-cmdk` _(not yet published)_ | VS Code snippets extension |
260
315
 
261
316
  ---
262
317
 
@@ -1 +1,16 @@
1
- export {}
1
+ import { API, FileInfo } from "jscodeshift";
2
+ type TransformFn = (fileInfo: FileInfo, api: API) => string;
3
+ /** The two streams the CLI writes to; `process` satisfies this, tests pass collectors. */
4
+ export interface CliIo {
5
+ readonly stdout: NodeJS.WritableStream;
6
+ readonly stderr: NodeJS.WritableStream;
7
+ }
8
+ /** Test seam: replace how a transform is loaded (the bundled bin imports `./transforms/<name>.mjs`). */
9
+ export interface CliDeps {
10
+ readonly loadTransform?: (name: string) => Promise<TransformFn>;
11
+ }
12
+ /**
13
+ * Run the codemod CLI. Resolves to the process exit code; writes only to `io`.
14
+ */
15
+ export declare function run(argv: readonly string[], io: CliIo, deps?: CliDeps): Promise<number>;
16
+ export {};
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
3
  import fsPromises, { readFile, writeFile } from "node:fs/promises";
4
- import path, { resolve } from "node:path";
4
+ import path, { relative } from "node:path";
5
+ import { parseArgs, promisify, styleText } from "node:util";
5
6
  import process$1 from "node:process";
6
7
  import fs from "node:fs";
7
8
  import "node:stream";
8
9
  import "node:events";
9
10
  import "node:stream/promises";
10
- import { promisify } from "node:util";
11
11
  import { execFile } from "node:child_process";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import os from "node:os";
@@ -43584,7 +43584,7 @@ var require_core = /* @__PURE__ */ __commonJSMin(((exports, module) => {
43584
43584
  }
43585
43585
  module.exports = enrichCore(core, getParser());
43586
43586
  }));
43587
- // src/core/utils/group-by.ts
43587
+ // src/codemod/cli.ts
43588
43588
  var import_jscodeshift = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
43589
43589
  /**
43590
43590
  * Copyright (c) Facebook, Inc. and its affiliates.
@@ -43594,21 +43594,6 @@ var import_jscodeshift = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(
43594
43594
  */
43595
43595
  module.exports = require_core();
43596
43596
  })))(), 1);
43597
- /**
43598
- * Groups items from an iterable into a plain object keyed by the string result of `keyFn`.
43599
- * Cross-browser replacement for `Object.groupBy()`.
43600
- */
43601
- function objectGroupBy(items, keyFn) {
43602
- const result = Object.create(null);
43603
- for (const item of items) {
43604
- const key = keyFn(item);
43605
- const existing = result[key];
43606
- if (existing) existing.push(item);
43607
- else result[key] = [item];
43608
- }
43609
- return result;
43610
- }
43611
- // src/codemod/cli.ts
43612
43597
  const AVAILABLE_TRANSFORMS = /* @__PURE__ */ new Map([
43613
43598
  ["import-rewrite", "Rewrites cmdk imports to modern-cmdk/react"],
43614
43599
  ["data-attrs", "Converts [cmdk-*] selectors to [data-command-*]"],
@@ -43619,102 +43604,200 @@ const AVAILABLE_TRANSFORMS = /* @__PURE__ */ new Map([
43619
43604
  * Dynamically import a transform module by name and return its default export.
43620
43605
  */
43621
43606
  async function loadTransform(name) {
43622
- return (await import(`./transforms/${name}.mjs`)).default;
43607
+ return (await import(
43608
+ /* @vite-ignore */
43609
+ `./transforms/${name}.mjs`
43610
+ )).default;
43623
43611
  }
43624
- function printUsage() {
43625
- AVAILABLE_TRANSFORMS.entries().forEach(([_name, _description]) => {});
43612
+ function usageText(stream) {
43613
+ const bold = (s) => styleText("bold", s, { stream });
43614
+ const opt = (s) => styleText("cyan", s.padEnd(17), { stream });
43615
+ return [
43616
+ `${bold("Usage:")} modern-cmdk <transform> <glob> [options]`,
43617
+ "",
43618
+ bold("Transforms:"),
43619
+ ...AVAILABLE_TRANSFORMS.entries().map(([name, description]) => ` ${opt(name)}${description}`).toArray(),
43620
+ "",
43621
+ bold("Options:"),
43622
+ ` ${opt("--dry-run")}List the files that would change; write nothing`,
43623
+ ` ${opt("--strict")}Exit 2 on usage errors (incl. unknown options), 1 on file errors`,
43624
+ ` ${opt("--concurrency=N")}Files processed at once (default 8)`,
43625
+ ` ${opt("-h, --help")}Show this help`,
43626
+ ""
43627
+ ].join("\n");
43626
43628
  }
43627
- function parseArgs(argv) {
43628
- const args = argv.slice(2);
43629
- const flags = args.values().filter((a) => a.startsWith("--")).toArray();
43630
- const positional = args.values().filter((a) => !a.startsWith("--")).toArray();
43631
- if (positional.length < 2) return null;
43632
- const transformName = positional[0];
43633
- const fileGlob = positional[1];
43634
- const dryRun = flags.some((f) => f === "--dry-run");
43635
- const concurrencyFlag = flags.values().find((f) => f.startsWith("--concurrency="));
43629
+ const OPTIONS = {
43630
+ help: {
43631
+ type: "boolean",
43632
+ short: "h"
43633
+ },
43634
+ strict: { type: "boolean" },
43635
+ "dry-run": { type: "boolean" },
43636
+ concurrency: { type: "string" }
43637
+ };
43638
+ function parse(argv) {
43639
+ let values;
43640
+ let positionals;
43641
+ let ignored = [];
43642
+ try {
43643
+ ({values, positionals} = parseCli(argv));
43644
+ } catch (err) {
43645
+ const strictArg = argv.includes("--strict");
43646
+ if (err.code !== "ERR_PARSE_ARGS_UNKNOWN_OPTION") return {
43647
+ kind: "usage-error",
43648
+ message: err instanceof Error ? err.message : String(err),
43649
+ strict: strictArg
43650
+ };
43651
+ const loose = parseArgs({
43652
+ args: [...argv],
43653
+ options: OPTIONS,
43654
+ allowPositionals: true,
43655
+ strict: false,
43656
+ tokens: true
43657
+ });
43658
+ ignored = loose.tokens.filter((t) => t.kind === "option" && !Object.hasOwn(OPTIONS, t.name)).map((t) => t.rawName);
43659
+ const badValue = loose.tokens.find((t) => t.kind === "option" && Object.hasOwn(OPTIONS, t.name) && (OPTIONS[t.name].type === "boolean" ? t.value !== void 0 : t.value === void 0 || !t.inlineValue && t.value.length > 1 && t.value.startsWith("-")));
43660
+ const badMessage = badValue?.kind === "option" ? badValue.value === void 0 ? `Option '${badValue.rawName} <value>' argument missing` : OPTIONS[badValue.name].type === "boolean" ? `Option '${badValue.rawName}' does not take an argument` : `Option '${badValue.rawName}' argument is ambiguous.\nDid you forget to specify the option argument for '${badValue.rawName}'?\nTo specify an option argument starting with a dash use '${badValue.rawName}=-XYZ'.` : void 0;
43661
+ if (badMessage !== void 0) return {
43662
+ kind: "usage-error",
43663
+ message: badMessage,
43664
+ strict: strictArg
43665
+ };
43666
+ if (strictArg || loose.values.help === true || loose.positionals.length < 2) return {
43667
+ kind: "usage-error",
43668
+ message: `unknown option ${ignored[0]}`,
43669
+ strict: strictArg
43670
+ };
43671
+ values = loose.values;
43672
+ positionals = loose.positionals;
43673
+ }
43674
+ const strict = values.strict === true;
43675
+ if (values.help) return { kind: "help" };
43676
+ if (positionals.length < 2) return {
43677
+ kind: "usage-error",
43678
+ message: void 0,
43679
+ strict
43680
+ };
43681
+ let concurrency = 8;
43682
+ if (values.concurrency !== void 0) {
43683
+ if (typeof values.concurrency !== "string" || !/^[1-9]\d*$/.test(values.concurrency)) return {
43684
+ kind: "usage-error",
43685
+ message: `--concurrency must be a positive integer, got "${String(values.concurrency)}"`,
43686
+ strict
43687
+ };
43688
+ concurrency = Number.parseInt(values.concurrency, 10);
43689
+ }
43636
43690
  return {
43637
- transformName,
43638
- fileGlob,
43639
- dryRun,
43640
- concurrency: concurrencyFlag ? Number.parseInt(concurrencyFlag.split("=")[1], 10) : 8
43691
+ kind: "run",
43692
+ transformName: positionals[0],
43693
+ fileGlob: positionals[1],
43694
+ dryRun: values["dry-run"] === true,
43695
+ strict,
43696
+ concurrency,
43697
+ ignored
43641
43698
  };
43642
43699
  }
43700
+ function parseCli(argv) {
43701
+ return parseArgs({
43702
+ args: [...argv],
43703
+ options: OPTIONS,
43704
+ allowPositionals: true,
43705
+ strict: true
43706
+ });
43707
+ }
43643
43708
  async function* chunked(items, size) {
43644
43709
  for (let i = 0; i < items.length; i += size) yield items.slice(i, i + size);
43645
43710
  }
43646
43711
  async function processFile(filePath, transformFn, apiForParser, dryRun) {
43647
- const source = await readFile(filePath, "utf-8");
43648
- const parser = filePath.endsWith(".tsx") || filePath.endsWith(".jsx") ? "tsx" : "ts";
43649
- const result = transformFn({
43650
- path: filePath,
43651
- source
43652
- }, apiForParser(parser));
43653
- if (result !== source) {
43654
- if (!dryRun) await writeFile(filePath, result, "utf-8");
43712
+ try {
43713
+ const source = await readFile(filePath, "utf-8");
43714
+ const parser = filePath.endsWith(".tsx") || filePath.endsWith(".jsx") ? "tsx" : "ts";
43715
+ const result = transformFn({
43716
+ path: filePath,
43717
+ source
43718
+ }, apiForParser(parser));
43719
+ if (result !== source) {
43720
+ if (!dryRun) await writeFile(filePath, result, "utf-8");
43721
+ return {
43722
+ filePath,
43723
+ changed: true
43724
+ };
43725
+ }
43726
+ return {
43727
+ filePath,
43728
+ changed: false
43729
+ };
43730
+ } catch (err) {
43655
43731
  return {
43656
43732
  filePath,
43657
- changed: true
43733
+ changed: false,
43734
+ error: err instanceof Error ? err : new Error(String(err))
43658
43735
  };
43659
43736
  }
43660
- return {
43661
- filePath,
43662
- changed: false
43663
- };
43664
43737
  }
43665
- async function main() {
43666
- const cliArgs = parseArgs(process.argv);
43667
- if (!cliArgs) {
43668
- printUsage();
43669
- process.exit(0);
43670
- }
43671
- const { transformName, fileGlob, dryRun, concurrency } = cliArgs;
43672
- if (!AVAILABLE_TRANSFORMS.has(transformName)) process.exit(1);
43673
- const files = await globby([fileGlob], {
43674
- absolute: true,
43675
- ignore: [
43676
- "**/node_modules/**",
43677
- "**/dist/**",
43678
- "**/build/**",
43679
- "**/.next/**"
43680
- ]
43681
- });
43682
- if (files.length === 0) process.exit(1);
43683
- const transformFn = await loadTransform(transformName);
43684
- const apiForParser = (parser) => ({
43685
- jscodeshift: import_jscodeshift.default.withParser(parser),
43686
- j: import_jscodeshift.default.withParser(parser),
43687
- stats: () => {},
43688
- report: () => {}
43689
- });
43690
- let resolveDone;
43691
- const done = new Promise((r) => {
43692
- resolveDone = r;
43693
- });
43694
- const allResults = [];
43695
- for await (const batch of chunked(files, concurrency)) (await Promise.allSettled(batch.map((filePath) => processFile(filePath, transformFn, apiForParser, dryRun)))).values().forEach((result) => {
43696
- if (result.status === "fulfilled") allResults.push(result.value);
43697
- else allResults.push({
43698
- filePath: "(unknown)",
43699
- changed: false,
43700
- error: result.reason
43738
+ /**
43739
+ * Run the codemod CLI. Resolves to the process exit code; writes only to `io`.
43740
+ */
43741
+ async function run(argv, io, deps = {}) {
43742
+ const out = (s) => void io.stdout.write(`${s}\n`);
43743
+ const err = (s) => void io.stderr.write(`${s}\n`);
43744
+ const fail = (s) => err(`${styleText("red", "error:", { stream: io.stderr })} ${s}`);
43745
+ try {
43746
+ const parsed = parse(argv);
43747
+ if (parsed.kind === "help") {
43748
+ out(usageText(io.stdout));
43749
+ return 0;
43750
+ }
43751
+ if (parsed.kind === "usage-error") {
43752
+ if (parsed.message !== void 0) fail(parsed.message);
43753
+ err(usageText(io.stderr));
43754
+ return parsed.strict ? 2 : 0;
43755
+ }
43756
+ const { transformName, fileGlob, dryRun, strict, concurrency, ignored } = parsed;
43757
+ for (const name of ignored) err(`warning: unknown option ${name} ignored (--strict makes this exit 2)`);
43758
+ if (!AVAILABLE_TRANSFORMS.has(transformName)) {
43759
+ fail(`unknown transform "${transformName}". Valid transforms: ${[...AVAILABLE_TRANSFORMS.keys()].join(", ")}`);
43760
+ return strict ? 2 : 1;
43761
+ }
43762
+ const files = await globby([fileGlob], {
43763
+ absolute: true,
43764
+ ignore: [
43765
+ "**/node_modules/**",
43766
+ "**/dist/**",
43767
+ "**/build/**",
43768
+ "**/.next/**"
43769
+ ]
43701
43770
  });
43702
- });
43703
- resolveDone(allResults);
43704
- const grouped = objectGroupBy(await done, (r) => {
43705
- if (r.error) return "errors";
43706
- if (r.changed) return "changed";
43707
- return "unchanged";
43708
- });
43709
- const changedFiles = grouped.changed ?? [];
43710
- const errorFiles = grouped.errors ?? [];
43711
- changedFiles.values().forEach((r) => {
43712
- r.filePath.replace(`${resolve(".")}/`, "");
43713
- });
43714
- if (errorFiles.length > 0) errorFiles.values().forEach((_r) => {});
43715
- if (dryRun && changedFiles.length > 0) {}
43771
+ if (files.length === 0) {
43772
+ fail(`no files matched "${fileGlob}"`);
43773
+ return 1;
43774
+ }
43775
+ const transformFn = await (deps.loadTransform ?? loadTransform)(transformName);
43776
+ const apiForParser = (parser) => ({
43777
+ jscodeshift: import_jscodeshift.default.withParser(parser),
43778
+ j: import_jscodeshift.default.withParser(parser),
43779
+ stats: () => {},
43780
+ report: () => {}
43781
+ });
43782
+ const results = [];
43783
+ for await (const batch of chunked(files, concurrency)) results.push(...await Promise.all(batch.map((filePath) => processFile(filePath, transformFn, apiForParser, dryRun))));
43784
+ const rel = (p) => relative(process.cwd(), p).replaceAll("\\", "/");
43785
+ let changed = 0;
43786
+ let errors = 0;
43787
+ for (const r of results) if (r.error) {
43788
+ errors++;
43789
+ err(`${rel(r.filePath)}: ${r.error.message}`);
43790
+ } else if (r.changed) {
43791
+ changed++;
43792
+ out(`${styleText("green", dryRun ? "would change" : "changed", { stream: io.stdout })} ${rel(r.filePath)}`);
43793
+ }
43794
+ const unchanged = results.length - changed - errors;
43795
+ out(`${changed} changed, ${unchanged} unchanged, ${errors} errors${dryRun ? " (dry run, nothing written)" : ""}`);
43796
+ return strict && errors > 0 ? 1 : 0;
43797
+ } catch (e) {
43798
+ fail(e instanceof Error ? e.message : String(e));
43799
+ return 1;
43800
+ }
43716
43801
  }
43717
- main().catch((_err) => {
43718
- process.exit(1);
43719
- });
43720
- export {};
43802
+ if (import.meta.main) process.exitCode = await run(process.argv.slice(2), process);
43803
+ export { run };
@@ -14,7 +14,7 @@ const ATTR_MAP = /* @__PURE__ */ new Map([
14
14
  const CSS_VAR_MAP = /* @__PURE__ */ new Map([["--cmdk-list-height", "--command-list-height"]]);
15
15
  /**
16
16
  * Replace all cmdk attribute selectors (bracketed) and CSS vars in a string.
17
- * Uses ES2026 Set.union to combine both maps into a single pass.
17
+ * Runs replaceAll for each ATTR_MAP entry (bracketed selectors), then for each CSS_VAR_MAP entry.
18
18
  */
19
19
  function replaceInString(input) {
20
20
  let result = input;
@@ -2,7 +2,7 @@
2
2
  interface SearchResult {
3
3
  readonly id: ItemId;
4
4
  readonly score: number;
5
- /** Character ranges that matched (for highlighting) — [start, end] tuples */
5
+ /** Character ranges that matched (for highlighting) — half-open [start, end) tuples, end exclusive */
6
6
  readonly matches: ReadonlyArray<readonly [start: number, end: number]>;
7
7
  }
8
8
  /** A scoring function that evaluates a single item against a query */
@@ -13,7 +13,7 @@ interface SearchEngine extends Disposable {
13
13
  index(items: readonly CommandItem[]): void;
14
14
  /** Search items by query, return ordered results — uses Iterator pipeline */
15
15
  search(query: string, items: readonly CommandItem[]): IteratorObject<SearchResult>;
16
- /** Remove items from index — uses Set.difference for efficient bulk removal */
16
+ /** Remove items from index by id */
17
17
  remove(ids: ReadonlySet<ItemId>): void;
18
18
  /** Clear the entire index */
19
19
  clear(): void;
@@ -162,7 +162,7 @@ interface FrecencyStorage extends Disposable {
162
162
  load(namespace: string): FrecencyData | Promise<FrecencyData>;
163
163
  save(namespace: string, data: FrecencyData): void | Promise<void>;
164
164
  }
165
- /** Frecency exponential decay bucket configuration */
165
+ /** Frecency step-weight configuration per recency bucket (hour/day/week/month/older) */
166
166
  interface FrecencyDecayConfig {
167
167
  readonly hourWeight?: number | undefined;
168
168
  readonly dayWeight?: number | undefined;
@@ -179,7 +179,7 @@ interface FrecencyOptions {
179
179
  }
180
180
  /** Default frecency decay configuration */
181
181
  export declare const DEFAULT_FRECENCY_DECAY: Required<FrecencyDecayConfig>;
182
- /** Options for creating a command machine — const type param for narrowed option inference */
182
+ /** Options for creating a command machine */
183
183
  interface CommandMachineOptions {
184
184
  /** Pre-registered items to populate on creation */
185
185
  readonly items?: readonly CommandItem[] | undefined;
@@ -205,7 +205,7 @@ interface CommandMachineOptions {
205
205
  readonly onSearchChange?: ((query: string) => void) | undefined;
206
206
  /** Whether keyboard navigation loops from last to first item (default: true) */
207
207
  readonly loop?: boolean | undefined;
208
- /** Item count threshold for automatic virtualization (default: 100) */
208
+ /** Accepted but not read yet: Command.List uses fixed thresholds (on above 100 matches, off at 50 or fewer) (default: 100) */
209
209
  readonly virtualizeThreshold?: number | undefined;
210
210
  }
211
211
  /** Default machine options */
@@ -213,7 +213,7 @@ export declare const DEFAULT_MACHINE_OPTIONS: Partial<CommandMachineOptions>;
213
213
  /** Initial state factory */
214
214
  export declare function createInitialState(options?: CommandMachineOptions): CommandState;
215
215
  /**
216
- * IndexedDB-backed frecency storage using idb-keyval 6.2.2.
216
+ * IndexedDB-backed frecency storage using idb-keyval.
217
217
  *
218
218
  * Persists frecency data across browser sessions. Implements both
219
219
  * `Disposable` and `AsyncDisposable` for use with `using` and `await using`.
@@ -250,7 +250,7 @@ export declare class IdbFrecencyStorage implements FrecencyStorage, AsyncDisposa
250
250
  /**
251
251
  * Computes a frecency bonus for an item based on usage frequency and recency.
252
252
  * Uses hour/day/week/month bucket boundaries with
253
- * configurable exponential decay weights per time bucket.
253
+ * configurable step weights per recency bucket (hour/day/week/month/older).
254
254
  */
255
255
  export declare function computeFrecencyBonus(history: FrecencyRecord, now?: number, config?: FrecencyDecayConfig): number;
256
256
  /**
@@ -290,7 +290,7 @@ export declare class MemoryFrecencyStorage implements FrecencyStorage {
290
290
  #private;
291
291
  load(namespace: string): FrecencyData;
292
292
  save(namespace: string, data: FrecencyData): void;
293
- /** Serialize all stored data — Iterator Helpers pipeline (ES2026) */
293
+ /** Serialize all stored data — Iterator Helpers pipeline (ES2025) */
294
294
  toJSON(): Record<string, {
295
295
  records: Array<[string, FrecencyRecord]>;
296
296
  }>;
@@ -340,7 +340,7 @@ interface ShortcutBinding {
340
340
  /**
341
341
  * Manages keyboard shortcut bindings with global keydown listening.
342
342
  * Supports `using` for automatic cleanup of both individual bindings and the
343
- * global document listener. Detects conflicts via Map.groupBy (ES2026).
343
+ * global document listener. Detects conflicts with the local mapGroupBy helper (core/utils/group-by.ts).
344
344
  */
345
345
  export declare class KeyboardShortcutRegistry implements Disposable {
346
346
  #private;
@@ -466,7 +466,7 @@ interface SearchEngineOptions {
466
466
  * Creates a search engine with pluggable scorer and incremental filtering.
467
467
  * When the query extends the previous one, only prior matches are re-scored.
468
468
  * Pre-caches lowercase at index time — zero toLowerCase() calls during search.
469
- * Uses Iterator Helpers (ES2026) for result pipelines and Set.difference for pruning.
469
+ * Uses Iterator Helpers (ES2025) for result pipelines.
470
470
  */
471
471
  export declare function createSearchEngine(options?: SearchEngineOptions): SearchEngine;
472
472
  interface CommandTelemetryHooks {
@@ -44,7 +44,7 @@ function storageKey(namespace) {
44
44
  }
45
45
  let idbModule;
46
46
  /**
47
- * IndexedDB-backed frecency storage using idb-keyval 6.2.2.
47
+ * IndexedDB-backed frecency storage using idb-keyval.
48
48
  *
49
49
  * Persists frecency data across browser sessions. Implements both
50
50
  * `Disposable` and `AsyncDisposable` for use with `using` and `await using`.
@@ -170,7 +170,7 @@ var MemoryFrecencyStorage = class {
170
170
  save(namespace, data) {
171
171
  this.#store.set(namespace, data);
172
172
  }
173
- /** Serialize all stored data — Iterator Helpers pipeline (ES2026) */
173
+ /** Serialize all stored data — Iterator Helpers pipeline (ES2025) */
174
174
  toJSON() {
175
175
  return Object.fromEntries(this.#store.entries().map(([namespace, data]) => [namespace, { records: data.records.entries().toArray() }]));
176
176
  }
@@ -181,7 +181,7 @@ var MemoryFrecencyStorage = class {
181
181
  // src/core/frecency/index.ts
182
182
  /** Shared empty bonus map — avoids allocation when no frecency records exist */
183
183
  const EMPTY_BONUS_MAP = /* @__PURE__ */ new Map();
184
- /** Pre-computed bucket boundaries in hours — avoids Duration.total() per call */
184
+ /** Bucket boundaries as hour constants — no per-call unit conversion */
185
185
  const BUCKET_HOURS = {
186
186
  hour: 1,
187
187
  day: 24,
@@ -191,7 +191,7 @@ const BUCKET_HOURS = {
191
191
  /**
192
192
  * Computes a frecency bonus for an item based on usage frequency and recency.
193
193
  * Uses hour/day/week/month bucket boundaries with
194
- * configurable exponential decay weights per time bucket.
194
+ * configurable step weights per recency bucket (hour/day/week/month/older).
195
195
  */
196
196
  function computeFrecencyBonus(history, now = Date.now(), config = DEFAULT_FRECENCY_DECAY) {
197
197
  const hours = (now - history.lastUsed) / 36e5;
@@ -397,7 +397,7 @@ function formatShortcut(parsed) {
397
397
  /**
398
398
  * Manages keyboard shortcut bindings with global keydown listening.
399
399
  * Supports `using` for automatic cleanup of both individual bindings and the
400
- * global document listener. Detects conflicts via Map.groupBy (ES2026).
400
+ * global document listener. Detects conflicts with the local mapGroupBy helper (core/utils/group-by.ts).
401
401
  */
402
402
  var KeyboardShortcutRegistry = class {
403
403
  #bindings = /* @__PURE__ */ new Map();
@@ -855,7 +855,7 @@ if (isDev && typeof window !== "undefined") leakRegistry = new FinalizationRegis
855
855
  * Creates a search engine with pluggable scorer and incremental filtering.
856
856
  * When the query extends the previous one, only prior matches are re-scored.
857
857
  * Pre-caches lowercase at index time — zero toLowerCase() calls during search.
858
- * Uses Iterator Helpers (ES2026) for result pipelines and Set.difference for pruning.
858
+ * Uses Iterator Helpers (ES2025) for result pipelines.
859
859
  */
860
860
  function createSearchEngine(options) {
861
861
  const customScorer = options?.scorer;
@@ -964,9 +964,10 @@ var TypedEmitter = class {
964
964
  // src/core/utils/scheduler.ts
965
965
  /** Whether we're in a browser environment with rAF available */
966
966
  const hasRAF = typeof requestAnimationFrame === "function";
967
- /** Feature detection for cutting-edge scheduling APIs */
967
+ /** Feature detection for cutting-edge scheduling APIs, read through globalThis: typeof guards only a bare
968
+ * identifier, so `typeof navigator?.scheduling` would throw where navigator is undeclared (runtimes without one). */
968
969
  const canYield = typeof globalThis.scheduler?.yield === "function";
969
- const hasInputPending = typeof navigator?.scheduling?.isInputPending === "function";
970
+ const hasInputPending = typeof globalThis.navigator?.scheduling?.isInputPending === "function";
970
971
  /**
971
972
  * Creates a batched update scheduler. Coalesces rapid state updates into a single
972
973
  * execution pass using rAF (browser) or queueMicrotask (Node.js).
@@ -18,7 +18,7 @@ export declare function CommandActivity({ mode, children }: CommandActivityProps
18
18
  interface SearchResult {
19
19
  readonly id: ItemId;
20
20
  readonly score: number;
21
- /** Character ranges that matched (for highlighting) — [start, end] tuples */
21
+ /** Character ranges that matched (for highlighting) — half-open [start, end) tuples, end exclusive */
22
22
  readonly matches: ReadonlyArray<readonly [start: number, end: number]>;
23
23
  }
24
24
  /** Search engine interface — pluggable into the state machine */
@@ -27,7 +27,7 @@ interface SearchEngine extends Disposable {
27
27
  index(items: readonly CommandItem$1[]): void;
28
28
  /** Search items by query, return ordered results — uses Iterator pipeline */
29
29
  search(query: string, items: readonly CommandItem$1[]): IteratorObject<SearchResult>;
30
- /** Remove items from index — uses Set.difference for efficient bulk removal */
30
+ /** Remove items from index by id */
31
31
  remove(ids: ReadonlySet<ItemId>): void;
32
32
  /** Clear the entire index */
33
33
  clear(): void;
@@ -172,7 +172,7 @@ interface FrecencyStorage extends Disposable {
172
172
  load(namespace: string): FrecencyData | Promise<FrecencyData>;
173
173
  save(namespace: string, data: FrecencyData): void | Promise<void>;
174
174
  }
175
- /** Frecency exponential decay bucket configuration */
175
+ /** Frecency step-weight configuration per recency bucket (hour/day/week/month/older) */
176
176
  interface FrecencyDecayConfig {
177
177
  readonly hourWeight?: number | undefined;
178
178
  readonly dayWeight?: number | undefined;
@@ -187,7 +187,7 @@ interface FrecencyOptions {
187
187
  readonly namespace?: string | undefined;
188
188
  readonly decayConfig?: FrecencyDecayConfig | undefined;
189
189
  }
190
- /** Options for creating a command machine — const type param for narrowed option inference */
190
+ /** Options for creating a command machine */
191
191
  interface CommandMachineOptions {
192
192
  /** Pre-registered items to populate on creation */
193
193
  readonly items?: readonly CommandItem$1[] | undefined;
@@ -213,7 +213,7 @@ interface CommandMachineOptions {
213
213
  readonly onSearchChange?: ((query: string) => void) | undefined;
214
214
  /** Whether keyboard navigation loops from last to first item (default: true) */
215
215
  readonly loop?: boolean | undefined;
216
- /** Item count threshold for automatic virtualization (default: 100) */
216
+ /** Accepted but not read yet: Command.List uses fixed thresholds (on above 100 matches, off at 50 or fewer) (default: 100) */
217
217
  readonly virtualizeThreshold?: number | undefined;
218
218
  }
219
219
  /**
@@ -237,7 +237,7 @@ interface ShortcutBinding {
237
237
  /**
238
238
  * Manages keyboard shortcut bindings with global keydown listening.
239
239
  * Supports `using` for automatic cleanup of both individual bindings and the
240
- * global document listener. Detects conflicts via Map.groupBy (ES2026).
240
+ * global document listener. Detects conflicts with the local mapGroupBy helper (core/utils/group-by.ts).
241
241
  */
242
242
  declare class KeyboardShortcutRegistry implements Disposable {
243
243
  #private;
@@ -371,7 +371,7 @@ export declare function CommandGroup({ ref, children, heading, priority, forceId
371
371
  interface CommandHighlightProps {
372
372
  /** The full text to display */
373
373
  readonly text: string;
374
- /** Match ranges from search scorer — [start, end] tuples */
374
+ /** Match ranges from the search scorer — half-open [start, end) tuples, end exclusive */
375
375
  readonly ranges: ReadonlyArray<readonly [start: number, end: number]>;
376
376
  /** Custom className for highlighted segments */
377
377
  readonly highlightClassName?: string;
@@ -463,6 +463,7 @@ type CommandRootId = string & {
463
463
  interface CommandContextValue {
464
464
  readonly machine: CommandMachine;
465
465
  readonly state: CommandState;
466
+ /** True while an optimistic active-item transition is in flight (search updates never set it). */
466
467
  readonly isPending: boolean;
467
468
  readonly updateSearch: (query: string) => void;
468
469
  readonly setOptimisticActiveId: (id: ItemId | null) => void;
@@ -490,6 +491,7 @@ interface CommandStableContextValue {
490
491
  /** State context — values that change on every search/navigation */
491
492
  interface CommandStateContextValue {
492
493
  readonly state: CommandState;
494
+ /** True while an optimistic active-item transition is in flight (search updates never set it). */
493
495
  readonly isPending: boolean;
494
496
  readonly filteredIdSet: ReadonlySet<ItemId>;
495
497
  /** IDs currently visible in virtual viewport + overscan. null = not virtualizing (render all filtered). */
@@ -520,14 +522,16 @@ export declare class CommandErrorBoundary extends Component<CommandErrorBoundary
520
522
  }
521
523
  interface UseCommandReturn {
522
524
  readonly state: CommandState;
525
+ /** True while an optimistic active-item transition is in flight. Search updates never set it: the machine applies them on its own scheduler tick, outside React. */
523
526
  readonly isPending: boolean;
527
+ /** Queue a search query on the machine. Not a React update: Command.Input owns the urgent input value. */
524
528
  readonly updateSearch: (query: string) => void;
525
529
  readonly setOptimisticActiveId: (id: ItemId | null) => void;
526
530
  /** O(1) membership set for filtered items — used by Item component */
527
531
  readonly filteredIdSet: ReadonlySet<ItemId>;
528
532
  readonly id: CommandRootId;
529
533
  }
530
- /** Core hook that binds a CommandMachine to React via useSyncExternalStore, useTransition, and useOptimistic. */
534
+ /** Core hook that binds a CommandMachine to React via useSyncExternalStore, useOptimistic, and a transition for optimistic active-item updates. */
531
535
  export declare function useCommand(machine: CommandMachine): UseCommandReturn;
532
536
  /** Get the full command state with proper subscription */
533
537
  export declare function useCommandState(): CommandState;
@@ -139,7 +139,7 @@ var MemoryFrecencyStorage = class {
139
139
  save(namespace, data) {
140
140
  this.#store.set(namespace, data);
141
141
  }
142
- /** Serialize all stored data — Iterator Helpers pipeline (ES2026) */
142
+ /** Serialize all stored data — Iterator Helpers pipeline (ES2025) */
143
143
  toJSON() {
144
144
  return Object.fromEntries(this.#store.entries().map(([namespace, data]) => [namespace, { records: data.records.entries().toArray() }]));
145
145
  }
@@ -150,7 +150,7 @@ var MemoryFrecencyStorage = class {
150
150
  // src/core/frecency/index.ts
151
151
  /** Shared empty bonus map — avoids allocation when no frecency records exist */
152
152
  const EMPTY_BONUS_MAP = /* @__PURE__ */ new Map();
153
- /** Pre-computed bucket boundaries in hours — avoids Duration.total() per call */
153
+ /** Bucket boundaries as hour constants — no per-call unit conversion */
154
154
  const BUCKET_HOURS = {
155
155
  hour: 1,
156
156
  day: 24,
@@ -160,7 +160,7 @@ const BUCKET_HOURS = {
160
160
  /**
161
161
  * Computes a frecency bonus for an item based on usage frequency and recency.
162
162
  * Uses hour/day/week/month bucket boundaries with
163
- * configurable exponential decay weights per time bucket.
163
+ * configurable step weights per recency bucket (hour/day/week/month/older).
164
164
  */
165
165
  function computeFrecencyBonus(history, now = Date.now(), config = DEFAULT_FRECENCY_DECAY) {
166
166
  const hours = (now - history.lastUsed) / 36e5;
@@ -362,7 +362,7 @@ function formatShortcut(parsed) {
362
362
  /**
363
363
  * Manages keyboard shortcut bindings with global keydown listening.
364
364
  * Supports `using` for automatic cleanup of both individual bindings and the
365
- * global document listener. Detects conflicts via Map.groupBy (ES2026).
365
+ * global document listener. Detects conflicts with the local mapGroupBy helper (core/utils/group-by.ts).
366
366
  */
367
367
  var KeyboardShortcutRegistry = class {
368
368
  #bindings = /* @__PURE__ */ new Map();
@@ -780,7 +780,7 @@ if (isDev && typeof window !== "undefined") leakRegistry = new FinalizationRegis
780
780
  * Creates a search engine with pluggable scorer and incremental filtering.
781
781
  * When the query extends the previous one, only prior matches are re-scored.
782
782
  * Pre-caches lowercase at index time — zero toLowerCase() calls during search.
783
- * Uses Iterator Helpers (ES2026) for result pipelines and Set.difference for pruning.
783
+ * Uses Iterator Helpers (ES2025) for result pipelines.
784
784
  */
785
785
  function createSearchEngine(options) {
786
786
  const customScorer = options?.scorer;
@@ -889,9 +889,10 @@ var TypedEmitter = class {
889
889
  // src/core/utils/scheduler.ts
890
890
  /** Whether we're in a browser environment with rAF available */
891
891
  const hasRAF = typeof requestAnimationFrame === "function";
892
- /** Feature detection for cutting-edge scheduling APIs */
892
+ /** Feature detection for cutting-edge scheduling APIs, read through globalThis: typeof guards only a bare
893
+ * identifier, so `typeof navigator?.scheduling` would throw where navigator is undeclared (runtimes without one). */
893
894
  const canYield = typeof globalThis.scheduler?.yield === "function";
894
- const hasInputPending = typeof navigator?.scheduling?.isInputPending === "function";
895
+ const hasInputPending = typeof globalThis.navigator?.scheduling?.isInputPending === "function";
895
896
  /**
896
897
  * Creates a batched update scheduler. Coalesces rapid state updates into a single
897
898
  * execution pass using rAF (browser) or queueMicrotask (Node.js).
@@ -1214,7 +1215,7 @@ function createCommandMachine(options = {}) {
1214
1215
  };
1215
1216
  }
1216
1217
  // src/react/hooks/use-command.ts
1217
- /** Core hook that binds a CommandMachine to React via useSyncExternalStore, useTransition, and useOptimistic. */
1218
+ /** Core hook that binds a CommandMachine to React via useSyncExternalStore, useOptimistic, and a transition for optimistic active-item updates. */
1218
1219
  function useCommand(machine) {
1219
1220
  const id = useId();
1220
1221
  const [isPending, startTransition] = useTransition();
@@ -1227,11 +1228,9 @@ function useCommand(machine) {
1227
1228
  });
1228
1229
  }, [rawSetOptimisticActiveId]);
1229
1230
  const updateSearch = useCallback((query) => {
1230
- startTransition(() => {
1231
- machine.send({
1232
- type: "SEARCH_CHANGE",
1233
- query
1234
- });
1231
+ machine.send({
1232
+ type: "SEARCH_CHANGE",
1233
+ query
1235
1234
  });
1236
1235
  }, [machine]);
1237
1236
  return {
@@ -1587,8 +1586,22 @@ function CommandHighlight({ text, ranges, highlightClassName }) {
1587
1586
  function CommandInput({ ref, placeholder = "Search...", onValueChange, ...props }) {
1588
1587
  const stable = useStableContext("Command.Input");
1589
1588
  const stateCtx = useStateContext("Command.Input");
1589
+ const machineState = useSyncExternalStore(stable.machine.subscribe, stable.machine.getState, stable.machine.getState);
1590
+ const [draft, setDraft] = useState(machineState.search);
1591
+ const inFlight = useRef([]);
1592
+ useLayoutEffect(() => {
1593
+ const { search } = machineState;
1594
+ const echoed = inFlight.current.lastIndexOf(search);
1595
+ if (echoed !== -1) inFlight.current.splice(0, echoed + 1);
1596
+ else {
1597
+ inFlight.current = [];
1598
+ setDraft(search);
1599
+ }
1600
+ }, [machineState]);
1590
1601
  const handleChange = useCallback((event) => {
1591
1602
  const { value } = event.target;
1603
+ inFlight.current.push(value);
1604
+ setDraft(value);
1592
1605
  stable.updateSearch(value);
1593
1606
  onValueChange?.(value);
1594
1607
  }, [stable.updateSearch, onValueChange]);
@@ -1607,7 +1620,7 @@ function CommandInput({ ref, placeholder = "Search...", onValueChange, ...props
1607
1620
  autoCorrect: "off",
1608
1621
  spellCheck: false,
1609
1622
  placeholder,
1610
- value: stateCtx.state.search,
1623
+ value: draft,
1611
1624
  onChange: handleChange,
1612
1625
  ...props
1613
1626
  });
@@ -6,6 +6,12 @@
6
6
  /* CSS Logical Properties: full RTL/LTR support */
7
7
  /* CSS Nesting, @layer cascade control, oklch() color space */
8
8
  /* Container queries for responsive item layout */
9
+ /* Secondary text (group headings, shortcuts, empty state, placeholder) is coloured with
10
+ --command-muted-color, never faded with opacity: opacity stacks on the consumer's text
11
+ colour and cannot be checked against it. The default is light-dark(), so it follows the
12
+ used color-scheme of the page (set color-scheme on :root or a parent, or override the
13
+ token); it is 4.5:1 or better on white, black and the usual near-white / near-black
14
+ surfaces. Only [data-disabled] items keep opacity (WCAG 1.4.3 exempts disabled controls). */
9
15
 
10
16
  /* ─── CSS Custom Properties ─── */
11
17
 
@@ -40,7 +46,7 @@
40
46
  }
41
47
 
42
48
  /* ─── User-Customizable Animation Properties ─── */
43
- /* Override these on [data-command-root] or :root to customize all animations */
49
+ /* Override these with unlayered CSS on [data-command-root] loaded after this file, or on a more specific unlayered selector; this block sits outside every @layer, so a normal (non-!important) value set inside a layer loses here whatever its selector, and a :root value is shadowed by this declaration */
44
50
 
45
51
  [data-command-root] {
46
52
  --command-spring-easing: linear(
@@ -99,10 +105,11 @@
99
105
  --command-item-height: 44px;
100
106
  --command-fade-edge-size: 16px;
101
107
  --command-highlight-color: oklch(0.85 0.15 90 / 0.3);
108
+ --command-muted-color: light-dark(oklch(0.45 0.015 260), oklch(0.75 0.015 260));
102
109
  }
103
110
 
104
111
  /* ─── Spring Easing ─── */
105
- /* linear() approximation of a critically-damped spring (stiffness: 100, damping: 15) */
112
+ /* linear() spring approximations: --command-spring-easing settles without overshoot; --command-spring-easing-bouncy peaks at 1.033 before settling */
106
113
  /* Generated from spring physics: x(t) = 1 - e^(-zt)(cos(wd*t) + (zw/wd)sin(wd*t)) */
107
114
 
108
115
  @layer command.base, command.layout, command.animation, command.a11y;
@@ -173,7 +180,9 @@
173
180
  font: inherit;
174
181
 
175
182
  &::placeholder {
176
- opacity: 0.5;
183
+ color: var(--command-muted-color);
184
+ /* Firefox fades ::placeholder by default; the colour token carries the contrast */
185
+ opacity: 1;
177
186
  }
178
187
  }
179
188
 
@@ -232,7 +241,7 @@
232
241
  font-size: 0.75em;
233
242
  text-transform: uppercase;
234
243
  letter-spacing: 0.05em;
235
- opacity: 0.6;
244
+ color: var(--command-muted-color);
236
245
  }
237
246
 
238
247
  /* ─── Separator ─── */
@@ -255,7 +264,7 @@
255
264
 
256
265
  [data-command-empty] {
257
266
  text-align: center;
258
- opacity: 0.6;
267
+ color: var(--command-muted-color);
259
268
  }
260
269
 
261
270
  /* ─── Shortcut Badge ─── */
@@ -263,7 +272,7 @@
263
272
  [data-command-shortcut] {
264
273
  font-family: inherit;
265
274
  font-size: 0.75em;
266
- opacity: 0.5;
275
+ color: var(--command-muted-color);
267
276
  letter-spacing: 0.05em;
268
277
  }
269
278
 
@@ -543,7 +552,7 @@
543
552
  }
544
553
 
545
554
  [data-command-group-heading] {
546
- opacity: 0.8;
555
+ color: inherit;
547
556
  }
548
557
 
549
558
  [data-command-highlight] {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modern-cmdk",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "The definitive command palette engine for the modern web",
6
6
  "license": "MIT",