# Fuzzy Omarchy Menu MVP Plan ## Implementation status Automated MVP development is complete as of `2026-08-19`: - Phases A–D are complete. - Phase F documentation and upstream-diff review are complete. - The matcher passes its unit suite and plugin/QML validation. - The 2,000-entry benchmark records a worst average below 10 ms and worst p95 below 12 ms on the development machine. - Phase E activation and interactive MVP verification were completed together with the user. The runtime copy is staged at `~/.config/omarchy/plugins/kailbert.omascope` and can be disabled to restore the built-in menu. Post-MVP visual and ranking enhancements were started from preserved commit `3b81bbe`. They add match highlighting, result metadata, conservative local usage tie-breaking, and restrained theme-aware geometry changes without adding a density configuration system. ## 1. Goal Build a drop-in replacement for Omarchy's `omarchy.menu` plugin that keeps the existing launcher UI and behavior while improving search in two distinct ways: 1. **fzf-style fuzzy matching:** ordered characters may contain gaps, so a query such as `frfx` finds `Firefox`. 2. **Conservative typo tolerance:** a small edit or adjacent transposition may still match, so `firefix` and `fierfox` can find `Firefox`. The MVP should feel forgiving without filling the result list with unrelated items. Exact matches must remain faster to recognize and rank above fuzzy ones. ## 2. Baseline and observed limitation The project starts from the built-in `omarchy.menu` shipped with Omarchy `4.0.0-1`. Current search behavior lives primarily in `MenuModel.js`: - `matchesQuery()` requires each query term to be a literal substring of the label, leaf ID, or aliases, or an exact whole word in the description. - `searchScore()` ranks only entries that passed that strict filter. - Application entries are merged into the menu and then pass through the same strict menu matcher. - Dmenu/select mode has a separate literal-substring filter in `Menu.qml`. Omarchy also ships `services/AppSearch.js`, but its `fuzzyScore()` still uses a strict pre-filter and the menu currently imports application rows with an empty query. It is therefore not sufficient for the combined launcher search. ## 3. MVP scope ### Included - Fuzzy matching for normal Omarchy menu search. - The same matching behavior for installed application rows shown in the menu. - Multiword queries where every term must independently match some searchable field. - Ranking that strongly favors exact, prefix, substring, and word-boundary matches before subsequence or typo matches. - Damerau-style adjacent-transposition support for common typing mistakes. - Unit tests for the pure JavaScript matching and ranking code. - Manual verification in the live Quickshell menu. - A clean activation and rollback workflow. ### Excluded from the MVP - Semantic search, synonyms, or AI-assisted matching. - Searching file contents, browser history, or web results. - Replacing the UI or changing keyboard navigation. - Calling the external `fzf` executable for each keystroke. - Highlighting individual matched characters in result rows. - Changing generic dmenu/select mode unless it can reuse the matcher without risking its stable-selection behavior. - Marketplace publication; the MVP only needs to be locally installable. ## 4. Matching behavior specification ### 4.1 Searchable fields Keep the existing fields and treat them with different weights: 1. visible label — highest weight; 2. leaf portion of the menu ID; 3. aliases, application generic name, and desktop keywords; 4. description — lowest weight. Normalize values by lowercasing and treating `.`, `_`, `-`, `/`, and camel-case boundaries as word boundaries. Preserve the original entry for rendering. ### 4.2 Per-term match tiers Return a structured result such as `{ matched, score, tier }` rather than a boolean. Lower scores are better, matching the existing menu sort direction. Suggested tiers: | Tier | Example | Relative priority | | --- | --- | --- | | Exact label/word | `firefox` → Firefox | Best | | Label prefix | `fire` → Firefox | Very high | | Word-boundary prefix | `term` → Ghostty Terminal | High | | Literal substring | `fox` → Firefox | High | | Acronym/subsequence | `frfx` → Firefox | Medium | | One-edit typo | `firefix` → Firefox | Lower | | Two-edit typo | long queries only | Lowest accepted | Apply bonuses for consecutive characters and word-boundary starts. Apply penalties for gaps, later start positions, field priority, edit count, and excess candidate length. ### 4.3 Typo thresholds Use deliberately narrow limits to avoid noisy results: - query length 1–3: no edit-distance matching; - query length 4–7: at most one edit; - query length 8 or more: at most two edits, plus a normalized-distance limit; - adjacent transposition counts as one edit; - compare typo terms against individual words, not an entire long description; - cap candidate word length relative to query length before computing distance. These thresholds are initial values and may be tuned from interactive testing. ### 4.4 Multiword behavior Split the normalized query into non-empty terms. Every term must match at least one field, but different terms may match different fields. Sum the best score for each term and retain existing menu depth/order as deterministic tiebreakers. Example: `fire brow` should match an application whose label is `Firefox` and generic name or description contains `Web Browser`. ### 4.5 Result ordering invariants - Existing exact matches must not rank worse than they do today. - A direct application-name match should beat an unrelated menu command. - Current-menu entries should retain their current advantage over drill-down entries when match quality is otherwise equal. - Sorting must be deterministic across rebuilds. - Empty search must keep the current menu ordering unchanged. ## 5. Proposed implementation Keep the matching engine in `MenuModel.js` so it remains pure JavaScript and testable with Node without starting Quickshell. Add small focused functions, likely: - `normalizeSearchText(value)` - `searchWords(value)` - `subsequenceScore(query, candidate)` - `boundedDamerauLevenshtein(query, candidate, maxDistance)` - `termMatchScore(term, candidate, fieldWeight)` - `entryMatch(entry, query)` Then: 1. Change `matchesQuery()` to delegate to `entryMatch()` and return its `matched` value for compatibility. 2. Change `searchScore()` to use the calculated fuzzy score while preserving depth, kind, and order tiebreakers. 3. Avoid calculating the same entry/query result twice during one display rebuild. Either expose one score function whose negative result means “no match,” or cache by query and entry ID in `Menu.qml`. 4. Leave `Menu.qml` UI and keyboard handling unchanged unless a small cache integration is required. 5. Decide after normal-menu testing whether dmenu mode should reuse a simplified matcher. Keeping it literal is acceptable for the MVP. Do not spawn `fzf` as a subprocess. The launcher rebuilds on every keystroke; in-process matching avoids process latency, quoting concerns, and asynchronous result races. ## 6. Work phases ### Phase A — Characterization tests - Create `tests/menu-search.test.js` using Node's built-in `node:test` and `node:assert` modules, requiring no extra dependencies. - Capture current exact-match and ordering behavior before changing code. - Build representative fixtures for menu commands and application rows. - Confirm the baseline tests run with `node --test`. Deliverable: tests that describe what must remain stable and currently failing tests for the desired fuzzy cases. ### Phase B — Subsequence matcher - Implement normalization and fzf-style ordered-subsequence scoring. - Add bonuses for word boundaries and consecutive runs. - Add gap and late-start penalties. - Integrate it below exact/prefix/substring tiers. - Verify `frfx`, `gty`, and similar abbreviated queries. Deliverable: all exact and subsequence tests pass. ### Phase C — Typo tolerance - Implement bounded Damerau–Levenshtein distance with early exit. - Apply length-based thresholds and compare against individual candidate words. - Integrate typo matches below subsequence matches. - Test substitutions, omissions, insertions, and adjacent transpositions. - Add negative tests for short/noisy queries. Deliverable: common misspellings work without broad false positives. ### Phase D — Ranking and performance - Combine field weights and match tiers into one deterministic score. - Preserve app/menu/depth/order tiebreak behavior. - Avoid duplicate score calculation per entry and query. - Benchmark a synthetic list of at least 2,000 entries over representative queries. Target a search rebuild comfortably below 16 ms on this machine, with no visible input lag. Deliverable: stable result ordering and acceptable per-keystroke latency. ### Phase E — Live integration - Symlink the repository into the user plugin directory under its manifest ID. - Rescan plugins, confirm discovery, and enable `kailbert.omascope`. - Verify Omarchy routes the stable `omarchy.menu` summon command to the clone. - Exercise opening, closing, navigation, app launch, menu actions, providers, dmenu/select mode, and the bar widget. - Inspect Quickshell logs for QML or JavaScript errors. - Remove/disable the clone and confirm the built-in menu is restored. Deliverable: the MVP works interactively and rollback is proven. ### Phase F — MVP cleanup - Document behavior, limitations, installation, and removal in `README.md`. - Record the upstream Omarchy version used for the clone. - Review the diff against `/usr/share/omarchy/shell/plugins/menu/` so the fuzzy implementation remains isolated and future rebases are understandable. - Tag the working state as `v0.1.0` only after all acceptance criteria pass. ## 7. Test matrix ### Positive cases - Exact: `firefox` → Firefox. - Prefix: `fire` → Firefox. - Substring: `fox` → Firefox. - Subsequence: `frfx` → Firefox. - Omission: `firfox` → Firefox. - Substitution: `firefix` → Firefox. - Transposition: `fierfox` → Firefox. - Acronym: `sys inf` → System Information where fields support it. - Multiword across fields: label term plus description/alias term. - Punctuation/case normalization. ### Negative cases - Two-character typo queries do not expand into many unrelated results. - A distant edit-distance candidate is rejected. - Query terms cannot match in reverse order within one subsequence. - Hidden and invisible entries remain excluded. - Empty search produces the original ordering. ### Regression cases - Exact menu commands and installed apps still open correctly. - Search within a submenu remains scoped to its descendants. - Current rows and drill-down rows retain their divider behavior. - Checked/conditional/provider entries continue to update. - Delete/uninstall behavior for application rows is unchanged. - The footer shows the Nerd Font Enter hint for the current primary action, exposes Delete only for application rows, and leaves its left side free for a future actions submenu. - Escape, arrows, Enter, Backspace, mouse selection, and summon routes work. - Dmenu/select/input modes retain their output and cancellation semantics. ## 8. Acceptance criteria The MVP is complete when all of the following are true: - `frfx`, `firfox`, `firefix`, and `fierfox` find Firefox when it is installed. - Exact and prefix matches consistently rank above fuzzy/typo matches. - Short queries do not create an obviously noisy result list. - Multiword queries require every term to match. - Empty-query ordering and normal navigation match the built-in menu. - Automated matching tests pass with only the system Node installation. - A 2,000-entry benchmark shows no visible typing lag. - The plugin can replace `omarchy.menu` and be rolled back cleanly. - Quickshell logs contain no new warnings or errors during the manual test. ## 9. Development activation and rollback Do not activate the scaffold before Phase E. When ready, use a symlink so edits in this repository hot-reload: ```bash ln -s "$HOME/projects/omarchy-fuzzy-launcher" \ "$HOME/.config/omarchy/plugins/kailbert.omascope" omarchy-shell shell rescanPlugins omarchy plugin enable kailbert.omascope ``` Enabling a plugin whose manifest declares `omarchy.clonedFrom: omarchy.menu` should disable/replace the built-in through Omarchy's normal plugin registry. Rollback: ```bash omarchy plugin disable kailbert.omascope ``` After confirming the built-in menu is restored, the development symlink may be removed manually. Never edit `/usr/share/omarchy/`; it remains the read-only upstream baseline. ## 10. Risks and mitigations - **False positives:** use conservative edit limits and disable typo expansion for short queries. - **Input lag:** use bounded algorithms, early exits, candidate-length checks, and one score calculation per entry/query. - **Ranking regressions:** characterize existing ordering before implementation and retain deterministic tiebreakers. - **Upstream drift:** keep fuzzy changes concentrated in `MenuModel.js`, record the upstream version, and compare against the packaged plugin after updates. - **Clone routing mistakes:** retain internal `omarchy.menu` IPC identifiers; only the manifest ID is namespaced. - **Shared-process failure:** malformed QML/JS can affect the long-running shell, so run pure-JS tests before enabling and inspect shell logs immediately. ## 11. First implementation session Start with Phase A and B: 1. Add the test fixtures and characterization tests. 2. Make the desired subsequence tests fail for the expected reason. 3. Implement normalization and `subsequenceScore()` in `MenuModel.js`. 4. Integrate subsequence matching beneath existing literal tiers. 5. Run unit tests and a small benchmark. This produces a useful first vertical slice without yet introducing the more error-prone edit-distance behavior.