@oh-my-pi/pi-coding-agent 15.9.3 → 15.9.67

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 (142) hide show
  1. package/CHANGELOG.md +74 -1
  2. package/dist/types/cli/classify-install-target.d.ts +5 -1
  3. package/dist/types/config/keybindings.d.ts +4 -1
  4. package/dist/types/config/settings-schema.d.ts +24 -5
  5. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  6. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +1 -0
  7. package/dist/types/eval/backend.d.ts +6 -6
  8. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  9. package/dist/types/eval/idle-timeout.d.ts +16 -14
  10. package/dist/types/eval/js/executor.d.ts +3 -3
  11. package/dist/types/eval/py/executor.d.ts +2 -2
  12. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  13. package/dist/types/modes/components/assistant-message.d.ts +16 -0
  14. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  15. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  16. package/dist/types/modes/components/error-banner.d.ts +11 -0
  17. package/dist/types/modes/components/model-selector.d.ts +1 -0
  18. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  19. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  20. package/dist/types/modes/components/user-message.d.ts +1 -1
  21. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  22. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  23. package/dist/types/modes/image-references.d.ts +17 -0
  24. package/dist/types/modes/interactive-mode.d.ts +8 -1
  25. package/dist/types/modes/types.d.ts +8 -1
  26. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  27. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  28. package/dist/types/session/blob-store.d.ts +12 -11
  29. package/dist/types/session/session-manager.d.ts +5 -3
  30. package/dist/types/system-prompt.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/eval-render.d.ts +8 -0
  35. package/dist/types/tools/render-utils.d.ts +25 -0
  36. package/dist/types/tui/code-cell.d.ts +6 -0
  37. package/dist/types/tui/hyperlink.d.ts +12 -0
  38. package/dist/types/tui/output-block.d.ts +11 -0
  39. package/dist/types/web/search/render.d.ts +1 -2
  40. package/package.json +9 -9
  41. package/src/autoresearch/dashboard.ts +11 -21
  42. package/src/cli/classify-install-target.ts +31 -5
  43. package/src/cli/claude-trace-cli.ts +13 -1
  44. package/src/cli/plugin-cli.ts +45 -0
  45. package/src/cli/web-search-cli.ts +0 -1
  46. package/src/config/keybindings.ts +58 -1
  47. package/src/config/model-registry.ts +54 -4
  48. package/src/config/settings-schema.ts +25 -5
  49. package/src/debug/raw-sse.ts +18 -4
  50. package/src/edit/file-snapshot-store.ts +1 -1
  51. package/src/edit/index.ts +1 -1
  52. package/src/edit/renderer.ts +7 -7
  53. package/src/edit/streaming.ts +1 -1
  54. package/src/eval/__tests__/agent-bridge.test.ts +100 -27
  55. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  56. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  57. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  58. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  59. package/src/eval/__tests__/shared-executors.test.ts +2 -2
  60. package/src/eval/agent-bridge.ts +4 -5
  61. package/src/eval/backend.ts +6 -6
  62. package/src/eval/bridge-timeout.ts +44 -0
  63. package/src/eval/idle-timeout.ts +33 -15
  64. package/src/eval/js/executor.ts +10 -10
  65. package/src/eval/llm-bridge.ts +4 -5
  66. package/src/eval/py/executor.ts +6 -6
  67. package/src/eval/py/kernel.ts +11 -1
  68. package/src/eval/py/spawn-options.ts +126 -0
  69. package/src/eval/py/tool-bridge.ts +43 -5
  70. package/src/export/ttsr.ts +9 -0
  71. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
  72. package/src/extensibility/extensions/runner.ts +2 -0
  73. package/src/internal-urls/docs-index.generated.ts +9 -8
  74. package/src/lsp/client.ts +80 -2
  75. package/src/lsp/index.ts +38 -4
  76. package/src/lsp/render.ts +3 -3
  77. package/src/main.ts +8 -2
  78. package/src/modes/components/agent-dashboard.ts +13 -4
  79. package/src/modes/components/assistant-message.ts +44 -1
  80. package/src/modes/components/copy-selector.ts +249 -0
  81. package/src/modes/components/custom-editor.ts +14 -2
  82. package/src/modes/components/error-banner.ts +33 -0
  83. package/src/modes/components/extensions/extension-list.ts +17 -8
  84. package/src/modes/components/history-search.ts +19 -11
  85. package/src/modes/components/model-selector.ts +125 -29
  86. package/src/modes/components/oauth-selector.ts +28 -12
  87. package/src/modes/components/session-observer-overlay.ts +13 -15
  88. package/src/modes/components/session-selector.ts +24 -13
  89. package/src/modes/components/tool-execution.ts +71 -13
  90. package/src/modes/components/transcript-container.ts +93 -32
  91. package/src/modes/components/tree-selector.ts +19 -7
  92. package/src/modes/components/user-message-selector.ts +25 -14
  93. package/src/modes/components/user-message.ts +9 -2
  94. package/src/modes/controllers/command-controller.ts +0 -116
  95. package/src/modes/controllers/event-controller.ts +67 -12
  96. package/src/modes/controllers/input-controller.ts +33 -1
  97. package/src/modes/controllers/selector-controller.ts +38 -1
  98. package/src/modes/image-references.ts +111 -0
  99. package/src/modes/interactive-mode.ts +52 -17
  100. package/src/modes/theme/theme.ts +46 -10
  101. package/src/modes/types.ts +11 -2
  102. package/src/modes/utils/copy-targets.ts +254 -0
  103. package/src/modes/utils/ui-helpers.ts +23 -2
  104. package/src/prompts/ci-green-request.md +5 -3
  105. package/src/prompts/system/project-prompt.md +1 -0
  106. package/src/prompts/tools/ast-edit.md +1 -1
  107. package/src/prompts/tools/ast-grep.md +1 -1
  108. package/src/prompts/tools/read.md +1 -1
  109. package/src/prompts/tools/search.md +1 -1
  110. package/src/sdk.ts +17 -9
  111. package/src/session/agent-session.ts +43 -14
  112. package/src/session/blob-store.ts +96 -9
  113. package/src/session/session-manager.ts +19 -10
  114. package/src/slash-commands/builtin-registry.ts +3 -11
  115. package/src/system-prompt.ts +4 -0
  116. package/src/task/render.ts +38 -11
  117. package/src/tiny/title-client.ts +7 -1
  118. package/src/tool-discovery/mode.ts +24 -0
  119. package/src/tools/archive-reader.ts +339 -31
  120. package/src/tools/bash.ts +18 -8
  121. package/src/tools/browser/render.ts +5 -4
  122. package/src/tools/debug.ts +3 -3
  123. package/src/tools/eval-render.ts +24 -9
  124. package/src/tools/eval.ts +14 -19
  125. package/src/tools/fetch.ts +34 -14
  126. package/src/tools/gh.ts +65 -11
  127. package/src/tools/index.ts +6 -8
  128. package/src/tools/read.ts +65 -19
  129. package/src/tools/render-utils.ts +46 -0
  130. package/src/tools/search-tool-bm25.ts +4 -6
  131. package/src/tools/search.ts +60 -11
  132. package/src/tools/ssh.ts +21 -8
  133. package/src/tools/write.ts +17 -8
  134. package/src/tui/code-cell.ts +19 -4
  135. package/src/tui/hyperlink.ts +42 -7
  136. package/src/tui/output-block.ts +14 -0
  137. package/src/web/search/index.ts +2 -2
  138. package/src/web/search/render.ts +23 -55
  139. package/dist/types/eval/heartbeat.d.ts +0 -45
  140. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  141. package/src/eval/heartbeat.ts +0 -74
  142. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
package/src/tools/eval.ts CHANGED
@@ -4,7 +4,7 @@ import { prompt } from "@oh-my-pi/pi-utils";
4
4
  import * as z from "zod/v4";
5
5
  import { jsBackend, pythonBackend } from "../eval";
6
6
  import type { ExecutorBackend, ExecutorBackendResult } from "../eval/backend";
7
- import { EVAL_HEARTBEAT_OP } from "../eval/heartbeat";
7
+ import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP } from "../eval/bridge-timeout";
8
8
  import { IdleTimeout } from "../eval/idle-timeout";
9
9
  import { defaultEvalSessionId } from "../eval/session-id";
10
10
  import type { EvalCellResult, EvalDisplayOutput, EvalLanguage, EvalStatusEvent, EvalToolDetails } from "../eval/types";
@@ -313,16 +313,13 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
313
313
  for (let i = 0; i < cells.length; i++) {
314
314
  const cell = cells[i];
315
315
  const backend = cell.resolved.backend;
316
- // The per-cell `timeout` is a wall-clock budget on the cell's *own*
317
- // work, but it is paused while a host-side `agent()`/`llm()` bridge
318
- // call is in flight: those calls pump a heartbeat (see
319
- // `withBridgeHeartbeat`) that re-arms the watchdog, so a long fanout
320
- // or a slow completion runs to completion. Nothing else re-arms it —
321
- // compute, stdout, `log()`/`phase()`, and ordinary tool calls all
322
- // count against the budget — so a cell that is not delegating to an
323
- // agent/llm is bounded by a plain wall-clock timeout. The watchdog
324
- // drives `combinedSignal`; we pass no wall-clock deadline downstream
325
- // so the backends never arm a competing fixed timer.
316
+ // The per-cell `timeout` is a budget on the cell runtime's *own*
317
+ // work. Host-side `agent()`/`parallel()`/`llm()` bridge calls suspend
318
+ // that budget entirely and restart a fresh timeout window when control
319
+ // returns to Python/JS. Compute, stdout, `log()`/`phase()`, and
320
+ // ordinary tool calls all count against the budget. The watchdog drives
321
+ // `combinedSignal`; we pass no wall-clock deadline downstream so the
322
+ // backends never arm a competing fixed timer.
326
323
  const idleTimeoutMs = timeoutSecondsFromMs(cell.timeoutMs) * 1000;
327
324
  const idle = new IdleTimeout(idleTimeoutMs);
328
325
  const combinedSignal = signal
@@ -355,14 +352,12 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
355
352
  outputSink!.push(chunk);
356
353
  },
357
354
  onStatus: event => {
358
- // Only a bridge heartbeat re-arms the watchdog: it is the
359
- // keepalive `agent()`/`llm()` pump while a host-side call is
360
- // in flight, so those calls effectively pause the budget. It
361
- // carries no payload — bump and drop it. Every other event
362
- // (compute helpers, log()/phase(), tool results) renders but
363
- // counts against the plain wall-clock budget.
364
- if (event.op === EVAL_HEARTBEAT_OP) {
365
- idle.bump();
355
+ if (event.op === EVAL_TIMEOUT_PAUSE_OP) {
356
+ idle.pause();
357
+ return;
358
+ }
359
+ if (event.op === EVAL_TIMEOUT_RESUME_OP) {
360
+ idle.resume();
366
361
  return;
367
362
  }
368
363
  cellResult.statusEvents ??= [];
@@ -13,8 +13,8 @@ import { type Theme, theme } from "../modes/theme/theme";
13
13
  import type { ToolSession } from "../sdk";
14
14
  import type { AgentStorage } from "../session/agent-storage";
15
15
  import { DEFAULT_MAX_BYTES, truncateHead } from "../session/streaming-output";
16
- import { renderStatusLine } from "../tui";
17
- import { CachedOutputBlock } from "../tui/output-block";
16
+ import { renderStatusLine, urlHyperlink } from "../tui";
17
+ import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
18
18
  import { formatDimensionNote, resizeImage } from "../utils/image-resize";
19
19
  import { ensureTool } from "../utils/tools-manager";
20
20
  import { extractWithParallel, findParallelApiKey, getParallelExtractContent } from "../web/parallel";
@@ -1437,6 +1437,27 @@ function countNonEmptyLines(text: string): number {
1437
1437
  return text.split("\n").filter(l => l.trim()).length;
1438
1438
  }
1439
1439
 
1440
+ function readUrlLinkTarget(input: string): string {
1441
+ try {
1442
+ return parseReadUrlTarget(input)?.path ?? input;
1443
+ } catch {
1444
+ return input;
1445
+ }
1446
+ }
1447
+
1448
+ function formatReadUrlDescription(input: string): string {
1449
+ const target = readUrlLinkTarget(input);
1450
+ const displayUrl = target.match(/^www\./i) ? `https://${target}` : target;
1451
+ const domain = getDomain(displayUrl);
1452
+ const urlPath = truncate(displayUrl.replace(/^https?:\/\/[^/]+/, ""), 50, "…");
1453
+ const label = `${domain}${urlPath ? ` ${urlPath}` : ""}`.trim();
1454
+ return urlHyperlink(target, label);
1455
+ }
1456
+
1457
+ function formatReadUrlMetadataValue(url: string, uiTheme: Theme): string {
1458
+ return urlHyperlink(url, uiTheme.fg("mdLinkUrl", url));
1459
+ }
1460
+
1440
1461
  /** Render URL read call (URL preview) */
1441
1462
  export function renderReadUrlCall(
1442
1463
  args: { path?: string; url?: string; raw?: boolean },
@@ -1444,9 +1465,7 @@ export function renderReadUrlCall(
1444
1465
  uiTheme: Theme = theme,
1445
1466
  ): Component {
1446
1467
  const url = args.path ?? args.url ?? "";
1447
- const domain = getDomain(url);
1448
- const path = truncate(url.replace(/^https?:\/\/[^/]+/, ""), 50, "…");
1449
- const description = `${domain}${path ? ` ${path}` : ""}`.trim();
1468
+ const description = formatReadUrlDescription(url);
1450
1469
  const meta: string[] = [];
1451
1470
  if (args.raw) meta.push("raw");
1452
1471
  const text = renderStatusLine({ icon: "pending", title: "Read", description, meta }, uiTheme);
@@ -1465,19 +1484,18 @@ export function renderReadUrlResult(
1465
1484
  const rawErrorText = result.content?.find(c => c.type === "text")?.text ?? "";
1466
1485
  const errorText = (rawErrorText || "No response data").replace(/^Error:\s*/, "");
1467
1486
  const urlText = details?.finalUrl ?? details?.url ?? "";
1468
- const description = urlText ? `${getDomain(urlText)}${urlText.replace(/^https?:\/\/[^/]+/, "")}` : undefined;
1487
+ const description = urlText ? formatReadUrlDescription(urlText) : undefined;
1469
1488
  const header = renderStatusLine({ icon: "error", title: "Read", description }, uiTheme);
1470
1489
  const errorLines = errorText.split("\n").map(line => uiTheme.fg("error", replaceTabs(line)));
1471
1490
  const outputBlock = new CachedOutputBlock();
1472
- return {
1491
+ return markFramedBlockComponent({
1473
1492
  render: (width: number) =>
1474
1493
  outputBlock.render({ header, state: "error", sections: [{ lines: errorLines }], width }, uiTheme),
1475
1494
  invalidate: () => outputBlock.invalidate(),
1476
- };
1495
+ });
1477
1496
  }
1478
1497
 
1479
- const domain = getDomain(details.finalUrl);
1480
- const path = truncate(details.finalUrl.replace(/^https?:\/\/[^/]+/, ""), 50, "…");
1498
+ const description = formatReadUrlDescription(details.finalUrl);
1481
1499
  const hasRedirect = details.url !== details.finalUrl;
1482
1500
  const hasNotes = details.notes.length > 0;
1483
1501
  const truncation = details.meta?.truncation;
@@ -1487,7 +1505,7 @@ export function renderReadUrlResult(
1487
1505
  {
1488
1506
  icon: truncated ? "warning" : "success",
1489
1507
  title: "Read",
1490
- description: `${domain}${path ? ` ${path}` : ""}`,
1508
+ description,
1491
1509
  },
1492
1510
  uiTheme,
1493
1511
  );
@@ -1505,7 +1523,9 @@ export function renderReadUrlResult(
1505
1523
  `${uiTheme.fg("muted", "Method:")} ${details.method}`,
1506
1524
  ];
1507
1525
  if (hasRedirect) {
1508
- metadataLines.push(`${uiTheme.fg("muted", "Final URL:")} ${uiTheme.fg("mdLinkUrl", details.finalUrl)}`);
1526
+ metadataLines.push(
1527
+ `${uiTheme.fg("muted", "Final URL:")} ${formatReadUrlMetadataValue(details.finalUrl, uiTheme)}`,
1528
+ );
1509
1529
  }
1510
1530
  const lineLabel = `${lineCount} line${lineCount === 1 ? "" : "s"}`;
1511
1531
  metadataLines.push(`${uiTheme.fg("muted", "Lines:")} ${lineLabel}`);
@@ -1522,7 +1542,7 @@ export function renderReadUrlResult(
1522
1542
  let lastExpanded: boolean | undefined;
1523
1543
  let contentPreviewLines: string[] | undefined;
1524
1544
 
1525
- return {
1545
+ return markFramedBlockComponent({
1526
1546
  render: (width: number) => {
1527
1547
  const { expanded } = options;
1528
1548
 
@@ -1562,5 +1582,5 @@ export function renderReadUrlResult(
1562
1582
  contentPreviewLines = undefined;
1563
1583
  lastExpanded = undefined;
1564
1584
  },
1565
- };
1585
+ });
1566
1586
  }
package/src/tools/gh.ts CHANGED
@@ -792,6 +792,18 @@ function repoFromRepositoryUrl(value: string | undefined): string | undefined {
792
792
  return value.slice(REPO_API_URL_PREFIX.length);
793
793
  }
794
794
 
795
+ function githubRepoSlugEquals(left: string | undefined, right: string): boolean {
796
+ if (left === undefined || left.length !== right.length) return false;
797
+ for (let idx = 0; idx < left.length; idx += 1) {
798
+ let leftCode = left.charCodeAt(idx);
799
+ let rightCode = right.charCodeAt(idx);
800
+ if (leftCode >= 65 && leftCode <= 90) leftCode += 32;
801
+ if (rightCode >= 65 && rightCode <= 90) rightCode += 32;
802
+ if (leftCode !== rightCode) return false;
803
+ }
804
+ return true;
805
+ }
806
+
795
807
  function apiUserToGhUser(user: GhApiUser | null | undefined): GhUser | undefined {
796
808
  if (!user) return undefined;
797
809
  const login = user.login ?? undefined;
@@ -1646,7 +1658,7 @@ async function resolveGitHubRepo(
1646
1658
  runRepo: string | undefined,
1647
1659
  signal?: AbortSignal,
1648
1660
  ): Promise<string> {
1649
- if (repo && runRepo && repo !== runRepo) {
1661
+ if (repo && runRepo && !githubRepoSlugEquals(repo, runRepo)) {
1650
1662
  throw new ToolError("run URL repository does not match the provided repo");
1651
1663
  }
1652
1664
 
@@ -1712,6 +1724,33 @@ export async function resolveDefaultRepoMemoized(cwd: string, signal?: AbortSign
1712
1724
  return untilAborted(signal, pending);
1713
1725
  }
1714
1726
 
1727
+ /**
1728
+ * Best-effort cached cwd → `owner/repo` resolution that swallows any failure
1729
+ * (not a git checkout, no GitHub remote, `gh` unauthenticated, …) into
1730
+ * `undefined`. Use where the cwd repo is a convenience fallback, not a safety
1731
+ * check.
1732
+ */
1733
+ async function tryResolveCurrentRepo(cwd: string, signal: AbortSignal | undefined): Promise<string | undefined> {
1734
+ try {
1735
+ return await resolveDefaultRepoMemoized(cwd, signal);
1736
+ } catch {
1737
+ return undefined;
1738
+ }
1739
+ }
1740
+
1741
+ /**
1742
+ * Best-effort fresh cwd → `owner/repo` resolution for safety checks that must
1743
+ * reflect the repository currently mounted at `cwd`, not the process-lifetime
1744
+ * default-repo cache.
1745
+ */
1746
+ async function tryResolveCurrentRepoFresh(cwd: string, signal: AbortSignal | undefined): Promise<string | undefined> {
1747
+ try {
1748
+ return await resolveGitHubRepo(cwd, undefined, undefined, signal);
1749
+ } catch {
1750
+ return undefined;
1751
+ }
1752
+ }
1753
+
1715
1754
  /**
1716
1755
  * Matches search-query qualifiers that already scope to a repository, org, or
1717
1756
  * user. When present, callers should avoid layering a default `repo:<current>`
@@ -1738,11 +1777,7 @@ async function resolveSearchRepoScope(
1738
1777
  ): Promise<string | undefined> {
1739
1778
  if (repo) return repo;
1740
1779
  if (query && REPO_SCOPE_QUALIFIER_PATTERN.test(query)) return undefined;
1741
- try {
1742
- return await resolveDefaultRepoMemoized(cwd, signal);
1743
- } catch {
1744
- return undefined;
1745
- }
1780
+ return tryResolveCurrentRepo(cwd, signal);
1746
1781
  }
1747
1782
 
1748
1783
  async function resolveGitHubBranchHead(
@@ -3338,8 +3373,9 @@ async function executeRunWatch(
3338
3373
  onUpdate: AgentToolUpdateCallback<GhToolDetails> | undefined,
3339
3374
  ): Promise<AgentToolResult<GhToolDetails>> {
3340
3375
  const branchInput = normalizeOptionalString(params.branch);
3376
+ const explicitRepo = normalizeOptionalString(params.repo);
3341
3377
  const runReference = parseRunReference(params.run);
3342
- const repo = await resolveGitHubRepo(session.cwd, undefined, runReference.repo, signal);
3378
+ const repo = await resolveGitHubRepo(session.cwd, explicitRepo, runReference.repo, signal);
3343
3379
  const intervalSeconds = RUN_WATCH_INTERVAL_DEFAULT;
3344
3380
  const graceSeconds = RUN_WATCH_GRACE_DEFAULT;
3345
3381
  const tail = resolveTailLimit(params.tail);
@@ -3419,10 +3455,28 @@ async function executeRunWatch(
3419
3455
  }
3420
3456
  }
3421
3457
 
3422
- const branch = branchInput ?? (await requireCurrentGitBranch(session.cwd, signal));
3423
- const headSha = branchInput
3424
- ? await resolveGitHubBranchHead(session.cwd, repo, branch, signal)
3425
- : await requireCurrentGitHead(session.cwd, signal);
3458
+ let branch: string;
3459
+ let headSha: string;
3460
+ if (branchInput) {
3461
+ branch = branchInput;
3462
+ headSha = await resolveGitHubBranchHead(session.cwd, repo, branch, signal);
3463
+ } else {
3464
+ // No branch/run selector — derive the commit from the current checkout,
3465
+ // but only when cwd actually points at `repo`. Otherwise we'd watch an
3466
+ // unrelated commit SHA against the explicit repo and silently stream a
3467
+ // confident wrong-repo status (issue #1949). GitHub `owner/repo` slugs
3468
+ // are case-insensitive — `gh repo view` returns the canonical casing
3469
+ // while callers may pass any casing — so the equality check normalizes
3470
+ // both sides before deciding the cwd is a different repo (PR #1951).
3471
+ const cwdRepo = await tryResolveCurrentRepoFresh(session.cwd, signal);
3472
+ if (!githubRepoSlugEquals(cwdRepo, repo)) {
3473
+ throw new ToolError(
3474
+ `Cannot infer the watched commit for ${repo}: current checkout is ${cwdRepo ?? "not a GitHub repository"}. Pass \`branch\` or \`run\` to scope the watch.`,
3475
+ );
3476
+ }
3477
+ branch = await requireCurrentGitBranch(session.cwd, signal);
3478
+ headSha = await requireCurrentGitHead(session.cwd, signal);
3479
+ }
3426
3480
  let pollCount = 0;
3427
3481
  let settledSuccessSignature: string | undefined;
3428
3482
 
@@ -23,6 +23,7 @@ import type { CustomMessage } from "../session/messages";
23
23
  import type { ToolChoiceQueue } from "../session/tool-choice-queue";
24
24
  import { TaskTool } from "../task";
25
25
  import type { AgentOutputManager } from "../task/output-manager";
26
+ import { countToolsForAutoDiscovery, resolveEffectiveToolDiscoveryMode } from "../tool-discovery/mode";
26
27
  import type { DiscoverableTool, DiscoverableToolSearchIndex } from "../tool-discovery/tool-index";
27
28
  import type { EventBus } from "../utils/event-bus";
28
29
  import { WebSearchTool } from "../web/search";
@@ -420,14 +421,11 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
420
421
  }
421
422
  }
422
423
  // Resolve effective tool discovery mode.
423
- // tools.discoveryMode takes precedence; mcp.discoveryMode is a back-compat alias for "mcp-only".
424
- const toolsDiscoveryMode = session.settings.get("tools.discoveryMode");
425
- const effectiveDiscoveryMode: "off" | "mcp-only" | "all" =
426
- toolsDiscoveryMode !== "off"
427
- ? (toolsDiscoveryMode as "off" | "mcp-only" | "all")
428
- : session.settings.get("mcp.discoveryMode")
429
- ? "mcp-only"
430
- : "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
+ );
431
429
  const discoveryActive = effectiveDiscoveryMode !== "off";
432
430
 
433
431
  const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
package/src/tools/read.ts CHANGED
@@ -29,7 +29,7 @@ import {
29
29
  truncateLine,
30
30
  } from "../session/streaming-output";
31
31
  import { fileHyperlink, renderCodeCell, renderMarkdownCell, renderStatusLine, tryResolveInternalUrlSync } from "../tui";
32
- import { CachedOutputBlock } from "../tui/output-block";
32
+ import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
33
33
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
34
34
  import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading";
35
35
  import { convertFileWithMarkit } from "../utils/markit";
@@ -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;
@@ -2373,11 +2413,11 @@ export const readToolRenderer = {
2373
2413
  const header = renderStatusLine({ icon: "error", title }, uiTheme);
2374
2414
  const errorLines = errorText.split("\n").map(line => uiTheme.fg("error", replaceTabs(line)));
2375
2415
  const outputBlock = new CachedOutputBlock();
2376
- return {
2416
+ return markFramedBlockComponent({
2377
2417
  render: (width: number) =>
2378
2418
  outputBlock.render({ header, state: "error", sections: [{ lines: errorLines }], width }, uiTheme),
2379
2419
  invalidate: () => outputBlock.invalidate(),
2380
- };
2420
+ });
2381
2421
  }
2382
2422
  const details = result.details;
2383
2423
  const rawText = result.content?.find(c => c.type === "text")?.text ?? "";
@@ -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}` },
@@ -2421,7 +2465,7 @@ export const readToolRenderer = {
2421
2465
  const detailLines = contentText ? contentText.split("\n").map(line => uiTheme.fg("toolOutput", line)) : [];
2422
2466
  const lines = [...detailLines, ...warningLines];
2423
2467
  const outputBlock = new CachedOutputBlock();
2424
- return {
2468
+ return markFramedBlockComponent({
2425
2469
  render: (width: number) =>
2426
2470
  outputBlock.render(
2427
2471
  {
@@ -2438,17 +2482,19 @@ export const readToolRenderer = {
2438
2482
  uiTheme,
2439
2483
  ),
2440
2484
  invalidate: () => outputBlock.invalidate(),
2441
- };
2485
+ });
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,12 +2509,12 @@ 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;
2470
2516
  let cachedLines: string[] | undefined;
2471
- return {
2517
+ return markFramedBlockComponent({
2472
2518
  render: (width: number) => {
2473
2519
  const expanded = options.expanded;
2474
2520
  if (cachedLines && cachedWidth === width && cachedExpanded === expanded) return cachedLines;
@@ -2505,7 +2551,7 @@ export const readToolRenderer = {
2505
2551
  cachedExpanded = undefined;
2506
2552
  cachedLines = undefined;
2507
2553
  },
2508
- };
2554
+ });
2509
2555
  },
2510
2556
  mergeCallAndResult: true,
2511
2557
  };
@@ -171,6 +171,52 @@ export function formatMoreItems(remaining: number, itemType: string): string {
171
171
  return `… ${safeRemaining} more ${pluralize(itemType, safeRemaining)}`;
172
172
  }
173
173
 
174
+ /**
175
+ * Maximum rows a tool's streaming/pending *call* preview may render before it is
176
+ * capped. This is intentionally conservative: the preview still sits inside a
177
+ * transcript that already consumed some viewport rows, and tool blocks carry
178
+ * extra chrome (status/header/border/"more lines"), so a "reasonable" raw code
179
+ * or command preview like 10-12 lines can still overflow and strand its top
180
+ * while the block is volatile. Keeping the live call window short avoids that
181
+ * across terminals without turning the transcript into an interactive scroller.
182
+ */
183
+ export const CALL_PREVIEW_MAX_LINES = 6;
184
+
185
+ /**
186
+ * Cap a pre-rendered pending/call preview to a bounded window. When truncated,
187
+ * show both the head and the live tail so the user can still see what the tool
188
+ * is currently writing while the volatile block stays short enough not to strand
189
+ * its top above the viewport. `Ctrl+O` widens the bounded window, but does not
190
+ * fully uncap live tool previews for the same reason.
191
+ *
192
+ * `prefix` (raw, e.g. a dim tree gutter) is prepended to the summary line so
193
+ * nested previews stay aligned.
194
+ */
195
+ export function capPreviewLines(
196
+ lines: string[],
197
+ theme: Theme,
198
+ options: { max?: number; expanded?: boolean; prefix?: string } = {},
199
+ ): string[] {
200
+ const max = options.max ?? (options.expanded ? PREVIEW_LIMITS.EXPANDED_LINES : CALL_PREVIEW_MAX_LINES);
201
+ if (lines.length <= max) return lines;
202
+ if (max <= 1) {
203
+ const hint = formatExpandHint(theme, options.expanded, true);
204
+ const moreLine = `${formatMoreItems(lines.length, "line")}${hint ? ` ${hint}` : ""}`;
205
+ return [`${options.prefix ?? ""}${theme.fg("dim", moreLine)}`];
206
+ }
207
+ const bodyBudget = max - 1; // reserve one summary row
208
+ const headCount = Math.max(1, Math.ceil(bodyBudget / 2));
209
+ const tailCount = Math.max(1, bodyBudget - headCount);
210
+ const hidden = Math.max(0, lines.length - headCount - tailCount);
211
+ const hint = formatExpandHint(theme, options.expanded, true);
212
+ const moreLine = `${formatMoreItems(hidden, "line")}${hint ? ` ${hint}` : ""}`;
213
+ return [
214
+ ...lines.slice(0, headCount),
215
+ `${options.prefix ?? ""}${theme.fg("dim", moreLine)}`,
216
+ ...lines.slice(lines.length - tailCount),
217
+ ];
218
+ }
219
+
174
220
  export function formatMeta(meta: string[], theme: Theme): string {
175
221
  return meta.length > 0 ? ` ${theme.fg("muted", meta.join(theme.sep.dot))}` : "";
176
222
  }
@@ -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