diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 0daae55800..f55c14bd05 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -77,6 +77,7 @@ export const Flag = { OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"), OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"), OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"), + OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"), OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"], OPENCODE_DISABLE_EMBEDDED_WEB_UI: truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI"), diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index a05b273e44..5c38c2871f 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -20,7 +20,7 @@ import { ReadTool } from "../../tool/read" import { WebFetchTool } from "../../tool/webfetch" import { EditTool } from "../../tool/edit" import { WriteTool } from "../../tool/write" -import { WebSearchTool } from "../../tool/websearch" +import { WebSearchTool, webSearchProviderLabel } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" import { ShellTool } from "../../tool/shell" @@ -148,7 +148,7 @@ function edit(info: ToolProps) { function websearch(info: ToolProps) { inline({ icon: "◈", - title: `Exa Web Search "${info.input.query}"`, + title: `${webSearchProviderLabel(info.metadata.provider)} "${info.input.query}"`, }) } @@ -469,7 +469,10 @@ export const RunCommand = effectCmd({ } inline({ icon: "✗", - title: `${part.tool} failed`, + title: + part.tool === "websearch" + ? `${webSearchProviderLabel(props(part).metadata.provider)} failed` + : `${part.tool} failed`, }) UI.error(part.state.error) } diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 0d899a8bae..8fca0de0c8 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -9,6 +9,7 @@ import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/c import { useBindings } from "../../keymap" import { Locale } from "@/util/locale" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" +import { webSearchProviderLabel } from "@/tool/websearch" import path from "path" import stripAnsi from "strip-ansi" import type { @@ -89,6 +90,7 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { - + )} @@ -400,7 +403,7 @@ function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; sub ) } -function AssistantTool(props: { part: SessionMessageAssistantTool }) { +function AssistantTool(props: { part: SessionMessageAssistantTool; sessionID: string }) { const input = createMemo(() => toolInputRecord(props.part.state.input)) const toolprops = { get input() { @@ -412,6 +415,7 @@ function AssistantTool(props: { part: SessionMessageAssistantTool }) { get output() { return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content) }, + sessionID: props.sessionID, part: props.part, } return ( @@ -469,6 +473,7 @@ type ToolProps = { input: Record metadata: Record output?: string + sessionID: string part: SessionMessageAssistantTool } @@ -775,9 +780,10 @@ function CodeSearch(props: ToolProps) { } function WebSearch(props: ToolProps) { + const label = createMemo(() => webSearchProviderLabel(props.metadata.provider)) return ( - Exa Web Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} + {label()} "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} {(results) => <>({results()} results)} ) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d2b50c32f8..af70f83711 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -45,7 +45,7 @@ import type { GrepTool } from "@/tool/grep" import type { EditTool } from "@/tool/edit" import type { ApplyPatchTool } from "@/tool/apply_patch" import type { WebFetchTool } from "@/tool/webfetch" -import type { WebSearchTool } from "@/tool/websearch" +import { webSearchProviderLabel, type WebSearchTool } from "@/tool/websearch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" import type { SkillTool } from "@/tool/skill" @@ -1933,10 +1933,11 @@ function WebFetch(props: ToolProps) { } function WebSearch(props: ToolProps) { - const metadata = props.metadata as { numResults?: number } + const metadata = props.metadata as { numResults?: number; provider?: unknown } return ( - Exa Web Search "{props.input.query}" ({metadata.numResults} results) + {webSearchProviderLabel(metadata.provider)} "{props.input.query}"{" "} + ({metadata.numResults} results) ) } diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 5e7e80b66a..fd4c96d124 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -13,6 +13,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Locale } from "@/util/locale" import { Global } from "@opencode-ai/core/global" import { ShellID } from "@/tool/shell/id" +import { webSearchProviderLabel } from "@/tool/websearch" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" @@ -338,7 +339,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const query = typeof data.query === "string" ? data.query : "" return { icon: "◈", - title: `Exa Web Search "${query}"`, + title: `${webSearchProviderLabel(data.provider)} "${query}"`, body: ( diff --git a/packages/opencode/src/command/template/review.txt b/packages/opencode/src/command/template/review.txt index b745247e7f..43c6738577 100644 --- a/packages/opencode/src/command/template/review.txt +++ b/packages/opencode/src/command/template/review.txt @@ -85,7 +85,7 @@ Use these to inform your review: - **Explore agent** - Find how existing code handles similar problems. Check patterns, conventions, and prior art before claiming something doesn't fit. - **Exa Code Context** - Verify correct usage of libraries/APIs before flagging something as wrong. -- **Exa Web Search** - Research best practices if you're unsure about a pattern. +- **Web Search** - Research best practices if you're unsure about a pattern. If you're uncertain about something and can't verify it with these tools, say "I'm not sure about X" rather than flagging it as a definite issue. diff --git a/packages/opencode/src/tool/mcp-exa.ts b/packages/opencode/src/tool/mcp-websearch.ts similarity index 63% rename from packages/opencode/src/tool/mcp-exa.ts rename to packages/opencode/src/tool/mcp-websearch.ts index af9a3390e3..208924cba5 100644 --- a/packages/opencode/src/tool/mcp-exa.ts +++ b/packages/opencode/src/tool/mcp-websearch.ts @@ -1,9 +1,10 @@ import { Duration, Effect, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" -const URL = process.env.EXA_API_KEY +export const EXA_URL = process.env.EXA_API_KEY ? `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(process.env.EXA_API_KEY)}` : "https://mcp.exa.ai/mcp" +export const PARALLEL_URL = "https://search.parallel.ai/mcp" const McpResult = Schema.Struct({ result: Schema.Struct({ @@ -18,11 +19,23 @@ const McpResult = Schema.Struct({ const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(McpResult)) -const parseSse = Effect.fn("McpExa.parseSse")(function* (body: string) { +const parsePayload = (payload: string) => + Effect.gen(function* () { + const trimmed = payload.trim() + if (!trimmed.startsWith("{")) return undefined + const data = yield* decode(trimmed) + return data.result.content.find((item) => item.text)?.text + }) + +export const parseResponse = Effect.fn("McpWebSearch.parseResponse")(function* (body: string) { + const trimmed = body.trim() + const direct = trimmed ? yield* parsePayload(trimmed) : undefined + if (direct) return direct + for (const line of body.split("\n")) { if (!line.startsWith("data: ")) continue - const data = yield* decode(line.substring(6)) - if (data.result.content[0]?.text) return data.result.content[0].text + const data = yield* parsePayload(line.substring(6)) + if (data) return data } return undefined }) @@ -35,6 +48,13 @@ export const SearchArgs = Schema.Struct({ contextMaxCharacters: Schema.optional(Schema.Number), }) +export const ParallelSearchArgs = Schema.Struct({ + objective: Schema.String, + search_queries: Schema.Array(Schema.String), + session_id: Schema.optional(Schema.String), + model_name: Schema.optional(Schema.String), +}) + const McpRequest = (args: Schema.Struct) => Schema.Struct({ jsonrpc: Schema.Literal("2.0"), @@ -48,14 +68,17 @@ const McpRequest = (args: Schema.Struct) => export const call = ( http: HttpClient.HttpClient, + url: string, tool: string, args: Schema.Struct, value: Schema.Struct.Type, timeout: Duration.Input, + headers?: Record, ) => Effect.gen(function* () { - const request = yield* HttpClientRequest.post(URL).pipe( + const request = yield* HttpClientRequest.post(url).pipe( HttpClientRequest.accept("application/json, text/event-stream"), + HttpClientRequest.setHeaders(headers ?? {}), HttpClientRequest.schemaBodyJson(McpRequest(args))({ jsonrpc: "2.0" as const, id: 1 as const, @@ -69,5 +92,5 @@ export const call = ( Effect.timeoutOrElse({ duration: timeout, orElse: () => Effect.die(new Error(`${tool} request timed out`)) }), ) const body = yield* response.text - return yield* parseSse(body) + return yield* parseResponse(body) }) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a4eb31acc7..b288bf7ae5 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -49,6 +49,13 @@ import { Permission } from "@/permission" const log = Log.create({ service: "tool.registry" }) +export function webSearchEnabled( + providerID: ProviderID, + flags = { exa: Flag.OPENCODE_ENABLE_EXA, parallel: Flag.OPENCODE_ENABLE_PARALLEL }, +) { + return providerID === ProviderID.opencode || flags.exa || flags.parallel +} + type TaskDef = Tool.InferDef type ReadDef = Tool.InferDef @@ -284,7 +291,7 @@ export const layer: Layer.Layer< const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { const filtered = (yield* all()).filter((tool) => { if (tool.id === WebSearchTool.id) { - return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA + return webSearchEnabled(input.providerID) } const usePatch = diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index ff4c696a25..0218ecbe3b 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -1,8 +1,11 @@ import { Effect, Schema } from "effect" import { HttpClient } from "effect/unstable/http" import * as Tool from "./tool" -import * as McpExa from "./mcp-exa" +import * as McpWebSearch from "./mcp-websearch" import DESCRIPTION from "./websearch.txt" +import { Flag } from "@opencode-ai/core/flag/flag" +import { checksum } from "@opencode-ai/core/util/encode" +import { InstallationVersion } from "@opencode-ai/core/installation/version" export const Parameters = Schema.Struct({ query: Schema.String.annotate({ description: "Websearch query" }), @@ -21,6 +24,81 @@ export const Parameters = Schema.Struct({ }), }) +const WebSearchProviderSchema = Schema.Literals(["exa", "parallel"]) +export type WebSearchProvider = Schema.Schema.Type + +export function selectWebSearchProvider( + sessionID: string, + flags = { exa: Flag.OPENCODE_ENABLE_EXA, parallel: Flag.OPENCODE_ENABLE_PARALLEL }, +): WebSearchProvider { + const override = process.env.OPENCODE_WEBSEARCH_PROVIDER + if (override === "exa" || override === "parallel") return override + if (flags.parallel) return "parallel" + if (flags.exa) return "exa" + + return Number.parseInt(checksum(sessionID) ?? "0", 36) % 2 === 0 ? "exa" : "parallel" +} + +export function webSearchProviderLabel(provider: unknown) { + if (provider === "parallel") return "Parallel Web Search" + if (provider === "exa") return "Exa Web Search" + return "Web Search" +} + +export function webSearchModelName(extra: Tool.Context["extra"]) { + const model = extra?.model + if (!model || typeof model !== "object") return undefined + const api = "api" in model && model.api && typeof model.api === "object" ? model.api : undefined + const apiID = api && "id" in api && typeof api.id === "string" ? api.id : undefined + const id = "id" in model && typeof model.id === "string" ? model.id : undefined + return (apiID ?? id)?.slice(0, 100) +} + +function parallelAuthHeaders() { + const headers = { "User-Agent": `opencode/${InstallationVersion}` } + if (!process.env.PARALLEL_API_KEY) return headers + return { ...headers, Authorization: `Bearer ${process.env.PARALLEL_API_KEY}` } +} + +function callProvider( + http: HttpClient.HttpClient, + provider: WebSearchProvider, + params: Schema.Schema.Type, + ctx: Tool.Context, +) { + if (provider === "parallel") { + return McpWebSearch.call( + http, + McpWebSearch.PARALLEL_URL, + "web_search", + McpWebSearch.ParallelSearchArgs, + { + objective: params.query, + search_queries: [params.query], + session_id: ctx.sessionID, + model_name: webSearchModelName(ctx.extra), + }, + "25 seconds", + parallelAuthHeaders(), + ) + } + + return McpWebSearch.call( + http, + McpWebSearch.EXA_URL, + "web_search_exa", + McpWebSearch.SearchArgs, + { + query: params.query, + type: params.type || "auto", + numResults: params.numResults || 8, + livecrawl: params.livecrawl || "fallback", + contextMaxCharacters: params.contextMaxCharacters, + }, + "25 seconds", + ) +} + export const WebSearchTool = Tool.define( "websearch", Effect.gen(function* () { @@ -33,6 +111,10 @@ export const WebSearchTool = Tool.define( parameters: Parameters, execute: (params: Schema.Schema.Type, ctx: Tool.Context) => Effect.gen(function* () { + const provider = selectWebSearchProvider(ctx.sessionID) + const title = webSearchProviderLabel(provider) + yield* ctx.metadata({ title: `${title} "${params.query}"`, metadata: { provider } }) + yield* ctx.ask({ permission: "websearch", patterns: [params.query], @@ -43,27 +125,16 @@ export const WebSearchTool = Tool.define( livecrawl: params.livecrawl, type: params.type, contextMaxCharacters: params.contextMaxCharacters, + provider, }, }) - const result = yield* McpExa.call( - http, - "web_search_exa", - McpExa.SearchArgs, - { - query: params.query, - type: params.type || "auto", - numResults: params.numResults || 8, - livecrawl: params.livecrawl || "fallback", - contextMaxCharacters: params.contextMaxCharacters, - }, - "25 seconds", - ) + const result = yield* callProvider(http, provider, params, ctx) return { output: result ?? "No search results found. Please try a different query.", - title: `Web search: ${params.query}`, - metadata: {}, + title: `${title}: ${params.query}`, + metadata: { provider }, } }).pipe(Effect.orDie), } diff --git a/packages/opencode/src/tool/websearch.txt b/packages/opencode/src/tool/websearch.txt index 551c0f3b59..ad5238cbd5 100644 --- a/packages/opencode/src/tool/websearch.txt +++ b/packages/opencode/src/tool/websearch.txt @@ -1,12 +1,12 @@ -- Search the web using Exa AI - performs real-time web searches and can scrape content from specific URLs +- Search the web using the session's web search provider - performs real-time web searches and can scrape content from specific URLs - Provides up-to-date information for current events and recent data - Supports configurable result counts and returns the content from the most relevant websites - Use this tool for accessing information beyond knowledge cutoff - Searches are performed automatically within a single API call Usage notes: - - Supports live crawling modes: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling) - - Search types: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search) + - Supports live crawling modes when available: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling) + - Search types when available: 'auto' (balanced), 'fast' (quick results), 'deep' (comprehensive search) - Configurable context length for optimal LLM integration - Domain filtering and advanced search options available diff --git a/packages/opencode/test/tool/websearch.test.ts b/packages/opencode/test/tool/websearch.test.ts new file mode 100644 index 0000000000..477fe2b428 --- /dev/null +++ b/packages/opencode/test/tool/websearch.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { parseResponse } from "../../src/tool/mcp-websearch" +import { + selectWebSearchProvider, + webSearchModelName, + webSearchProviderLabel, +} from "../../src/tool/websearch" +import { ProviderID } from "../../src/provider/schema" +import { webSearchEnabled } from "../../src/tool/registry" + +const SESSION_ID = "ses_0196aabbccddeeff001122334455" + +describe("websearch provider", () => { + test("selects a stable provider per session", () => { + expect(selectWebSearchProvider(SESSION_ID)).toBe(selectWebSearchProvider(SESSION_ID)) + }) + + test("supports an operational override", () => { + const original = process.env.OPENCODE_WEBSEARCH_PROVIDER + + try { + process.env.OPENCODE_WEBSEARCH_PROVIDER = "parallel" + expect(selectWebSearchProvider(SESSION_ID)).toBe("parallel") + + process.env.OPENCODE_WEBSEARCH_PROVIDER = "exa" + expect(selectWebSearchProvider(SESSION_ID)).toBe("exa") + } finally { + if (original === undefined) delete process.env.OPENCODE_WEBSEARCH_PROVIDER + else process.env.OPENCODE_WEBSEARCH_PROVIDER = original + } + }) + + test("routes to Exa when the Exa flag is enabled", () => { + expect(selectWebSearchProvider(SESSION_ID, { exa: true, parallel: false })).toBe("exa") + }) + + test("routes to Parallel when the Parallel flag is enabled", () => { + expect(selectWebSearchProvider(SESSION_ID, { exa: false, parallel: true })).toBe("parallel") + }) + + test("is only enabled for opencode or explicit websearch provider flags", () => { + expect(webSearchEnabled(ProviderID.opencode, { exa: false, parallel: false })).toBe(true) + expect(webSearchEnabled(ProviderID.openai, { exa: false, parallel: false })).toBe(false) + expect(webSearchEnabled(ProviderID.openai, { exa: true, parallel: false })).toBe(true) + expect(webSearchEnabled(ProviderID.openai, { exa: false, parallel: true })).toBe(true) + }) + + test("uses branded labels", () => { + expect(webSearchProviderLabel("parallel")).toBe("Parallel Web Search") + expect(webSearchProviderLabel("exa")).toBe("Exa Web Search") + expect(webSearchProviderLabel(undefined)).toBe("Web Search") + }) + + test("uses the provider API model id for Parallel analytics", () => { + expect( + webSearchModelName({ + model: { + id: "claude-opus-4-7", + api: { id: "claude-opus-4.7" }, + }, + }), + ).toBe("claude-opus-4.7") + }) +}) + +describe("websearch MCP response parser", () => { + const payload = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + content: [ + { + type: "text", + text: "search results", + }, + ], + }, + }) + + test("parses plain JSON-RPC responses", async () => { + await expect(Effect.runPromise(parseResponse(payload))).resolves.toBe("search results") + }) + + test("parses SSE JSON-RPC responses", async () => { + await expect(Effect.runPromise(parseResponse(`event: message\ndata: ${payload}\n\n`))).resolves.toBe("search results") + }) + + test("ignores non-JSON SSE data frames", async () => { + await expect(Effect.runPromise(parseResponse(`data: [DONE]\ndata: ${payload}\n\n`))).resolves.toBe("search results") + }) +}) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index c36a52f81e..92b6e95acc 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -317,7 +317,17 @@ function taskAgent( } } -export function getToolInfo(tool: string, input: any = {}): ToolInfo { +function webSearchProviderLabel(provider: unknown) { + if (provider === "parallel") return "Parallel Web Search" + if (provider === "exa") return "Exa Web Search" + return "Web Search" +} + +export function getToolInfo( + tool: string, + input: any = {}, + metadata: Record | undefined = {}, +): ToolInfo { const i18n = useI18n() switch (tool) { case "read": @@ -353,7 +363,7 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { case "websearch": return { icon: "window-cursor", - title: i18n.t("ui.tool.websearch"), + title: webSearchProviderLabel(metadata?.provider), subtitle: input.query, } case "task": { @@ -692,7 +702,11 @@ function isContextGroupTool(part: PartType): part is ToolPart { } function contextToolDetail(part: ToolPart): string | undefined { - const info = getToolInfo(part.tool, part.state.input ?? {}) + const info = getToolInfo( + part.tool, + part.state.input ?? {}, + "metadata" in part.state ? part.state.metadata : undefined, + ) if (info.subtitle) return info.subtitle if (part.state.status === "error") return part.state.error if ((part.state.status === "running" || part.state.status === "completed") && part.state.title) @@ -744,7 +758,11 @@ function contextToolTrigger(part: ToolPart, i18n: ReturnType) { } } default: { - const info = getToolInfo(part.tool, input) + const info = getToolInfo( + part.tool, + input, + "metadata" in part.state ? part.state.metadata : undefined, + ) return { title: info.title, subtitle: info.subtitle || contextToolDetail(part), @@ -1224,6 +1242,7 @@ export interface ToolProps { input: Record metadata: Record tool: string + sessionID?: string output?: string status?: string hideDetails?: boolean @@ -1346,6 +1365,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { { const value = props.input.query if (typeof value !== "string") return "" return value }) + const title = createMemo(() => webSearchProviderLabel(props.metadata.provider)) return ( , "children" | "variant"> { tool: string error: string + title?: string defaultOpen?: boolean subtitle?: string href?: string @@ -23,8 +24,9 @@ export function ToolErrorCard(props: ToolErrorCardProps) { }) const open = () => state.open const copied = () => state.copied - const [split, rest] = splitProps(props, ["tool", "error", "defaultOpen", "subtitle", "href"]) + const [split, rest] = splitProps(props, ["tool", "error", "title", "defaultOpen", "subtitle", "href"]) const name = createMemo(() => { + if (split.title) return split.title const map: Record = { read: "ui.tool.read", list: "ui.tool.list",