diff --git a/webview-ui/src/hooks/__tests__/useSettingsSearch.exclusions.spec.ts b/webview-ui/src/hooks/__tests__/useSettingsSearch.exclusions.spec.ts new file mode 100644 index 0000000000..f26f0495e5 --- /dev/null +++ b/webview-ui/src/hooks/__tests__/useSettingsSearch.exclusions.spec.ts @@ -0,0 +1,54 @@ +// npx vitest run src/hooks/__tests__/useSettingsSearch.exclusions.spec.ts + +import { renderHook } from "@testing-library/react" + +import { useSettingsSearch } from "../useSettingsSearch" + +// Mock react-i18next +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +// Mock settings data to include excluded paths and normal settings +vi.mock("@/i18n/locales/en/settings.json", () => ({ + default: { + modelInfo: { + inputPrice: "Input price", + outputPrice: "Output price", + }, + validation: { + apiKey: "You must provide a valid API key", + }, + browser: { + enable: { + label: "Enable browser tool", + description: "Allows Roo to use a browser", + }, + }, + }, +})) + +describe("useSettingsSearch - exclusions", () => { + it("does not return excluded modelInfo entries", () => { + const { result } = renderHook(() => useSettingsSearch("price")) + + const modelInfoResults = result.current.filter((r) => r.id.startsWith("modelInfo.")) + expect(modelInfoResults).toHaveLength(0) + }) + + it("does not return excluded validation entries", () => { + const { result } = renderHook(() => useSettingsSearch("api key")) + + const validationResults = result.current.filter((r) => r.id.startsWith("validation.")) + expect(validationResults).toHaveLength(0) + }) + + it("still returns actionable settings", () => { + const { result } = renderHook(() => useSettingsSearch("browser")) + + const browserResult = result.current.find((r) => r.id === "browser.enable") + expect(browserResult).toBeDefined() + }) +}) diff --git a/webview-ui/src/utils/__tests__/parseSettingsI18nKeys.spec.ts b/webview-ui/src/utils/__tests__/parseSettingsI18nKeys.spec.ts index f26b6c6e5f..0b0f7ccebd 100644 --- a/webview-ui/src/utils/__tests__/parseSettingsI18nKeys.spec.ts +++ b/webview-ui/src/utils/__tests__/parseSettingsI18nKeys.spec.ts @@ -1,6 +1,35 @@ import { parseSettingsI18nKeys, type SectionName, sectionNames } from "../parseSettingsI18nKeys" describe("parseSettingsI18nKeys", () => { + it("should exclude display-only and helper entries", () => { + const translations = { + modelInfo: { + inputPrice: "Input price", + }, + validation: { + apiKey: "You must provide an API key", + }, + placeholders: { + apiKey: "Enter API Key", + }, + browser: { + enable: { + label: "Enable browser tool", + }, + }, + } + + const results = parseSettingsI18nKeys(translations) + + // Excluded categories + expect(results.find((r) => r.id.startsWith("modelInfo."))).toBeUndefined() + expect(results.find((r) => r.id.startsWith("validation."))).toBeUndefined() + expect(results.find((r) => r.id.startsWith("placeholders."))).toBeUndefined() + + // Included actionable setting + expect(results.find((r) => r.id === "browser.enable")).toBeDefined() + }) + describe("basic parsing functionality", () => { it("should parse settings with label property", () => { const translations = { diff --git a/webview-ui/src/utils/__tests__/settingsSearchExclusions.spec.ts b/webview-ui/src/utils/__tests__/settingsSearchExclusions.spec.ts new file mode 100644 index 0000000000..6407c6cdd3 --- /dev/null +++ b/webview-ui/src/utils/__tests__/settingsSearchExclusions.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest" + +import { getExclusionReason, shouldExcludeFromSearch } from "../settingsSearchExclusions" + +describe("settingsSearchExclusions", () => { + describe("shouldExcludeFromSearch", () => { + it("excludes modelInfo display fields", () => { + expect(shouldExcludeFromSearch("modelInfo.inputPrice")).toBe(true) + expect(shouldExcludeFromSearch("modelInfo.outputPrice")).toBe(true) + expect(shouldExcludeFromSearch("modelInfo.contextWindow")).toBe(true) + }) + + it("excludes validation messages", () => { + expect(shouldExcludeFromSearch("validation.apiKey")).toBe(true) + expect(shouldExcludeFromSearch("validation.modelId")).toBe(true) + }) + + it("excludes placeholders", () => { + expect(shouldExcludeFromSearch("placeholders.apiKey")).toBe(true) + expect(shouldExcludeFromSearch("placeholders.baseUrl")).toBe(true) + }) + + it("excludes custom model pricing", () => { + expect(shouldExcludeFromSearch("providers.customModel.pricing.input")).toBe(true) + expect(shouldExcludeFromSearch("providers.customModel.pricing.output")).toBe(true) + }) + + it("excludes service tier display-only entries", () => { + expect(shouldExcludeFromSearch("serviceTier.columns.tier")).toBe(true) + expect(shouldExcludeFromSearch("serviceTier.pricingTableTitle")).toBe(true) + }) + + it("does not exclude actionable settings", () => { + expect(shouldExcludeFromSearch("browser.enable")).toBe(false) + expect(shouldExcludeFromSearch("providers.apiProvider")).toBe(false) + expect(shouldExcludeFromSearch("terminal.outputLineLimit")).toBe(false) + }) + + it("does not exclude settings that merely contain keywords", () => { + expect(shouldExcludeFromSearch("providers.enablePromptCaching")).toBe(false) + }) + }) + + describe("getExclusionReason", () => { + it("returns reason for excluded ids", () => { + const reason = getExclusionReason("modelInfo.inputPrice") + expect(reason).toBeDefined() + expect(reason?.length).toBeGreaterThan(0) + }) + + it("returns undefined for included ids", () => { + expect(getExclusionReason("browser.enable")).toBeUndefined() + }) + }) +}) diff --git a/webview-ui/src/utils/parseSettingsI18nKeys.ts b/webview-ui/src/utils/parseSettingsI18nKeys.ts index 0a80133745..f7fadc2103 100644 --- a/webview-ui/src/utils/parseSettingsI18nKeys.ts +++ b/webview-ui/src/utils/parseSettingsI18nKeys.ts @@ -1,3 +1,5 @@ +import { shouldExcludeFromSearch } from "./settingsSearchExclusions" + /** * Utility for parsing i18n translation structure to extract searchable settings information. * @@ -384,5 +386,7 @@ export function parseSettingsI18nKeys( } } - return results + const filteredResults = results.filter((setting) => !shouldExcludeFromSearch(setting.id)) + + return filteredResults } diff --git a/webview-ui/src/utils/settingsSearchExclusions.ts b/webview-ui/src/utils/settingsSearchExclusions.ts new file mode 100644 index 0000000000..ebbf3b1a1b --- /dev/null +++ b/webview-ui/src/utils/settingsSearchExclusions.ts @@ -0,0 +1,85 @@ +export interface ExclusionRule { + /** Pattern to match against a parsed setting id */ + pattern: string | RegExp + /** Human-readable reason for exclusion */ + reason: string + /** Example ids matched by this rule */ + examples?: string[] +} + +const SEARCH_EXCLUSIONS: ExclusionRule[] = [ + { + pattern: /^modelInfo\./, + reason: "Model information is display-only and not configurable", + examples: ["modelInfo.inputPrice", "modelInfo.outputPrice", "modelInfo.contextWindow"], + }, + { + pattern: /^providers\.customModel\.pricing\./, + reason: "Custom model pricing fields are display-focused and should not clutter search", + examples: ["providers.customModel.pricing.input", "providers.customModel.pricing.output"], + }, + { + pattern: /^validation\./, + reason: "Validation messages are error text, not settings", + examples: ["validation.apiKey", "validation.modelId"], + }, + { + pattern: /^placeholders\./, + reason: "Placeholder text is helper content, not a setting", + examples: ["placeholders.apiKey", "placeholders.baseUrl"], + }, + { + pattern: /^defaults\./, + reason: "Default value descriptions are informational only", + examples: ["defaults.ollamaUrl", "defaults.lmStudioUrl"], + }, + { + pattern: /^labels\./, + reason: "Generic labels are helper text, not settings", + examples: ["labels.customArn", "labels.useCustomArn"], + }, + { + pattern: /^thinkingBudget\./, + reason: "Thinking budget entries are display-only", + examples: ["thinkingBudget.maxTokens", "thinkingBudget.maxThinkingTokens"], + }, + { + pattern: /^serviceTier\.columns\./, + reason: "Service tier column headers are display-only", + examples: ["serviceTier.columns.tier", "serviceTier.columns.input"], + }, + { + pattern: /^serviceTier\.pricingTableTitle$/, + reason: "Service tier table title is display-only", + examples: ["serviceTier.pricingTableTitle"], + }, + { + pattern: /^modelPicker\.simplifiedExplanation$/, + reason: "Model picker helper text is informational", + examples: ["modelPicker.simplifiedExplanation"], + }, + { + pattern: /^modelInfo\.gemini\.(freeRequests|pricingDetails|billingEstimate)$/, + reason: "Gemini pricing notes are display-only", + examples: ["modelInfo.gemini.freeRequests", "modelInfo.gemini.pricingDetails"], + }, +] + +function matchesRule(settingId: string, rule: ExclusionRule): boolean { + if (typeof rule.pattern === "string") { + return settingId === rule.pattern + } + + return rule.pattern.test(settingId) +} + +export function shouldExcludeFromSearch(settingId: string): boolean { + return SEARCH_EXCLUSIONS.some((rule) => matchesRule(settingId, rule)) +} + +export function getExclusionReason(settingId: string): string | undefined { + const rule = SEARCH_EXCLUSIONS.find((candidate) => matchesRule(settingId, candidate)) + return rule?.reason +} + +export { SEARCH_EXCLUSIONS }