@oh-my-pi/pi-coding-agent 15.9.3 → 15.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -1
- package/dist/types/cli/classify-install-target.d.ts +5 -1
- package/dist/types/config/settings-schema.d.ts +13 -4
- package/dist/types/modes/components/assistant-message.d.ts +11 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -1
- package/dist/types/modes/components/error-banner.d.ts +11 -0
- package/dist/types/modes/components/tool-execution.d.ts +15 -0
- package/dist/types/modes/components/transcript-container.d.ts +1 -0
- package/dist/types/modes/components/user-message.d.ts +1 -1
- package/dist/types/modes/image-references.d.ts +17 -0
- package/dist/types/modes/interactive-mode.d.ts +7 -0
- package/dist/types/modes/types.d.ts +7 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
- package/dist/types/session/blob-store.d.ts +12 -11
- package/dist/types/session/session-manager.d.ts +5 -3
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/tiny/title-client.d.ts +16 -1
- package/dist/types/tool-discovery/mode.d.ts +8 -0
- package/dist/types/tools/archive-reader.d.ts +5 -1
- package/dist/types/tui/hyperlink.d.ts +12 -0
- package/dist/types/web/search/render.d.ts +1 -2
- package/package.json +9 -9
- package/src/cli/classify-install-target.ts +31 -5
- package/src/cli/plugin-cli.ts +45 -0
- package/src/cli/web-search-cli.ts +0 -1
- package/src/config/model-registry.ts +54 -4
- package/src/config/settings-schema.ts +14 -4
- package/src/eval/__tests__/agent-bridge.test.ts +72 -0
- package/src/eval/py/tool-bridge.ts +43 -5
- package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/main.ts +7 -1
- package/src/modes/components/assistant-message.ts +22 -0
- package/src/modes/components/custom-editor.ts +14 -2
- package/src/modes/components/error-banner.ts +33 -0
- package/src/modes/components/tool-execution.ts +44 -0
- package/src/modes/components/transcript-container.ts +93 -32
- package/src/modes/components/user-message.ts +9 -2
- package/src/modes/controllers/event-controller.ts +42 -3
- package/src/modes/controllers/input-controller.ts +33 -1
- package/src/modes/image-references.ts +111 -0
- package/src/modes/interactive-mode.ts +48 -13
- package/src/modes/types.ts +10 -1
- package/src/modes/utils/ui-helpers.ts +23 -2
- package/src/prompts/ci-green-request.md +5 -3
- package/src/prompts/system/project-prompt.md +1 -0
- package/src/sdk.ts +17 -9
- package/src/session/agent-session.ts +37 -12
- package/src/session/blob-store.ts +96 -9
- package/src/session/session-manager.ts +19 -10
- package/src/system-prompt.ts +4 -0
- package/src/tiny/title-client.ts +7 -1
- package/src/tool-discovery/mode.ts +24 -0
- package/src/tools/archive-reader.ts +339 -31
- package/src/tools/fetch.ts +29 -9
- package/src/tools/gh.ts +65 -11
- package/src/tools/index.ts +6 -8
- package/src/tools/read.ts +58 -12
- package/src/tools/search-tool-bm25.ts +4 -6
- package/src/tools/search.ts +60 -11
- package/src/tui/hyperlink.ts +42 -7
- package/src/web/search/index.ts +2 -2
- package/src/web/search/render.ts +20 -52
package/src/tools/read.ts
CHANGED
|
@@ -2316,6 +2316,49 @@ interface ReadRenderArgs {
|
|
|
2316
2316
|
raw?: boolean;
|
|
2317
2317
|
}
|
|
2318
2318
|
|
|
2319
|
+
const INTERNAL_URL_LIKE_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
2320
|
+
|
|
2321
|
+
function splitReadRenderPath(rawPath: string): { path: string; sel?: string } {
|
|
2322
|
+
if (INTERNAL_URL_LIKE_RE.test(rawPath)) {
|
|
2323
|
+
const internal = splitInternalUrlSel(rawPath);
|
|
2324
|
+
if (internal.sel) return internal;
|
|
2325
|
+
}
|
|
2326
|
+
return splitPathAndSel(rawPath);
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
function firstReadSelectorLine(sel: string | undefined): number | undefined {
|
|
2330
|
+
if (!sel) return undefined;
|
|
2331
|
+
try {
|
|
2332
|
+
const parsed = parseSel(sel);
|
|
2333
|
+
if (parsed.kind !== "lines") return undefined;
|
|
2334
|
+
return parsed.ranges[0].startLine;
|
|
2335
|
+
} catch {
|
|
2336
|
+
return undefined;
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
function formatReadPathLink(
|
|
2341
|
+
rawPath: string,
|
|
2342
|
+
options: {
|
|
2343
|
+
resolvedPath?: string;
|
|
2344
|
+
suffixResolution?: { from: string; to: string };
|
|
2345
|
+
offset?: number;
|
|
2346
|
+
fallbackLabel?: string;
|
|
2347
|
+
},
|
|
2348
|
+
): string {
|
|
2349
|
+
const split = splitReadRenderPath(rawPath);
|
|
2350
|
+
const basePath = split.path || rawPath;
|
|
2351
|
+
const selectorSuffix = split.sel ? `:${split.sel}` : "";
|
|
2352
|
+
const plainDisplayPath = options.suffixResolution
|
|
2353
|
+
? shortenPath(options.suffixResolution.to)
|
|
2354
|
+
: shortenPath(basePath || options.resolvedPath || options.fallbackLabel || rawPath);
|
|
2355
|
+
const target = options.resolvedPath ?? tryResolveInternalUrlSync(basePath);
|
|
2356
|
+
const line = firstReadSelectorLine(split.sel) ?? options.offset;
|
|
2357
|
+
const linkOptions = line !== undefined ? { line } : undefined;
|
|
2358
|
+
const displayPath = target ? fileHyperlink(target, plainDisplayPath, linkOptions) : plainDisplayPath;
|
|
2359
|
+
return `${displayPath}${selectorSuffix}`;
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2319
2362
|
export const readToolRenderer = {
|
|
2320
2363
|
renderCall(args: ReadRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
2321
2364
|
if (isReadableUrlPath(args.file_path || args.path || "")) {
|
|
@@ -2323,13 +2366,10 @@ export const readToolRenderer = {
|
|
|
2323
2366
|
}
|
|
2324
2367
|
|
|
2325
2368
|
const rawPath = args.file_path || args.path || "";
|
|
2326
|
-
const shortPath = shortenPath(rawPath);
|
|
2327
|
-
const linkTarget = tryResolveInternalUrlSync(rawPath);
|
|
2328
|
-
const filePath = linkTarget ? fileHyperlink(linkTarget, shortPath) : shortPath;
|
|
2329
2369
|
const offset = args.offset;
|
|
2330
2370
|
const limit = args.limit;
|
|
2331
2371
|
|
|
2332
|
-
let pathDisplay =
|
|
2372
|
+
let pathDisplay = formatReadPathLink(rawPath, { offset, fallbackLabel: "…" }) || "…";
|
|
2333
2373
|
if (offset !== undefined || limit !== undefined) {
|
|
2334
2374
|
const startLine = offset ?? 1;
|
|
2335
2375
|
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
|
@@ -2363,7 +2403,7 @@ export const readToolRenderer = {
|
|
|
2363
2403
|
const rawErrorText = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
2364
2404
|
const errorText = (rawErrorText || "Unknown error").replace(/^Error:\s*/, "");
|
|
2365
2405
|
const rawPath = args?.file_path || args?.path || "";
|
|
2366
|
-
const filePath = shortenPath(rawPath);
|
|
2406
|
+
const filePath = formatReadPathLink(rawPath, { offset: args?.offset }) || shortenPath(rawPath);
|
|
2367
2407
|
let title = filePath ? `Read ${filePath}` : "Read";
|
|
2368
2408
|
if (args?.offset !== undefined || args?.limit !== undefined) {
|
|
2369
2409
|
const startLine = args.offset ?? 1;
|
|
@@ -2388,8 +2428,8 @@ export const readToolRenderer = {
|
|
|
2388
2428
|
const contentText = details?.displayContent?.text ?? stripOutputNotice(rawText, details?.meta);
|
|
2389
2429
|
const imageContent = result.content?.find(c => c.type === "image");
|
|
2390
2430
|
const rawPath = args?.file_path || args?.path || "";
|
|
2391
|
-
const
|
|
2392
|
-
const lang = getLanguageFromPath(
|
|
2431
|
+
const renderPath = splitReadRenderPath(rawPath);
|
|
2432
|
+
const lang = getLanguageFromPath(renderPath.path);
|
|
2393
2433
|
|
|
2394
2434
|
const warningLines: string[] = [];
|
|
2395
2435
|
const truncation = details?.meta?.truncation;
|
|
@@ -2412,7 +2452,11 @@ export const readToolRenderer = {
|
|
|
2412
2452
|
|
|
2413
2453
|
if (imageContent) {
|
|
2414
2454
|
const suffix = details?.suffixResolution;
|
|
2415
|
-
const displayPath =
|
|
2455
|
+
const displayPath = formatReadPathLink(rawPath, {
|
|
2456
|
+
resolvedPath: details?.resolvedPath,
|
|
2457
|
+
suffixResolution: suffix,
|
|
2458
|
+
fallbackLabel: "image",
|
|
2459
|
+
});
|
|
2416
2460
|
const correction = suffix ? ` ${uiTheme.fg("dim", `(corrected from ${shortenPath(suffix.from)})`)}` : "";
|
|
2417
2461
|
const header = renderStatusLine(
|
|
2418
2462
|
{ icon: suffix ? "warning" : "success", title: "Read", description: `${displayPath}${correction}` },
|
|
@@ -2442,13 +2486,15 @@ export const readToolRenderer = {
|
|
|
2442
2486
|
}
|
|
2443
2487
|
|
|
2444
2488
|
const suffix = details?.suffixResolution;
|
|
2445
|
-
const plainDisplayPath = suffix ? shortenPath(suffix.to) : filePath;
|
|
2446
2489
|
// resolvedPath is the absolute fs path for fs-backed reads (regular files plus
|
|
2447
2490
|
// local:// / memory:// / skill:// / artifact:// resources). Fall back to a sync
|
|
2448
2491
|
// resolver for fs-backed internal URLs so the title is clickable even before the
|
|
2449
2492
|
// result lands or if the handler didn't populate resolvedPath.
|
|
2450
|
-
const
|
|
2451
|
-
|
|
2493
|
+
const displayPath = formatReadPathLink(rawPath, {
|
|
2494
|
+
resolvedPath: details?.resolvedPath,
|
|
2495
|
+
suffixResolution: suffix,
|
|
2496
|
+
offset: args?.offset,
|
|
2497
|
+
});
|
|
2452
2498
|
const correction = suffix ? ` ${uiTheme.fg("dim", `(corrected from ${shortenPath(suffix.from)})`)}` : "";
|
|
2453
2499
|
let title = displayPath ? `Read ${displayPath}${correction}` : "Read";
|
|
2454
2500
|
if (args?.offset !== undefined || args?.limit !== undefined) {
|
|
@@ -2463,7 +2509,7 @@ export const readToolRenderer = {
|
|
|
2463
2509
|
const n = details.conflictCount;
|
|
2464
2510
|
title += ` ${uiTheme.fg("warning", `(⚠ ${n} conflict${n === 1 ? "" : "s"})`)}`;
|
|
2465
2511
|
}
|
|
2466
|
-
const rawRequested = args?.raw === true || isRawSelector(parseSel(
|
|
2512
|
+
const rawRequested = args?.raw === true || isRawSelector(parseSel(renderPath.sel));
|
|
2467
2513
|
const isMarkdown = details?.contentType === "text/markdown" && !rawRequested;
|
|
2468
2514
|
let cachedWidth: number | undefined;
|
|
2469
2515
|
let cachedExpanded: boolean | undefined;
|
|
@@ -6,6 +6,7 @@ import * as z from "zod/v4";
|
|
|
6
6
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
7
7
|
import type { Theme } from "../modes/theme/theme";
|
|
8
8
|
import searchToolBm25Description from "../prompts/tools/search-tool-bm25.md" with { type: "text" };
|
|
9
|
+
import { resolveEffectiveToolDiscoveryMode } from "../tool-discovery/mode";
|
|
9
10
|
import {
|
|
10
11
|
buildDiscoverableToolSearchIndex,
|
|
11
12
|
type DiscoverableTool,
|
|
@@ -198,12 +199,9 @@ export class SearchToolBm25Tool implements AgentTool<typeof searchToolBm25Schema
|
|
|
198
199
|
constructor(private readonly session: ToolSession) {}
|
|
199
200
|
|
|
200
201
|
static createIf(session: ToolSession): SearchToolBm25Tool | null {
|
|
201
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
(toolsDiscoveryMode !== undefined && toolsDiscoveryMode !== "off") ||
|
|
205
|
-
session.settings.get("mcp.discoveryMode") === true;
|
|
206
|
-
if (!active) return null;
|
|
202
|
+
// Direct createTools() calls do not know the final MCP/extension catalog yet, so
|
|
203
|
+
// auto mode is activated later by createAgentSession after the full registry exists.
|
|
204
|
+
if (resolveEffectiveToolDiscoveryMode(session.settings, 0) === "off") return null;
|
|
207
205
|
return supportsToolDiscoveryExecution(session) ? new SearchToolBm25Tool(session) : null;
|
|
208
206
|
}
|
|
209
207
|
|
package/src/tools/search.ts
CHANGED
|
@@ -16,7 +16,15 @@ import type { InternalResource, ResolveContext } from "../internal-urls/types";
|
|
|
16
16
|
import type { Theme } from "../modes/theme/theme";
|
|
17
17
|
import searchDescription from "../prompts/tools/search.md" with { type: "text" };
|
|
18
18
|
import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead, truncateLine } from "../session/streaming-output";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
Ellipsis,
|
|
21
|
+
fileHyperlink,
|
|
22
|
+
renderStatusLine,
|
|
23
|
+
renderTreeList,
|
|
24
|
+
truncateToWidth,
|
|
25
|
+
tryResolveInternalUrlSync,
|
|
26
|
+
uriHyperlink,
|
|
27
|
+
} from "../tui";
|
|
20
28
|
import { resolveFileDisplayMode } from "../utils/file-display-mode";
|
|
21
29
|
import type { ToolSession } from ".";
|
|
22
30
|
import {
|
|
@@ -1162,6 +1170,26 @@ interface SearchRenderArgs {
|
|
|
1162
1170
|
|
|
1163
1171
|
const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
|
|
1164
1172
|
|
|
1173
|
+
const SEARCH_CODE_FRAME_LINE_RE = /^\s*\*?(\d+)│/;
|
|
1174
|
+
|
|
1175
|
+
function searchScopeMeta(details: SearchToolDetails | undefined): string | undefined {
|
|
1176
|
+
if (!details?.scopePath) return undefined;
|
|
1177
|
+
const label = details.searchPath ? fileHyperlink(details.searchPath, details.scopePath) : details.scopePath;
|
|
1178
|
+
return `in ${label}`;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function linkUrlLikeSearchHeader(raw: string, styled: string): { line: string; absPath?: string } {
|
|
1182
|
+
const resolvedPath = tryResolveInternalUrlSync(raw);
|
|
1183
|
+
if (resolvedPath) return { line: fileHyperlink(resolvedPath, styled), absPath: resolvedPath };
|
|
1184
|
+
return { line: uriHyperlink(raw, styled) };
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function parseSearchDisplayLineNumber(line: string): number | undefined {
|
|
1188
|
+
const match = SEARCH_CODE_FRAME_LINE_RE.exec(line);
|
|
1189
|
+
if (!match) return undefined;
|
|
1190
|
+
return Number.parseInt(match[1]!, 10);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1165
1193
|
export const searchToolRenderer = {
|
|
1166
1194
|
inline: true,
|
|
1167
1195
|
renderCall(args: SearchRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
@@ -1237,8 +1265,11 @@ export const searchToolRenderer = {
|
|
|
1237
1265
|
: undefined;
|
|
1238
1266
|
|
|
1239
1267
|
if (matchCount === 0) {
|
|
1268
|
+
const meta = ["0 matches"];
|
|
1269
|
+
const scopeMeta = searchScopeMeta(details);
|
|
1270
|
+
if (scopeMeta) meta.push(scopeMeta);
|
|
1240
1271
|
const header = renderStatusLine(
|
|
1241
|
-
{ icon: "warning", title: "Search", description: args?.pattern, meta
|
|
1272
|
+
{ icon: "warning", title: "Search", description: args?.pattern, meta },
|
|
1242
1273
|
uiTheme,
|
|
1243
1274
|
);
|
|
1244
1275
|
const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
|
|
@@ -1248,7 +1279,8 @@ export const searchToolRenderer = {
|
|
|
1248
1279
|
|
|
1249
1280
|
const summaryParts = [formatCount("match", matchCount), formatCount("file", fileCount)];
|
|
1250
1281
|
const meta = [...summaryParts];
|
|
1251
|
-
|
|
1282
|
+
const scopeMeta = searchScopeMeta(details);
|
|
1283
|
+
if (scopeMeta) meta.push(scopeMeta);
|
|
1252
1284
|
if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
|
|
1253
1285
|
const description = args?.pattern ?? undefined;
|
|
1254
1286
|
const header = renderStatusLine(
|
|
@@ -1287,10 +1319,11 @@ export const searchToolRenderer = {
|
|
|
1287
1319
|
maxCollapsedLines: collapsedMatchLineBudget,
|
|
1288
1320
|
itemType: "match",
|
|
1289
1321
|
renderItem: group => {
|
|
1290
|
-
// Track directory context within a group
|
|
1291
|
-
//
|
|
1292
|
-
// from formatGroupedFiles (single-# when directory is `.`).
|
|
1322
|
+
// Track directory/file context within a group so headers and code-frame
|
|
1323
|
+
// lines link to the backing file, with line-specific links for matches.
|
|
1293
1324
|
let contextDir = searchBase ?? "";
|
|
1325
|
+
const hasFileHeader = group.some(line => line.startsWith("# "));
|
|
1326
|
+
let currentFilePath: string | undefined = hasFileHeader ? undefined : searchBase;
|
|
1294
1327
|
return group.map(line => {
|
|
1295
1328
|
if (line.startsWith("## ")) {
|
|
1296
1329
|
// Strip optional ` (suffix)` and `#hash` before resolving.
|
|
@@ -1300,6 +1333,7 @@ export const searchToolRenderer = {
|
|
|
1300
1333
|
.replace(/\s+\([^)]*\)\s*$/, "")
|
|
1301
1334
|
.replace(/#[0-9a-f]+$/, "");
|
|
1302
1335
|
const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined;
|
|
1336
|
+
currentFilePath = absPath;
|
|
1303
1337
|
const styled = uiTheme.fg("dim", line);
|
|
1304
1338
|
return absPath ? fileHyperlink(absPath, styled) : styled;
|
|
1305
1339
|
}
|
|
@@ -1310,22 +1344,37 @@ export const searchToolRenderer = {
|
|
|
1310
1344
|
.replace(/\s+\([^)]*\)\s*$/, "");
|
|
1311
1345
|
if (INTERNAL_URL_DISPLAY_RE.test(raw)) {
|
|
1312
1346
|
contextDir = "";
|
|
1313
|
-
|
|
1347
|
+
const styled = uiTheme.fg("accent", line);
|
|
1348
|
+
const linked = linkUrlLikeSearchHeader(raw, styled);
|
|
1349
|
+
currentFilePath = linked.absPath;
|
|
1350
|
+
return linked.line;
|
|
1314
1351
|
}
|
|
1315
1352
|
const isDirectory = raw.endsWith("/");
|
|
1316
1353
|
const name = isDirectory ? raw.replace(/\/$/, "") : raw.replace(/#[0-9a-f]+$/, "");
|
|
1317
1354
|
if (isDirectory) {
|
|
1318
|
-
|
|
1319
|
-
|
|
1355
|
+
const absPath = searchBase
|
|
1356
|
+
? name === "."
|
|
1357
|
+
? searchBase
|
|
1358
|
+
: path.join(searchBase, name)
|
|
1359
|
+
: undefined;
|
|
1360
|
+
if (absPath) {
|
|
1361
|
+
contextDir = absPath;
|
|
1320
1362
|
}
|
|
1321
|
-
|
|
1363
|
+
currentFilePath = undefined;
|
|
1364
|
+
const styled = uiTheme.fg("accent", line);
|
|
1365
|
+
return absPath ? fileHyperlink(absPath, styled) : styled;
|
|
1322
1366
|
}
|
|
1323
1367
|
// Root-level file emitted by formatGroupedFiles when the directory is `.`.
|
|
1324
1368
|
const absPath = searchBase && name ? path.join(searchBase, name) : undefined;
|
|
1369
|
+
currentFilePath = absPath;
|
|
1325
1370
|
const styled = uiTheme.fg("accent", line);
|
|
1326
1371
|
return absPath ? fileHyperlink(absPath, styled) : styled;
|
|
1327
1372
|
}
|
|
1328
|
-
|
|
1373
|
+
const styled = uiTheme.fg("toolOutput", line);
|
|
1374
|
+
const lineNumber = parseSearchDisplayLineNumber(line);
|
|
1375
|
+
return currentFilePath && lineNumber !== undefined
|
|
1376
|
+
? fileHyperlink(currentFilePath, styled, { line: lineNumber })
|
|
1377
|
+
: styled;
|
|
1329
1378
|
});
|
|
1330
1379
|
},
|
|
1331
1380
|
},
|
package/src/tui/hyperlink.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OSC 8 terminal hyperlink support for
|
|
2
|
+
* OSC 8 terminal hyperlink support for paths and URLs.
|
|
3
3
|
*
|
|
4
4
|
* Wraps display text in `ESC ] 8 ; id=HASH ; URI ESC \ TEXT ESC ] 8 ; ; ESC \`
|
|
5
5
|
* sequences when the active terminal supports hyperlinks and the user setting
|
|
@@ -63,6 +63,46 @@ export function isHyperlinkEnabled(): boolean {
|
|
|
63
63
|
return TERMINAL.hyperlinks;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function safeHyperlinkUri(uri: string): string | undefined {
|
|
67
|
+
if (!uri || /[\x00-\x1f\x7f]/.test(uri)) return undefined;
|
|
68
|
+
return uri;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function wrapHyperlink(uri: string, displayText: string): string {
|
|
72
|
+
if (!isHyperlinkEnabled()) return displayText;
|
|
73
|
+
// Do not double-wrap if the text already embeds an OSC 8 sequence.
|
|
74
|
+
if (displayText.includes("\x1b]8;")) return displayText;
|
|
75
|
+
const safeUri = safeHyperlinkUri(uri);
|
|
76
|
+
if (!safeUri) return displayText;
|
|
77
|
+
const id = buildLinkId(safeUri);
|
|
78
|
+
return `${OSC}8;id=${id};${safeUri}${ST}${displayText}${OSC}8;;${ST}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Wrap `displayText` in an OSC 8 hyperlink pointing at `uri`.
|
|
83
|
+
*
|
|
84
|
+
* Returns `displayText` unchanged when hyperlinks are disabled, `uri` contains
|
|
85
|
+
* terminal control bytes, or `displayText` already contains an OSC 8 sequence.
|
|
86
|
+
*/
|
|
87
|
+
export function uriHyperlink(uri: string, displayText: string): string {
|
|
88
|
+
return wrapHyperlink(uri, displayText);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Wrap `displayText` in an OSC 8 hyperlink pointing at an HTTP(S) URL.
|
|
93
|
+
* `www.example.com` inputs are linked as `https://www.example.com`.
|
|
94
|
+
*/
|
|
95
|
+
export function urlHyperlink(url: string, displayText: string): string {
|
|
96
|
+
const normalized = url.match(/^www\./i) ? `https://${url}` : url;
|
|
97
|
+
try {
|
|
98
|
+
const parsed = new URL(normalized);
|
|
99
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return displayText;
|
|
100
|
+
return wrapHyperlink(parsed.href, displayText);
|
|
101
|
+
} catch {
|
|
102
|
+
return displayText;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
66
106
|
/**
|
|
67
107
|
* Wrap `displayText` in an OSC 8 hyperlink pointing at the given absolute file path.
|
|
68
108
|
*
|
|
@@ -78,12 +118,7 @@ export function isHyperlinkEnabled(): boolean {
|
|
|
78
118
|
* @param opts - Optional line/col position appended as `?line=N&col=M` query params
|
|
79
119
|
*/
|
|
80
120
|
export function fileHyperlink(absPath: string, displayText: string, opts?: { line?: number; col?: number }): string {
|
|
81
|
-
|
|
82
|
-
// Do not double-wrap if the text already embeds an OSC 8 sequence.
|
|
83
|
-
if (displayText.includes("\x1b]8;")) return displayText;
|
|
84
|
-
const uri = buildFileUri(absPath, opts);
|
|
85
|
-
const id = buildLinkId(uri);
|
|
86
|
-
return `${OSC}8;id=${id};${uri}${ST}${displayText}${OSC}8;;${ST}`;
|
|
121
|
+
return wrapHyperlink(buildFileUri(absPath, opts), displayText);
|
|
87
122
|
}
|
|
88
123
|
|
|
89
124
|
/**
|
package/src/web/search/index.ts
CHANGED
|
@@ -278,8 +278,8 @@ export const webSearchCustomTool: CustomTool<typeof webSearchSchema, SearchRende
|
|
|
278
278
|
return renderSearchCall(args, options, theme);
|
|
279
279
|
},
|
|
280
280
|
|
|
281
|
-
renderResult(result, options: RenderResultOptions, theme: Theme) {
|
|
282
|
-
return renderSearchResult(result, options, theme);
|
|
281
|
+
renderResult(result, options: RenderResultOptions, theme: Theme, args) {
|
|
282
|
+
return renderSearchResult(result, options, theme, args);
|
|
283
283
|
},
|
|
284
284
|
};
|
|
285
285
|
|
package/src/web/search/render.ts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
8
|
-
import {
|
|
8
|
+
import { Markdown, Text } from "@oh-my-pi/pi-tui";
|
|
9
9
|
import type { RenderResultOptions } from "../../extensibility/custom-tools/types";
|
|
10
|
-
import type
|
|
10
|
+
import { getMarkdownTheme, type Theme } from "../../modes/theme/theme";
|
|
11
11
|
import {
|
|
12
12
|
formatAge,
|
|
13
13
|
formatCount,
|
|
@@ -26,8 +26,6 @@ import { getSearchProviderLabel } from "./provider";
|
|
|
26
26
|
import type { SearchResponse } from "./types";
|
|
27
27
|
|
|
28
28
|
const MAX_COLLAPSED_ANSWER_LINES = PREVIEW_LIMITS.COLLAPSED_LINES;
|
|
29
|
-
const MAX_EXPANDED_ANSWER_LINES = PREVIEW_LIMITS.EXPANDED_LINES;
|
|
30
|
-
const MAX_ANSWER_LINE_LEN = TRUNCATE_LENGTHS.LINE;
|
|
31
29
|
const MAX_SNIPPET_LINES = 2;
|
|
32
30
|
const MAX_SNIPPET_LINE_LEN = TRUNCATE_LENGTHS.LINE;
|
|
33
31
|
const MAX_COLLAPSED_ITEMS = PREVIEW_LIMITS.COLLAPSED_ITEMS;
|
|
@@ -75,7 +73,6 @@ export function renderSearchResult(
|
|
|
75
73
|
theme: Theme,
|
|
76
74
|
args?: {
|
|
77
75
|
query?: string;
|
|
78
|
-
allowLongAnswer?: boolean;
|
|
79
76
|
maxAnswerLines?: number;
|
|
80
77
|
},
|
|
81
78
|
): Component {
|
|
@@ -104,13 +101,6 @@ export function renderSearchResult(
|
|
|
104
101
|
// Get answer text
|
|
105
102
|
const answerText = typeof response.answer === "string" ? response.answer.trim() : "";
|
|
106
103
|
const contentText = answerText || rawText;
|
|
107
|
-
const answerLines = contentText
|
|
108
|
-
? contentText
|
|
109
|
-
.split("\n")
|
|
110
|
-
.filter(l => l.trim())
|
|
111
|
-
.map(l => l.trim())
|
|
112
|
-
: [];
|
|
113
|
-
const totalAnswerLines = answerLines.length;
|
|
114
104
|
|
|
115
105
|
const providerLabel = provider !== "none" ? getSearchProviderLabel(provider) : "None";
|
|
116
106
|
const queryPreview = args?.query
|
|
@@ -159,6 +149,7 @@ export function renderSearchResult(
|
|
|
159
149
|
metaLines.push(`${theme.fg("muted", "Queries:")} ${theme.fg("text", queryList.join("; "))}${suffix}`);
|
|
160
150
|
}
|
|
161
151
|
|
|
152
|
+
const answerMarkdown = contentText ? new Markdown(contentText, 0, 0, getMarkdownTheme()) : undefined;
|
|
162
153
|
const outputBlock = new CachedOutputBlock();
|
|
163
154
|
|
|
164
155
|
return {
|
|
@@ -166,14 +157,22 @@ export function renderSearchResult(
|
|
|
166
157
|
// Read mutable state at render time
|
|
167
158
|
const { expanded } = options;
|
|
168
159
|
|
|
169
|
-
//
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
160
|
+
// Answer lines: full markdown when expanded, capped markdown preview when collapsed.
|
|
161
|
+
const answerWidth = Math.max(20, width - 3);
|
|
162
|
+
const renderedAnswer = answerMarkdown ? answerMarkdown.render(answerWidth) : [];
|
|
163
|
+
let answerLines: string[];
|
|
164
|
+
if (renderedAnswer.length === 0) {
|
|
165
|
+
answerLines = [theme.fg("muted", "No answer text returned")];
|
|
166
|
+
} else if (expanded) {
|
|
167
|
+
answerLines = renderedAnswer;
|
|
168
|
+
} else {
|
|
169
|
+
const collapsedCap = args?.maxAnswerLines ?? MAX_COLLAPSED_ANSWER_LINES;
|
|
170
|
+
answerLines = renderedAnswer.slice(0, collapsedCap);
|
|
171
|
+
const remaining = renderedAnswer.length - answerLines.length;
|
|
172
|
+
if (remaining > 0) {
|
|
173
|
+
answerLines.push(theme.fg("muted", formatMoreItems(remaining, "line")));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
177
176
|
|
|
178
177
|
const sourceTree = renderTreeList(
|
|
179
178
|
{
|
|
@@ -217,37 +216,6 @@ export function renderSearchResult(
|
|
|
217
216
|
theme,
|
|
218
217
|
);
|
|
219
218
|
|
|
220
|
-
// Build answer section
|
|
221
|
-
const answerState = sourceCount > 0 ? "success" : "warning";
|
|
222
|
-
const borderColor: "warning" | "dim" = answerState === "warning" ? "warning" : "dim";
|
|
223
|
-
const border = (t: string) => theme.fg(borderColor, t);
|
|
224
|
-
const contentPrefix = border(`${theme.boxSharp.vertical} `);
|
|
225
|
-
const contentSuffix = border(theme.boxSharp.vertical);
|
|
226
|
-
const contentWidth = Math.max(0, width - visibleWidth(contentPrefix) - visibleWidth(contentSuffix));
|
|
227
|
-
const answerTreeLines = answerPreview.length > 0 ? answerPreview : ["No answer text returned"];
|
|
228
|
-
const answerTree = renderTreeList(
|
|
229
|
-
{
|
|
230
|
-
items: answerTreeLines,
|
|
231
|
-
expanded: true,
|
|
232
|
-
maxCollapsed: answerTreeLines.length,
|
|
233
|
-
itemType: "line",
|
|
234
|
-
renderItem: (line, context) => {
|
|
235
|
-
const coloredLine =
|
|
236
|
-
line === "No answer text returned" ? theme.fg("muted", line) : theme.fg("dim", line);
|
|
237
|
-
if (!args?.allowLongAnswer) {
|
|
238
|
-
return coloredLine;
|
|
239
|
-
}
|
|
240
|
-
const prefixWidth = visibleWidth(context.continuePrefix);
|
|
241
|
-
const wrapWidth = Math.max(10, contentWidth - prefixWidth);
|
|
242
|
-
return wrapTextWithAnsi(coloredLine, wrapWidth);
|
|
243
|
-
},
|
|
244
|
-
},
|
|
245
|
-
theme,
|
|
246
|
-
);
|
|
247
|
-
if (remainingAnswer > 0) {
|
|
248
|
-
answerTree.push(theme.fg("muted", formatMoreItems(remainingAnswer, "line")));
|
|
249
|
-
}
|
|
250
|
-
|
|
251
219
|
return outputBlock.render(
|
|
252
220
|
{
|
|
253
221
|
header,
|
|
@@ -262,7 +230,7 @@ export function renderSearchResult(
|
|
|
262
230
|
: []),
|
|
263
231
|
{
|
|
264
232
|
label: theme.fg("toolTitle", "Answer"),
|
|
265
|
-
lines:
|
|
233
|
+
lines: answerLines,
|
|
266
234
|
},
|
|
267
235
|
{
|
|
268
236
|
label: theme.fg("toolTitle", "Sources"),
|