Task hover action
diff --git a/src/renderer/components/TaskSettingsRows.tsx b/src/renderer/components/TaskSettingsRows.tsx
index 95dd7d854..8866cdfc3 100644
--- a/src/renderer/components/TaskSettingsRows.tsx
+++ b/src/renderer/components/TaskSettingsRows.tsx
@@ -21,7 +21,7 @@ export const AutoGenerateTaskNamesRow: React.FC = ({ taskSettings }) =
(taskSettings.errorScope === 'autoGenerateName' || taskSettings.errorScope === 'load');
return (
-
+
Auto-generate task names
@@ -47,7 +47,7 @@ export const AutoInferTaskNamesRow: React.FC
= ({ taskSettings }) => {
const isDisabled = taskSettings.loading || taskSettings.saving || !taskSettings.autoGenerateName;
return (
-
+
@@ -78,7 +78,7 @@ export const AutoApproveByDefaultRow: React.FC = ({ taskSettings }) =>
Boolean(taskSettings.error) && taskSettings.errorScope === 'autoApproveByDefault';
return (
-
+
@@ -120,7 +120,7 @@ export const CreateWorktreeByDefaultRow: React.FC
= ({ taskSettings })
Boolean(taskSettings.error) && taskSettings.errorScope === 'createWorktreeByDefault';
return (
-
+
Create worktree by default
@@ -143,7 +143,7 @@ export const AutoTrustWorktreesRow: React.FC
= ({ taskSettings }) => {
const showError = Boolean(taskSettings.error) && taskSettings.errorScope === 'autoTrustWorktrees';
return (
-
+
diff --git a/src/renderer/components/TelemetryCard.tsx b/src/renderer/components/TelemetryCard.tsx
index 9ed96f4a4..e42c8097c 100644
--- a/src/renderer/components/TelemetryCard.tsx
+++ b/src/renderer/components/TelemetryCard.tsx
@@ -8,7 +8,7 @@ const TelemetryCard: React.FC = () => {
useTelemetryConsent();
return (
-
+
Privacy & Telemetry
diff --git a/src/renderer/components/TerminalSettingsCard.tsx b/src/renderer/components/TerminalSettingsCard.tsx
index 511335ba7..03bb536e5 100644
--- a/src/renderer/components/TerminalSettingsCard.tsx
+++ b/src/renderer/components/TerminalSettingsCard.tsx
@@ -177,7 +177,7 @@ const TerminalSettingsCard: React.FC = () => {
const hasAnyResults = filteredPopularOptions.length > 0 || filteredInstalledOptions.length > 0;
return (
-
+
Terminal font
diff --git a/src/renderer/components/ThemeCard.tsx b/src/renderer/components/ThemeCard.tsx
index a41da76f1..1d07aaa76 100644
--- a/src/renderer/components/ThemeCard.tsx
+++ b/src/renderer/components/ThemeCard.tsx
@@ -13,7 +13,7 @@ const ThemeCard: React.FC = () => {
];
return (
-
+
Color mode
Choose how Emdash looks.
diff --git a/src/renderer/components/WorkspaceProviderInfoCard.tsx b/src/renderer/components/WorkspaceProviderInfoCard.tsx
index 4dd88b0e8..671b5f338 100644
--- a/src/renderer/components/WorkspaceProviderInfoCard.tsx
+++ b/src/renderer/components/WorkspaceProviderInfoCard.tsx
@@ -261,7 +261,10 @@ export const WorkspaceProviderInfoCard: React.FC = () => {
return (
<>
-
+
Workspace Provider
Run tasks on your own infrastructure. Configure provision and teardown scripts per-project
diff --git a/src/renderer/hooks/useSettingsSearch.ts b/src/renderer/hooks/useSettingsSearch.ts
new file mode 100644
index 000000000..f4e8e3f01
--- /dev/null
+++ b/src/renderer/hooks/useSettingsSearch.ts
@@ -0,0 +1,474 @@
+import { useMemo, useState, useCallback, useRef, useEffect } from 'react';
+
+export interface SearchableSetting {
+ id: string;
+ label: string;
+ description: string;
+ category: string;
+ keywords: string[];
+ synonyms: string[];
+ tabId: string;
+}
+
+// Comprehensive settings index with semantic keywords and synonyms
+export const SETTINGS_INDEX: SearchableSetting[] = [
+ // General Tab
+ {
+ id: 'telemetry',
+ label: 'Telemetry',
+ description: 'Help improve Emdash by sending anonymous usage data',
+ category: 'Privacy',
+ keywords: ['telemetry', 'analytics', 'tracking', 'data collection', 'usage'],
+ synonyms: ['statistics', 'metrics', 'monitoring', 'reporting', 'analytics'],
+ tabId: 'general',
+ },
+ {
+ id: 'auto-generate-task-names',
+ label: 'Auto-generate task names',
+ description: 'Automatically generate task names from context',
+ category: 'Tasks',
+ keywords: ['task names', 'auto generate', 'automatic naming', 'task titles'],
+ synonyms: ['naming', 'titles', 'automatic names', 'generate names'],
+ tabId: 'general',
+ },
+ {
+ id: 'auto-infer-task-names',
+ label: 'Auto-infer task names',
+ description: 'Infer task names from the first message',
+ category: 'Tasks',
+ keywords: ['infer names', 'detect names', 'smart naming'],
+ synonyms: ['guess names', 'auto detect', 'smart titles'],
+ tabId: 'general',
+ },
+ {
+ id: 'auto-approve-by-default',
+ label: 'Auto-approve by default',
+ description: 'Automatically approve tool operations without asking',
+ category: 'Tasks',
+ keywords: ['auto approve', 'permissions', 'tool approval', 'automatic approval'],
+ synonyms: ['skip prompts', 'less strict', 'automatic permissions', 'approve automatically'],
+ tabId: 'general',
+ },
+ {
+ id: 'create-worktree-by-default',
+ label: 'Create worktree by default',
+ description: 'Create a git worktree for each new task',
+ category: 'Tasks',
+ keywords: ['worktree', 'git worktree', 'branch isolation', 'task isolation'],
+ synonyms: ['git branches', 'isolated workspace', 'separate branch'],
+ tabId: 'general',
+ },
+ {
+ id: 'auto-trust-worktrees',
+ label: 'Auto-trust worktrees',
+ description: 'Automatically trust repositories in new worktrees',
+ category: 'Tasks',
+ keywords: ['trust', 'worktree trust', 'repository trust', 'git trust'],
+ synonyms: ['trusted repos', 'auto trust', 'repository verification'],
+ tabId: 'general',
+ },
+ {
+ id: 'notifications-enabled',
+ label: 'Enable notifications',
+ description: 'Show notification messages',
+ category: 'Notifications',
+ keywords: ['notifications', 'alerts', 'messages', 'banner'],
+ synonyms: ['popups', 'alerts', 'notify', 'warnings'],
+ tabId: 'general',
+ },
+ {
+ id: 'notification-sound',
+ label: 'Notification sound',
+ description: 'Play a sound when notifications appear',
+ category: 'Notifications',
+ keywords: ['sound', 'audio', 'notification sound', 'beep'],
+ synonyms: ['chime', 'alert sound', 'ding', 'notification audio'],
+ tabId: 'general',
+ },
+ {
+ id: 'os-notifications',
+ label: 'OS notifications',
+ description: 'Show native operating system notifications',
+ category: 'Notifications',
+ keywords: ['os notifications', 'native notifications', 'system notifications'],
+ synonyms: ['desktop alerts', 'native alerts', 'system alerts'],
+ tabId: 'general',
+ },
+ {
+ id: 'sound-focus-mode',
+ label: 'Sound focus mode',
+ description: 'When to play notification sounds',
+ category: 'Notifications',
+ keywords: ['focus mode', 'sound mode', 'notification timing'],
+ synonyms: ['quiet mode', 'do not disturb', 'sound settings'],
+ tabId: 'general',
+ },
+ {
+ id: 'sound-profile',
+ label: 'Sound profile',
+ description: 'Choose the notification sound style',
+ category: 'Notifications',
+ keywords: ['sound profile', 'audio theme', 'notification style'],
+ synonyms: ['sound theme', 'audio profile', 'notification tone'],
+ tabId: 'general',
+ },
+
+ // Agents Tab
+ {
+ id: 'default-agent',
+ label: 'Default agent',
+ description: 'Choose the default AI agent for new tasks',
+ category: 'Agents',
+ keywords: ['default agent', 'default model', 'preferred agent', 'ai provider'],
+ synonyms: ['default ai', 'main agent', 'preferred model', 'default llm'],
+ tabId: 'clis-models',
+ },
+ {
+ id: 'review-agent',
+ label: 'Review agent',
+ description: 'Enable code review with an AI agent',
+ category: 'Agents',
+ keywords: ['review', 'code review', 'review agent', 'pr review'],
+ synonyms: ['code check', 'review bot', 'pr reviewer', 'code analysis'],
+ tabId: 'clis-models',
+ },
+ {
+ id: 'review-prompt',
+ label: 'Review prompt',
+ description: 'Custom instructions for the review agent',
+ category: 'Agents',
+ keywords: ['review prompt', 'custom instructions', 'review settings'],
+ synonyms: ['review instructions', 'custom prompt', 'review guidelines'],
+ tabId: 'clis-models',
+ },
+ {
+ id: 'cli-agents',
+ label: 'CLI agents',
+ description: 'Manage installed CLI agents and their status',
+ category: 'Agents',
+ keywords: ['cli agents', 'command line', 'installed agents', 'agent status'],
+ synonyms: ['terminal agents', 'cli tools', 'command agents'],
+ tabId: 'clis-models',
+ },
+
+ // Integrations Tab
+ {
+ id: 'integrations',
+ label: 'Integrations',
+ description: 'Connect external services like GitHub, Linear, Jira',
+ category: 'Integrations',
+ keywords: ['integrations', 'github', 'linear', 'jira', 'gitlab', 'services'],
+ synonyms: ['connections', 'external services', 'third party', 'apps'],
+ tabId: 'integrations',
+ },
+ {
+ id: 'workspace-provider',
+ label: 'Workspace provider',
+ description: 'Configure workspace provisioning settings',
+ category: 'Integrations',
+ keywords: ['workspace', 'provisioning', 'cloud workspace', 'remote'],
+ synonyms: ['cloud dev', 'remote workspace', 'workspace setup'],
+ tabId: 'integrations',
+ },
+
+ // Repository Tab
+ {
+ id: 'branch-prefix',
+ label: 'Branch prefix',
+ description: 'Prefix for automatically generated branch names',
+ category: 'Repository',
+ keywords: ['branch prefix', 'branch naming', 'git branches', 'prefix'],
+ synonyms: ['branch name', 'naming convention', 'git prefix'],
+ tabId: 'repository',
+ },
+ {
+ id: 'push-on-create',
+ label: 'Push on create',
+ description: 'Automatically push branches when creating a PR',
+ category: 'Repository',
+ keywords: ['push', 'auto push', 'git push', 'branch push'],
+ synonyms: ['auto publish', 'automatic push', 'push branches'],
+ tabId: 'repository',
+ },
+ {
+ id: 'auto-close-issues',
+ label: 'Auto-close linked issues',
+ description: 'Close linked issues when PR is created',
+ category: 'Repository',
+ keywords: ['close issues', 'linked issues', 'auto close', 'issue tracking'],
+ synonyms: ['close tickets', 'auto resolve', 'issue automation'],
+ tabId: 'repository',
+ },
+
+ // Interface Tab
+ {
+ id: 'theme',
+ label: 'Theme',
+ description: 'Choose your preferred color theme',
+ category: 'Appearance',
+ keywords: ['theme', 'color scheme', 'dark mode', 'light mode', 'appearance'],
+ synonyms: ['colors', 'visual style', 'ui theme', 'dark theme', 'light theme'],
+ tabId: 'interface',
+ },
+ {
+ id: 'terminal-font-family',
+ label: 'Terminal font family',
+ description: 'Custom font for the integrated terminal',
+ category: 'Terminal',
+ keywords: ['terminal font', 'font family', 'monospace', 'terminal style'],
+ synonyms: ['console font', 'terminal typeface', 'font settings'],
+ tabId: 'interface',
+ },
+ {
+ id: 'terminal-font-size',
+ label: 'Terminal font size',
+ description: 'Font size for the integrated terminal',
+ category: 'Terminal',
+ keywords: ['font size', 'terminal size', 'text size', 'zoom'],
+ synonyms: ['terminal zoom', 'text zoom', 'font scaling'],
+ tabId: 'interface',
+ },
+ {
+ id: 'auto-copy-selection',
+ label: 'Auto-copy on selection',
+ description: 'Automatically copy text when selected in terminal',
+ category: 'Terminal',
+ keywords: ['auto copy', 'copy selection', 'clipboard', 'terminal copy'],
+ synonyms: ['automatic copy', 'select to copy', 'clipboard sync'],
+ tabId: 'interface',
+ },
+ {
+ id: 'mac-option-is-meta',
+ label: 'Mac Option is Meta',
+ description: 'Use Option key as Meta key in terminal',
+ category: 'Terminal',
+ keywords: ['option key', 'meta key', 'mac terminal', 'keyboard'],
+ synonyms: ['alt key', 'terminal keys', 'modifier keys'],
+ tabId: 'interface',
+ },
+ {
+ id: 'keyboard-shortcuts',
+ label: 'Keyboard shortcuts',
+ description: 'Customize keyboard shortcuts for common actions',
+ category: 'Keyboard',
+ keywords: ['keyboard', 'shortcuts', 'keybindings', 'hotkeys', 'custom keys'],
+ synonyms: ['key combos', 'shortcut keys', 'keyboard commands', 'accelerators'],
+ tabId: 'interface',
+ },
+ {
+ id: 'auto-right-sidebar',
+ label: 'Auto right sidebar',
+ description: 'Automatically show/hide right sidebar based on context',
+ category: 'Workspace',
+ keywords: ['sidebar', 'right panel', 'auto hide', 'auto show'],
+ synonyms: ['side panel', 'right sidebar', 'panel behavior'],
+ tabId: 'interface',
+ },
+ {
+ id: 'resource-monitor',
+ label: 'Resource monitor',
+ description: 'Show system resource usage in the sidebar',
+ category: 'Workspace',
+ keywords: ['resource monitor', 'cpu', 'memory', 'system stats'],
+ synonyms: ['performance', 'system monitor', 'usage stats', 'resources'],
+ tabId: 'interface',
+ },
+ {
+ id: 'browser-preview',
+ label: 'Browser preview',
+ description: 'Enable built-in browser preview for web projects',
+ category: 'Workspace',
+ keywords: ['browser', 'preview', 'web preview', 'live preview'],
+ synonyms: ['web view', 'browser view', 'site preview', 'live reload'],
+ tabId: 'interface',
+ },
+ {
+ id: 'task-hover-action',
+ label: 'Task hover action',
+ description: 'Action to show when hovering over tasks',
+ category: 'Workspace',
+ keywords: ['task hover', 'hover action', 'delete', 'archive'],
+ synonyms: ['hover behavior', 'task actions', 'mouse hover'],
+ tabId: 'interface',
+ },
+ {
+ id: 'hidden-tools',
+ label: 'Hidden tools',
+ description: 'Manage which tools are hidden from the interface',
+ category: 'Tools',
+ keywords: ['hidden tools', 'tool visibility', 'show tools', 'hide tools'],
+ synonyms: ['tool settings', 'visible tools', 'tool preferences'],
+ tabId: 'interface',
+ },
+];
+
+export interface SearchResult {
+ setting: SearchableSetting;
+ score: number;
+ matches: {
+ label: boolean;
+ description: boolean;
+ keywords: boolean;
+ synonyms: boolean;
+ };
+}
+
+function calculateSimilarity(query: string, text: string): number {
+ const queryLower = query.toLowerCase();
+ const textLower = text.toLowerCase();
+
+ // Exact match
+ if (textLower === queryLower) return 1;
+
+ // Starts with query
+ if (textLower.startsWith(queryLower)) return 0.9;
+
+ // Contains query as whole word
+ if (new RegExp(`\\b${queryLower}\\b`).test(textLower)) return 0.8;
+
+ // Contains query
+ if (textLower.includes(queryLower)) return 0.6;
+
+ // Check for word-by-word partial matches
+ const queryWords = queryLower.split(/\s+/);
+ const textWords = textLower.split(/\s+/);
+
+ let matchCount = 0;
+ for (const queryWord of queryWords) {
+ if (queryWord.length < 2) continue;
+ for (const textWord of textWords) {
+ if (textWord.includes(queryWord) || queryWord.includes(textWord)) {
+ matchCount++;
+ break;
+ }
+ }
+ }
+
+ if (matchCount > 0) {
+ return 0.4 * (matchCount / queryWords.length);
+ }
+
+ return 0;
+}
+
+export function searchSettings(query: string): SearchResult[] {
+ if (!query.trim()) return [];
+
+ const results: SearchResult[] = [];
+
+ for (const setting of SETTINGS_INDEX) {
+ let score = 0;
+ const matches = {
+ label: false,
+ description: false,
+ keywords: false,
+ synonyms: false,
+ };
+
+ // Check label (highest weight)
+ const labelScore = calculateSimilarity(query, setting.label);
+ if (labelScore > 0) {
+ score += labelScore * 4;
+ matches.label = true;
+ }
+
+ // Check description
+ const descScore = calculateSimilarity(query, setting.description);
+ if (descScore > 0) {
+ score += descScore * 2;
+ matches.description = true;
+ }
+
+ // Check keywords
+ for (const keyword of setting.keywords) {
+ const keywordScore = calculateSimilarity(query, keyword);
+ if (keywordScore > 0) {
+ score += keywordScore * 1.5;
+ matches.keywords = true;
+ }
+ }
+
+ // Check synonyms (semantic matching)
+ for (const synonym of setting.synonyms) {
+ const synonymScore = calculateSimilarity(query, synonym);
+ if (synonymScore > 0) {
+ score += synonymScore * 1.2;
+ matches.synonyms = true;
+ }
+ }
+
+ if (score > 0.3) {
+ results.push({ setting, score, matches });
+ }
+ }
+
+ // Sort by score descending
+ return results.sort((a, b) => b.score - a.score);
+}
+
+export function useSettingsSearch() {
+ const [query, setQuery] = useState('');
+ const inputRef = useRef(null);
+
+ const focusSearch = useCallback(() => {
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ }, []);
+
+ return {
+ query,
+ setQuery,
+ inputRef,
+ focusSearch,
+ };
+}
+
+export function groupResultsByTab(results: SearchResult[]) {
+ const grouped = new Map();
+
+ for (const result of results) {
+ const tabId = result.setting.tabId;
+ if (!grouped.has(tabId)) {
+ grouped.set(tabId, []);
+ }
+ grouped.get(tabId)!.push(result);
+ }
+
+ return grouped;
+}
+
+// Highlight matches - returns array of strings and indices for highlighting
+export function getHighlightSegments(
+ text: string,
+ query: string
+): Array<{ text: string; match: boolean }> {
+ if (!query.trim()) return [{ text, match: false }];
+
+ const queryLower = query.toLowerCase();
+ const textLower = text.toLowerCase();
+
+ const segments: Array<{ text: string; match: boolean }> = [];
+ let lastIndex = 0;
+
+ // Find all occurrences
+ let index = textLower.indexOf(queryLower);
+ while (index !== -1) {
+ // Add text before match
+ if (index > lastIndex) {
+ segments.push({ text: text.slice(lastIndex, index), match: false });
+ }
+
+ // Add highlighted match
+ segments.push({ text: text.slice(index, index + query.length), match: true });
+
+ lastIndex = index + query.length;
+ index = textLower.indexOf(queryLower, lastIndex);
+ }
+
+ // Add remaining text
+ if (lastIndex < text.length) {
+ segments.push({ text: text.slice(lastIndex), match: false });
+ }
+
+ return segments.length > 0 ? segments : [{ text, match: false }];
+}
From 81437913dd7b1d3c1609a0ce698d4a2541359f60 Mon Sep 17 00:00:00 2001
From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com>
Date: Tue, 7 Apr 2026 10:55:37 +0200
Subject: [PATCH 2/4] refactor: improve settings search components and
integrations
---
src/renderer/components/IntegrationsCard.tsx | 55 +++++-----
src/renderer/components/SettingsPage.tsx | 19 ++--
.../components/SettingsSearchInput.tsx | 14 +--
.../components/SettingsSearchResults.tsx | 12 +--
.../components/integrations/registry.ts | 25 +++++
src/renderer/hooks/useSettingsSearch.ts | 100 ++++++++++++------
src/test/renderer/settingsSearchIndex.test.ts | 28 +++++
7 files changed, 170 insertions(+), 83 deletions(-)
create mode 100644 src/renderer/components/integrations/registry.ts
create mode 100644 src/test/renderer/settingsSearchIndex.test.ts
diff --git a/src/renderer/components/IntegrationsCard.tsx b/src/renderer/components/IntegrationsCard.tsx
index 984e1d125..90c088017 100644
--- a/src/renderer/components/IntegrationsCard.tsx
+++ b/src/renderer/components/IntegrationsCard.tsx
@@ -26,6 +26,7 @@ import { useModalContext } from '../contexts/ModalProvider';
import GitLabSetupForm from './integrations/GitLabSetupForm';
import ForgejoSetupForm from './integrations/ForgejoSetupForm';
import { GithubDeviceFlowModal } from './GithubDeviceFlowModal';
+import { INTEGRATION_REGISTRY, type IntegrationId } from './integrations/registry';
/** Light mode: original SVG colors. Dark / dark-black: primary colour. */
const SvgLogo = ({ raw }: { raw: string }) => {
@@ -472,21 +473,27 @@ const IntegrationsCard: React.FC = () => {
}
}, []);
- const integrations = [
+ // Runtime state per integration. Static metadata (name/description/order)
+ // lives in INTEGRATION_REGISTRY so it can be shared with the settings
+ // search index.
+ const integrationRuntime: Record<
+ IntegrationId,
{
- id: 'github',
- name: 'GitHub',
- description: 'Connect your repositories',
+ logoSvg: string;
+ connected: boolean;
+ loading: boolean;
+ onConnect: () => void;
+ onDisconnect: () => void;
+ }
+ > = {
+ github: {
logoSvg: githubSvg,
connected: authenticated,
loading: isLoading,
onConnect: handleGithubConnect,
onDisconnect: handleGithubDisconnect,
},
- {
- id: 'linear',
- name: 'Linear',
- description: 'Work on Linear tickets',
+ linear: {
logoSvg: linearSvg,
connected: linearConnected,
loading: linearLoading,
@@ -496,10 +503,7 @@ const IntegrationsCard: React.FC = () => {
},
onDisconnect: handleLinearDisconnect,
},
- {
- id: 'jira',
- name: 'Jira',
- description: 'Work on Jira tickets',
+ jira: {
logoSvg: jiraSvg,
connected: jiraConnected,
loading: jiraLoading,
@@ -509,10 +513,7 @@ const IntegrationsCard: React.FC = () => {
},
onDisconnect: handleJiraDisconnect,
},
- {
- id: 'gitlab',
- name: 'GitLab',
- description: 'Work on GitLab issues',
+ gitlab: {
logoSvg: gitlabSvg,
connected: gitlabConnected,
loading: gitlabLoading,
@@ -522,10 +523,7 @@ const IntegrationsCard: React.FC = () => {
},
onDisconnect: handleGitlabDisconnect,
},
- {
- id: 'plain',
- name: 'Plain',
- description: 'Work on support threads',
+ plain: {
logoSvg: plainSvg,
connected: plainConnected,
loading: plainLoading,
@@ -535,10 +533,7 @@ const IntegrationsCard: React.FC = () => {
},
onDisconnect: handlePlainDisconnect,
},
- {
- id: 'forgejo',
- name: 'Forgejo',
- description: 'Work on Forgejo issues',
+ forgejo: {
logoSvg: forgejoSvg,
connected: forgejoConnected,
loading: forgejoLoading,
@@ -548,10 +543,7 @@ const IntegrationsCard: React.FC = () => {
},
onDisconnect: handleForgejoDisconnect,
},
- {
- id: 'sentry',
- name: 'Sentry',
- description: 'Fix errors from Sentry',
+ sentry: {
logoSvg: sentrySvg,
connected: sentryConnected,
loading: sentryLoading,
@@ -561,7 +553,12 @@ const IntegrationsCard: React.FC = () => {
},
onDisconnect: handleSentryDisconnect,
},
- ];
+ };
+
+ const integrations = INTEGRATION_REGISTRY.map((entry) => ({
+ ...entry,
+ ...integrationRuntime[entry.id as IntegrationId],
+ }));
return (
<>
diff --git a/src/renderer/components/SettingsPage.tsx b/src/renderer/components/SettingsPage.tsx
index 3556dc866..718b7f264 100644
--- a/src/renderer/components/SettingsPage.tsx
+++ b/src/renderer/components/SettingsPage.tsx
@@ -7,7 +7,6 @@ import { Button } from './ui/button';
import SettingsSearchInput from './SettingsSearchInput';
import SettingsSearchResults from './SettingsSearchResults';
import { searchSettings, type SearchResult } from '@/hooks/useSettingsSearch';
-import { groupResultsByTab } from '@/hooks/useSettingsSearch';
// Import existing settings cards
import TelemetryCard from './TelemetryCard';
@@ -139,6 +138,14 @@ export const SETTING_ELEMENT_IDS: Record = {
'hidden-tools': 'hidden-tools-settings-card',
};
+function getTabButtonClasses(isActive: boolean, isExternal: boolean): string {
+ const base =
+ 'flex w-full items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors';
+ if (isActive) return `${base} bg-muted text-foreground`;
+ if (isExternal) return `${base} text-muted-foreground hover:bg-muted/60`;
+ return `${base} text-foreground hover:bg-muted/60`;
+}
+
interface SectionConfig {
title?: string;
action?: React.ReactNode;
@@ -417,13 +424,7 @@ export const SettingsPage: React.FC = ({ initialTab, onClose
setActiveTab(tab.id as SettingsPageTab);
}
}}
- className={`flex w-full items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors ${
- isActive
- ? 'bg-muted text-foreground'
- : tab.isExternal
- ? 'text-muted-foreground hover:bg-muted/60'
- : 'text-foreground hover:bg-muted/60'
- }`}
+ className={getTabButtonClasses(isActive, !!tab.isExternal)}
>
{tab.label}
{tab.isExternal && }
@@ -431,8 +432,6 @@ export const SettingsPage: React.FC = ({ initialTab, onClose
);
})}
-
- {/* Content container */}
{searchQuery.trim() ? (
diff --git a/src/renderer/components/SettingsSearchInput.tsx b/src/renderer/components/SettingsSearchInput.tsx
index 81f8fd707..5245840a7 100644
--- a/src/renderer/components/SettingsSearchInput.tsx
+++ b/src/renderer/components/SettingsSearchInput.tsx
@@ -1,5 +1,7 @@
-import React, { useEffect, forwardRef } from 'react';
-import { Search, X, Command } from 'lucide-react';
+import React, { forwardRef } from 'react';
+import { Search, X } from 'lucide-react';
+
+const isMac = typeof navigator !== 'undefined' && /mac|iphone|ipad|ipod/i.test(navigator.platform);
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
@@ -29,9 +31,7 @@ export const SettingsSearchInput = forwardRef
{
onQueryChange('');
- if (typeof ref === 'function') {
- // ref callback - can't easily call focus
- } else if (ref) {
+ if (ref && typeof ref === 'object') {
ref.current?.focus();
}
}}
@@ -39,8 +39,8 @@ export const SettingsSearchInput = forwardRef
) : (
-
-
+
+ {isMac ? '⌘' : 'Ctrl'}
F
)}
diff --git a/src/renderer/components/SettingsSearchResults.tsx b/src/renderer/components/SettingsSearchResults.tsx
index 7d5d78f9c..a0343fe1d 100644
--- a/src/renderer/components/SettingsSearchResults.tsx
+++ b/src/renderer/components/SettingsSearchResults.tsx
@@ -35,7 +35,7 @@ function highlightMatches(text: string, query: string) {
parts.push(text.slice(lastIndex, index));
}
parts.push(
-
+
{text.slice(index, index + query.length)}
);
@@ -67,11 +67,9 @@ export const SettingsSearchResults: React.FC = ({
return (
-
-
- {results.length} {results.length === 1 ? 'result' : 'results'}
-
-
+
+ {results.length} {results.length === 1 ? 'result' : 'results'}
+
{Array.from(grouped.entries()).map(([tabId, tabResults]) => (
= ({
key={result.setting.id}
type="button"
onClick={() => onResultClick(tabId as SettingsPageTab, result.setting.id)}
- className="group flex items-start justify-between gap-4 rounded-lg border border-border/60 bg-muted/30 px-4 py-3 text-left transition-colors hover:border-border hover:bg-muted/50"
+ className="group flex items-start justify-between gap-4 rounded-md px-4 py-3 text-left transition-colors hover:bg-muted/60"
>
diff --git a/src/renderer/components/integrations/registry.ts b/src/renderer/components/integrations/registry.ts
new file mode 100644
index 000000000..1aa60a606
--- /dev/null
+++ b/src/renderer/components/integrations/registry.ts
@@ -0,0 +1,25 @@
+// Static metadata for all integrations rendered by IntegrationsCard.
+//
+// This is the source of truth for which integrations exist. Both the
+// IntegrationsCard UI and the settings search index are validated against
+// this list (see src/test/renderer/settingsSearchIndex.test.ts) so that
+// adding a new integration here will fail tests until it is also added to
+// SETTINGS_INDEX.
+
+export interface IntegrationRegistryEntry {
+ id: string;
+ name: string;
+ description: string;
+}
+
+export const INTEGRATION_REGISTRY: readonly IntegrationRegistryEntry[] = [
+ { id: 'github', name: 'GitHub', description: 'Connect your repositories' },
+ { id: 'linear', name: 'Linear', description: 'Work on Linear tickets' },
+ { id: 'jira', name: 'Jira', description: 'Work on Jira tickets' },
+ { id: 'gitlab', name: 'GitLab', description: 'Work on GitLab issues' },
+ { id: 'plain', name: 'Plain', description: 'Work on support threads' },
+ { id: 'forgejo', name: 'Forgejo', description: 'Work on Forgejo issues' },
+ { id: 'sentry', name: 'Sentry', description: 'Fix errors from Sentry' },
+] as const;
+
+export type IntegrationId = (typeof INTEGRATION_REGISTRY)[number]['id'];
diff --git a/src/renderer/hooks/useSettingsSearch.ts b/src/renderer/hooks/useSettingsSearch.ts
index f4e8e3f01..7558f03a8 100644
--- a/src/renderer/hooks/useSettingsSearch.ts
+++ b/src/renderer/hooks/useSettingsSearch.ts
@@ -1,5 +1,3 @@
-import { useMemo, useState, useCallback, useRef, useEffect } from 'react';
-
export interface SearchableSetting {
id: string;
label: string;
@@ -161,6 +159,69 @@ export const SETTINGS_INDEX: SearchableSetting[] = [
synonyms: ['connections', 'external services', 'third party', 'apps'],
tabId: 'integrations',
},
+ {
+ id: 'github',
+ label: 'GitHub',
+ description: 'Connect your GitHub repositories',
+ category: 'Integrations',
+ keywords: ['github', 'git', 'repository', 'pull request', 'pr', 'issues'],
+ synonyms: ['gh', 'octocat', 'source control'],
+ tabId: 'integrations',
+ },
+ {
+ id: 'gitlab',
+ label: 'GitLab',
+ description: 'Work on GitLab issues',
+ category: 'Integrations',
+ keywords: ['gitlab', 'git', 'repository', 'merge request', 'mr', 'issues'],
+ synonyms: ['gl', 'source control'],
+ tabId: 'integrations',
+ },
+ {
+ id: 'forgejo',
+ label: 'Forgejo',
+ description: 'Work on Forgejo issues',
+ category: 'Integrations',
+ keywords: ['forgejo', 'gitea', 'git', 'self-hosted', 'repository', 'issues'],
+ synonyms: ['codeberg', 'self hosted git'],
+ tabId: 'integrations',
+ },
+ {
+ id: 'linear',
+ label: 'Linear',
+ description: 'Work on Linear tickets',
+ category: 'Integrations',
+ keywords: ['linear', 'tickets', 'issues', 'project management', 'tracker'],
+ synonyms: ['linear app', 'issue tracker'],
+ tabId: 'integrations',
+ },
+ {
+ id: 'jira',
+ label: 'Jira',
+ description: 'Work on Jira tickets',
+ category: 'Integrations',
+ keywords: ['jira', 'atlassian', 'tickets', 'issues', 'project management'],
+ synonyms: ['issue tracker', 'atlassian jira'],
+ tabId: 'integrations',
+ },
+ {
+ id: 'plain',
+ label: 'Plain',
+ description: 'Work on support threads',
+ category: 'Integrations',
+ keywords: ['plain', 'support', 'customer support', 'tickets', 'threads'],
+ synonyms: ['helpdesk', 'support inbox'],
+ tabId: 'integrations',
+ },
+ {
+ id: 'sentry',
+ label: 'Sentry',
+ description: 'Fix errors from Sentry',
+ category: 'Integrations',
+ keywords: ['sentry', 'errors', 'exceptions', 'monitoring', 'crash reporting'],
+ synonyms: ['error tracking', 'observability', 'bug tracker'],
+ tabId: 'integrations',
+ },
{
id: 'workspace-provider',
label: 'Workspace provider',
@@ -313,20 +374,17 @@ export interface SearchResult {
};
}
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
function calculateSimilarity(query: string, text: string): number {
const queryLower = query.toLowerCase();
const textLower = text.toLowerCase();
- // Exact match
if (textLower === queryLower) return 1;
-
- // Starts with query
if (textLower.startsWith(queryLower)) return 0.9;
-
- // Contains query as whole word
- if (new RegExp(`\\b${queryLower}\\b`).test(textLower)) return 0.8;
-
- // Contains query
+ if (new RegExp(`\\b${escapeRegExp(queryLower)}\\b`).test(textLower)) return 0.8;
if (textLower.includes(queryLower)) return 0.6;
// Check for word-by-word partial matches
@@ -337,7 +395,7 @@ function calculateSimilarity(query: string, text: string): number {
for (const queryWord of queryWords) {
if (queryWord.length < 2) continue;
for (const textWord of textWords) {
- if (textWord.includes(queryWord) || queryWord.includes(textWord)) {
+ if (textWord.length >= 2 && textWord.includes(queryWord)) {
matchCount++;
break;
}
@@ -402,28 +460,10 @@ export function searchSettings(query: string): SearchResult[] {
}
}
- // Sort by score descending
return results.sort((a, b) => b.score - a.score);
}
-export function useSettingsSearch() {
- const [query, setQuery] = useState('');
- const inputRef = useRef(null);
-
- const focusSearch = useCallback(() => {
- inputRef.current?.focus();
- inputRef.current?.select();
- }, []);
-
- return {
- query,
- setQuery,
- inputRef,
- focusSearch,
- };
-}
-
-export function groupResultsByTab(results: SearchResult[]) {
+export function groupResultsByTab(results: SearchResult[]): Map {
const grouped = new Map();
for (const result of results) {
diff --git a/src/test/renderer/settingsSearchIndex.test.ts b/src/test/renderer/settingsSearchIndex.test.ts
new file mode 100644
index 000000000..98e808475
--- /dev/null
+++ b/src/test/renderer/settingsSearchIndex.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+import { SETTINGS_INDEX } from '../../renderer/hooks/useSettingsSearch';
+import { INTEGRATION_REGISTRY } from '../../renderer/components/integrations/registry';
+
+describe('settings search index', () => {
+ it('has an entry for every integration in the registry', () => {
+ const indexedIds = new Set(SETTINGS_INDEX.map((entry) => entry.id));
+ const missing = INTEGRATION_REGISTRY.filter((entry) => !indexedIds.has(entry.id)).map(
+ (entry) => entry.id
+ );
+
+ expect(
+ missing,
+ `INTEGRATION_REGISTRY contains ids that are not present in SETTINGS_INDEX: ${missing.join(
+ ', '
+ )}. Add an entry to SETTINGS_INDEX in src/renderer/hooks/useSettingsSearch.ts so the new integration is searchable.`
+ ).toEqual([]);
+ });
+
+ it('routes every integration entry to the integrations tab', () => {
+ const integrationIds = new Set(INTEGRATION_REGISTRY.map((entry) => entry.id));
+ const misrouted = SETTINGS_INDEX.filter(
+ (entry) => integrationIds.has(entry.id) && entry.tabId !== 'integrations'
+ );
+
+ expect(misrouted).toEqual([]);
+ });
+});
From 35be7fc4da4b6d27a5b131966b8b850065b5bda7 Mon Sep 17 00:00:00 2001
From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com>
Date: Tue, 7 Apr 2026 11:01:52 +0200
Subject: [PATCH 3/4] refactor: simplify settings search index and scoring
Collapse keywords/synonyms into a single aliases array, drop unused
category/matches fields and the duplicated getHighlightSegments helper,
and replace the multi-tier similarity + word fallback with a flat
exact/startsWith/includes scorer. Behaviour for normal queries is
unchanged; edge cases (special chars, 1-char queries) are more
predictable.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/renderer/hooks/useSettingsSearch.ts | 299 +++++-------------------
1 file changed, 62 insertions(+), 237 deletions(-)
diff --git a/src/renderer/hooks/useSettingsSearch.ts b/src/renderer/hooks/useSettingsSearch.ts
index 7558f03a8..7c70a1655 100644
--- a/src/renderer/hooks/useSettingsSearch.ts
+++ b/src/renderer/hooks/useSettingsSearch.ts
@@ -2,112 +2,95 @@ export interface SearchableSetting {
id: string;
label: string;
description: string;
- category: string;
- keywords: string[];
- synonyms: string[];
+ aliases: string[];
tabId: string;
}
-// Comprehensive settings index with semantic keywords and synonyms
+export interface SearchResult {
+ setting: SearchableSetting;
+ score: number;
+}
+
+// Hand-curated index of searchable settings. Order does not matter; results
+// are ranked by score. When adding a new setting, prefer adding a few
+// natural-language aliases over adding more weight tiers.
export const SETTINGS_INDEX: SearchableSetting[] = [
// General Tab
{
id: 'telemetry',
label: 'Telemetry',
description: 'Help improve Emdash by sending anonymous usage data',
- category: 'Privacy',
- keywords: ['telemetry', 'analytics', 'tracking', 'data collection', 'usage'],
- synonyms: ['statistics', 'metrics', 'monitoring', 'reporting', 'analytics'],
+ aliases: ['analytics', 'tracking', 'data collection', 'usage', 'metrics', 'monitoring'],
tabId: 'general',
},
{
id: 'auto-generate-task-names',
label: 'Auto-generate task names',
description: 'Automatically generate task names from context',
- category: 'Tasks',
- keywords: ['task names', 'auto generate', 'automatic naming', 'task titles'],
- synonyms: ['naming', 'titles', 'automatic names', 'generate names'],
+ aliases: ['naming', 'titles', 'automatic names', 'task titles'],
tabId: 'general',
},
{
id: 'auto-infer-task-names',
label: 'Auto-infer task names',
description: 'Infer task names from the first message',
- category: 'Tasks',
- keywords: ['infer names', 'detect names', 'smart naming'],
- synonyms: ['guess names', 'auto detect', 'smart titles'],
+ aliases: ['detect names', 'smart naming', 'guess names'],
tabId: 'general',
},
{
id: 'auto-approve-by-default',
label: 'Auto-approve by default',
description: 'Automatically approve tool operations without asking',
- category: 'Tasks',
- keywords: ['auto approve', 'permissions', 'tool approval', 'automatic approval'],
- synonyms: ['skip prompts', 'less strict', 'automatic permissions', 'approve automatically'],
+ aliases: ['permissions', 'tool approval', 'skip prompts', 'approve automatically'],
tabId: 'general',
},
{
id: 'create-worktree-by-default',
label: 'Create worktree by default',
description: 'Create a git worktree for each new task',
- category: 'Tasks',
- keywords: ['worktree', 'git worktree', 'branch isolation', 'task isolation'],
- synonyms: ['git branches', 'isolated workspace', 'separate branch'],
+ aliases: ['worktree', 'git worktree', 'branch isolation', 'isolated workspace'],
tabId: 'general',
},
{
id: 'auto-trust-worktrees',
label: 'Auto-trust worktrees',
description: 'Automatically trust repositories in new worktrees',
- category: 'Tasks',
- keywords: ['trust', 'worktree trust', 'repository trust', 'git trust'],
- synonyms: ['trusted repos', 'auto trust', 'repository verification'],
+ aliases: ['trust', 'repository trust', 'trusted repos'],
tabId: 'general',
},
{
id: 'notifications-enabled',
label: 'Enable notifications',
description: 'Show notification messages',
- category: 'Notifications',
- keywords: ['notifications', 'alerts', 'messages', 'banner'],
- synonyms: ['popups', 'alerts', 'notify', 'warnings'],
+ aliases: ['alerts', 'banner', 'popups', 'notify'],
tabId: 'general',
},
{
id: 'notification-sound',
label: 'Notification sound',
description: 'Play a sound when notifications appear',
- category: 'Notifications',
- keywords: ['sound', 'audio', 'notification sound', 'beep'],
- synonyms: ['chime', 'alert sound', 'ding', 'notification audio'],
+ aliases: ['audio', 'beep', 'chime', 'ding', 'alert sound'],
tabId: 'general',
},
{
id: 'os-notifications',
label: 'OS notifications',
description: 'Show native operating system notifications',
- category: 'Notifications',
- keywords: ['os notifications', 'native notifications', 'system notifications'],
- synonyms: ['desktop alerts', 'native alerts', 'system alerts'],
+ aliases: ['native notifications', 'system notifications', 'desktop alerts'],
tabId: 'general',
},
{
id: 'sound-focus-mode',
label: 'Sound focus mode',
description: 'When to play notification sounds',
- category: 'Notifications',
- keywords: ['focus mode', 'sound mode', 'notification timing'],
- synonyms: ['quiet mode', 'do not disturb', 'sound settings'],
+ aliases: ['focus mode', 'quiet mode', 'do not disturb'],
tabId: 'general',
},
{
id: 'sound-profile',
label: 'Sound profile',
description: 'Choose the notification sound style',
- category: 'Notifications',
- keywords: ['sound profile', 'audio theme', 'notification style'],
- synonyms: ['sound theme', 'audio profile', 'notification tone'],
+ aliases: ['audio theme', 'sound theme', 'notification tone'],
tabId: 'general',
},
@@ -116,36 +99,28 @@ export const SETTINGS_INDEX: SearchableSetting[] = [
id: 'default-agent',
label: 'Default agent',
description: 'Choose the default AI agent for new tasks',
- category: 'Agents',
- keywords: ['default agent', 'default model', 'preferred agent', 'ai provider'],
- synonyms: ['default ai', 'main agent', 'preferred model', 'default llm'],
+ aliases: ['default model', 'preferred agent', 'ai provider', 'default llm'],
tabId: 'clis-models',
},
{
id: 'review-agent',
label: 'Review agent',
description: 'Enable code review with an AI agent',
- category: 'Agents',
- keywords: ['review', 'code review', 'review agent', 'pr review'],
- synonyms: ['code check', 'review bot', 'pr reviewer', 'code analysis'],
+ aliases: ['code review', 'pr review', 'review bot', 'code analysis'],
tabId: 'clis-models',
},
{
id: 'review-prompt',
label: 'Review prompt',
description: 'Custom instructions for the review agent',
- category: 'Agents',
- keywords: ['review prompt', 'custom instructions', 'review settings'],
- synonyms: ['review instructions', 'custom prompt', 'review guidelines'],
+ aliases: ['custom instructions', 'review instructions', 'review guidelines'],
tabId: 'clis-models',
},
{
id: 'cli-agents',
label: 'CLI agents',
description: 'Manage installed CLI agents and their status',
- category: 'Agents',
- keywords: ['cli agents', 'command line', 'installed agents', 'agent status'],
- synonyms: ['terminal agents', 'cli tools', 'command agents'],
+ aliases: ['command line', 'installed agents', 'terminal agents', 'cli tools'],
tabId: 'clis-models',
},
@@ -154,81 +129,63 @@ export const SETTINGS_INDEX: SearchableSetting[] = [
id: 'integrations',
label: 'Integrations',
description: 'Connect external services like GitHub, Linear, Jira',
- category: 'Integrations',
- keywords: ['integrations', 'github', 'linear', 'jira', 'gitlab', 'services'],
- synonyms: ['connections', 'external services', 'third party', 'apps'],
+ aliases: ['connections', 'external services', 'third party', 'apps'],
tabId: 'integrations',
},
{
id: 'github',
label: 'GitHub',
description: 'Connect your GitHub repositories',
- category: 'Integrations',
- keywords: ['github', 'git', 'repository', 'pull request', 'pr', 'issues'],
- synonyms: ['gh', 'octocat', 'source control'],
+ aliases: ['gh', 'git', 'repository', 'pull request', 'pr', 'octocat', 'source control'],
tabId: 'integrations',
},
{
id: 'gitlab',
label: 'GitLab',
description: 'Work on GitLab issues',
- category: 'Integrations',
- keywords: ['gitlab', 'git', 'repository', 'merge request', 'mr', 'issues'],
- synonyms: ['gl', 'source control'],
+ aliases: ['gl', 'git', 'repository', 'merge request', 'mr', 'source control'],
tabId: 'integrations',
},
{
id: 'forgejo',
label: 'Forgejo',
description: 'Work on Forgejo issues',
- category: 'Integrations',
- keywords: ['forgejo', 'gitea', 'git', 'self-hosted', 'repository', 'issues'],
- synonyms: ['codeberg', 'self hosted git'],
+ aliases: ['gitea', 'self-hosted', 'self hosted git', 'codeberg', 'repository'],
tabId: 'integrations',
},
{
id: 'linear',
label: 'Linear',
description: 'Work on Linear tickets',
- category: 'Integrations',
- keywords: ['linear', 'tickets', 'issues', 'project management', 'tracker'],
- synonyms: ['linear app', 'issue tracker'],
+ aliases: ['tickets', 'issues', 'project management', 'issue tracker'],
tabId: 'integrations',
},
{
id: 'jira',
label: 'Jira',
description: 'Work on Jira tickets',
- category: 'Integrations',
- keywords: ['jira', 'atlassian', 'tickets', 'issues', 'project management'],
- synonyms: ['issue tracker', 'atlassian jira'],
+ aliases: ['atlassian', 'tickets', 'issues', 'project management'],
tabId: 'integrations',
},
{
id: 'plain',
label: 'Plain',
description: 'Work on support threads',
- category: 'Integrations',
- keywords: ['plain', 'support', 'customer support', 'tickets', 'threads'],
- synonyms: ['helpdesk', 'support inbox'],
+ aliases: ['support', 'customer support', 'helpdesk', 'support inbox'],
tabId: 'integrations',
},
{
id: 'sentry',
label: 'Sentry',
description: 'Fix errors from Sentry',
- category: 'Integrations',
- keywords: ['sentry', 'errors', 'exceptions', 'monitoring', 'crash reporting'],
- synonyms: ['error tracking', 'observability', 'bug tracker'],
+ aliases: ['errors', 'exceptions', 'monitoring', 'error tracking', 'observability'],
tabId: 'integrations',
},
{
id: 'workspace-provider',
label: 'Workspace provider',
description: 'Configure workspace provisioning settings',
- category: 'Integrations',
- keywords: ['workspace', 'provisioning', 'cloud workspace', 'remote'],
- synonyms: ['cloud dev', 'remote workspace', 'workspace setup'],
+ aliases: ['provisioning', 'cloud workspace', 'remote workspace'],
tabId: 'integrations',
},
@@ -237,27 +194,21 @@ export const SETTINGS_INDEX: SearchableSetting[] = [
id: 'branch-prefix',
label: 'Branch prefix',
description: 'Prefix for automatically generated branch names',
- category: 'Repository',
- keywords: ['branch prefix', 'branch naming', 'git branches', 'prefix'],
- synonyms: ['branch name', 'naming convention', 'git prefix'],
+ aliases: ['branch naming', 'git branches', 'naming convention'],
tabId: 'repository',
},
{
id: 'push-on-create',
label: 'Push on create',
description: 'Automatically push branches when creating a PR',
- category: 'Repository',
- keywords: ['push', 'auto push', 'git push', 'branch push'],
- synonyms: ['auto publish', 'automatic push', 'push branches'],
+ aliases: ['auto push', 'git push', 'auto publish'],
tabId: 'repository',
},
{
id: 'auto-close-issues',
label: 'Auto-close linked issues',
description: 'Close linked issues when PR is created',
- category: 'Repository',
- keywords: ['close issues', 'linked issues', 'auto close', 'issue tracking'],
- synonyms: ['close tickets', 'auto resolve', 'issue automation'],
+ aliases: ['close tickets', 'issue automation', 'auto resolve'],
tabId: 'repository',
},
@@ -266,197 +217,107 @@ export const SETTINGS_INDEX: SearchableSetting[] = [
id: 'theme',
label: 'Theme',
description: 'Choose your preferred color theme',
- category: 'Appearance',
- keywords: ['theme', 'color scheme', 'dark mode', 'light mode', 'appearance'],
- synonyms: ['colors', 'visual style', 'ui theme', 'dark theme', 'light theme'],
+ aliases: ['color scheme', 'dark mode', 'light mode', 'appearance', 'colors', 'ui theme'],
tabId: 'interface',
},
{
id: 'terminal-font-family',
label: 'Terminal font family',
description: 'Custom font for the integrated terminal',
- category: 'Terminal',
- keywords: ['terminal font', 'font family', 'monospace', 'terminal style'],
- synonyms: ['console font', 'terminal typeface', 'font settings'],
+ aliases: ['terminal font', 'monospace', 'console font', 'typeface'],
tabId: 'interface',
},
{
id: 'terminal-font-size',
label: 'Terminal font size',
description: 'Font size for the integrated terminal',
- category: 'Terminal',
- keywords: ['font size', 'terminal size', 'text size', 'zoom'],
- synonyms: ['terminal zoom', 'text zoom', 'font scaling'],
+ aliases: ['text size', 'terminal zoom', 'font scaling'],
tabId: 'interface',
},
{
id: 'auto-copy-selection',
label: 'Auto-copy on selection',
description: 'Automatically copy text when selected in terminal',
- category: 'Terminal',
- keywords: ['auto copy', 'copy selection', 'clipboard', 'terminal copy'],
- synonyms: ['automatic copy', 'select to copy', 'clipboard sync'],
+ aliases: ['clipboard', 'select to copy', 'terminal copy'],
tabId: 'interface',
},
{
id: 'mac-option-is-meta',
label: 'Mac Option is Meta',
description: 'Use Option key as Meta key in terminal',
- category: 'Terminal',
- keywords: ['option key', 'meta key', 'mac terminal', 'keyboard'],
- synonyms: ['alt key', 'terminal keys', 'modifier keys'],
+ aliases: ['option key', 'meta key', 'alt key', 'modifier keys'],
tabId: 'interface',
},
{
id: 'keyboard-shortcuts',
label: 'Keyboard shortcuts',
description: 'Customize keyboard shortcuts for common actions',
- category: 'Keyboard',
- keywords: ['keyboard', 'shortcuts', 'keybindings', 'hotkeys', 'custom keys'],
- synonyms: ['key combos', 'shortcut keys', 'keyboard commands', 'accelerators'],
+ aliases: ['keybindings', 'hotkeys', 'shortcut keys', 'accelerators'],
tabId: 'interface',
},
{
id: 'auto-right-sidebar',
label: 'Auto right sidebar',
description: 'Automatically show/hide right sidebar based on context',
- category: 'Workspace',
- keywords: ['sidebar', 'right panel', 'auto hide', 'auto show'],
- synonyms: ['side panel', 'right sidebar', 'panel behavior'],
+ aliases: ['side panel', 'right panel', 'panel behavior'],
tabId: 'interface',
},
{
id: 'resource-monitor',
label: 'Resource monitor',
description: 'Show system resource usage in the sidebar',
- category: 'Workspace',
- keywords: ['resource monitor', 'cpu', 'memory', 'system stats'],
- synonyms: ['performance', 'system monitor', 'usage stats', 'resources'],
+ aliases: ['cpu', 'memory', 'system stats', 'performance', 'usage stats'],
tabId: 'interface',
},
{
id: 'browser-preview',
label: 'Browser preview',
description: 'Enable built-in browser preview for web projects',
- category: 'Workspace',
- keywords: ['browser', 'preview', 'web preview', 'live preview'],
- synonyms: ['web view', 'browser view', 'site preview', 'live reload'],
+ aliases: ['web preview', 'live preview', 'web view', 'live reload'],
tabId: 'interface',
},
{
id: 'task-hover-action',
label: 'Task hover action',
description: 'Action to show when hovering over tasks',
- category: 'Workspace',
- keywords: ['task hover', 'hover action', 'delete', 'archive'],
- synonyms: ['hover behavior', 'task actions', 'mouse hover'],
+ aliases: ['hover behavior', 'task actions', 'mouse hover'],
tabId: 'interface',
},
{
id: 'hidden-tools',
label: 'Hidden tools',
description: 'Manage which tools are hidden from the interface',
- category: 'Tools',
- keywords: ['hidden tools', 'tool visibility', 'show tools', 'hide tools'],
- synonyms: ['tool settings', 'visible tools', 'tool preferences'],
+ aliases: ['tool visibility', 'show tools', 'hide tools', 'tool preferences'],
tabId: 'interface',
},
];
-export interface SearchResult {
- setting: SearchableSetting;
- score: number;
- matches: {
- label: boolean;
- description: boolean;
- keywords: boolean;
- synonyms: boolean;
- };
-}
-
-function escapeRegExp(value: string): string {
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-}
-
-function calculateSimilarity(query: string, text: string): number {
- const queryLower = query.toLowerCase();
- const textLower = text.toLowerCase();
-
- if (textLower === queryLower) return 1;
- if (textLower.startsWith(queryLower)) return 0.9;
- if (new RegExp(`\\b${escapeRegExp(queryLower)}\\b`).test(textLower)) return 0.8;
- if (textLower.includes(queryLower)) return 0.6;
-
- // Check for word-by-word partial matches
- const queryWords = queryLower.split(/\s+/);
- const textWords = textLower.split(/\s+/);
-
- let matchCount = 0;
- for (const queryWord of queryWords) {
- if (queryWord.length < 2) continue;
- for (const textWord of textWords) {
- if (textWord.length >= 2 && textWord.includes(queryWord)) {
- matchCount++;
- break;
- }
- }
- }
-
- if (matchCount > 0) {
- return 0.4 * (matchCount / queryWords.length);
- }
-
+// Score a single field. Higher is better; 0 means no match.
+function scoreField(query: string, text: string): number {
+ const q = query.toLowerCase();
+ const t = text.toLowerCase();
+ if (t === q) return 1;
+ if (t.startsWith(q)) return 0.8;
+ if (t.includes(q)) return 0.5;
return 0;
}
export function searchSettings(query: string): SearchResult[] {
- if (!query.trim()) return [];
+ const trimmed = query.trim();
+ if (!trimmed) return [];
const results: SearchResult[] = [];
for (const setting of SETTINGS_INDEX) {
let score = 0;
- const matches = {
- label: false,
- description: false,
- keywords: false,
- synonyms: false,
- };
-
- // Check label (highest weight)
- const labelScore = calculateSimilarity(query, setting.label);
- if (labelScore > 0) {
- score += labelScore * 4;
- matches.label = true;
- }
-
- // Check description
- const descScore = calculateSimilarity(query, setting.description);
- if (descScore > 0) {
- score += descScore * 2;
- matches.description = true;
- }
-
- // Check keywords
- for (const keyword of setting.keywords) {
- const keywordScore = calculateSimilarity(query, keyword);
- if (keywordScore > 0) {
- score += keywordScore * 1.5;
- matches.keywords = true;
- }
+ score += scoreField(trimmed, setting.label) * 4;
+ score += scoreField(trimmed, setting.description) * 2;
+ for (const alias of setting.aliases) {
+ score += scoreField(trimmed, alias) * 1.5;
}
- // Check synonyms (semantic matching)
- for (const synonym of setting.synonyms) {
- const synonymScore = calculateSimilarity(query, synonym);
- if (synonymScore > 0) {
- score += synonymScore * 1.2;
- matches.synonyms = true;
- }
- }
-
- if (score > 0.3) {
- results.push({ setting, score, matches });
+ if (score > 0) {
+ results.push({ setting, score });
}
}
@@ -476,39 +337,3 @@ export function groupResultsByTab(results: SearchResult[]): Map {
- if (!query.trim()) return [{ text, match: false }];
-
- const queryLower = query.toLowerCase();
- const textLower = text.toLowerCase();
-
- const segments: Array<{ text: string; match: boolean }> = [];
- let lastIndex = 0;
-
- // Find all occurrences
- let index = textLower.indexOf(queryLower);
- while (index !== -1) {
- // Add text before match
- if (index > lastIndex) {
- segments.push({ text: text.slice(lastIndex, index), match: false });
- }
-
- // Add highlighted match
- segments.push({ text: text.slice(index, index + query.length), match: true });
-
- lastIndex = index + query.length;
- index = textLower.indexOf(queryLower, lastIndex);
- }
-
- // Add remaining text
- if (lastIndex < text.length) {
- segments.push({ text: text.slice(lastIndex), match: false });
- }
-
- return segments.length > 0 ? segments : [{ text, match: false }];
-}
From afa4f849ddabdf5eec5de87abb6e7553ba19ec8c Mon Sep 17 00:00:00 2001
From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com>
Date: Tue, 7 Apr 2026 11:15:41 +0200
Subject: [PATCH 4/4] refactor(settings): inline scroll target into search
index
Move elementId from a separate SETTING_ELEMENT_IDS map in SettingsPage
into the SearchableSetting type as a required field. Single source of
truth, and TypeScript now enforces that every searchable setting has a
scroll target.
---
src/renderer/components/SettingsPage.tsx | 52 ++-----------------
.../components/SettingsSearchResults.tsx | 4 +-
src/renderer/hooks/useSettingsSearch.ts | 40 ++++++++++++++
3 files changed, 45 insertions(+), 51 deletions(-)
diff --git a/src/renderer/components/SettingsPage.tsx b/src/renderer/components/SettingsPage.tsx
index 718b7f264..6adda849c 100644
--- a/src/renderer/components/SettingsPage.tsx
+++ b/src/renderer/components/SettingsPage.tsx
@@ -98,46 +98,6 @@ interface SettingsPageProps {
onClose: () => void;
}
-// Map setting IDs to their DOM element IDs for scrolling
-export const SETTING_ELEMENT_IDS: Record = {
- // General
- telemetry: 'telemetry-card',
- 'auto-generate-task-names': 'auto-generate-task-names-row',
- 'auto-infer-task-names': 'auto-infer-task-names-row',
- 'auto-approve-by-default': 'auto-approve-by-default-row',
- 'create-worktree-by-default': 'create-worktree-by-default-row',
- 'auto-trust-worktrees': 'auto-trust-worktrees-row',
- 'notifications-enabled': 'notification-settings-card',
- 'notification-sound': 'notification-settings-card',
- 'os-notifications': 'notification-settings-card',
- 'sound-focus-mode': 'notification-settings-card',
- 'sound-profile': 'notification-settings-card',
- // Agents
- 'default-agent': 'default-agent-settings-card',
- 'review-agent': 'review-agent-settings-card',
- 'review-prompt': 'review-agent-settings-card',
- 'cli-agents': 'cli-agents-section',
- // Integrations
- integrations: 'integrations-card',
- 'workspace-provider': 'workspace-provider-card',
- // Repository
- 'branch-prefix': 'repository-settings-card',
- 'push-on-create': 'repository-settings-card',
- 'auto-close-issues': 'repository-settings-card',
- // Interface
- theme: 'theme-card',
- 'terminal-font-family': 'terminal-settings-card',
- 'terminal-font-size': 'terminal-settings-card',
- 'auto-copy-selection': 'terminal-settings-card',
- 'mac-option-is-meta': 'terminal-settings-card',
- 'keyboard-shortcuts': 'keyboard-settings-card',
- 'auto-right-sidebar': 'right-sidebar-settings-card',
- 'resource-monitor': 'resource-monitor-settings-card',
- 'browser-preview': 'browser-preview-settings-card',
- 'task-hover-action': 'task-hover-action-card',
- 'hidden-tools': 'hidden-tools-settings-card',
-};
-
function getTabButtonClasses(isActive: boolean, isExternal: boolean): string {
const base =
'flex w-full items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors';
@@ -165,20 +125,14 @@ export const SettingsPage: React.FC = ({ initialTab, onClose
setSearchResults(query.trim() ? searchSettings(query) : []);
}, []);
- const handleSearchResultClick = useCallback((tabId: SettingsPageTab, settingId: string) => {
+ const handleSearchResultClick = useCallback((tabId: SettingsPageTab, elementId: string) => {
setSearchQuery('');
setSearchResults([]);
setActiveTab(tabId);
- // Use requestAnimationFrame to scroll after React has re-rendered
+ // Scroll after React has re-rendered the newly active tab.
requestAnimationFrame(() => {
- const elementId = SETTING_ELEMENT_IDS[settingId];
- if (elementId) {
- const element = document.getElementById(elementId);
- if (element) {
- element.scrollIntoView({ behavior: 'smooth', block: 'center' });
- }
- }
+ document.getElementById(elementId)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}, []);
diff --git a/src/renderer/components/SettingsSearchResults.tsx b/src/renderer/components/SettingsSearchResults.tsx
index a0343fe1d..0e34d7927 100644
--- a/src/renderer/components/SettingsSearchResults.tsx
+++ b/src/renderer/components/SettingsSearchResults.tsx
@@ -18,7 +18,7 @@ const TAB_LABELS: Record = {
interface SettingsSearchResultsProps {
results: SearchResult[];
query: string;
- onResultClick: (tabId: SettingsPageTab, settingId: string) => void;
+ onResultClick: (tabId: SettingsPageTab, elementId: string) => void;
}
function highlightMatches(text: string, query: string) {
@@ -87,7 +87,7 @@ export const SettingsSearchResults: React.FC = ({