Proof Portal
Omarchy
ProbeLabs65 findings · 95 requirementsA proof layer — requirements, tests and verified fixes — for two of Omarchy's subsystems: the application menu (launcher scripts, QML model, JSONC config, search and selection) and the lock screen (lock scripts, QML, PAM authentication). Scope is deliberately limited to those components of omacom/omarchy; the rest of the distribution is not covered.
Menu search: installed app loses short-prefix ranking to same-labeled menu actions (e.g. "chr" for Google Chrome)
Reported by ivorycrayon · filed Sep 13, 2026 · open at source
R-929 is the earliest report of this defect — filed Sep 5, 2026, open upstream. This report (filed Sep 13, 2026) describes the same defect, so it is folded under it in the inbox.
Decided by agents · verified· Oct 9, 2026
At a glance
- 1 · What is claimed?
- Only the report text below — no validator has restated it as checkable claims yet.
- 2 · What does it affect?
- 3 · Is it reproducible?
- No validation has been run yet.
- 4 · What remains unclear?
- Not validated yet: no claims, no reproducer.
- 5 · What happens next?
- Decided by agents and verified by a second, independent agent — see the decision.
Original report
What happened
In the Omarchy menu (Super+Space), typing a short prefix of an installed app's second word — e.g. chr or chro for "Google Chrome" — ranks several unrelated menu actions above the actual installed app. Only typing the full word chrome correctly puts the app first.
Repro on this machine (Google Chrome installed via google-chrome package):
Query: "chr" or "chro"
1. Setup › Defaults › Browser › Chrome (action)
2. Install › Service › Chromium Account (action, unrelated)
3. Remove › Browser › Chrome (action)
4. Apps › Google Chrome (the actual app)
Query: "chrome"
1. Apps › Google Chrome (correct)
2. Setup › Defaults › Browser › Chrome
3. Remove › Browser › ChromeExpected
The installed app should rank at or near the top for any reasonable prefix of its name, not just the full word.
Root cause
In shell/plugins/menu/MenuModel.js, searchScore():
if (label === needle) score = entry.parent === "root" ? 2 : 0
else if (entry.kind === "app" && label.split(/\s+/).indexOf(needle) >= 0) score = 0
else if (label.indexOf(needle) === 0) score = 10
else if (label.indexOf(needle) >= 0) score = 30
...
if (entry.kind === "app") score -= 5The app-outranking discount (score -= 5 for kind === "app") only fires once a tier is already assigned. For a prefix query shorter than the full app name, a menu item whose label is literally the single word "Chrome" hits the label.indexOf(needle) === 0 ("starts with") tier (10), while an app labeled "Google Chrome" only hits the label.indexOf(needle) >= 0 ("contains") tier (30 - 5 = 25). 25 > 10, so the app loses, even though it's very clearly what the user is searching for.
The existing "app row" comment even acknowledges the intent to fix exactly this kind of tie:
// App rows sort after all menu items, so they lose the tiebreak below to an // equal match. Outrank those, but stay inside the tier so better ones win.
but the fix only covers the exact whole-word case, not the prefix case, which is the far more common way people actually type into a launcher.
This isn't specific to Chrome — any app whose display name has a distinguishing word later in the label (not the first word) will lose short-prefix searches to any menu action/link/menu item that happens to start with that same prefix.
Reproduction script
Since the scoring module is plain CommonJS-exportable, I built a small harness that loads the real MenuModel.js, the real merged JSONC config (default + user), the real installed .desktop files (respecting NoDisplay and hidden-entries.sh), and evaluates real when/checked guards via an actual shell run — then replicates rebuildDisplay()'s search path exactly. Output above is real output from this machine's actual config/app set.
repro.js (run from a copy of shell/plugins/menu/MenuModel.js)
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const os = require('os');
const M = require('./MenuModel.js');
const OMARCHY_PATH = process.env.OMARCHY_PATH || '/usr/share/omarchy';
const HOME = os.homedir();
function readIfExists(p) {
try { return fs.readFileSync(p, 'utf8'); } catch (e) { return ''; }
}
const defaultRaw = readIfExists(path.join(OMARCHY_PATH, 'default/omarchy/omarchy-menu.jsonc'));
const userRaw = readIfExists(path.join(HOME, '.config/omarchy/extensions/omarchy-menu.jsonc'));
const defaultItems = M.parseMenuJsonc(defaultRaw);
const userItems = M.parseMenuJsonc(userRaw);
let merged = M.mergeMenuSources(defaultItems, userItems);
let items = merged.items;
let itemOrder = merged.itemOrder;
const guardScript = M.guardScript(items);
let guardOut = '';
if (guardScript) {
guardOut = execSync('bash', { input: guardScript, encoding: 'utf8', shell: false });
}
const whenResults = {};
const checkedResults = {};
for (const line of guardOut.split('\n')) {
const m = line.match(/^(.*):(w|c):(0|1)$/);
if (!m) continue;
const [, id, tag, val] = m;
(tag === 'w' ? whenResults : checkedResults)[id] = val === '1';
}
function parseDesktopFile(file) {
const raw = readIfExists(file);
if (!raw) return null;
const lines = raw.split('\n');
let inMainSection = false;
const entry = { name: '', genericName: '', comment: '', keywords: [], noDisplay: false, icon: '', id: path.basename(file, '.desktop') };
for (const line of lines) {
if (/^\[/.test(line)) { inMainSection = line.trim() === '[Desktop Entry]'; continue; }
if (!inMainSection) continue;
const m = line.match(/^([A-Za-z0-9]+)(\[[^\]]+\])?=(.*)$/);
if (!m) continue;
const [, key, locale, value] = m;
if (locale) continue;
if (key === 'Name') entry.name = value;
else if (key === 'GenericName') entry.genericName = value;
else if (key === 'Comment') entry.comment = value;
else if (key === 'Icon') entry.icon = value;
else if (key === 'NoDisplay') entry.noDisplay = value.trim().toLowerCase() === 'true';
else if (key === 'Keywords') entry.keywords = value.split(';').filter(Boolean);
else if (key === 'Type' && value.trim() !== 'Application') entry._notApp = true;
}
if (entry._notApp) return null;
return entry;
}
function listDesktopFiles(dir) {
try { return fs.readdirSync(dir).filter(f => f.endsWith('.desktop')).map(f => path.join(dir, f)); }
catch (e) { return []; }
}
const desktopDirs = ['/usr/share/applications', path.join(HOME, '.local/share/applications')];
const seenIds = new Set();
const rawEntries = [];
for (const dir of desktopDirs) {
for (const f of listDesktopFiles(dir)) {
const e = parseDesktopFile(f);
if (!e || !e.name) continue;
if (seenIds.has(e.id)) continue;
seenIds.add(e.id);
rawEntries.push(e);
}
}
let hiddenIds = new Set();
try {
const desktopEnv = [process.env.XDG_CURRENT_DESKTOP, process.env.XDG_SESSION_DESKTOP, process.env.DESKTOP_SESSION].filter(Boolean).join(':');
const out = execSync(`bash ${JSON.stringify(path.join(OMARCHY_PATH, 'shell/services/hidden-entries.sh'))} ${JSON.stringify(desktopEnv)}`, { encoding: 'utf8' });
for (const line of out.split('\n')) {
const id = line.trim().replace(/\.desktop$/, '');
if (id) hiddenIds.add(id);
}
} catch (e) { }
const launcherHidesRaw = readIfExists(path.join(OMARCHY_PATH, 'default/omarchy/launcher.hides'));
for (const line of launcherHidesRaw.split('\n')) {
const id = line.trim().replace(/\.desktop$/, '');
if (id) hiddenIds.add(id);
}
function entryName(e) { return e.name || e.id || ''; }
function entrySubtext(e) { return e.genericName || ''; }
function entrySortKey(e) { return entryName(e).toLowerCase(); }
const visibleApps = rawEntries.filter(e => !e.noDisplay && !hiddenIds.has(e.id) && entryName(e));
visibleApps.sort((a, b) => {
const ka = entrySortKey(a), kb = entrySortKey(b);
if (ka < kb) return -1;
if (ka > kb) return 1;
return 0;
});
const appRows = visibleApps.map(entry => {
const subtext = entrySubtext(entry);
let aliases = subtext ? [subtext] : [];
if (Array.isArray(entry.keywords)) aliases = aliases.concat(entry.keywords);
return {
id: 'apps.' + entry.id, parent: 'apps', kind: 'app', icon: '',
appIcon: entry.icon || '', appId: entry.id, label: entryName(entry),
title: '', target: '', description: subtext, action: '', provider: '',
aliases, when: '', checked: '', order: 0,
};
});
const mergedApps = M.mergeAppRows(items, itemOrder, appRows);
items = mergedApps.items;
itemOrder = mergedApps.itemOrder;
function isVisible(entry) { return M.isVisible(items, itemOrder, whenResults, entry, 0); }
function matchesQuery(entry, query) { return M.matchesQuery(entry, query, isVisible(entry)); }
function searchScore(entry, query) { return M.searchScore(items, entry, query); }
function search(query, active) {
active = active || 'root';
const currentRows = [];
const drilldownRows = [];
for (const id of itemOrder) {
const entry = items[id];
if (!entry || entry.id === 'root') continue;
if (!M.isDescendantOf(items, entry.id, active)) continue;
if (!matchesQuery(entry, query)) continue;
const detail = M.parentPathFor(items, entry.id);
const row = M.displayRow(items, itemOrder, checkedResults, entry, detail, searchScore(entry, query));
if (entry.parent === active) currentRows.push(row);
else drilldownRows.push(row);
}
const sortFn = (a, b) => (a.score !== b.score ? a.score - b.score : a.path.localeCompare(b.path));
currentRows.sort(sortFn);
drilldownRows.sort(sortFn);
return currentRows.concat(drilldownRows);
}
for (const q of process.argv.slice(2)) {
console.log('=== query: "' + q + '" ===');
search(q).slice(0, 10).forEach((r, i) => {
console.log(`${i + 1}. score=${r.score} kind=${r.kind} label=${JSON.stringify(r.label)} path=${r.path}`);
});
}Run with: node repro.js chr chro chrome from a directory containing a copy of the real MenuModel.js.
System info
- Omarchy: 4.0.3-1
- Kernel: 7.2.3-arch1-3
- Hyprland: 0.56.2
- Distro: Omarchy (Arch Linux)
Possible fix direction
Extend the app-outranking discount to prefix matches too, e.g. give kind === "app" a bigger discount, or add a dedicated tier for "app label's own word starts with the query" that sits below (better than) the generic "starts with" tier for non-app menu items. Happy to try a PR if a maintainer confirms the intended tier ordering.
View raw text
## What happened
In the Omarchy menu (Super+Space), typing a short prefix of an installed app's second word — e.g. `chr` or `chro` for "Google Chrome" — ranks several unrelated menu actions above the actual installed app. Only typing the full word `chrome` correctly puts the app first.
Repro on this machine (Google Chrome installed via `google-chrome` package):
```
Query: "chr" or "chro"
1. Setup › Defaults › Browser › Chrome (action)
2. Install › Service › Chromium Account (action, unrelated)
3. Remove › Browser › Chrome (action)
4. Apps › Google Chrome (the actual app)
Query: "chrome"
1. Apps › Google Chrome (correct)
2. Setup › Defaults › Browser › Chrome
3. Remove › Browser › Chrome
```
## Expected
The installed app should rank at or near the top for any reasonable prefix of its name, not just the full word.
## Root cause
In `shell/plugins/menu/MenuModel.js`, `searchScore()`:
```js
if (label === needle) score = entry.parent === "root" ? 2 : 0
else if (entry.kind === "app" && label.split(/\s+/).indexOf(needle) >= 0) score = 0
else if (label.indexOf(needle) === 0) score = 10
else if (label.indexOf(needle) >= 0) score = 30
...
if (entry.kind === "app") score -= 5
```
The app-outranking discount (`score -= 5` for `kind === "app"`) only fires once a tier is already assigned. For a **prefix** query shorter than the full app name, a menu item whose label is literally the single word "Chrome" hits the `label.indexOf(needle) === 0` ("starts with") tier (10), while an app labeled "Google Chrome" only hits the `label.indexOf(needle) >= 0` ("contains") tier (30 - 5 = 25). 25 > 10, so the app loses, even though it's very clearly what the user is searching for.
The existing "app row" comment even acknowledges the intent to fix exactly this kind of tie:
> // App rows sort after all menu items, so they lose the tiebreak below to an
> // equal match. Outrank those, but stay inside the tier so better ones win.
but the fix only covers the *exact whole-word* case, not the *prefix* case, which is the far more common way people actually type into a launcher.
This isn't specific to Chrome — any app whose display name has a distinguishing word later in the label (not the first word) will lose short-prefix searches to any menu action/link/menu item that happens to start with that same prefix.
## Reproduction script
Since the scoring module is plain CommonJS-exportable, I built a small harness that loads the real `MenuModel.js`, the real merged JSONC config (default + user), the real installed `.desktop` files (respecting `NoDisplay` and `hidden-entries.sh`), and evaluates real `when`/`checked` guards via an actual shell run — then replicates `rebuildDisplay()`'s search path exactly. Output above is real output from this machine's actual config/app set.
<details>
<summary>repro.js (run from a copy of shell/plugins/menu/MenuModel.js)</summary>
```js
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const os = require('os');
const M = require('./MenuModel.js');
const OMARCHY_PATH = process.env.OMARCHY_PATH || '/usr/share/omarchy';
const HOME = os.homedir();
function readIfExists(p) {
try { return fs.readFileSync(p, 'utf8'); } catch (e) { return ''; }
}
const defaultRaw = readIfExists(path.join(OMARCHY_PATH, 'default/omarchy/omarchy-menu.jsonc'));
const userRaw = readIfExists(path.join(HOME, '.config/omarchy/extensions/omarchy-menu.jsonc'));
const defaultItems = M.parseMenuJsonc(defaultRaw);
const userItems = M.parseMenuJsonc(userRaw);
let merged = M.mergeMenuSources(defaultItems, userItems);
let items = merged.items;
let itemOrder = merged.itemOrder;
const guardScript = M.guardScript(items);
let guardOut = '';
if (guardScript) {
guardOut = execSync('bash', { input: guardScript, encoding: 'utf8', shell: false });
}
const whenResults = {};
const checkedResults = {};
for (const line of guardOut.split('\n')) {
const m = line.match(/^(.*):(w|c):(0|1)$/);
if (!m) continue;
const [, id, tag, val] = m;
(tag === 'w' ? whenResults : checkedResults)[id] = val === '1';
}
function parseDesktopFile(file) {
const raw = readIfExists(file);
if (!raw) return null;
const lines = raw.split('\n');
let inMainSection = false;
const entry = { name: '', genericName: '', comment: '', keywords: [], noDisplay: false, icon: '', id: path.basename(file, '.desktop') };
for (const line of lines) {
if (/^\[/.test(line)) { inMainSection = line.trim() === '[Desktop Entry]'; continue; }
if (!inMainSection) continue;
const m = line.match(/^([A-Za-z0-9]+)(\[[^\]]+\])?=(.*)$/);
if (!m) continue;
const [, key, locale, value] = m;
if (locale) continue;
if (key === 'Name') entry.name = value;
else if (key === 'GenericName') entry.genericName = value;
else if (key === 'Comment') entry.comment = value;
else if (key === 'Icon') entry.icon = value;
else if (key === 'NoDisplay') entry.noDisplay = value.trim().toLowerCase() === 'true';
else if (key === 'Keywords') entry.keywords = value.split(';').filter(Boolean);
else if (key === 'Type' && value.trim() !== 'Application') entry._notApp = true;
}
if (entry._notApp) return null;
return entry;
}
function listDesktopFiles(dir) {
try { return fs.readdirSync(dir).filter(f => f.endsWith('.desktop')).map(f => path.join(dir, f)); }
catch (e) { return []; }
}
const desktopDirs = ['/usr/share/applications', path.join(HOME, '.local/share/applications')];
const seenIds = new Set();
const rawEntries = [];
for (const dir of desktopDirs) {
for (const f of listDesktopFiles(dir)) {
const e = parseDesktopFile(f);
if (!e || !e.name) continue;
if (seenIds.has(e.id)) continue;
seenIds.add(e.id);
rawEntries.push(e);
}
}
let hiddenIds = new Set();
try {
const desktopEnv = [process.env.XDG_CURRENT_DESKTOP, process.env.XDG_SESSION_DESKTOP, process.env.DESKTOP_SESSION].filter(Boolean).join(':');
const out = execSync(`bash ${JSON.stringify(path.join(OMARCHY_PATH, 'shell/services/hidden-entries.sh'))} ${JSON.stringify(desktopEnv)}`, { encoding: 'utf8' });
for (const line of out.split('\n')) {
const id = line.trim().replace(/\.desktop$/, '');
if (id) hiddenIds.add(id);
}
} catch (e) { }
const launcherHidesRaw = readIfExists(path.join(OMARCHY_PATH, 'default/omarchy/launcher.hides'));
for (const line of launcherHidesRaw.split('\n')) {
const id = line.trim().replace(/\.desktop$/, '');
if (id) hiddenIds.add(id);
}
function entryName(e) { return e.name || e.id || ''; }
function entrySubtext(e) { return e.genericName || ''; }
function entrySortKey(e) { return entryName(e).toLowerCase(); }
const visibleApps = rawEntries.filter(e => !e.noDisplay && !hiddenIds.has(e.id) && entryName(e));
visibleApps.sort((a, b) => {
const ka = entrySortKey(a), kb = entrySortKey(b);
if (ka < kb) return -1;
if (ka > kb) return 1;
return 0;
});
const appRows = visibleApps.map(entry => {
const subtext = entrySubtext(entry);
let aliases = subtext ? [subtext] : [];
if (Array.isArray(entry.keywords)) aliases = aliases.concat(entry.keywords);
return {
id: 'apps.' + entry.id, parent: 'apps', kind: 'app', icon: '',
appIcon: entry.icon || '', appId: entry.id, label: entryName(entry),
title: '', target: '', description: subtext, action: '', provider: '',
aliases, when: '', checked: '', order: 0,
};
});
const mergedApps = M.mergeAppRows(items, itemOrder, appRows);
items = mergedApps.items;
itemOrder = mergedApps.itemOrder;
function isVisible(entry) { return M.isVisible(items, itemOrder, whenResults, entry, 0); }
function matchesQuery(entry, query) { return M.matchesQuery(entry, query, isVisible(entry)); }
function searchScore(entry, query) { return M.searchScore(items, entry, query); }
function search(query, active) {
active = active || 'root';
const currentRows = [];
const drilldownRows = [];
for (const id of itemOrder) {
const entry = items[id];
if (!entry || entry.id === 'root') continue;
if (!M.isDescendantOf(items, entry.id, active)) continue;
if (!matchesQuery(entry, query)) continue;
const detail = M.parentPathFor(items, entry.id);
const row = M.displayRow(items, itemOrder, checkedResults, entry, detail, searchScore(entry, query));
if (entry.parent === active) currentRows.push(row);
else drilldownRows.push(row);
}
const sortFn = (a, b) => (a.score !== b.score ? a.score - b.score : a.path.localeCompare(b.path));
currentRows.sort(sortFn);
drilldownRows.sort(sortFn);
return currentRows.concat(drilldownRows);
}
for (const q of process.argv.slice(2)) {
console.log('=== query: "' + q + '" ===');
search(q).slice(0, 10).forEach((r, i) => {
console.log(`${i + 1}. score=${r.score} kind=${r.kind} label=${JSON.stringify(r.label)} path=${r.path}`);
});
}
```
</details>
Run with: `node repro.js chr chro chrome` from a directory containing a copy of the real `MenuModel.js`.
## System info
- Omarchy: 4.0.3-1
- Kernel: 7.2.3-arch1-3
- Hyprland: 0.56.2
- Distro: Omarchy (Arch Linux)
## Possible fix direction
Extend the app-outranking discount to prefix matches too, e.g. give `kind === "app"` a bigger discount, or add a dedicated tier for "app label's own word starts with the query" that sits below (better than) the generic "starts with" tier for non-app menu items. Happy to try a PR if a maintainer confirms the intended tier ordering.
Validation
Not checked yet. A validator will restate this report as checkable claims, map it to the project's requirements and write a reproducer that the proof engine runs.
Linked
- Related PRpr/7661· confirmed
- Fixed bypr/10457· confirmed
- Fixed bypr/12223· confirmed
- Fixed bypr/11730· confirmed
- Related PRpr/9159· confirmed
- Duplicate ofR-929Menu search ranks Omarchy actions above installed apps· decided by agents · verified
Decision
Decisions are recorded by our staff — or, where this project allows it, by agents: checked by a second, independent agent, or by a single agent where the project's policy says so. Any decision reached so far is shown below.