feat(coding-agent/web): added web search CLI subcommand and refactored provider management

- Added new `omp q` CLI subcommand for testing web search providers with query, provider, recency, and limit options.
- Added web search provider information API with centralized provider-info module supporting authentication requirements and provider metadata.
- Added support for `hour` recency filter option in Perplexity web search.
- Refactored web search provider definitions into centralized provider-info module for improved maintainability.
- Updated web search result rendering to support long-form answers with text wrapping in CLI mode.
- Removed related questions section from web search result rendering.
This commit is contained in:
can1357
2026-02-05 11:22:25 +01:00
parent 3ff628764d
commit b9ab0cc5cf
9 changed files with 679 additions and 216 deletions
+7 -1
View File
@@ -1,14 +1,20 @@
# Changelog
## [Unreleased]
### Added
- Added `omp q` CLI subcommand for testing web search providers with query, provider, recency, and limit options
- Added web search provider information API with authentication requirements and provider metadata
- Added support for `hour` recency filter option in Perplexity web search
- Support for image file mentions—images are now automatically detected, resized, and attached when referenced with @filepath syntax
- Image dimension information displayed in file mention UI to show image properties alongside text files
### Changed
- Refactored web search provider definitions into centralized provider-info module for better maintainability
- Updated web search result rendering to support long-form answers with text wrapping in CLI mode
- Removed related questions section from web search result rendering
- Updated Perplexity API types to support extended message content formats including images, files, and PDFs
- Updated Perplexity search to use 'pro' search type for improved search quality and relevance
- File mention messages now support both text content and image attachments, with optional line count for text files
- Updated file mention processing to respect image auto-resize settings
+1
View File
@@ -196,6 +196,7 @@ ${chalk.bold("Subcommands:")}
jupyter Manage the shared Jupyter gateway
shell Interactive shell console (brush-core test)
grep Test grep tool
q Test web search providers
${chalk.bold("Options:")}
--model <pattern> Model to use (fuzzy match: "opus", "gpt-5.2", or "p-openai/gpt-5.2")
@@ -0,0 +1,148 @@
/**
* Web search CLI command handlers.
*
* Handles `omp q`/`omp web-search` subcommands for testing web search providers.
*/
import chalk from "chalk";
import { APP_NAME } from "../config";
import { initTheme, theme } from "../modes/theme/theme";
import { renderWebSearchResult } from "../web/search/render";
import { runWebSearchQuery, type WebSearchParams } from "../web/search/index";
import type { WebSearchProvider } from "../web/search/types";
export interface WebSearchCommandArgs {
query: string;
provider?: WebSearchProvider | "auto";
recency?: "day" | "week" | "month" | "year";
limit?: number;
expanded: boolean;
}
const PROVIDERS: Array<WebSearchProvider | "auto"> = [
"auto",
"anthropic",
"perplexity",
"exa",
"jina",
"gemini",
"codex",
];
const RECENCY_OPTIONS: WebSearchCommandArgs["recency"][] = ["day", "week", "month", "year"];
/**
* Parse web search subcommand arguments.
* Returns undefined if not a web search command.
*/
export function parseWebSearchArgs(args: string[]): WebSearchCommandArgs | undefined {
if (args.length === 0 || (args[0] !== "q" && args[0] !== "web-search")) {
return undefined;
}
const result: WebSearchCommandArgs = {
query: "",
expanded: true,
};
const positional: string[] = [];
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (arg === "--provider") {
result.provider = args[++i] as WebSearchCommandArgs["provider"];
} else if (arg === "--recency") {
result.recency = args[++i] as WebSearchCommandArgs["recency"];
} else if (arg === "--limit" || arg === "-l") {
result.limit = Number.parseInt(args[++i], 10);
} else if (arg === "--compact") {
result.expanded = false;
} else if (!arg.startsWith("-")) {
positional.push(arg);
}
}
if (positional.length > 0) {
result.query = positional.join(" ");
}
return result;
}
export async function runWebSearchCommand(cmd: WebSearchCommandArgs): Promise<void> {
if (!cmd.query) {
writeStderr(chalk.red("Error: Query is required"));
process.exit(1);
}
if (cmd.provider && !PROVIDERS.includes(cmd.provider)) {
writeStderr(chalk.red(`Error: Unknown provider "${cmd.provider}"`));
writeStderr(chalk.dim(`Valid providers: ${PROVIDERS.join(", ")}`));
process.exit(1);
}
if (cmd.recency && !RECENCY_OPTIONS.includes(cmd.recency)) {
writeStderr(chalk.red(`Error: Invalid recency "${cmd.recency}"`));
writeStderr(chalk.dim(`Valid recency values: ${RECENCY_OPTIONS.join(", ")}`));
process.exit(1);
}
if (cmd.limit !== undefined && Number.isNaN(cmd.limit)) {
writeStderr(chalk.red("Error: --limit must be a number"));
process.exit(1);
}
await initTheme();
const params: WebSearchParams = {
query: cmd.query,
provider: cmd.provider,
recency: cmd.recency,
limit: cmd.limit,
};
const result = await runWebSearchQuery(params);
const component = renderWebSearchResult(result, { expanded: cmd.expanded, isPartial: false }, theme, {
query: cmd.query,
provider: cmd.provider,
allowLongAnswer: true,
maxAnswerLines: cmd.expanded ? undefined : 6,
});
const width = Math.max(60, process.stdout.columns ?? 100);
writeStdout(component.render(width).join("\n"));
if (result.details?.error) {
process.exitCode = 1;
}
}
export function printWebSearchHelp(): void {
writeStdout(`${chalk.bold(`${APP_NAME} q`)} - Test web search providers
${chalk.bold("Usage:")}
${APP_NAME} q [options] <query>
${APP_NAME} web-search [options] <query>
${chalk.bold("Arguments:")}
query Search query text
${chalk.bold("Options:")}
--provider <name> Provider: ${PROVIDERS.join(", ")}
--recency <value> Recency filter (Perplexity only): ${RECENCY_OPTIONS.join(", ")}
-l, --limit <n> Max results to return
--compact Render condensed output
-h, --help Show this help
${chalk.bold("Examples:")}
${APP_NAME} q --provider=exa "what's the color of the sky"
${APP_NAME} q --provider=perplexity --recency=week "latest TypeScript 5.7 changes"
`);
}
function writeStdout(message: string): void {
process.stdout.write(`${message}\n`);
}
function writeStderr(message: string): void {
process.stderr.write(`${message}\n`);
}
+11
View File
@@ -563,6 +563,17 @@ export async function main(args: string[]) {
return;
}
// Handle web search subcommand (for testing web search providers)
const webSearchCmd = parseWebSearchArgs(args);
if (webSearchCmd) {
if (args.includes("--help") || args.includes("-h")) {
printWebSearchHelp();
return;
}
await runWebSearchCommand(webSearchCmd);
return;
}
// Handle shell subcommand (for testing brush-core shell)
const shellCmd = parseShellArgs(args);
if (shellCmd) {
+30 -103
View File
@@ -24,13 +24,12 @@ import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { ty
import webSearchDescription from "../../prompts/tools/web-search.md" with { type: "text" };
import type { ToolSession } from "../../tools";
import { formatAge } from "../../tools/render-utils";
import { findAnthropicAuth } from "./auth";
import { searchAnthropic } from "./providers/anthropic";
import { searchCodex, hasCodexWebSearch } from "./providers/codex";
import { searchExa } from "./providers/exa";
import { searchGemini, hasGeminiWebSearch } from "./providers/gemini";
import { findApiKey as findJinaKey, searchJina } from "./providers/jina";
import { findApiKey as findPerplexityKey, searchPerplexity } from "./providers/perplexity";
import {
formatWebSearchProviderLabel,
getWebSearchProviderInfo,
WEB_SEARCH_PROVIDER_ORDER,
WEB_SEARCH_PROVIDERS,
} from "./provider-info";
import { renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./render";
import type { WebSearchProvider, WebSearchResponse } from "./types";
import { WebSearchProviderError } from "./types";
@@ -58,90 +57,6 @@ export type WebSearchParams = {
limit?: number;
};
interface WebSearchProviderDefinition {
id: WebSearchProvider;
label: string;
isAvailable: () => Promise<boolean>;
search: (params: {
query: string;
limit?: number;
recency?: "day" | "week" | "month" | "year";
systemPrompt: string;
signal?: AbortSignal;
}) => Promise<WebSearchResponse>;
}
const webSearchProviders: WebSearchProviderDefinition[] = [
{
id: "exa",
label: "Exa",
isAvailable: async () => Boolean(findExaKey()),
search: async ({ query, limit }) =>
searchExa({
query,
num_results: limit,
}),
},
{
id: "jina",
label: "Jina",
isAvailable: async () => Boolean(findJinaKey()),
search: async ({ query, limit }) =>
searchJina({
query,
num_results: limit,
}),
},
{
id: "perplexity",
label: "Perplexity",
isAvailable: async () => Boolean(findPerplexityKey()),
search: async ({ query, limit, recency, systemPrompt }) =>
searchPerplexity({
query,
system_prompt: systemPrompt,
search_recency_filter: recency,
num_results: limit,
}),
},
{
id: "anthropic",
label: "Anthropic",
isAvailable: async () => Boolean(await findAnthropicAuth()),
search: async ({ query, limit, systemPrompt }) =>
searchAnthropic({
query,
system_prompt: systemPrompt,
num_results: limit,
}),
},
{
id: "gemini",
label: "Gemini",
isAvailable: hasGeminiWebSearch,
search: async ({ query, limit, systemPrompt }) =>
searchGemini({
query,
system_prompt: systemPrompt,
num_results: limit,
}),
},
{
id: "codex",
label: "Codex",
isAvailable: hasCodexWebSearch,
search: async ({ query, limit, systemPrompt, signal }) =>
searchCodex({
signal,
query,
system_prompt: systemPrompt,
num_results: limit,
}),
},
];
const webSearchProviderMap = new Map(webSearchProviders.map(provider => [provider.id, provider]));
/** Preferred provider set via settings (default: auto) */
let preferredProvider: WebSearchProvider | "auto" = "auto";
@@ -154,21 +69,18 @@ export function setPreferredWebSearchProvider(provider: WebSearchProvider | "aut
async function getAvailableProviders(): Promise<WebSearchProvider[]> {
const providers: WebSearchProvider[] = [];
for (const provider of webSearchProviders) {
if (await provider.isAvailable()) {
providers.push(provider.id);
for (const provider of WEB_SEARCH_PROVIDER_ORDER) {
const definition = getWebSearchProviderInfo(provider);
if (await definition.isAvailable()) {
providers.push(provider);
}
}
return providers;
}
function formatProviderLabel(provider: WebSearchProvider): string {
return webSearchProviderMap.get(provider)?.label ?? provider;
}
function formatProviderList(providers: WebSearchProvider[]): string {
return providers.map(provider => formatProviderLabel(provider)).join(", ");
return providers.map(provider => formatWebSearchProviderLabel(provider)).join(", ");
}
function formatProviderError(error: unknown, provider: WebSearchProvider): string {
@@ -177,12 +89,12 @@ function formatProviderError(error: unknown, provider: WebSearchProvider): strin
return "Anthropic web search returned 404 (model or endpoint not found).";
}
if (error.status === 401 || error.status === 403) {
return `${formatProviderLabel(error.provider)} authorization failed (${error.status}). Check API key or base URL.`;
return `${formatWebSearchProviderLabel(error.provider)} authorization failed (${error.status}). Check API key or base URL.`;
}
return error.message;
}
if (error instanceof Error) return error.message;
return `Unknown error from ${formatProviderLabel(provider)}`;
return `Unknown error from ${formatWebSearchProviderLabel(provider)}`;
}
async function resolveProviderChain(
@@ -305,7 +217,7 @@ async function executeWebSearch(
for (const provider of providers) {
lastProvider = provider;
const providerDefinition = webSearchProviderMap.get(provider);
const providerDefinition = WEB_SEARCH_PROVIDERS[provider];
if (!providerDefinition) {
lastError = new Error(`Unknown web search provider: ${provider}`);
if (!allowFallback) break;
@@ -313,7 +225,7 @@ async function executeWebSearch(
}
try {
const response = await providerDefinition.search({
query: params.query,
query: params.query.replace(/202\d/g, String(new Date().getFullYear())), // LUL
limit: params.limit,
recency: params.recency,
systemPrompt: webSearchSystemPrompt,
@@ -343,6 +255,15 @@ async function executeWebSearch(
};
}
/**
* Execute a web search query for CLI/testing workflows.
*/
export async function runWebSearchQuery(
params: WebSearchParams,
): Promise<{ content: Array<{ type: "text"; text: string }>; details: WebSearchRenderDetails }> {
return executeWebSearch("cli-web-search", params);
}
/**
* Web search tool implementation.
*
@@ -693,4 +614,10 @@ export async function hasExaWebSearch(): Promise<boolean> {
return exaKey !== null;
}
export {
formatWebSearchProviderLabel,
getWebSearchProviderInfo,
WEB_SEARCH_PROVIDER_ORDER,
WEB_SEARCH_PROVIDERS,
} from "./provider-info";
export type { WebSearchProvider, WebSearchResponse } from "./types";
@@ -0,0 +1,124 @@
import { findAnthropicAuth } from "./auth";
import { searchAnthropic } from "./providers/anthropic";
import { searchCodex, hasCodexWebSearch } from "./providers/codex";
import { searchExa } from "./providers/exa";
import { searchGemini, hasGeminiWebSearch } from "./providers/gemini";
import { findApiKey as findJinaKey, searchJina } from "./providers/jina";
import { findApiKey as findPerplexityKey, searchPerplexity } from "./providers/perplexity";
import type { WebSearchProvider, WebSearchResponse } from "./types";
import { findApiKey as findExaKey } from "../../exa/mcp-client";
export interface WebSearchProviderInfo {
id: WebSearchProvider;
label: string;
/** Authentication requirement summary. */
auth: string;
/** Query hint for UI or docs. */
query: string;
isAvailable: () => Promise<boolean>;
search: (params: {
query: string;
limit?: number;
recency?: "day" | "week" | "month" | "year";
systemPrompt: string;
signal?: AbortSignal;
}) => Promise<WebSearchResponse>;
}
export const WEB_SEARCH_PROVIDER_ORDER: WebSearchProvider[] = [
"exa",
"jina",
"perplexity",
"anthropic",
"gemini",
"codex",
];
export const WEB_SEARCH_PROVIDERS: Record<WebSearchProvider, WebSearchProviderInfo> = {
exa: {
id: "exa",
label: "Exa",
auth: "EXA_API_KEY",
query: "Search query",
isAvailable: async () => Boolean(findExaKey()),
search: async ({ query, limit }) =>
searchExa({
query,
num_results: limit,
}),
},
jina: {
id: "jina",
label: "Jina",
auth: "JINA_API_KEY",
query: "Search query",
isAvailable: async () => Boolean(findJinaKey()),
search: async ({ query, limit }) =>
searchJina({
query,
num_results: limit,
}),
},
perplexity: {
id: "perplexity",
label: "Perplexity",
auth: "PERPLEXITY_API_KEY / PPLX_API_KEY",
query: "Search query",
isAvailable: async () => Boolean(findPerplexityKey()),
search: async ({ query, limit, recency, systemPrompt }) =>
searchPerplexity({
query,
system_prompt: systemPrompt,
search_recency_filter: recency,
num_results: limit,
}),
},
anthropic: {
id: "anthropic",
label: "Anthropic",
auth: "ANTHROPIC_SEARCH_API_KEY / OAuth",
query: "Search query",
isAvailable: async () => Boolean(await findAnthropicAuth()),
search: async ({ query, limit, systemPrompt }) =>
searchAnthropic({
query,
system_prompt: systemPrompt,
num_results: limit,
}),
},
gemini: {
id: "gemini",
label: "Gemini",
auth: "OAuth (agent.db: google-antigravity/google-gemini-cli)",
query: "Search query",
isAvailable: hasGeminiWebSearch,
search: async ({ query, limit, systemPrompt }) =>
searchGemini({
query,
system_prompt: systemPrompt,
num_results: limit,
}),
},
codex: {
id: "codex",
label: "Codex",
auth: "OAuth (agent.db: openai-codex)",
query: "Search query",
isAvailable: hasCodexWebSearch,
search: async ({ query, limit, systemPrompt, signal }) =>
searchCodex({
signal,
query,
system_prompt: systemPrompt,
num_results: limit,
}),
},
};
export function formatWebSearchProviderLabel(provider: WebSearchProvider): string {
return WEB_SEARCH_PROVIDERS[provider]?.label ?? "Unknown";
}
export function getWebSearchProviderInfo(provider: WebSearchProvider): WebSearchProviderInfo {
return WEB_SEARCH_PROVIDERS[provider];
}
@@ -7,6 +7,7 @@
import { getEnvApiKey } from "@oh-my-pi/pi-ai";
import type {
PerplexityMessageOutput,
PerplexityRequest,
PerplexityResponse,
WebSearchCitation,
@@ -20,7 +21,7 @@ const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions";
export interface PerplexitySearchParams {
query: string;
system_prompt?: string;
search_recency_filter?: "day" | "week" | "month" | "year";
search_recency_filter?: "hour" | "day" | "week" | "month" | "year";
num_results?: number;
}
@@ -53,7 +54,7 @@ async function callPerplexity(apiKey: string, request: PerplexityRequest): Promi
}
/** Calculate age in seconds from ISO date string */
function dateToAgeSeconds(dateStr: string | undefined): number | undefined {
function dateToAgeSeconds(dateStr: string | null | undefined): number | undefined {
if (!dateStr) return undefined;
try {
const date = new Date(dateStr);
@@ -64,9 +65,16 @@ function dateToAgeSeconds(dateStr: string | undefined): number | undefined {
}
}
function messageContentToText(content: PerplexityMessageOutput["content"]): string {
if (!content) return "";
if (typeof content === "string") return content;
return content.map(chunk => (chunk.type === "text" ? chunk.text : "")).join("");
}
/** Parse API response into unified WebSearchResponse */
function parseResponse(response: PerplexityResponse): WebSearchResponse {
const answer = response.choices[0]?.message?.content ?? "";
const messageContent = response.choices[0]?.message?.content ?? null;
const answer = messageContentToText(messageContent);
// Build sources by matching citations to search_results
const sources: WebSearchSource[] = [];
@@ -82,7 +90,7 @@ function parseResponse(response: PerplexityResponse): WebSearchResponse {
title: searchResult?.title ?? url,
url,
snippet: searchResult?.snippet,
publishedDate: searchResult?.date,
publishedDate: searchResult?.date ?? undefined,
ageSeconds: dateToAgeSeconds(searchResult?.date),
});
citations.push({
@@ -96,7 +104,7 @@ function parseResponse(response: PerplexityResponse): WebSearchResponse {
title: searchResult.title ?? searchResult.url,
url: searchResult.url,
snippet: searchResult.snippet,
publishedDate: searchResult.date,
publishedDate: searchResult.date ?? undefined,
ageSeconds: dateToAgeSeconds(searchResult.date),
});
}
@@ -107,12 +115,13 @@ function parseResponse(response: PerplexityResponse): WebSearchResponse {
answer: answer || undefined,
sources,
citations: citations.length > 0 ? citations : undefined,
relatedQuestions: response.related_questions,
usage: {
inputTokens: response.usage.prompt_tokens,
outputTokens: response.usage.completion_tokens,
totalTokens: response.usage.total_tokens,
},
usage: response.usage
? {
inputTokens: response.usage.prompt_tokens,
outputTokens: response.usage.completion_tokens,
totalTokens: response.usage.total_tokens,
}
: undefined,
model: response.model,
requestId: response.id,
};
@@ -134,7 +143,6 @@ export async function searchPerplexity(params: PerplexitySearchParams): Promise<
const request: PerplexityRequest = {
model: "sonar-pro",
messages,
return_related_questions: false,
web_search_options: {
search_type: "pro",
search_context_size: "high",
+75 -66
View File
@@ -4,7 +4,7 @@
* Tree-based rendering with collapsed/expanded states for web search results.
*/
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
import type { Theme } from "../../modes/theme/theme";
import {
@@ -20,6 +20,7 @@ import {
truncateToWidth,
} from "../../tools/render-utils";
import { renderOutputBlock, renderStatusLine, renderTreeList } from "../../tui";
import { formatWebSearchProviderLabel } from "./provider-info";
import type { WebSearchResponse } from "./types";
const MAX_COLLAPSED_ANSWER_LINES = PREVIEW_LIMITS.COLLAPSED_LINES;
@@ -70,7 +71,12 @@ export function renderWebSearchResult(
result: { content: Array<{ type: string; text?: string }>; details?: WebSearchRenderDetails },
options: RenderResultOptions,
theme: Theme,
args?: { query?: string; provider?: string },
args?: {
query?: string;
provider?: string;
allowLongAnswer?: boolean;
maxAnswerLines?: number;
},
): Component {
const { expanded } = options;
const details = result.details;
@@ -90,10 +96,6 @@ export function renderWebSearchResult(
const sourceCount = sources.length;
const citations = Array.isArray(response.citations) ? response.citations : [];
const citationCount = citations.length;
const related = Array.isArray(response.relatedQuestions)
? response.relatedQuestions.filter(item => typeof item === "string")
: [];
const relatedCount = related.length;
const searchQueries = Array.isArray(response.searchQueries)
? response.searchQueries.filter(item => typeof item === "string")
: [];
@@ -102,20 +104,21 @@ export function renderWebSearchResult(
// Get answer text
const answerText = typeof response.answer === "string" ? response.answer.trim() : "";
const contentText = answerText || rawText;
const totalAnswerLines = contentText ? contentText.split("\n").filter(l => l.trim()).length : 0;
const answerLines = contentText
? contentText
.split("\n")
.filter(l => l.trim())
.map(l => l.trim())
: [];
const totalAnswerLines = answerLines.length;
const answerLimit = expanded ? MAX_EXPANDED_ANSWER_LINES : MAX_COLLAPSED_ANSWER_LINES;
const answerPreview = contentText ? getPreviewLines(contentText, answerLimit, MAX_ANSWER_LINE_LEN) : [];
const answerPreview = contentText
? args?.allowLongAnswer
? answerLines.slice(0, args.maxAnswerLines ?? answerLines.length)
: getPreviewLines(contentText, answerLimit, MAX_ANSWER_LINE_LEN)
: [];
const providerLabel =
provider === "anthropic"
? "Anthropic"
: provider === "perplexity"
? "Perplexity"
: provider === "exa"
? "Exa"
: provider === "jina"
? "Jina"
: "Unknown";
const providerLabel = formatWebSearchProviderLabel(provider);
const queryPreview = args?.query
? truncateToWidth(args.query, 80)
: searchQueries[0]
@@ -132,20 +135,6 @@ export function renderWebSearchResult(
);
const remainingAnswer = totalAnswerLines - answerPreview.length;
const answerLines = answerPreview.length > 0 ? answerPreview : ["No answer text returned"];
const answerTree = renderTreeList(
{
items: answerLines,
expanded: true,
maxCollapsed: answerLines.length,
itemType: "line",
renderItem: line => (line === "No answer text returned" ? theme.fg("muted", line) : theme.fg("dim", line)),
},
theme,
);
if (remainingAnswer > 0) {
answerTree.push(theme.fg("muted", formatMoreItems(remainingAnswer, "line")));
}
const sourceTree = renderTreeList(
{
@@ -185,28 +174,12 @@ export function renderWebSearchResult(
theme,
);
const relatedLines = related.length > 0 ? related : ["No related questions"];
const relatedTree = renderTreeList(
{
items: relatedLines,
expanded,
maxCollapsed: MAX_COLLAPSED_ITEMS,
itemType: "question",
renderItem: line => theme.fg("muted", line === "No related questions" ? line : `${theme.format.dash} ${line}`),
},
theme,
);
if (!expanded && relatedCount > MAX_COLLAPSED_ITEMS) {
relatedTree.push(theme.fg("muted", formatMoreItems(relatedCount - MAX_COLLAPSED_ITEMS, "question")));
}
const metaLines: string[] = [];
metaLines.push(`${theme.fg("muted", "Provider:")} ${theme.fg("text", providerLabel)}`);
if (response.model) metaLines.push(`${theme.fg("muted", "Model:")} ${theme.fg("text", response.model)}`);
metaLines.push(`${theme.fg("muted", "Sources:")} ${theme.fg("text", String(sourceCount))}`);
if (citationCount > 0)
metaLines.push(`${theme.fg("muted", "Citations:")} ${theme.fg("text", String(citationCount))}`);
if (relatedCount > 0) metaLines.push(`${theme.fg("muted", "Related:")} ${theme.fg("text", String(relatedCount))}`);
if (response.usage) {
const usageParts: string[] = [];
if (response.usage.inputTokens !== undefined) usageParts.push(`in ${response.usage.inputTokens}`);
@@ -228,30 +201,66 @@ export function renderWebSearchResult(
metaLines.push(`${theme.fg("muted", "Queries:")} ${theme.fg("text", queryList.join("; "))}${suffix}`);
}
const sections = [
...(queryPreview
? [
{
lines: [`${theme.fg("muted", "Query:")} ${theme.fg("text", queryPreview)}`],
},
]
: []),
{ label: theme.fg("toolTitle", "Answer"), lines: answerTree },
{
label: theme.fg("toolTitle", "Sources"),
lines: sourceTree.length > 0 ? sourceTree : [theme.fg("muted", "No sources returned")],
},
{ label: theme.fg("toolTitle", "Related"), lines: relatedTree },
{ label: theme.fg("toolTitle", "Metadata"), lines: metaLines },
];
return {
render: (width: number) =>
renderOutputBlock(
{
header,
state: sourceCount > 0 ? "success" : "warning",
sections,
sections: [
...(queryPreview
? [
{
lines: [`${theme.fg("muted", "Query:")} ${theme.fg("text", queryPreview)}`],
},
]
: []),
{
label: theme.fg("toolTitle", "Answer"),
lines: (() => {
const state = sourceCount > 0 ? "success" : "warning";
const borderColor: "warning" | "dim" = state === "warning" ? "warning" : "dim";
const border = (text: string) => theme.fg(borderColor, text);
const contentPrefix = border(`${theme.boxSharp.vertical} `);
const contentSuffix = border(theme.boxSharp.vertical);
const contentWidth = Math.max(
0,
width - visibleWidth(contentPrefix) - visibleWidth(contentSuffix),
);
const answerTreeLines = answerPreview.length > 0 ? answerPreview : ["No answer text returned"];
const answerTree = renderTreeList(
{
items: answerTreeLines,
expanded: true,
maxCollapsed: answerTreeLines.length,
itemType: "line",
renderItem: (line, context) => {
const coloredLine =
line === "No answer text returned"
? theme.fg("muted", line)
: theme.fg("dim", line);
if (!args?.allowLongAnswer) {
return coloredLine;
}
const prefixWidth = visibleWidth(context.continuePrefix);
const wrapWidth = Math.max(10, contentWidth - prefixWidth);
return wrapTextWithAnsi(coloredLine, wrapWidth);
},
},
theme,
);
if (remainingAnswer > 0) {
answerTree.push(theme.fg("muted", formatMoreItems(remainingAnswer, "line")));
}
return answerTree;
})(),
},
{
label: theme.fg("toolTitle", "Sources"),
lines: sourceTree.length > 0 ? sourceTree : [theme.fg("muted", "No sources returned")],
},
{ label: theme.fg("toolTitle", "Metadata"), lines: metaLines },
],
width,
},
theme,
+263 -34
View File
@@ -45,8 +45,6 @@ export interface WebSearchResponse {
sources: WebSearchSource[];
/** Text citations with context */
citations?: WebSearchCitation[];
/** Follow-up questions (perplexity) */
relatedQuestions?: string[];
/** Intermediate search queries (anthropic) */
searchQueries?: string[];
/** Token usage metrics */
@@ -148,52 +146,283 @@ export interface AnthropicApiResponse {
}
/** Perplexity API types */
export interface PerplexityMessage {
role: "system" | "user" | "assistant";
content: string;
export type PerplexityChatMessageRole = "system" | "user" | "assistant" | "tool";
export interface PerplexityUrl {
url: string;
}
export interface PerplexityVideoUrl {
url: string;
frame_interval?: string | number;
}
export interface PerplexityContentTextChunk {
type: "text";
text: string;
}
export interface PerplexityContentImageChunk {
type: "image_url";
image_url: PerplexityUrl | string;
}
export interface PerplexityContentFileChunk {
type: "file_url";
file_url: PerplexityUrl | string;
file_name?: string | null;
}
export interface PerplexityContentPdfChunk {
type: "pdf_url";
pdf_url: PerplexityUrl | string;
}
export interface PerplexityContentVideoChunk {
type: "video_url";
video_url: PerplexityVideoUrl | string;
}
export type PerplexityContentChunk =
| PerplexityContentTextChunk
| PerplexityContentImageChunk
| PerplexityContentFileChunk
| PerplexityContentPdfChunk
| PerplexityContentVideoChunk;
export interface PerplexityWebSearchStepDetails {
search_results: PerplexitySearchResult[];
search_keywords: string[];
}
export interface PerplexityFetchUrlContentStepDetails {
contents: PerplexitySearchResult[];
}
export interface PerplexityExecutePythonStepDetails {
code: string;
result: string;
}
export interface PerplexityReasoningStepInput {
thought: string;
type?: string | null;
web_search?: PerplexityWebSearchStepDetails | null;
fetch_url_content?: PerplexityFetchUrlContentStepDetails | null;
execute_python?: PerplexityExecutePythonStepDetails | null;
}
export interface PerplexityReasoningStepOutput {
thought: string;
type?: string | null;
web_search?: PerplexityWebSearchStepDetails | null;
fetch_url_content?: PerplexityFetchUrlContentStepDetails | null;
execute_python?: PerplexityExecutePythonStepDetails | null;
}
export interface PerplexityToolCallFunction {
name?: string | null;
arguments?: string | null;
}
export interface PerplexityToolCall {
id?: string | null;
type?: "function" | null;
function?: PerplexityToolCallFunction | null;
}
export interface PerplexityMessageInput {
role: PerplexityChatMessageRole;
content: string | PerplexityContentChunk[] | null;
reasoning_steps?: PerplexityReasoningStepInput[] | null;
tool_calls?: PerplexityToolCall[] | null;
tool_call_id?: string | null;
}
export interface PerplexityMessageOutput {
role: PerplexityChatMessageRole;
content: string | PerplexityContentChunk[] | null;
reasoning_steps?: PerplexityReasoningStepOutput[] | null;
tool_calls?: PerplexityToolCall[] | null;
tool_call_id?: string | null;
}
export type PerplexityMessage = PerplexityMessageInput;
export interface PerplexityResponseFormatText {
type: "text";
}
export interface PerplexityJSONSchema {
schema: Record<string, unknown>;
name?: string | null;
description?: string | null;
strict?: boolean | null;
}
export interface PerplexityResponseFormatJSONSchema {
type: "json_schema";
json_schema: PerplexityJSONSchema;
}
export interface PerplexityRegexSchema {
regex: string;
name?: string | null;
description?: string | null;
strict?: boolean | null;
}
export interface PerplexityResponseFormatRegex {
type: "regex";
regex: PerplexityRegexSchema;
}
export type PerplexityResponseFormat =
| PerplexityResponseFormatText
| PerplexityResponseFormatJSONSchema
| PerplexityResponseFormatRegex;
export interface PerplexityParameterSpec {
type: string;
properties: Record<string, unknown>;
required?: string[] | null;
additional_properties?: boolean | null;
}
export interface PerplexityFunctionSpec {
name: string;
description: string;
parameters: PerplexityParameterSpec;
strict?: boolean | null;
}
export interface PerplexityToolSpec {
type: "function";
function: PerplexityFunctionSpec;
}
export interface PerplexityUserLocation {
latitude?: number | null;
longitude?: number | null;
country?: string | null;
city?: string | null;
region?: string | null;
}
export interface PerplexityWebSearchOptions {
search_context_size?: "low" | "medium" | "high";
search_type?: "fast" | "pro" | "auto" | null;
user_location?: PerplexityUserLocation | null;
image_results_enhanced_relevance?: boolean;
}
export interface PerplexityRequest {
max_tokens?: number | null;
n?: number | null;
model: string;
messages: PerplexityMessage[];
temperature?: number;
max_tokens?: number;
search_domain_filter?: string[];
search_recency_filter?: "day" | "week" | "month" | "year";
return_images?: boolean;
return_related_questions?: boolean;
web_search_options?: {
search_type?: "fast" | "pro";
search_context_size?: "low" | "medium" | "high";
};
stream?: boolean | null;
stop?: string | string[] | null;
cum_logprobs?: boolean | null;
logprobs?: boolean | null;
top_logprobs?: number | null;
best_of?: number | null;
response_metadata?: Record<string, unknown> | null;
response_format?: PerplexityResponseFormat | null;
diverse_first_token?: boolean | null;
_inputs?: number[] | null;
_prompt_token_length?: number | null;
messages: PerplexityMessageInput[];
tools?: PerplexityToolSpec[] | null;
tool_choice?: "none" | "auto" | "required" | null;
parallel_tool_calls?: boolean | null;
web_search_options?: PerplexityWebSearchOptions;
search_mode?: "web" | "academic" | "sec" | null;
return_images?: boolean | null;
return_related_questions?: boolean | null;
num_search_results?: number;
num_images?: number;
enable_search_classifier?: boolean | null;
disable_search?: boolean | null;
search_domain_filter?: string[] | null;
search_language_filter?: string[] | null;
search_tenant?: string | null;
ranking_model?: string | null;
latitude?: number | null;
longitude?: number | null;
country?: string | null;
search_recency_filter?: "hour" | "day" | "week" | "month" | "year" | null;
search_after_date_filter?: string | null;
search_before_date_filter?: string | null;
last_updated_before_filter?: string | null;
last_updated_after_filter?: string | null;
image_format_filter?: string[] | null;
image_domain_filter?: string[] | null;
safe_search?: boolean | null;
file_workspace_id?: string | null;
updated_before_timestamp?: number | null;
updated_after_timestamp?: number | null;
search_internal_properties?: Record<string, unknown> | null;
use_threads?: boolean | null;
thread_id?: string | null;
stream_mode?: "full" | "concise";
_debug_pro_search?: boolean;
has_image_url?: boolean;
reasoning_effort?: "minimal" | "low" | "medium" | "high" | null;
language_preference?: string | null;
user_original_query?: string | null;
_force_new_agent?: boolean | null;
}
export interface PerplexitySearchResult {
title: string;
url: string;
date?: string;
date?: string | null;
last_updated?: string | null;
snippet?: string;
source?: "web" | "attachment";
}
export interface PerplexityCost {
input_tokens_cost: number;
output_tokens_cost: number;
reasoning_tokens_cost?: number | null;
request_cost?: number | null;
citation_tokens_cost?: number | null;
search_queries_cost?: number | null;
total_cost: number;
}
export interface PerplexityUsageInfo {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
search_context_size?: string | null;
citation_tokens?: number | null;
num_search_queries?: number | null;
reasoning_tokens?: number | null;
cost: PerplexityCost;
}
export type PerplexityCompletionResponseType = "message" | "info" | "end_of_stream";
export type PerplexityCompletionResponseStatus = "PENDING" | "COMPLETED";
export interface PerplexityChoice {
index: number;
finish_reason?: "stop" | "length" | null;
message: PerplexityMessageOutput;
delta: PerplexityMessageOutput;
}
export interface PerplexityResponse {
id: string;
model: string;
created: number;
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
search_context_size?: string;
};
citations?: string[];
search_results?: PerplexitySearchResult[];
related_questions?: string[];
choices: Array<{
index: number;
finish_reason: string;
message: {
role: string;
content: string;
};
}>;
usage?: PerplexityUsageInfo | null;
object?: string;
choices: PerplexityChoice[];
citations?: string[] | null;
search_results?: PerplexitySearchResult[] | null;
type?: PerplexityCompletionResponseType | null;
status?: PerplexityCompletionResponseStatus | null;
}