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

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 (128) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/dist/types/cli/args.d.ts +1 -1
  3. package/dist/types/cli/gallery-cli.d.ts +43 -0
  4. package/dist/types/cli/gallery-fixtures/agentic.d.ts +2 -0
  5. package/dist/types/cli/gallery-fixtures/codeintel.d.ts +3 -0
  6. package/dist/types/cli/gallery-fixtures/edit.d.ts +3 -0
  7. package/dist/types/cli/gallery-fixtures/fs.d.ts +2 -0
  8. package/dist/types/cli/gallery-fixtures/index.d.ts +4 -0
  9. package/dist/types/cli/gallery-fixtures/interaction.d.ts +3 -0
  10. package/dist/types/cli/gallery-fixtures/memory.d.ts +2 -0
  11. package/dist/types/cli/gallery-fixtures/misc.d.ts +3 -0
  12. package/dist/types/cli/gallery-fixtures/search.d.ts +3 -0
  13. package/dist/types/cli/gallery-fixtures/shell.d.ts +3 -0
  14. package/dist/types/cli/gallery-fixtures/types.d.ts +44 -0
  15. package/dist/types/cli/gallery-fixtures/web.d.ts +2 -0
  16. package/dist/types/cli/gallery-screenshot.d.ts +35 -0
  17. package/dist/types/commands/gallery.d.ts +47 -0
  18. package/dist/types/config/keybindings.d.ts +6 -1
  19. package/dist/types/config/model-id-affixes.d.ts +2 -0
  20. package/dist/types/config/model-registry.d.ts +8 -1
  21. package/dist/types/config/settings-schema.d.ts +32 -6
  22. package/dist/types/extensibility/plugins/marketplace-auto-update.d.ts +8 -0
  23. package/dist/types/lsp/types.d.ts +10 -0
  24. package/dist/types/main.d.ts +3 -2
  25. package/dist/types/memory-backend/index.d.ts +2 -1
  26. package/dist/types/memory-backend/resolve.d.ts +1 -1
  27. package/dist/types/memory-backend/types.d.ts +1 -1
  28. package/dist/types/modes/components/custom-editor.d.ts +2 -1
  29. package/dist/types/modes/components/tool-execution.d.ts +18 -0
  30. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  31. package/dist/types/modes/index.d.ts +5 -4
  32. package/dist/types/modes/interactive-mode.d.ts +1 -1
  33. package/dist/types/modes/setup-version.d.ts +11 -0
  34. package/dist/types/modes/setup-wizard/index.d.ts +2 -1
  35. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +2 -1
  36. package/dist/types/modes/types.d.ts +1 -1
  37. package/dist/types/sdk.d.ts +1 -1
  38. package/dist/types/task/executor.d.ts +7 -0
  39. package/dist/types/telemetry-export.d.ts +1 -1
  40. package/dist/types/tools/eval-render.d.ts +1 -8
  41. package/dist/types/tools/fetch.d.ts +15 -7
  42. package/dist/types/tools/render-utils.d.ts +8 -0
  43. package/dist/types/tools/renderers.d.ts +16 -2
  44. package/dist/types/tools/search.d.ts +1 -1
  45. package/dist/types/tools/write.d.ts +2 -0
  46. package/dist/types/web/scrapers/github.d.ts +22 -0
  47. package/dist/types/web/search/providers/perplexity.d.ts +8 -1
  48. package/dist/types/web/search/types.d.ts +1 -1
  49. package/package.json +9 -9
  50. package/scripts/dev-launch +42 -0
  51. package/scripts/dev-launch-preload.ts +19 -0
  52. package/src/cli/args.ts +2 -2
  53. package/src/cli/gallery-cli.ts +223 -0
  54. package/src/cli/gallery-fixtures/agentic.ts +292 -0
  55. package/src/cli/gallery-fixtures/codeintel.ts +188 -0
  56. package/src/cli/gallery-fixtures/edit.ts +194 -0
  57. package/src/cli/gallery-fixtures/fs.ts +153 -0
  58. package/src/cli/gallery-fixtures/index.ts +40 -0
  59. package/src/cli/gallery-fixtures/interaction.ts +49 -0
  60. package/src/cli/gallery-fixtures/memory.ts +81 -0
  61. package/src/cli/gallery-fixtures/misc.ts +221 -0
  62. package/src/cli/gallery-fixtures/search.ts +213 -0
  63. package/src/cli/gallery-fixtures/shell.ts +167 -0
  64. package/src/cli/gallery-fixtures/types.ts +41 -0
  65. package/src/cli/gallery-fixtures/web.ts +158 -0
  66. package/src/cli/gallery-screenshot.ts +279 -0
  67. package/src/cli-commands.ts +1 -0
  68. package/src/commands/gallery.ts +52 -0
  69. package/src/commands/launch.ts +1 -1
  70. package/src/config/keybindings.ts +15 -6
  71. package/src/config/model-equivalence.ts +35 -12
  72. package/src/config/model-id-affixes.ts +39 -22
  73. package/src/config/model-registry.ts +16 -16
  74. package/src/config/settings-schema.ts +18 -5
  75. package/src/config/settings.ts +11 -0
  76. package/src/dap/client.ts +14 -16
  77. package/src/edit/renderer.ts +36 -48
  78. package/src/eval/__tests__/agent-bridge.test.ts +75 -32
  79. package/src/eval/agent-bridge.ts +34 -7
  80. package/src/extensibility/extensions/runner.ts +1 -0
  81. package/src/extensibility/plugins/doctor.ts +0 -1
  82. package/src/extensibility/plugins/marketplace-auto-update.ts +49 -0
  83. package/src/goals/tools/goal-tool.ts +2 -2
  84. package/src/internal-urls/docs-index.generated.ts +5 -5
  85. package/src/lsp/client.ts +104 -55
  86. package/src/lsp/types.ts +10 -0
  87. package/src/main.ts +44 -49
  88. package/src/memory-backend/index.ts +13 -1
  89. package/src/memory-backend/resolve.ts +3 -5
  90. package/src/memory-backend/types.ts +1 -1
  91. package/src/modes/components/custom-editor.ts +10 -1
  92. package/src/modes/components/status-line.ts +3 -5
  93. package/src/modes/components/tool-execution.ts +61 -16
  94. package/src/modes/controllers/command-controller.ts +13 -2
  95. package/src/modes/controllers/input-controller.ts +11 -3
  96. package/src/modes/controllers/selector-controller.ts +2 -2
  97. package/src/modes/index.ts +5 -4
  98. package/src/modes/interactive-mode.ts +17 -3
  99. package/src/modes/setup-version.ts +11 -0
  100. package/src/modes/setup-wizard/index.ts +3 -2
  101. package/src/modes/setup-wizard/scenes/web-search.ts +3 -2
  102. package/src/modes/types.ts +1 -1
  103. package/src/modes/utils/context-usage.ts +10 -6
  104. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  105. package/src/sdk.ts +21 -23
  106. package/src/session/agent-session.ts +7 -7
  107. package/src/slash-commands/builtin-registry.ts +1 -1
  108. package/src/slash-commands/helpers/usage-report.ts +2 -0
  109. package/src/task/executor.ts +20 -2
  110. package/src/task/render.ts +1 -2
  111. package/src/telemetry-export.ts +25 -7
  112. package/src/tools/eval-backends.ts +6 -17
  113. package/src/tools/eval-render.ts +21 -18
  114. package/src/tools/eval.ts +5 -4
  115. package/src/tools/fetch.ts +94 -84
  116. package/src/tools/render-utils.ts +17 -3
  117. package/src/tools/renderers.ts +16 -1
  118. package/src/tools/report-tool-issue.ts +1 -1
  119. package/src/tools/search.ts +173 -81
  120. package/src/tools/todo.ts +20 -7
  121. package/src/tools/write.ts +22 -1
  122. package/src/web/scrapers/github.ts +255 -3
  123. package/src/web/scrapers/youtube.ts +3 -2
  124. package/src/web/search/providers/perplexity.ts +199 -51
  125. package/src/web/search/render.ts +39 -54
  126. package/src/web/search/types.ts +5 -1
  127. package/dist/types/eval/__tests__/shared-executors.test.d.ts +0 -1
  128. package/src/eval/__tests__/shared-executors.test.ts +0 -609
@@ -166,6 +166,13 @@ export interface ExecutorOptions {
166
166
  outputSchema?: unknown;
167
167
  /** Parent task recursion depth (0 = top-level, 1 = first child, etc.) */
168
168
  taskDepth?: number;
169
+ /**
170
+ * Override the `task.maxRuntimeMs` wall-clock cap for this run. When provided
171
+ * it wins over the settings value; `0` disables the per-subagent wall-clock
172
+ * limit entirely. Used by the eval `agent()` bridge, whose parent cell
173
+ * watchdog is already suspended for the call's duration.
174
+ */
175
+ maxRuntimeMs?: number;
169
176
  enableLsp?: boolean;
170
177
  signal?: AbortSignal;
171
178
  onProgress?: (progress: AgentProgress) => void;
@@ -625,7 +632,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
625
632
  agent.readSummarize === false ? { "read.summarize.enabled": false } : undefined,
626
633
  );
627
634
  const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
628
- const maxRuntimeMs = Math.max(0, Math.trunc(Number(settings.get("task.maxRuntimeMs") ?? 0) || 0));
635
+ const maxRuntimeMs = Math.max(
636
+ 0,
637
+ Math.trunc(Number(options.maxRuntimeMs ?? settings.get("task.maxRuntimeMs") ?? 0) || 0),
638
+ );
629
639
  const parentDepth = options.taskDepth ?? 0;
630
640
  const childDepth = parentDepth + 1;
631
641
  const atMaxDepth = maxRecursionDepth >= 0 && childDepth >= maxRecursionDepth;
@@ -1484,7 +1494,15 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1484
1494
  if (lastAssistant.stopReason === "aborted") {
1485
1495
  aborted = abortReason === "signal" || runtimeLimitExceeded || abortReason === undefined;
1486
1496
  if (aborted) {
1487
- abortReasonText ??= resolveAbortReasonText();
1497
+ // A real caller signal or the wall-clock timer carries a precise
1498
+ // reason (signal.reason / "runtime limit exceeded"). An internal
1499
+ // turn abort (abortReason === undefined) does NOT — prefer the
1500
+ // assistant message's own errorMessage ("Request was aborted" or a
1501
+ // specific stream error) over the misleading "Cancelled by caller".
1502
+ abortReasonText ??=
1503
+ abortReason === "signal" || runtimeLimitExceeded
1504
+ ? resolveAbortReasonText()
1505
+ : lastAssistant.errorMessage?.trim() || resolveAbortReasonText();
1488
1506
  }
1489
1507
  exitCode = 1;
1490
1508
  } else if (lastAssistant.stopReason === "error") {
@@ -13,7 +13,6 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
13
  import { formatContextUsage } from "../modes/components/status-line/context-thresholds";
14
14
  import type { Theme } from "../modes/theme/theme";
15
15
  import {
16
- capPreviewLines,
17
16
  formatBadge,
18
17
  formatDuration,
19
18
  formatMoreItems,
@@ -566,7 +565,7 @@ export function renderCall(
566
565
  const content = line ? theme.fg("muted", replaceTabs(line)) : "";
567
566
  return ` ${vertical} ${content}`;
568
567
  });
569
- lines.push(...capPreviewLines(contextLines, theme, { expanded: options.expanded, prefix: ` ${vertical} ` }));
568
+ lines.push(...contextLines);
570
569
  }
571
570
 
572
571
  // `Tasks` is the last child unless the isolation flag follows it.
@@ -23,11 +23,7 @@
23
23
  * `sdk-trace-base@2.7` exports cleanly on Bun.
24
24
  */
25
25
  import { logger, postmortem } from "@oh-my-pi/pi-utils";
26
- import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
27
- import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
28
- import { resourceFromAttributes } from "@opentelemetry/resources";
29
- import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
30
- import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
26
+ import type * as TraceNode from "@opentelemetry/sdk-trace-node";
31
27
 
32
28
  /**
33
29
  * Periodic flush interval. A long-lived `omp` process (the ACP server is
@@ -36,7 +32,8 @@ import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
36
32
  */
37
33
  const FLUSH_INTERVAL_MS = 30_000;
38
34
 
39
- let provider: NodeTracerProvider | undefined;
35
+ let provider: TraceNode.NodeTracerProvider | undefined;
36
+ let initPromise: Promise<void> | undefined;
40
37
 
41
38
  /**
42
39
  * Whether {@link initTelemetryExport} registered a real provider. The CLI uses
@@ -53,8 +50,10 @@ export function isTelemetryExportEnabled(): boolean {
53
50
  * the OTEL kill-switches are engaged), so it is safe to call unconditionally at
54
51
  * startup.
55
52
  */
56
- export function initTelemetryExport(): void {
53
+ export async function initTelemetryExport(): Promise<void> {
57
54
  if (provider) return;
55
+ if (initPromise) return initPromise;
56
+
58
57
  // The OTEL env contract parses booleans and enum lists case-insensitively, so
59
58
  // OTEL_SDK_DISABLED=TRUE and OTEL_TRACES_EXPORTER=None must also disable export.
60
59
  if (process.env.OTEL_SDK_DISABLED?.trim().toLowerCase() === "true") return;
@@ -77,6 +76,25 @@ export function initTelemetryExport(): void {
77
76
  return;
78
77
  }
79
78
 
79
+ initPromise = registerProvider();
80
+ return initPromise;
81
+ }
82
+
83
+ async function registerProvider(): Promise<void> {
84
+ const [
85
+ { AsyncLocalStorageContextManager },
86
+ { OTLPTraceExporter },
87
+ { resourceFromAttributes },
88
+ { BatchSpanProcessor },
89
+ { NodeTracerProvider },
90
+ ] = await Promise.all([
91
+ import("@opentelemetry/context-async-hooks"),
92
+ import("@opentelemetry/exporter-trace-otlp-proto"),
93
+ import("@opentelemetry/resources"),
94
+ import("@opentelemetry/sdk-trace-base"),
95
+ import("@opentelemetry/sdk-trace-node"),
96
+ ]);
97
+
80
98
  // The exporter reads endpoint/headers/timeout from OTEL_EXPORTER_OTLP_* itself,
81
99
  // so there is nothing to thread through here.
82
100
  const exporter = new OTLPTraceExporter();
@@ -1,4 +1,4 @@
1
- import { $env, $flag } from "@oh-my-pi/pi-utils";
1
+ import { $flag } from "@oh-my-pi/pi-utils";
2
2
  import type { ToolSession } from ".";
3
3
 
4
4
  export interface EvalBackendsAllowance {
@@ -6,21 +6,6 @@ export interface EvalBackendsAllowance {
6
6
  js: boolean;
7
7
  }
8
8
 
9
- /**
10
- * Parse PI_PY / PI_JS environment variables. Each is a boolean flag; unset
11
- * means "not specified, defer to settings". Returns null when neither is set
12
- * so the caller can fall through to `readEvalBackendsAllowance` per key.
13
- */
14
- function getEvalBackendsFromEnv(): EvalBackendsAllowance | null {
15
- const pyEnv = $env.PI_PY;
16
- const jsEnv = $env.PI_JS;
17
- if (pyEnv === undefined && jsEnv === undefined) return null;
18
- return {
19
- python: pyEnv === undefined ? true : $flag("PI_PY"),
20
- js: jsEnv === undefined ? true : $flag("PI_JS"),
21
- };
22
- }
23
-
24
9
  /** Read per-backend allowance from settings (defaults true). */
25
10
  export function readEvalBackendsAllowance(session: ToolSession): EvalBackendsAllowance {
26
11
  return {
@@ -34,5 +19,9 @@ export function readEvalBackendsAllowance(session: ToolSession): EvalBackendsAll
34
19
  * override the per-key settings; otherwise settings (defaults true) win.
35
20
  */
36
21
  export function resolveEvalBackends(session: ToolSession): EvalBackendsAllowance {
37
- return getEvalBackendsFromEnv() ?? readEvalBackendsAllowance(session);
22
+ const settings = readEvalBackendsAllowance(session);
23
+ return {
24
+ python: $flag("PI_PY", settings.python),
25
+ js: $flag("PI_JS", settings.js),
26
+ };
38
27
  }
@@ -40,14 +40,6 @@ import {
40
40
  wrapBrackets,
41
41
  } from "./render-utils";
42
42
  export const EVAL_DEFAULT_PREVIEW_LINES = 10;
43
- /**
44
- * Rows of source kept in the *pending* eval preview. The window follows the
45
- * streaming edge (newest lines pinned to the bottom) so you can watch the code
46
- * being written, while staying bounded — a volatile tool block taller than the
47
- * viewport would otherwise strand its scrolled-off head out of native scrollback
48
- * on ED3-risk terminals. Matches the streaming windows used by edit/write.
49
- */
50
- export const EVAL_STREAMING_PREVIEW_LINES = 12;
51
43
 
52
44
  function languageForHighlighter(language: EvalLanguage | undefined): "python" | "javascript" {
53
45
  return language === "js" ? "javascript" : "python";
@@ -517,15 +509,12 @@ export const evalToolRenderer = {
517
509
  title: cell.title,
518
510
  status: "pending",
519
511
  width,
520
- codeMaxLines: EVAL_STREAMING_PREVIEW_LINES,
521
- // Follow the streaming edge with a bounded tail window so the
522
- // newest source stays visible as it is written, instead of
523
- // rendering every line of a >100-line `code` which would
524
- // overflow the viewport and, because a tool block is volatile
525
- // (it collapses to a capped result), strand its scrolled-off head
526
- // out of native scrollback, cutting the box top. `Ctrl+O` lifts
527
- // the window via `expanded` for a deliberate full view.
528
- codeTail: true,
512
+ // Always render the full source: the code is fixed input, not the
513
+ // streaming part, so it is never compacted. While still pending
514
+ // (args streaming) the block is not yet committed to native
515
+ // scrollback its head is only committed once a result exists and
516
+ // the code has finalized (see `isStreamingPreviewAppendOnly`).
517
+ codeMaxLines: Number.POSITIVE_INFINITY,
529
518
  expanded: options.expanded,
530
519
  animate,
531
520
  },
@@ -628,7 +617,9 @@ export const evalToolRenderer = {
628
617
  duration: cell.durationMs,
629
618
  output: outputLines.length > 0 ? outputLines.join("\n") : undefined,
630
619
  outputMaxLines: outputLines.length,
631
- codeMaxLines: expanded ? Number.POSITIVE_INFINITY : EVAL_DEFAULT_PREVIEW_LINES,
620
+ // Code is fixed input — always shown in full, never compacted.
621
+ // Only `output` honors the collapsed preview cap above.
622
+ codeMaxLines: Number.POSITIVE_INFINITY,
632
623
  expanded,
633
624
  width,
634
625
  animate,
@@ -760,6 +751,18 @@ export const evalToolRenderer = {
760
751
  },
761
752
  };
762
753
  },
754
+
755
+ // Append-only once a result exists (args complete → code finalized). The code
756
+ // is rendered in full as a fixed top-anchored prefix, and the streamed stdout
757
+ // below it only appends rows at the bottom, so the scrolled-off head commits
758
+ // to native scrollback instead of being yanked — collapsed or expanded, since
759
+ // the collapsed output cap keeps its sliding tail in the bottom live region.
760
+ // Returns false while still pending: the code is mid-stream (args incomplete)
761
+ // and its header still reads "pending", so committing it would strand a stale
762
+ // pending preview in history.
763
+ isStreamingPreviewAppendOnly(_args: EvalRenderArgs, _options: RenderResultOptions, result?: unknown): boolean {
764
+ return result != null;
765
+ },
763
766
  mergeCallAndResult: true,
764
767
  inline: true,
765
768
  };
package/src/tools/eval.ts CHANGED
@@ -130,11 +130,12 @@ function timeoutSecondsFromMs(timeoutMs: number): number {
130
130
  }
131
131
 
132
132
  async function resolveBackend(session: ToolSession, language: EvalLanguage): Promise<ResolvedBackend> {
133
- const allowPy = (session.settings.get("eval.py") as boolean | undefined) ?? true;
134
- const allowJs = (session.settings.get("eval.js") as boolean | undefined) ?? true;
133
+ const backends = resolveEvalBackends(session);
134
+ const allowPy = backends.python;
135
+ const allowJs = backends.js;
135
136
 
136
137
  if (language === "python") {
137
- if (!allowPy) throw new ToolError("Python backend is disabled (eval.py = false).");
138
+ if (!allowPy) throw new ToolError("Python backend is disabled (PI_PY=0 or eval.py = false).");
138
139
  if (!(await pythonBackend.isAvailable(session))) {
139
140
  throw new ToolError(
140
141
  'Python backend is unavailable in this session. Pass language: "js" or install the python kernel.',
@@ -142,7 +143,7 @@ async function resolveBackend(session: ToolSession, language: EvalLanguage): Pro
142
143
  }
143
144
  return { backend: pythonBackend };
144
145
  }
145
- if (!allowJs) throw new ToolError("JavaScript backend is disabled (eval.js = false).");
146
+ if (!allowJs) throw new ToolError("JavaScript backend is disabled (PI_JS=0 or eval.js = false).");
146
147
  return { backend: jsBackend };
147
148
  }
148
149
 
@@ -581,14 +581,25 @@ function parseFeedToMarkdown(content: string, maxItems = 10): string {
581
581
  */
582
582
  const REMOTE_READER_MAX_MS = 10_000;
583
583
 
584
+ /** Reader backends for {@link renderHtmlToText}, in default priority order. */
585
+ export type FetchProvider = "native" | "trafilatura" | "lynx" | "parallel" | "jina";
586
+
587
+ const FETCH_PROVIDER_ORDER: readonly FetchProvider[] = ["native", "trafilatura", "lynx", "parallel", "jina"];
588
+
584
589
  /**
585
- * Render HTML to markdown using Parallel, jina, trafilatura, lynx, then the
586
- * in-process native converter. The overall `timeout` budget bounds the call,
587
- * but remote reader requests are additionally capped at `REMOTE_READER_MAX_MS`
588
- * so that a hung remote endpoint cannot prevent local fallbacks from running.
589
- * Only a real `userSignal` cancellation aborts the chain remote per-attempt
590
- * timeouts and the overall reader-mode timeout still allow later renderers
591
- * (especially the purely-local native converter) to be tried.
590
+ * Render HTML to markdown by trying reader backends in priority order: native
591
+ * (in-process), trafilatura, lynx, Parallel, then Jina. The `providers.fetch`
592
+ * setting picks the order `auto` uses the default above; any specific backend
593
+ * is tried first, then the remaining backends as fallbacks. Every backend's
594
+ * output must clear the same quality gate (>100 non-whitespace chars and not
595
+ * {@link isLowQualityOutput}) before it is accepted, otherwise the next backend
596
+ * is tried.
597
+ *
598
+ * The overall `timeout` budget bounds the whole call; remote backends (Parallel,
599
+ * Jina) are additionally capped at `REMOTE_READER_MAX_MS` so a hung endpoint
600
+ * cannot starve later renderers — especially the purely-local native converter,
601
+ * which always works on already-loaded HTML. Only a real `userSignal`
602
+ * cancellation aborts the chain (#1449).
592
603
  */
593
604
  export async function renderHtmlToText(
594
605
  url: string,
@@ -607,92 +618,74 @@ export async function renderHtmlToText(
607
618
  signal: overallSignal,
608
619
  };
609
620
  const remoteBudgetMs = Math.min(timeout * 1000, REMOTE_READER_MAX_MS);
610
-
611
- // Try Parallel extract first when credentials are configured
612
- if (settings.get("providers.parallelFetch") && findParallelApiKey(storage)) {
613
- try {
621
+ // Per-attempt budget for remote endpoints so one stall cannot consume the
622
+ // whole reader-mode budget and starve the local fallbacks.
623
+ const remoteSignal = () => ptree.combineSignals(userSignal, remoteBudgetMs);
624
+
625
+ const runners: Record<FetchProvider, () => Promise<string | null>> = {
626
+ // Purely local, no network/subprocess: still works on already-loaded HTML
627
+ // even after remote/subprocess attempts are aborted by the budget.
628
+ native: () => htmlToMarkdown(html, { cleanContent: true }),
629
+ trafilatura: async () => {
630
+ const trafilatura = await ensureTool("trafilatura", { signal: overallSignal, silent: true });
631
+ if (!trafilatura) return null;
632
+ const result = await ptree.exec([trafilatura, "-u", url, "--output-format", "markdown"], execOptions);
633
+ return result.ok ? result.stdout : null;
634
+ },
635
+ lynx: async () => {
636
+ if (!hasCommand("lynx")) return null;
637
+ const result = await ptree.exec(["lynx", "-dump", "-nolist", "-width", "250", url], execOptions);
638
+ return result.ok ? result.stdout : null;
639
+ },
640
+ parallel: async () => {
641
+ if (!findParallelApiKey(storage)) return null;
614
642
  const parallelResult = await extractWithParallel(
615
643
  [url],
616
- {
617
- objective: "Extract the main content",
618
- excerpts: true,
619
- fullContent: false,
620
- signal: ptree.combineSignals(userSignal, remoteBudgetMs),
621
- },
644
+ { objective: "Extract the main content", excerpts: true, fullContent: false, signal: remoteSignal() },
622
645
  storage,
623
646
  );
624
647
  const firstDocument = parallelResult.results[0];
625
- if (firstDocument) {
626
- const content = getParallelExtractContent(firstDocument);
627
- if (content.trim().length > 100 && !isLowQualityOutput(content)) {
628
- return { content, ok: true, method: "parallel" };
629
- }
630
- }
631
- } catch {
632
- // Parallel extract failed or stalled; honour real cancellation only.
633
- userSignal?.throwIfAborted();
634
- }
635
- }
636
-
637
- // Try jina reader API with its own sub-budget so a stall cannot starve
638
- // later fallbacks (#1449).
639
- try {
640
- const jinaUrl = `https://r.jina.ai/${url}`;
641
- const response = await fetch(jinaUrl, {
642
- headers: { Accept: "text/markdown" },
643
- signal: ptree.combineSignals(userSignal, remoteBudgetMs),
644
- });
645
- if (response.ok) {
646
- const content = await response.text();
647
- if (content.trim().length > 100 && !isLowQualityOutput(content)) {
648
- return { content, ok: true, method: "jina" };
649
- }
650
- }
651
- } catch {
652
- // Jina failed or stalled; honour real cancellation only.
653
- userSignal?.throwIfAborted();
654
- }
648
+ return firstDocument ? getParallelExtractContent(firstDocument) : null;
649
+ },
650
+ jina: async () => {
651
+ const response = await fetch(`https://r.jina.ai/${url}`, {
652
+ headers: { Accept: "text/markdown" },
653
+ signal: remoteSignal(),
654
+ });
655
+ return response.ok ? await response.text() : null;
656
+ },
657
+ };
655
658
 
656
- // Try trafilatura (auto-install via uv/pip)
657
- try {
658
- const trafilatura = await ensureTool("trafilatura", { signal: overallSignal, silent: true });
659
- if (trafilatura) {
660
- const result = await ptree.exec([trafilatura, "-u", url, "--output-format", "markdown"], execOptions);
661
- if (result.ok && result.stdout.trim().length > 100) {
662
- return { content: result.stdout, ok: true, method: "trafilatura" };
663
- }
664
- }
665
- } catch {
666
- // trafilatura unavailable or stalled; continue to next method.
659
+ const preference = settings.get("providers.fetch");
660
+ const order: readonly FetchProvider[] =
661
+ preference === "auto"
662
+ ? FETCH_PROVIDER_ORDER
663
+ : [preference, ...FETCH_PROVIDER_ORDER.filter(method => method !== preference)];
664
+
665
+ // Highest-priority output that is substantial but fails the low-quality gate.
666
+ // Surfaced (ok: true) only when no backend clears the gate, so the caller's
667
+ // targeted fallbacks (llms.txt / document extraction) still run and we beat
668
+ // returning the unrendered raw HTML.
669
+ let lowQuality: { content: string; method: FetchProvider } | null = null;
670
+
671
+ for (const method of order) {
672
+ // Honour real user cancellation between attempts; remote per-attempt and
673
+ // overall-budget timeouts still fall through to later (local) renderers.
667
674
  userSignal?.throwIfAborted();
668
- }
669
-
670
- // Try lynx (can't auto-install, system package)
671
- try {
672
- const lynx = hasCommand("lynx");
673
- if (lynx) {
674
- const result = await ptree.exec(["lynx", "-dump", "-nolist", "-width", "250", url], execOptions);
675
- if (result.ok) {
676
- return { content: result.stdout, ok: true, method: "lynx" };
675
+ try {
676
+ const content = await runners[method]();
677
+ if (!content || content.trim().length <= 100) continue;
678
+ if (!isLowQualityOutput(content)) {
679
+ return { content, ok: true, method };
677
680
  }
681
+ lowQuality ??= { content, method };
682
+ } catch {
683
+ userSignal?.throwIfAborted();
678
684
  }
679
- } catch {
680
- // lynx failed or stalled; continue to native converter.
681
- userSignal?.throwIfAborted();
682
685
  }
683
686
 
684
- // Fall back to native converter (purely local, no network/subprocess).
685
- // Always attempted: even if remote renderers and subprocesses were aborted
686
- // by the overall reader-mode timeout, this still works on already-loaded
687
- // HTML (#1449).
688
- try {
689
- const content = await htmlToMarkdown(html, { cleanContent: true });
690
- if (content.trim().length > 100 && !isLowQualityOutput(content)) {
691
- return { content, ok: true, method: "native" };
692
- }
693
- } catch {
694
- // Native converter failed; nothing else to try.
695
- userSignal?.throwIfAborted();
687
+ if (lowQuality) {
688
+ return { content: lowQuality.content, ok: true, method: lowQuality.method };
696
689
  }
697
690
  return { content: "", ok: false, method: "none" };
698
691
  }
@@ -1141,10 +1134,27 @@ async function renderUrl(
1141
1134
  throw new ToolAbortError();
1142
1135
  }
1143
1136
 
1144
- // 5E: Render HTML with lynx or html2text
1137
+ // 5E: Render HTML via the reader-backend chain (native/trafilatura/lynx/parallel/jina)
1145
1138
  const htmlResult = await renderHtmlToText(finalUrl, rawContent, timeout, settings, signal, storage);
1146
1139
  if (!htmlResult.ok) {
1147
- notes.push("html rendering failed (lynx/html2text unavailable)");
1140
+ notes.push("html rendering failed (no reader backend produced usable output)");
1141
+
1142
+ const llmResult = await tryLlmEndpoints(finalUrl, timeout, signal);
1143
+ if (llmResult) {
1144
+ notes.push(`Used llms.txt fallback: ${llmResult.endpoint}`);
1145
+ const output = finalizeOutput(llmResult.content);
1146
+ return {
1147
+ url,
1148
+ finalUrl,
1149
+ contentType: "text/plain",
1150
+ method: "llms.txt",
1151
+ content: output.content,
1152
+ fetchedAt,
1153
+ truncated: output.truncated,
1154
+ notes,
1155
+ };
1156
+ }
1157
+
1148
1158
  const output = finalizeOutput(rawContent);
1149
1159
  return {
1150
1160
  url,
@@ -221,10 +221,24 @@ export function formatMeta(meta: string[], theme: Theme): string {
221
221
  return meta.length > 0 ? ` ${theme.fg("muted", meta.join(theme.sep.dot))}` : "";
222
222
  }
223
223
 
224
- export function formatErrorMessage(message: string | undefined, theme: Theme): string {
224
+ function sanitizeErrorText(message: string | undefined): string {
225
225
  const clean = (message ?? "").replace(/^Error:\s*/, "").trim();
226
- const safe = clean ? replaceTabs(truncateToWidth(clean, TRUNCATE_LENGTHS.LINE)) : "Unknown error";
227
- return `${theme.styledSymbol("status.error", "error")} ${theme.fg("error", `Error: ${safe}`)}`;
226
+ return clean ? replaceTabs(truncateToWidth(clean, TRUNCATE_LENGTHS.LINE)) : "Unknown error";
227
+ }
228
+
229
+ export function formatErrorMessage(message: string | undefined, theme: Theme): string {
230
+ return `${theme.styledSymbol("status.error", "error")} ${theme.fg("error", `Error: ${sanitizeErrorText(message)}`)}`;
231
+ }
232
+
233
+ /**
234
+ * Error message rendered as a subordinate detail line beneath a status header
235
+ * that already carries the error icon (e.g. `✘ Write: <path>`). The header's
236
+ * icon already signals failure, so this omits the redundant error symbol and
237
+ * "Error:" prefix that `formatErrorMessage` adds for standalone single-line
238
+ * errors, indenting two columns to sit under the header title instead.
239
+ */
240
+ export function formatErrorDetail(message: string | undefined, theme: Theme): string {
241
+ return ` ${theme.fg("error", sanitizeErrorText(message))}`;
228
242
  }
229
243
 
230
244
  export function formatEmptyMessage(message: string, theme: Theme): string {
@@ -31,7 +31,7 @@ import { sshToolRenderer } from "./ssh";
31
31
  import { todoToolRenderer } from "./todo";
32
32
  import { writeToolRenderer } from "./write";
33
33
 
34
- type ToolRenderer = {
34
+ export type ToolRenderer = {
35
35
  renderCall: (args: unknown, options: RenderResultOptions, theme: Theme) => Component;
36
36
  renderResult: (
37
37
  result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean },
@@ -40,6 +40,21 @@ type ToolRenderer = {
40
40
  args?: unknown,
41
41
  ) => Component;
42
42
  mergeCallAndResult?: boolean;
43
+ /**
44
+ * While a tool's preview is still streaming, report whether the
45
+ * currently-rendered preview is append-only: its rows only grow at the bottom
46
+ * and never re-layout above the bottom live region (a full, top-anchored
47
+ * content/code preview). The transcript reports this up to the TUI so a
48
+ * streaming preview taller than the viewport commits its scrolled-off head to
49
+ * native scrollback instead of dropping it (see
50
+ * `ToolExecutionComponent.isTranscriptBlockAppendOnly`). `result` is the
51
+ * latest (possibly partial) tool result, or `undefined` before one exists —
52
+ * `eval`/`bash` use its presence to defer committing until the streamed input
53
+ * (code) has finalized. Omit (or return `false`) for previews that slide a
54
+ * tail window or later collapse to a compact result — committing their head
55
+ * would strand stale rows.
56
+ */
57
+ isStreamingPreviewAppendOnly?: (args: unknown, options: RenderResultOptions, result?: unknown) => boolean;
43
58
  /** Render without background box, inline in the response flow */
44
59
  inline?: boolean;
45
60
  };
@@ -41,7 +41,7 @@ function buildReportToolIssueParams(activeBuiltinNames: readonly string[]) {
41
41
  }
42
42
 
43
43
  export function isAutoQaEnabled(settings?: Settings): boolean {
44
- return $flag("PI_AUTO_QA") || !!settings?.get("dev.autoqa");
44
+ return $flag("PI_AUTO_QA", !!settings?.get("dev.autoqa"));
45
45
  }
46
46
 
47
47
  // ───────────────────────────────────────────────────────────────────────────