@oh-my-pi/pi-coding-agent 15.9.1 → 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.
Files changed (109) hide show
  1. package/CHANGELOG.md +68 -2
  2. package/dist/types/cli/classify-install-target.d.ts +5 -1
  3. package/dist/types/cli/dry-balance-cli.d.ts +104 -0
  4. package/dist/types/commands/dry-balance.d.ts +31 -0
  5. package/dist/types/config/model-registry.d.ts +2 -0
  6. package/dist/types/config/models-config-schema.d.ts +3 -0
  7. package/dist/types/config/settings-schema.d.ts +13 -4
  8. package/dist/types/config/settings.d.ts +11 -0
  9. package/dist/types/discovery/helpers.d.ts +1 -0
  10. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
  11. package/dist/types/hindsight/bank.d.ts +17 -9
  12. package/dist/types/hindsight/mental-models.d.ts +1 -1
  13. package/dist/types/hindsight/state.d.ts +9 -3
  14. package/dist/types/mcp/manager.d.ts +1 -1
  15. package/dist/types/modes/components/assistant-message.d.ts +11 -0
  16. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  17. package/dist/types/modes/components/error-banner.d.ts +11 -0
  18. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  19. package/dist/types/modes/components/transcript-container.d.ts +4 -2
  20. package/dist/types/modes/components/user-message.d.ts +1 -1
  21. package/dist/types/modes/image-references.d.ts +17 -0
  22. package/dist/types/modes/interactive-mode.d.ts +7 -0
  23. package/dist/types/modes/types.d.ts +7 -0
  24. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  25. package/dist/types/session/agent-session.d.ts +9 -0
  26. package/dist/types/session/auth-storage.d.ts +2 -2
  27. package/dist/types/session/blob-store.d.ts +12 -11
  28. package/dist/types/session/session-manager.d.ts +5 -3
  29. package/dist/types/system-prompt.d.ts +2 -0
  30. package/dist/types/task/types.d.ts +2 -0
  31. package/dist/types/tiny/title-client.d.ts +16 -1
  32. package/dist/types/tool-discovery/mode.d.ts +8 -0
  33. package/dist/types/tools/archive-reader.d.ts +5 -1
  34. package/dist/types/tools/index.d.ts +16 -0
  35. package/dist/types/tools/path-utils.d.ts +11 -0
  36. package/dist/types/tui/hyperlink.d.ts +12 -0
  37. package/dist/types/web/search/render.d.ts +1 -2
  38. package/package.json +9 -9
  39. package/src/cli/classify-install-target.ts +31 -5
  40. package/src/cli/dry-balance-cli.ts +823 -0
  41. package/src/cli/plugin-cli.ts +45 -0
  42. package/src/cli/web-search-cli.ts +0 -1
  43. package/src/cli-commands.ts +1 -0
  44. package/src/commands/dry-balance.ts +43 -0
  45. package/src/config/model-registry.ts +60 -4
  46. package/src/config/models-config-schema.ts +2 -0
  47. package/src/config/settings-schema.ts +14 -4
  48. package/src/config/settings.ts +38 -0
  49. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
  50. package/src/discovery/github.ts +37 -1
  51. package/src/discovery/helpers.ts +3 -1
  52. package/src/eval/__tests__/agent-bridge.test.ts +72 -0
  53. package/src/eval/py/tool-bridge.ts +43 -5
  54. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
  55. package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
  56. package/src/hindsight/backend.ts +184 -35
  57. package/src/hindsight/bank.ts +32 -22
  58. package/src/hindsight/mental-models.ts +1 -1
  59. package/src/hindsight/state.ts +21 -7
  60. package/src/internal-urls/docs-index.generated.ts +6 -6
  61. package/src/internal-urls/omp-protocol.ts +8 -2
  62. package/src/main.ts +7 -1
  63. package/src/mcp/manager.ts +40 -21
  64. package/src/modes/components/assistant-message.ts +22 -0
  65. package/src/modes/components/custom-editor.ts +14 -2
  66. package/src/modes/components/error-banner.ts +33 -0
  67. package/src/modes/components/tool-execution.ts +44 -0
  68. package/src/modes/components/transcript-container.ts +102 -30
  69. package/src/modes/components/tree-selector.ts +29 -2
  70. package/src/modes/components/user-message.ts +9 -2
  71. package/src/modes/controllers/event-controller.ts +42 -3
  72. package/src/modes/controllers/input-controller.ts +41 -3
  73. package/src/modes/image-references.ts +111 -0
  74. package/src/modes/interactive-mode.ts +48 -13
  75. package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
  76. package/src/modes/types.ts +10 -1
  77. package/src/modes/utils/ui-helpers.ts +23 -2
  78. package/src/prompts/agents/explore.md +1 -0
  79. package/src/prompts/agents/librarian.md +1 -0
  80. package/src/prompts/ci-green-request.md +5 -3
  81. package/src/prompts/dry-balance-bench.md +8 -0
  82. package/src/prompts/system/project-prompt.md +1 -0
  83. package/src/sdk.ts +99 -18
  84. package/src/session/agent-session.ts +103 -19
  85. package/src/session/auth-storage.ts +4 -0
  86. package/src/session/blob-store.ts +96 -9
  87. package/src/session/session-manager.ts +19 -10
  88. package/src/system-prompt.ts +4 -0
  89. package/src/task/executor.ts +6 -2
  90. package/src/task/index.ts +8 -7
  91. package/src/task/types.ts +2 -0
  92. package/src/tiny/title-client.ts +7 -1
  93. package/src/tool-discovery/mode.ts +24 -0
  94. package/src/tools/archive-reader.ts +339 -31
  95. package/src/tools/bash.ts +3 -4
  96. package/src/tools/fetch.ts +29 -9
  97. package/src/tools/gh.ts +65 -11
  98. package/src/tools/index.ts +22 -8
  99. package/src/tools/job.ts +3 -3
  100. package/src/tools/memory-reflect.ts +2 -2
  101. package/src/tools/path-utils.ts +21 -0
  102. package/src/tools/read.ts +58 -12
  103. package/src/tools/search-tool-bm25.ts +4 -6
  104. package/src/tools/search.ts +78 -12
  105. package/src/tui/hyperlink.ts +42 -7
  106. package/src/utils/file-mentions.ts +7 -107
  107. package/src/utils/title-generator.ts +58 -37
  108. package/src/web/search/index.ts +2 -2
  109. package/src/web/search/render.ts +20 -52
@@ -2,6 +2,7 @@ import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
2
2
  import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
3
3
  import type { ToolChoice } from "@oh-my-pi/pi-ai";
4
4
  import { logger } from "@oh-my-pi/pi-utils";
5
+ import type { AsyncJobManager } from "../async/job-manager";
5
6
  import type { PromptTemplate } from "../config/prompt-templates";
6
7
  import type { Settings } from "../config/settings";
7
8
  import { EditTool } from "../edit";
@@ -22,6 +23,7 @@ import type { CustomMessage } from "../session/messages";
22
23
  import type { ToolChoiceQueue } from "../session/tool-choice-queue";
23
24
  import { TaskTool } from "../task";
24
25
  import type { AgentOutputManager } from "../task/output-manager";
26
+ import { countToolsForAutoDiscovery, resolveEffectiveToolDiscoveryMode } from "../tool-discovery/mode";
25
27
  import type { DiscoverableTool, DiscoverableToolSearchIndex } from "../tool-discovery/tool-index";
26
28
  import type { EventBus } from "../utils/event-bus";
27
29
  import { WebSearchTool } from "../web/search";
@@ -183,6 +185,21 @@ export interface ToolSession {
183
185
  modelRegistry?: import("../config/model-registry").ModelRegistry;
184
186
  /** Agent output manager for unique agent:// IDs across task invocations */
185
187
  agentOutputManager?: AgentOutputManager;
188
+ /**
189
+ * Async job manager scoped to this session.
190
+ *
191
+ * - Top-level session that constructed one: its own manager.
192
+ * - Subagent (`parentTaskPrefix` set): the parent's manager, so background
193
+ * bash/task work and `onJobComplete` deliveries flow into the conversation
194
+ * that spawned it.
195
+ * - Secondary in-process top-level session that found a singleton already
196
+ * installed (issue #1923): `undefined`. Tools refuse async work rather
197
+ * than silently route completions into the owning session's `yieldQueue`.
198
+ *
199
+ * Tools MUST use this instead of `AsyncJobManager.instance()` so a secondary
200
+ * session never borrows the owning session's manager by accident.
201
+ */
202
+ asyncJobManager?: AsyncJobManager;
186
203
  /** MCP manager visible to subagents without relying on the process-global singleton. */
187
204
  mcpManager?: MCPManager;
188
205
  /** Local protocol root to propagate to nested subagents and eval-created agents. */
@@ -404,14 +421,11 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
404
421
  }
405
422
  }
406
423
  // Resolve effective tool discovery mode.
407
- // tools.discoveryMode takes precedence; mcp.discoveryMode is a back-compat alias for "mcp-only".
408
- const toolsDiscoveryMode = session.settings.get("tools.discoveryMode");
409
- const effectiveDiscoveryMode: "off" | "mcp-only" | "all" =
410
- toolsDiscoveryMode !== "off"
411
- ? (toolsDiscoveryMode as "off" | "mcp-only" | "all")
412
- : session.settings.get("mcp.discoveryMode")
413
- ? "mcp-only"
414
- : "off";
424
+ // tools.discoveryMode controls the new modes; mcp.discoveryMode remains a back-compat alias for "mcp-only".
425
+ const effectiveDiscoveryMode = resolveEffectiveToolDiscoveryMode(
426
+ session.settings,
427
+ countToolsForAutoDiscovery(requestedTools ?? Object.keys(BUILTIN_TOOLS)),
428
+ );
415
429
  const discoveryActive = effectiveDiscoveryMode !== "off";
416
430
 
417
431
  const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
package/src/tools/job.ts CHANGED
@@ -3,7 +3,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
3
3
  import { Text } from "@oh-my-pi/pi-tui";
4
4
  import { prompt } from "@oh-my-pi/pi-utils";
5
5
  import * as z from "zod/v4";
6
- import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled } from "../async";
6
+ import { type AsyncJob, type AsyncJobManager, isBackgroundJobSupportEnabled } from "../async";
7
7
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
8
8
  import type { Theme } from "../modes/theme/theme";
9
9
  import jobDescription from "../prompts/tools/job.md" with { type: "text" };
@@ -90,7 +90,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
90
90
  onUpdate?: AgentToolUpdateCallback<JobToolDetails>,
91
91
  _context?: AgentToolContext,
92
92
  ): Promise<AgentToolResult<JobToolDetails>> {
93
- const manager = AsyncJobManager.instance();
93
+ const manager = this.session.asyncJobManager;
94
94
  if (!manager) {
95
95
  return {
96
96
  content: [{ type: "text", text: "Async execution is disabled; no background jobs are available." }],
@@ -254,7 +254,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
254
254
  ): JobSnapshot[] {
255
255
  const now = Date.now();
256
256
  return jobs.map(j => {
257
- const current = AsyncJobManager.instance()?.getJob(j.id);
257
+ const current = this.session.asyncJobManager?.getJob(j.id);
258
258
  const latest = current ?? j;
259
259
  return {
260
260
  id: latest.id,
@@ -1,7 +1,7 @@
1
1
  import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
2
  import { logger, untilAborted } from "@oh-my-pi/pi-utils";
3
3
  import * as z from "zod/v4";
4
- import { ensureBankMission } from "../hindsight/bank";
4
+ import { ensureBankExists } from "../hindsight/bank";
5
5
  import reflectDescription from "../prompts/tools/reflect.md" with { type: "text" };
6
6
  import type { ToolSession } from ".";
7
7
 
@@ -67,7 +67,7 @@ export class MemoryReflectTool implements AgentTool<typeof memoryReflectSchema>
67
67
  }
68
68
 
69
69
  try {
70
- await ensureBankMission(state.client, state.bankId, state.config, state.missionsSet);
70
+ await ensureBankExists(state.client, state.bankId, state.config, state.banksSet);
71
71
  const response = await state.client.reflect(state.bankId, params.query, {
72
72
  context: params.context,
73
73
  budget: state.config.recallBudget,
@@ -217,6 +217,27 @@ export function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null
217
217
  return merged as [LineRange, ...LineRange[]];
218
218
  }
219
219
 
220
+ /**
221
+ * Extract the line-range component from a read-tool selector that may also
222
+ * carry a verbatim/index display mode (`raw`, `conflicts`) — alone or compounded
223
+ * with a range (`raw:50-100`, `50-100:raw`). Returns the parsed ranges when the
224
+ * selector names any, otherwise `undefined` (pure `raw`/`conflicts`/none).
225
+ *
226
+ * Used by content search, which honors line ranges as a match filter but has no
227
+ * use for verbatim/conflict display modes — so those selectors are accepted and
228
+ * treated as an unfiltered, whole-resource search rather than rejected.
229
+ */
230
+ export function selectorLineRanges(sel: string | undefined): [LineRange, ...LineRange[]] | undefined {
231
+ if (!sel) return undefined;
232
+ for (const chunk of sel.split(":")) {
233
+ const lower = chunk.toLowerCase();
234
+ if (lower === "raw" || lower === "conflicts") continue;
235
+ const ranges = parseLineRanges(chunk);
236
+ if (ranges) return ranges;
237
+ }
238
+ return undefined;
239
+ }
240
+
220
241
  /** Return `true` when `lineNumber` (1-indexed) falls in any of the supplied ranges. */
221
242
  export function isLineInRanges(lineNumber: number, ranges: readonly LineRange[]): boolean {
222
243
  for (const range of ranges) {
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 = filePath || "…";
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 filePath = shortenPath(rawPath);
2392
- const lang = getLanguageFromPath(splitPathAndSel(rawPath).path);
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 = suffix ? shortenPath(suffix.to) : filePath || rawPath || "image";
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 absForLink = details?.resolvedPath ?? tryResolveInternalUrlSync(rawPath);
2451
- const displayPath = absForLink ? fileHyperlink(absForLink, plainDisplayPath) : plainDisplayPath;
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(splitPathAndSel(rawPath).sel));
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
- // Active when new tools.discoveryMode is non-"off" or legacy mcp.discoveryMode is true
202
- const toolsDiscoveryMode = session.settings.get("tools.discoveryMode");
203
- const active =
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
 
@@ -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 { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
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 {
@@ -38,6 +46,8 @@ import {
38
46
  type ResolvedSearchTarget,
39
47
  resolveReadPath,
40
48
  resolveToolSearchScope,
49
+ selectorLineRanges,
50
+ splitInternalUrlSel,
41
51
  splitPathAndSel,
42
52
  } from "./path-utils";
43
53
  import {
@@ -109,6 +119,21 @@ interface SearchPathSpec {
109
119
  function parsePathSpecs(rawEntries: readonly string[]): SearchPathSpec[] {
110
120
  const specs: SearchPathSpec[] = [];
111
121
  for (const entry of rawEntries) {
122
+ // Internal URLs (`artifact://`, `skill://`, …) use the URL-aware splitter,
123
+ // which peels selector-shaped tails only for selector-capable schemes and
124
+ // leaves opaque ones (`mcp://`) intact. Unlike filesystem paths, their
125
+ // verbatim/index display modes (`raw`, `conflicts`) carry no meaning for
126
+ // content search, so we accept them — searching the whole resource — and
127
+ // still honor any embedded line range as a match filter.
128
+ const internalSplit = splitInternalUrlSel(entry);
129
+ if (internalSplit.sel !== undefined) {
130
+ specs.push({
131
+ original: entry,
132
+ clean: internalSplit.path,
133
+ ranges: selectorLineRanges(internalSplit.sel),
134
+ });
135
+ continue;
136
+ }
112
137
  const split = splitPathAndSel(entry);
113
138
  let clean = entry;
114
139
  let ranges: [LineRange, ...LineRange[]] | undefined;
@@ -259,7 +284,7 @@ interface IndexedContentLines {
259
284
  }
260
285
 
261
286
  const INTERNAL_URL_DISPLAY_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
262
- const OMP_ROOT_URL_RE = /^omp:\/\/\/?$/i;
287
+ const OMP_ROOT_URL_RE = /^omp:\/\/(?:\/?|docs\/?)$/i;
263
288
 
264
289
  function normalizeSearchLine(line: string): string {
265
290
  return line.endsWith("\r") ? line.slice(0, -1) : line;
@@ -1145,6 +1170,26 @@ interface SearchRenderArgs {
1145
1170
 
1146
1171
  const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
1147
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
+
1148
1193
  export const searchToolRenderer = {
1149
1194
  inline: true,
1150
1195
  renderCall(args: SearchRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
@@ -1220,8 +1265,11 @@ export const searchToolRenderer = {
1220
1265
  : undefined;
1221
1266
 
1222
1267
  if (matchCount === 0) {
1268
+ const meta = ["0 matches"];
1269
+ const scopeMeta = searchScopeMeta(details);
1270
+ if (scopeMeta) meta.push(scopeMeta);
1223
1271
  const header = renderStatusLine(
1224
- { icon: "warning", title: "Search", description: args?.pattern, meta: ["0 matches"] },
1272
+ { icon: "warning", title: "Search", description: args?.pattern, meta },
1225
1273
  uiTheme,
1226
1274
  );
1227
1275
  const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
@@ -1231,7 +1279,8 @@ export const searchToolRenderer = {
1231
1279
 
1232
1280
  const summaryParts = [formatCount("match", matchCount), formatCount("file", fileCount)];
1233
1281
  const meta = [...summaryParts];
1234
- if (details?.scopePath) meta.push(`in ${details.scopePath}`);
1282
+ const scopeMeta = searchScopeMeta(details);
1283
+ if (scopeMeta) meta.push(scopeMeta);
1235
1284
  if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
1236
1285
  const description = args?.pattern ?? undefined;
1237
1286
  const header = renderStatusLine(
@@ -1270,10 +1319,11 @@ export const searchToolRenderer = {
1270
1319
  maxCollapsedLines: collapsedMatchLineBudget,
1271
1320
  itemType: "match",
1272
1321
  renderItem: group => {
1273
- // Track directory context within a group for ## file headers.
1274
- // `# foo/` is a directory header; `# foo.ts` is a root-level file
1275
- // 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.
1276
1324
  let contextDir = searchBase ?? "";
1325
+ const hasFileHeader = group.some(line => line.startsWith("# "));
1326
+ let currentFilePath: string | undefined = hasFileHeader ? undefined : searchBase;
1277
1327
  return group.map(line => {
1278
1328
  if (line.startsWith("## ")) {
1279
1329
  // Strip optional ` (suffix)` and `#hash` before resolving.
@@ -1283,6 +1333,7 @@ export const searchToolRenderer = {
1283
1333
  .replace(/\s+\([^)]*\)\s*$/, "")
1284
1334
  .replace(/#[0-9a-f]+$/, "");
1285
1335
  const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined;
1336
+ currentFilePath = absPath;
1286
1337
  const styled = uiTheme.fg("dim", line);
1287
1338
  return absPath ? fileHyperlink(absPath, styled) : styled;
1288
1339
  }
@@ -1293,22 +1344,37 @@ export const searchToolRenderer = {
1293
1344
  .replace(/\s+\([^)]*\)\s*$/, "");
1294
1345
  if (INTERNAL_URL_DISPLAY_RE.test(raw)) {
1295
1346
  contextDir = "";
1296
- return uiTheme.fg("accent", line);
1347
+ const styled = uiTheme.fg("accent", line);
1348
+ const linked = linkUrlLikeSearchHeader(raw, styled);
1349
+ currentFilePath = linked.absPath;
1350
+ return linked.line;
1297
1351
  }
1298
1352
  const isDirectory = raw.endsWith("/");
1299
1353
  const name = isDirectory ? raw.replace(/\/$/, "") : raw.replace(/#[0-9a-f]+$/, "");
1300
1354
  if (isDirectory) {
1301
- if (searchBase) {
1302
- contextDir = name === "." ? searchBase : path.join(searchBase, name);
1355
+ const absPath = searchBase
1356
+ ? name === "."
1357
+ ? searchBase
1358
+ : path.join(searchBase, name)
1359
+ : undefined;
1360
+ if (absPath) {
1361
+ contextDir = absPath;
1303
1362
  }
1304
- return uiTheme.fg("accent", line);
1363
+ currentFilePath = undefined;
1364
+ const styled = uiTheme.fg("accent", line);
1365
+ return absPath ? fileHyperlink(absPath, styled) : styled;
1305
1366
  }
1306
1367
  // Root-level file emitted by formatGroupedFiles when the directory is `.`.
1307
1368
  const absPath = searchBase && name ? path.join(searchBase, name) : undefined;
1369
+ currentFilePath = absPath;
1308
1370
  const styled = uiTheme.fg("accent", line);
1309
1371
  return absPath ? fileHyperlink(absPath, styled) : styled;
1310
1372
  }
1311
- return uiTheme.fg("toolOutput", line);
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;
1312
1378
  });
1313
1379
  },
1314
1380
  },
@@ -1,5 +1,5 @@
1
1
  /**
2
- * OSC 8 terminal hyperlink support for file paths.
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
- if (!isHyperlinkEnabled()) return displayText;
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
  /**
@@ -10,8 +10,6 @@ import path from "node:path";
10
10
  import { formatHashlineHeader, formatNumberedLines, type SnapshotStore } from "@oh-my-pi/hashline";
11
11
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
12
12
  import type { ImageContent } from "@oh-my-pi/pi-ai";
13
- import { glob } from "@oh-my-pi/pi-natives";
14
- import { fuzzyMatch } from "@oh-my-pi/pi-tui";
15
13
  import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils";
16
14
  import { normalizeToLF } from "../edit/normalize";
17
15
  import type { FileMentionMessage } from "../session/messages";
@@ -30,27 +28,6 @@ const LEADING_PUNCTUATION_REGEX = /^[`"'([{<]+/;
30
28
  const TRAILING_PUNCTUATION_REGEX = /[)\]}>.,;:!?"'`]+$/;
31
29
  const MENTION_BOUNDARY_REGEX = /[\s([{<"'`]/;
32
30
  const DEFAULT_DIR_LIMIT = 500;
33
- const MIN_FUZZY_QUERY_LENGTH = 5;
34
- const MAX_RESOLUTION_CANDIDATES = 20_000;
35
- const PATH_SEPARATOR_REGEX = /[/._\-\s]+/g;
36
-
37
- type MentionDiscoveryProfile = {
38
- hidden: boolean;
39
- gitignore: boolean;
40
- includeNodeModules: boolean;
41
- maxResults: number;
42
- cache: boolean;
43
- };
44
-
45
- function getMentionCandidateDiscoveryProfile(): MentionDiscoveryProfile {
46
- return {
47
- hidden: true,
48
- gitignore: true,
49
- cache: true,
50
- includeNodeModules: true,
51
- maxResults: MAX_RESOLUTION_CANDIDATES,
52
- };
53
- }
54
31
 
55
32
  // Avoid OOM when users @mention very large files. Above these limits we skip
56
33
  // auto-reading and only include the path in the message.
@@ -70,16 +47,6 @@ function sanitizeMentionPath(rawPath: string): string | null {
70
47
  return cleaned.length > 0 ? cleaned : null;
71
48
  }
72
49
 
73
- type MentionCandidate = {
74
- path: string;
75
- pathLower: string;
76
- normalizedPath: string;
77
- };
78
-
79
- function normalizeMentionQuery(query: string): string {
80
- return query.toLowerCase().replace(PATH_SEPARATOR_REGEX, "");
81
- }
82
-
83
50
  async function pathExists(filePath: string): Promise<boolean> {
84
51
  try {
85
52
  await Bun.file(filePath).stat();
@@ -89,75 +56,13 @@ async function pathExists(filePath: string): Promise<boolean> {
89
56
  }
90
57
  }
91
58
 
92
- async function listMentionCandidates(cwd: string): Promise<MentionCandidate[]> {
93
- let entries: string[];
94
- try {
95
- const discoveryProfile = getMentionCandidateDiscoveryProfile();
96
- const result = await glob({
97
- pattern: "**/*",
98
- path: cwd,
99
- ...discoveryProfile,
100
- });
101
- entries = result.matches.map(match => match.path);
102
- } catch {
103
- return [];
104
- }
105
-
106
- entries.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
107
- const candidates: MentionCandidate[] = [];
108
- for (const entry of entries) {
109
- const pathLower = entry.toLowerCase();
110
- const normalizedPath = normalizeMentionQuery(entry);
111
- if (normalizedPath.length === 0) {
112
- continue;
113
- }
114
- candidates.push({ path: entry, pathLower, normalizedPath });
115
- }
116
- return candidates;
117
- }
118
-
119
- async function resolveMentionPath(
120
- filePath: string,
121
- cwd: string,
122
- getMentionCandidates: () => Promise<MentionCandidate[]>,
123
- ): Promise<string | null> {
59
+ async function resolveMentionPath(filePath: string, cwd: string): Promise<string | null> {
60
+ // Exact resolution only. The TUI @-selector inserts the real, complete path, so a
61
+ // mention that does not resolve to an existing file or directory is prose, not a file
62
+ // reference. Fuzzy/prefix guessing here previously dragged in unrelated same-named
63
+ // files; that disambiguation belongs to the selector's display, not post-send.
124
64
  const absolutePath = resolveReadPath(filePath, cwd);
125
- if (await pathExists(absolutePath)) {
126
- return filePath;
127
- }
128
-
129
- const queryLower = filePath.toLowerCase();
130
- const candidates = await getMentionCandidates();
131
- const prefixMatches = candidates.filter(candidate => candidate.pathLower.startsWith(queryLower));
132
- if (prefixMatches.length === 1) {
133
- return prefixMatches[0]?.path ?? null;
134
- }
135
- if (prefixMatches.length > 1) {
136
- return null;
137
- }
138
-
139
- const normalizedQuery = normalizeMentionQuery(filePath);
140
- if (normalizedQuery.length < MIN_FUZZY_QUERY_LENGTH) {
141
- return null;
142
- }
143
-
144
- const scored = candidates
145
- .map(candidate => ({ candidate, match: fuzzyMatch(normalizedQuery, candidate.normalizedPath) }))
146
- .filter(entry => entry.match.matches)
147
- .sort((a, b) => {
148
- if (a.match.score !== b.match.score) {
149
- return a.match.score - b.match.score;
150
- }
151
- return a.candidate.path.localeCompare(b.candidate.path);
152
- });
153
-
154
- if (scored.length === 0) {
155
- return null;
156
- }
157
-
158
- const best = scored[0];
159
-
160
- return best?.candidate.path ?? null;
65
+ return (await pathExists(absolutePath)) ? filePath : null;
161
66
  }
162
67
 
163
68
  function buildTextOutput(textContent: string): { output: string; lineCount: number } {
@@ -285,14 +190,9 @@ export async function generateFileMentionMessages(
285
190
  const autoResizeImages = options?.autoResizeImages ?? true;
286
191
 
287
192
  const files: FileMentionMessage["files"] = [];
288
- let mentionCandidatesPromise: Promise<MentionCandidate[]> | null = null;
289
- const getMentionCandidates = (): Promise<MentionCandidate[]> => {
290
- mentionCandidatesPromise ??= listMentionCandidates(cwd);
291
- return mentionCandidatesPromise;
292
- };
293
193
 
294
194
  for (const filePath of filePaths) {
295
- const resolvedPath = await resolveMentionPath(filePath, cwd, getMentionCandidates);
195
+ const resolvedPath = await resolveMentionPath(filePath, cwd);
296
196
  if (!resolvedPath) {
297
197
  continue;
298
198
  }