Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion scripts/test-root-search-ranking.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const {
scoreRootSearchCandidate,
scoreRootSearchFields,
} = ranking;
const { assembleRootSearchSections } = sections;
const { assembleRootSearchSections, isRootResultPromotionCandidate } = sections;

const now = Date.UTC(2026, 4, 17);

Expand Down Expand Up @@ -1001,6 +1001,64 @@ test('weak browser history does not autocomplete', () => {
assert.equal(getSharedRootCompletion(query, [history]), null);
});

test('a settings sub-item ranks below a real command even on an exact match', () => {
const query = 'twf';
// Real extension command that only fuzzily matches the query.
const realCommand = candidate({
query,
id: 'toggle-window-float',
title: 'Toggle Window Float',
subtype: 'extension-command',
source: 'command',
fields: [{ value: 'Toggle Window Float', kind: 'label' }],
});
// Settings sub-item whose title matches the query EXACTLY, with the fallback
// noise penalty applied (as the launcher does for settingsSubItem commands).
const subItem = candidate({
query,
id: 'twf-setting',
title: 'TWF',
subtype: 'system-command',
source: 'command',
noisePenalty: 200,
commandExtra: { settingsSubItem: true },
fields: [{ value: 'TWF', kind: 'label' }],
});
// Even though the sub-item is an exact match (which normally wins the
// exact-match tiebreaker), it must rank below the fuzzily-matched real command.
const ranked = rankRootSearchCandidates([subItem, realCommand]);
assert.deepEqual(ranked.map((c) => c.command.id), ['toggle-window-float', 'twf-setting']);
});

test('a weak settings sub-item match is dropped below the promotion floor', () => {
// Strong (exact) sub-item match survives the penalty and is promoted.
const strong = candidate({
query: 'color filters',
id: 'color-filters',
title: 'Color filters',
subtype: 'system-command',
source: 'command',
noisePenalty: 200,
commandExtra: { settingsSubItem: true },
fields: [{ value: 'Color filters', kind: 'label' }],
});
assert.equal(isRootResultPromotionCandidate(strong, 'color filters'), true);

// Weak (subsequence) sub-item match falls under the 500 floor and is dropped,
// so partial/fuzzy queries don't flood results with settings toggles.
const weak = candidate({
query: 'clf',
id: 'color-filters',
title: 'Color filters',
subtype: 'system-command',
source: 'command',
noisePenalty: 200,
commandExtra: { settingsSubItem: true },
fields: [{ value: 'Color filters', kind: 'label' }],
});
assert.equal(isRootResultPromotionCandidate(weak, 'clf'), false);
});

// All tests passed if we reach this point without throwing an error.
// Using a ✓ here for consistency with the node:test output style.
console.log('✓ All root-search-ranking tests passed');
47 changes: 30 additions & 17 deletions src/main/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ export interface CommandInfo {
}>;
/** SuperCmd deeplink (e.g. `supercmd://extensions/<owner>/<ext>/<cmd>`). Set for extension and script commands. */
deeplink?: string;
/**
* True for an individual setting parsed from a pane's .searchTerms file
* (e.g. "Color Filters" inside Accessibility), as opposed to the top-level
* pane itself. Used by search ranking to treat these as a low-priority
* fallback so they don't crowd out apps, commands, and panes.
*/
settingsSubItem?: boolean;
/** Bundle path on disk (used for icon extraction) */
_bundlePath?: string;
}
Expand All @@ -119,7 +126,9 @@ const STALE_REFRESH_COOLDOWN_MS = 15_000;
// and are re-attached on load. Bump the version when CommandInfo shape changes
// in a breaking way.

const COMMANDS_DISK_CACHE_VERSION = 1;
// v2: settings panes now also expose their individual settings (search terms),
// e.g. "Color Filters" inside Accessibility — refresh cached commands to include them.
const COMMANDS_DISK_CACHE_VERSION = 2;
let commandsDiskCachePath: string | null = null;

function getCommandsDiskCachePath(): string {
Expand Down Expand Up @@ -922,31 +931,24 @@ async function discoverSettingsSearchTermCommands(
iconDataUrl: pane.iconDataUrl,
category: 'settings',
path: pane.path,
settingsSubItem: true,
_bundlePath: pane._bundlePath,
});
};

for (const [sectionRaw, sectionValue] of Object.entries(data)) {
const sectionTitle = cleanPaneName(sectionRaw);
const sectionKey = sectionRaw.toLowerCase();
const sectionKeywords: string[] = [sectionKey];

if (sectionTitle && sectionTitle.toLowerCase() !== paneTitleLower) {
addCommand(sectionTitle, sectionKeywords, `section:${sectionRaw}`);
}

// Each top-level key in a .searchTerms file is an internal identifier
// (e.g. "AX_DISPLAY_FILTER_ENABLED"), NOT a user-facing label. The human
// titles live in localizableStrings[].title — only surface those, otherwise
// the launcher fills up with junk commands named after raw identifiers.
const rows = Array.isArray((sectionValue as any)?.localizableStrings)
? (sectionValue as any).localizableStrings
: [];

for (const row of rows) {
const rowTitle = String(row?.title || '').trim();
if (!rowTitle) continue;
const keywords = [
sectionKey,
sectionTitle.toLowerCase(),
...splitSearchKeywords(String(row?.index || '')),
].filter(Boolean);
if (!rowTitle || rowTitle.toLowerCase() === paneTitleLower) continue;
const keywords = splitSearchKeywords(String(row?.index || ''));
addCommand(rowTitle, keywords, `${sectionRaw}:${rowTitle}`);
}
}
Expand Down Expand Up @@ -1151,12 +1153,23 @@ async function discoverSystemSettings(): Promise<CommandInfo[]> {
_bundlePath: extPath,
};

return paneCommand;
// Also surface the pane's individual settings (e.g. "Color Filters"
// inside Accessibility) by parsing the bundle's .searchTerms file, so
// they can be found by name just like in Spotlight.
const subItems = await discoverSettingsSearchTermCommands(
extPath,
paneCommand,
bundleId,
legacyBundleId,
searchTermsFileName
);

return [paneCommand, ...subItems];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Skip raw .searchTerms keys when appending subitems

When this new path appends every result returned by discoverSettingsSearchTermCommands, it also exposes the helper's section-level commands. For .searchTerms files whose top-level keys are internal identifiers (for example the Color Filters entry is keyed by AX_DISPLAY_FILTER_ENABLED), the helper adds that key as a command before adding the localized row title, and cleanPaneName does not remove underscores, so users will see bogus commands like AX_DISPLAY_FILTER_ENABLED alongside the intended Color filters result. Consider only appending row-derived titles or filtering section keys that look like identifiers.

Useful? React with 👍 / 👎.

})
);

for (const item of items) {
if (item) results.push(item);
if (item) results.push(...item);
}
}
}
Expand Down
20 changes: 18 additions & 2 deletions src/renderer/src/hooks/useLauncherCommandModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ export type UseLauncherCommandModelResult = {
selectedFileResultPath: string | null;
};

// Individual settings parsed from a pane's .searchTerms file (e.g. "Color
// Filters") are a low-priority search fallback. They're only considered once
// the query is at least this many chars, and carry a ranking penalty so they
// sit below real apps/commands/panes.
const SETTINGS_SUBITEM_MIN_QUERY_LEN = 3;
const SETTINGS_SUBITEM_NOISE_PENALTY = 200;

function inferCommandSubtype(command: CommandInfo): RootSearchSubtype {
if (command.category === 'app') return 'app';
if (getQuickLinkIdFromCommandId(command.id)) return 'quicklink';
Expand Down Expand Up @@ -522,9 +529,14 @@ export function useLauncherCommandModel({

const commandCandidates = useMemo<RootSearchCandidate[]>(() => {
if (!hasSearchQuery || aiMode || rootBangState.mode !== 'none') return [];
// Individual settings (e.g. "Color Filters") are a fallback tier: skip them
// for very short queries where they'd flood the list, and let them through
// only once the user has typed enough to have a specific setting in mind.
const allowSettingsSubItems = searchQuery.trim().length >= SETTINGS_SUBITEM_MIN_QUERY_LEN;
const searchableCommands = contextualCommands.filter((cmd) =>
cmd.id !== WEB_SEARCH_COMMAND_ID &&
(!hiddenListOnlyCommandIds.has(cmd.id) || hasSearchQuery)
(!hiddenListOnlyCommandIds.has(cmd.id) || hasSearchQuery) &&
(allowSettingsSubItems || !cmd.settingsSubItem)
);
return rankCommands(searchableCommands, searchQuery, commandAliases)
.map(({ command }) => {
Expand Down Expand Up @@ -556,7 +568,11 @@ export function useLauncherCommandModel({
sourceQualityBoost: command.alwaysOnTop ? 80 : 0,
freshnessBoost: 0,
pathLocationBoost: 0,
noisePenalty: 0,
// Demote individual settings below real apps/commands/panes so they
// only surface as a fallback when nothing better matches. Weak
// (contains/subsequence) sub-item matches fall under the promotion
// floor entirely; strong (exact/prefix) ones still appear, lower down.
noisePenalty: command.settingsSubItem ? SETTINGS_SUBITEM_NOISE_PENALTY : 0,
depthPenalty: 0,
}, searchQuery, rootSearchRanking);
})
Expand Down
6 changes: 4 additions & 2 deletions src/renderer/src/settings/ExtensionsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ const ExtensionsTab: React.FC<{
map.set(`${INSTALLED_APPLICATIONS_NAME}/${cmd.id}`, cmd);
continue;
}
if (cmd.category === 'settings') {
if (cmd.category === 'settings' && !cmd.settingsSubItem) {
map.set(`${SYSTEM_SETTINGS_NAME}/${cmd.id}`, cmd);
}
}
Expand Down Expand Up @@ -391,7 +391,9 @@ const ExtensionsTab: React.FC<{
}

const systemSettingsCommands = commands
.filter((cmd) => cmd.category === 'settings')
// Only the top-level panes are assignable here; individual settings
// (settingsSubItem) are a search-only fallback and would flood this list.
.filter((cmd) => cmd.category === 'settings' && !cmd.settingsSubItem)
.sort((a, b) => a.title.localeCompare(b.title));
if (systemSettingsCommands.length > 0) {
byExt.set(SYSTEM_SETTINGS_NAME, {
Expand Down
16 changes: 16 additions & 0 deletions src/renderer/src/utils/command-helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ import { renderQuickLinkIconGlyph } from './quicklink-icons';
import { scoreRootSearchFields } from './root-search-ranking';
import { getTranslitVariant } from './transliterate';

// Minimum query length before individual settings (e.g. "Color Filters") are
// considered. Keeps short, broad queries from filling up with system settings.
const SETTINGS_SUBITEM_MIN_QUERY_LEN = 3;

export interface LauncherAction {
id: string;
title: string;
Expand Down Expand Up @@ -312,6 +316,12 @@ export function filterCommands(
return null;
}

// Individual settings (e.g. "Color Filters") are a fallback tier: skip
// them for very short queries where they'd flood the list.
if (cmd.settingsSubItem && normalizedQuery.length < SETTINGS_SUBITEM_MIN_QUERY_LEN) {
return null;
}

const primaryScore = computeCommandScore(normalizedQuery, queryTerms, title, subtitle, normalizedAlias, candidates, keywordTokens);
const translitScore = translitVariant.isVariant
? computeCommandScore(translitVariant.query, transliteratedTerms, title, subtitle, normalizedAlias, candidates, keywordTokens)
Expand All @@ -330,6 +340,12 @@ export function filterCommands(
if (a.hasExactAliasMatch !== b.hasExactAliasMatch) {
return Number(b.hasExactAliasMatch) - Number(a.hasExactAliasMatch);
}
// Settings sub-items are a fallback tier — always sort them below real
// apps/commands/panes regardless of raw score, so they only show up once
// genuine matches are exhausted.
const aSub = Boolean(a.cmd.settingsSubItem);
const bSub = Boolean(b.cmd.settingsSubItem);
if (aSub !== bSub) return Number(aSub) - Number(bSub);
if (b.score !== a.score) return b.score - a.score;
return a.title.localeCompare(b.title);
});
Expand Down
11 changes: 11 additions & 0 deletions src/renderer/src/utils/root-search-ranking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,17 @@ export function compareRootSearchCandidates(a: RootSearchCandidate, b: RootSearc
const browserOrder = compareOrganicBrowserCandidates(a, b);
if (browserOrder !== 0) return browserOrder;

// Settings sub-items (e.g. "Color Filters" inside Accessibility) are a strict
// fallback tier: they always rank below any other internal result — even on an
// exact match — so they never displace the app/command/pane the user is after.
// Placed after the organic-browser partition so the "internal beats browser"
// rule still holds, and before the exact-match/intent tiers (which would
// otherwise let an exactly-named sub-item leapfrog a fuzzily-matched command).
// This is a clean binary partition, so the comparator stays transitive.
const aSettingsSubItem = Boolean(a.command.settingsSubItem);
const bSettingsSubItem = Boolean(b.command.settingsSubItem);
if (aSettingsSubItem !== bSettingsSubItem) return aSettingsSubItem ? 1 : -1;

if (a.isProtectedIntentMatch !== b.isProtectedIntentMatch) {
return a.isProtectedIntentMatch ? -1 : 1;
}
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/types/electron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export interface CommandInfo {
needsConfirmation?: boolean;
/** Always shown at the top of the list, even during search. */
alwaysOnTop?: boolean;
/** True for an individual setting parsed from a pane's .searchTerms file
* (e.g. "Color Filters" inside Accessibility). Ranked as a low-priority
* search fallback so it doesn't crowd out apps, commands, and panes. */
settingsSubItem?: boolean;
browserMatchKind?: 'open-tab' | 'history' | 'search';
browserResultKind?: 'open-tab' | 'bookmark' | 'history' | 'search';
browserFaviconUrl?: string;
Expand Down
Loading