Merge remote-tracking branch 'origin/main' into pr-586-r2

# Conflicts:
#	packages/coding-agent/CHANGELOG.md
This commit is contained in:
can1357
2026-04-01 15:33:13 +02:00
55 changed files with 1048 additions and 588 deletions
@@ -189,12 +189,11 @@ function extractFileHeader(diff: string): string {
return headerLines.join("\n");
}
function joinPatch(parts: string[]): string {
return parts
export function joinPatch(parts: string[]): string {
return `${parts
.map(part => (part.endsWith("\n") ? part : `${part}\n`))
.join("\n")
.trimEnd()
.concat("\n");
.replace(/\n+$/, "")}\n`;
}
function selectHunks(file: FileHunks, selector: HunkSelection["hunks"]): FileHunks["hunks"] {
@@ -1,7 +1,7 @@
import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import type { Api, Model } from "@oh-my-pi/pi-ai";
import { MODEL_ROLE_IDS } from "../config/model-registry";
import { parseModelPattern, resolveModelRoleValue } from "../config/model-resolver";
import { parseModelPattern, resolveModelRoleValue, resolveRoleSelection } from "../config/model-resolver";
import type { Settings } from "../config/settings";
import MODEL_PRIO from "../priority.json" with { type: "json" };
@@ -11,24 +11,6 @@ export interface ResolvedCommitModel {
thinkingLevel?: ThinkingLevel;
}
function resolveRoleSelection(
roles: readonly string[],
settings: Settings,
availableModels: Model<Api>[],
): { model: Model<Api>; thinkingLevel?: ThinkingLevel } | undefined {
const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
for (const role of roles) {
const resolved = resolveModelRoleValue(settings.getModelRole(role), availableModels, {
settings,
matchPreferences,
});
if (resolved.model) {
return { model: resolved.model, thinkingLevel: resolved.thinkingLevel };
}
}
return undefined;
}
export async function resolvePrimaryModel(
override: string | undefined,
settings: Settings,
@@ -31,7 +31,7 @@ import { type ConfigError, ConfigFile } from "../config";
import { parseModelString } from "../config/model-resolver";
import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
import type { Settings } from "./settings";
import { type Settings, settings } from "./settings";
export const kNoAuth = "N/A";
@@ -730,6 +730,14 @@ function normalizeSuppressedSelector(selector: string): string {
return `${parsed.provider}/${parsed.id}`;
}
function getDisabledProviderIdsFromSettings(): Set<string> {
try {
return new Set(settings.get("disabledProviders"));
} catch {
return new Set();
}
}
/**
* Model registry - loads and manages models, resolves API keys via AuthStorage.
*/
@@ -1670,11 +1678,19 @@ export class ModelRegistry {
* This is a fast check that doesn't refresh OAuth tokens.
*/
getAvailable(): Model<Api>[] {
return this.#models.filter(m => this.#keylessProviders.has(m.provider) || this.authStorage.hasAuth(m.provider));
const disabledProviders = getDisabledProviderIdsFromSettings();
return this.#models.filter(
m =>
!disabledProviders.has(m.provider) &&
(this.#keylessProviders.has(m.provider) || this.authStorage.hasAuth(m.provider)),
);
}
getDiscoverableProviders(): string[] {
return this.#discoverableProviders.map(provider => provider.provider);
const disabledProviders = getDisabledProviderIdsFromSettings();
return this.#discoverableProviders
.filter(provider => !disabledProviders.has(provider.provider))
.map(provider => provider.provider);
}
getProviderDiscoveryState(provider: string): ProviderDiscoveryState | undefined {
@@ -587,6 +587,27 @@ export function resolveModelOverride(
return { explicitThinkingLevel: false };
}
/**
* Resolve a list of role patterns to the first matching model.
*/
export function resolveRoleSelection(
roles: readonly string[],
settings: Settings,
availableModels: Model<Api>[],
): { model: Model<Api>; thinkingLevel?: ThinkingLevel } | undefined {
const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
for (const role of roles) {
const resolved = resolveModelRoleValue(settings.getModelRole(role), availableModels, {
settings,
matchPreferences,
});
if (resolved.model) {
return { model: resolved.model, thinkingLevel: resolved.thinkingLevel };
}
}
return undefined;
}
/**
* Resolve model patterns to actual Model objects with optional thinking levels
* Format: "pattern:level" where :level is optional
+1
View File
@@ -541,6 +541,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
}
if (parsedArgs.listModels !== undefined) {
await logger.timeAsync("settings:init:list-models", () => Settings.init({ cwd: getProjectDir() }));
await modelRegistry.refresh("online");
const searchPattern = typeof parsedArgs.listModels === "string" ? parsedArgs.listModels : undefined;
await listModels(modelRegistry, searchPattern);
+28 -3
View File
@@ -3,7 +3,7 @@
*/
import type { ToolCallContext } from "@oh-my-pi/pi-agent-core";
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 { FileDiagnosticsResult } from "../lsp";
import { renderDiff as renderDiffColored } from "../modes/components/diff";
@@ -21,7 +21,7 @@ import {
shortenPath,
truncateDiffByHunk,
} from "../tools/render-utils";
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
import type { HashlineToolEdit } from "./index";
import type { DiffError, DiffResult, Operation } from "./types";
@@ -222,6 +222,31 @@ function renderDiffSection(
return text;
}
function wrapEditRendererLine(line: string, width: number): string[] {
if (width <= 0) return [line];
if (line.length === 0) return [""];
const startAnsi = line.match(/^((?:\x1b\[[0-9;]*m)*)/)?.[1] ?? "";
const bodyWithReset = line.slice(startAnsi.length);
const body = bodyWithReset.endsWith("\x1b[39m") ? bodyWithReset.slice(0, -"\x1b[39m".length) : bodyWithReset;
const diffMatch = /^([+\-\s])(\s*\d+)\|(.*)$/s.exec(body);
if (!diffMatch) {
return wrapTextWithAnsi(line, width);
}
const [, marker, lineNum, content] = diffMatch;
const prefix = `${marker}${lineNum}|`;
const prefixWidth = visibleWidth(prefix);
const contentWidth = Math.max(1, width - prefixWidth);
const continuationPrefix = `${" ".repeat(Math.max(0, prefixWidth - 1))}|`;
const wrappedContent = wrapTextWithAnsi(content, contentWidth);
return wrappedContent.map(
(segment, index) => `${startAnsi}${index === 0 ? prefix : continuationPrefix}${segment}\x1b[39m`,
);
}
export const editToolRenderer = {
mergeCallAndResult: true,
@@ -357,7 +382,7 @@ export const editToolRenderer = {
}
const lines =
width > 0 ? text.split("\n").map(line => truncateToWidth(line, width, Ellipsis.Omit)) : text.split("\n");
width > 0 ? text.split("\n").flatMap(line => wrapEditRendererLine(line, width)) : text.split("\n");
cached = { key, lines };
return lines;
},
@@ -1,45 +0,0 @@
Search code snippets, and technical content.
This tool behaves more like grep than natural-language web search.
<instruction>
- Query with exact symbols, identifiers, error strings, CLI flags, filenames, import paths, and short code fragments
- Start with the smallest distinctive token; widen or add one nearby token only if the first query is too broad
- Prefer exact syntax when punctuation carries meaning, such as `Promise.withResolvers`, `useEffect(`, `--watch`, or `"direnv loading"`
- Keep `query` terse; remove filler words, prose, and request framing
- Use `code_context` only for a few disambiguating tokens such as language, library, framework, repo, runtime, or API name
- If a multi-word literal matters exactly, quote the shortest stable phrase first, then refine
- When looking for usage examples of a specific API, search the symbol first; add surrounding call syntax only when needed
</instruction>
<parameters>
- query: Grep-style code search query; use exact tokens, short fragments, or short quoted phrases
- code_context: Optional disambiguation tokens only, not a sentence
</parameters>
<examples>
Good queries:
- `Promise.withResolvers`
- `DIRENV_LOG_FORMAT`
- `"direnv loading"`
- `useState` with `code_context: react hooks`
- `app.get(` with `code_context: express`
- `ERR_REQUIRE_ESM` with `code_context: node`
Bad queries:
- `Need the official or source-backed way to silence direnv loading output`
- `How do I use Promise.withResolvers in Bun?`
- `find examples of React state hooks in TypeScript projects`
- `search GitHub for express routing docs`
</examples>
<avoid>
- Do not use this tool for broad conceptual research, comparisons, or authoritative sourcing; use `web_search`, `web_search_deep`, or `fetch` instead
- Do not put full-sentence instructions into `query` or `code_context`
- Do not pack many weak terms into one query; one strong token plus minimal context usually works better
</avoid>
<critical>
- `query` should be grep-style code search, not a natural-language request
- `code_context` is optional and should stay short
- If you need explanations, best practices, or comprehensive answers, use broader web search tools instead of this one
</critical>
+3 -1
View File
@@ -1009,7 +1009,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
}
// Add web search tools
customTools.push(...getSearchTools());
if (options.toolNames?.includes("web_search")) {
customTools.push(...getSearchTools());
}
// Discover and load custom tools from .omp/tools/, .claude/tools/, etc.
const builtInToolNames = builtinTools.map(t => t.name);
@@ -16,7 +16,7 @@ const CHECK_BYTE_COUNT = 1024;
const HEADER_LINE_LIMIT = 40;
const KNOWN_GENERATOR_PATTERN =
"(?:protoc(?:-gen-[\\w-]+)?|sqlc|buf|swagger(?:-codegen)?|openapi(?:-generator)?|grpc-gateway|mockery|stringer|easyjson|deepcopy-gen|defaulter-gen|conversion-gen|client-gen|lister-gen|informer-gen|kysely-codegen)";
"(?:protoc(?:-gen-[\\w-]+)?|sqlc|buf|swagger(?:-codegen)?|openapi(?:-generator)?|grpc-gateway|mockery|stringer|easyjson|deepcopy-gen|defaulter-gen|conversion-gen|client-gen|lister-gen|informer-gen|kysely-codegen|napi-rs)";
/**
* Strong marker patterns for generated-file headers.
@@ -382,8 +382,8 @@ export function getDiffStats(diffText: string): DiffStats {
export function formatDiffStats(added: number, removed: number, hunks: number, theme: Theme): string {
const parts: string[] = [];
if (added > 0) parts.push(theme.fg("success", `+${added}`));
if (removed > 0) parts.push(theme.fg("error", `-${removed}`));
if (added > 0) parts.push(theme.fg("toolDiffAdded", `+${added}`));
if (removed > 0) parts.push(theme.fg("toolDiffRemoved", `-${removed}`));
if (hunks > 0) parts.push(theme.fg("dim", `${hunks} hunk${hunks !== 1 ? "s" : ""}`));
return parts.join(theme.fg("dim", " / "));
}
@@ -7,7 +7,7 @@ import type { Api, Model } from "@oh-my-pi/pi-ai";
import { completeSimple } from "@oh-my-pi/pi-ai";
import { logger } from "@oh-my-pi/pi-utils";
import type { ModelRegistry } from "../config/model-registry";
import { resolveModelRoleValue } from "../config/model-resolver";
import { resolveRoleSelection } from "../config/model-resolver";
import { renderPromptTemplate } from "../config/prompt-templates";
import type { Settings } from "../config/settings";
import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
@@ -28,13 +28,9 @@ function getTitleModel(
const availableModels = registry.getAvailable();
if (availableModels.length === 0) return undefined;
const matchPreferences = { usageOrder: settings.getStorage()?.getModelUsageOrder() };
const configuredSmol = resolveModelRoleValue(settings.getModelRole("smol"), availableModels, {
settings,
matchPreferences,
});
if (configuredSmol.model) {
return { model: configuredSmol.model, thinkingLevel: configuredSmol.thinkingLevel };
const titleModel = resolveRoleSelection(["commit", "smol"], settings, availableModels);
if (titleModel) {
return { model: titleModel.model, thinkingLevel: titleModel.thinkingLevel };
}
if (currentModel) {
@@ -1,208 +0,0 @@
import type { Component } from "@oh-my-pi/pi-tui";
import { Text } from "@oh-my-pi/pi-tui";
import { callExaTool, findApiKey as findExaKey, formatSearchResults, isSearchResponse } from "../../exa/mcp-client";
import type { CustomToolResult, RenderResultOptions } from "../../extensibility/custom-tools/types";
import type { Theme } from "../../modes/theme/theme";
import {
formatCount,
formatExpandHint,
formatMoreItems,
formatStatusIcon,
replaceTabs,
truncateToWidth,
} from "../../tools/render-utils";
import type { CodeSearchProviderId } from "./types";
export interface CodeSearchToolParams {
query: string;
code_context?: string;
}
export interface CodeSearchSource {
title: string;
url: string;
repository: string;
path: string;
branch: string;
snippet?: string;
totalMatches?: string;
}
export interface CodeSearchResponse {
provider: CodeSearchProviderId;
query: string;
totalResults?: number;
sources: CodeSearchSource[];
}
export interface CodeSearchRenderDetails {
response?: CodeSearchResponse;
error?: string;
provider: CodeSearchProviderId;
}
function stringifyExaCodeResponse(payload: unknown): string {
if (typeof payload === "string") return payload;
if (typeof payload === "number" || typeof payload === "boolean") return String(payload);
if (payload === null || payload === undefined) return "";
const serialized = JSON.stringify(payload, null, 2);
return typeof serialized === "string" ? serialized : "";
}
function normalizeExaCodeSearchResponse(
params: CodeSearchToolParams,
payload: unknown,
formattedSearchResponse?: string,
): CodeSearchResponse {
const snippet = formattedSearchResponse ?? stringifyExaCodeResponse(payload);
return {
provider: "exa",
query: params.query,
sources: [
{
title: params.query,
url: "https://exa.ai/",
repository: "exa",
path: "code-search",
branch: "public-mcp",
snippet: snippet.length > 0 ? snippet : undefined,
},
],
};
}
async function searchCodeWithExa(params: CodeSearchToolParams): Promise<CodeSearchResponse> {
const exaParams = params.code_context
? { query: params.query, code_context: params.code_context }
: { query: params.query };
const response = await callExaTool("get_code_context_exa", exaParams, findExaKey());
if (isSearchResponse(response)) {
return normalizeExaCodeSearchResponse(params, response, formatSearchResults(response));
}
return normalizeExaCodeSearchResponse(params, response);
}
export function formatCodeSearchForLlm(response: CodeSearchResponse): string {
const parts: string[] = [];
const summaryParts: string[] = [response.provider];
if (response.totalResults !== undefined) {
summaryParts.push(`${response.totalResults.toLocaleString()} total matches`);
}
parts.push(`Code search via ${summaryParts.join(" · ")}`);
if (response.sources.length === 0) {
parts.push("No results found.");
return parts.join("\n");
}
for (const [index, source] of response.sources.entries()) {
const metadata: string[] = [source.repository, source.path];
if (source.totalMatches) metadata.push(`${source.totalMatches} matches`);
parts.push(`[${index + 1}] ${metadata.join(" · ")}`);
parts.push(` ${source.url}`);
if (source.snippet) {
for (const line of source.snippet.split("\n").slice(0, 8)) {
parts.push(` ${line}`);
}
}
}
return parts.join("\n");
}
export async function executeCodeSearch(
params: CodeSearchToolParams,
): Promise<CustomToolResult<CodeSearchRenderDetails>> {
try {
const response = await searchCodeWithExa(params);
return {
content: [{ type: "text", text: formatCodeSearchForLlm(response) }],
details: { provider: response.provider, response },
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: `Error: ${message}` }],
details: { provider: "exa", error: message },
};
}
}
export function renderCodeSearchCall(
args: CodeSearchToolParams,
_options: RenderResultOptions,
theme: Theme,
): Component {
let text = `${theme.fg("toolTitle", "Code Search")} ${theme.fg("accent", truncateToWidth(args.query, 80))}`;
if (args.code_context) {
text += ` ${theme.fg("dim", truncateToWidth(args.code_context, 40))}`;
}
return new Text(text, 0, 0);
}
export function renderCodeSearchResult(
result: { content: Array<{ type: string; text?: string }>; details?: CodeSearchRenderDetails },
options: RenderResultOptions,
uiTheme: Theme,
): Component {
const details = result.details;
if (details?.error) {
return new Text(
`${formatStatusIcon("error", uiTheme)} ${uiTheme.fg("error", `Error: ${replaceTabs(details.error)}`)}`,
0,
0,
);
}
const response = details?.response;
if (!response) {
return new Text(`${formatStatusIcon("warning", uiTheme)} ${uiTheme.fg("muted", "No code search results")}`, 0, 0);
}
const resultCount = response.sources.length;
const meta: string[] = [formatCount("result", resultCount), `provider:${response.provider}`];
if (response.totalResults !== undefined) {
meta.push(`${response.totalResults.toLocaleString()} total`);
}
const expandHint = formatExpandHint(uiTheme, options.expanded, resultCount > 1);
let text = `${formatStatusIcon(resultCount > 0 ? "success" : "warning", uiTheme)} ${uiTheme.fg("dim", meta.join(uiTheme.sep.dot))}${expandHint}`;
if (resultCount === 0) {
text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", "No results")}`;
return new Text(text, 0, 0);
}
const visibleSources = options.expanded ? response.sources : response.sources.slice(0, 1);
for (const [index, source] of visibleSources.entries()) {
const isLast = index === visibleSources.length - 1;
const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
const cont = isLast ? " " : uiTheme.tree.vertical;
text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("accent", truncateToWidth(replaceTabs(source.title), 100))}`;
text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg("mdLinkUrl", source.url)}`;
if (source.totalMatches) {
text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg("muted", `Matches: ${source.totalMatches}`)}`;
}
if (source.snippet) {
const snippetLines = source.snippet.split("\n").slice(0, options.expanded ? 6 : 3);
for (const line of snippetLines) {
text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
"toolOutput",
truncateToWidth(replaceTabs(line), 100),
)}`;
}
}
}
if (!options.expanded && response.sources.length > visibleSources.length) {
text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
"muted",
formatMoreItems(response.sources.length - visibleSources.length, "result"),
)}`;
}
return new Text(text, 0, 0);
}
+1 -36
View File
@@ -4,7 +4,6 @@
* Single tool supporting Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Tavily, Kagi, Z.AI, and Synthetic
* providers with provider-specific parameters exposed conditionally.
*
* Code search is also supported via the code_search tool.
*/
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { StringEnum } from "@oh-my-pi/pi-ai";
@@ -13,17 +12,9 @@ import { renderPromptTemplate } from "../../config/prompt-templates";
import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../extensibility/custom-tools/types";
import type { Theme } from "../../modes/theme/theme";
import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { type: "text" };
import codeSearchDescription from "../../prompts/tools/code-search.md" with { type: "text" };
import webSearchDescription from "../../prompts/tools/web-search.md" with { type: "text" };
import type { ToolSession } from "../../tools";
import { formatAge } from "../../tools/render-utils";
import {
type CodeSearchRenderDetails,
type CodeSearchToolParams,
executeCodeSearch,
renderCodeSearchCall,
renderCodeSearchResult,
} from "./code-search";
import { getSearchProvider, resolveProviderChain, type SearchProvider } from "./provider";
import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
import type { SearchProviderId, SearchResponse } from "./types";
@@ -262,34 +253,8 @@ export const webSearchCustomTool: CustomTool<typeof webSearchSchema, SearchRende
},
};
/** Schema for code context search */
const codeSearchParameters = Type.Object({
query: Type.String({ description: "Grep-style code search query; use exact tokens or short quoted phrases" }),
code_context: Type.Optional(Type.String({ description: "Optional disambiguation tokens only, not a sentence" })),
});
/** Code context search - optimized for code snippets and documentation */
export const codeSearchTool: CustomTool<typeof codeSearchParameters, CodeSearchRenderDetails> = {
name: "code_search",
label: "Code Search",
description: renderPromptTemplate(codeSearchDescription),
parameters: codeSearchParameters,
async execute(_toolCallId, params, _onUpdate, _ctx, _signal) {
return executeCodeSearch(params);
},
renderCall(args: CodeSearchToolParams, options: RenderResultOptions, theme: Theme) {
return renderCodeSearchCall(args, options, theme);
},
renderResult(result, options, theme) {
return renderCodeSearchResult(result, options, theme);
},
};
export function getSearchTools(): CustomTool<any, any>[] {
return [webSearchCustomTool, codeSearchTool];
return [webSearchCustomTool];
}
export { getSearchProvider, setPreferredSearchProvider } from "./provider";
@@ -20,8 +20,6 @@ export type SearchProviderId =
| "kagi"
| "synthetic";
export type CodeSearchProviderId = "exa";
export function isSearchProviderId(value: string): value is SearchProviderId {
return [
"exa",