diff --git a/scripts/test-root-search-ranking.mjs b/scripts/test-root-search-ranking.mjs index f052f8a1..8d72db05 100644 --- a/scripts/test-root-search-ranking.mjs +++ b/scripts/test-root-search-ranking.mjs @@ -70,7 +70,7 @@ const { scoreRootSearchCandidate, scoreRootSearchFields, } = ranking; -const { assembleRootSearchSections } = sections; +const { assembleRootSearchSections, isRootResultPromotionCandidate } = sections; const now = Date.UTC(2026, 4, 17); @@ -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'); diff --git a/src/main/commands.ts b/src/main/commands.ts index 4a89f301..ea6a78dc 100644 --- a/src/main/commands.ts +++ b/src/main/commands.ts @@ -99,6 +99,13 @@ export interface CommandInfo { }>; /** SuperCmd deeplink (e.g. `supercmd://extensions///`). 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; } @@ -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 { @@ -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}`); } } @@ -1151,12 +1153,23 @@ async function discoverSystemSettings(): Promise { _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]; }) ); for (const item of items) { - if (item) results.push(item); + if (item) results.push(...item); } } } diff --git a/src/renderer/src/hooks/useLauncherCommandModel.ts b/src/renderer/src/hooks/useLauncherCommandModel.ts index 44ca0fbc..962efa1e 100644 --- a/src/renderer/src/hooks/useLauncherCommandModel.ts +++ b/src/renderer/src/hooks/useLauncherCommandModel.ts @@ -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'; @@ -522,9 +529,14 @@ export function useLauncherCommandModel({ const commandCandidates = useMemo(() => { 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 }) => { @@ -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); }) diff --git a/src/renderer/src/settings/ExtensionsTab.tsx b/src/renderer/src/settings/ExtensionsTab.tsx index fcd69e5b..76d57de6 100644 --- a/src/renderer/src/settings/ExtensionsTab.tsx +++ b/src/renderer/src/settings/ExtensionsTab.tsx @@ -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); } } @@ -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, { diff --git a/src/renderer/src/utils/command-helpers.tsx b/src/renderer/src/utils/command-helpers.tsx index dfa5bcbc..7386d3a5 100644 --- a/src/renderer/src/utils/command-helpers.tsx +++ b/src/renderer/src/utils/command-helpers.tsx @@ -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; @@ -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) @@ -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); }); diff --git a/src/renderer/src/utils/root-search-ranking.ts b/src/renderer/src/utils/root-search-ranking.ts index 6eec2570..a248e8b5 100644 --- a/src/renderer/src/utils/root-search-ranking.ts +++ b/src/renderer/src/utils/root-search-ranking.ts @@ -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; } diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 203a7699..945ccc3b 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -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;