@oh-my-pi/pi-coding-agent 15.9.5 → 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 (192) hide show
  1. package/CHANGELOG.md +98 -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 +10 -2
  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 +43 -7
  22. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  23. package/dist/types/eval/backend.d.ts +6 -6
  24. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  25. package/dist/types/eval/idle-timeout.d.ts +16 -14
  26. package/dist/types/eval/js/executor.d.ts +3 -3
  27. package/dist/types/eval/py/executor.d.ts +2 -2
  28. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  29. package/dist/types/extensibility/plugins/marketplace-auto-update.d.ts +8 -0
  30. package/dist/types/lsp/types.d.ts +10 -0
  31. package/dist/types/main.d.ts +3 -2
  32. package/dist/types/memory-backend/index.d.ts +2 -1
  33. package/dist/types/memory-backend/resolve.d.ts +1 -1
  34. package/dist/types/memory-backend/types.d.ts +1 -1
  35. package/dist/types/modes/components/assistant-message.d.ts +5 -0
  36. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  37. package/dist/types/modes/components/custom-editor.d.ts +2 -1
  38. package/dist/types/modes/components/model-selector.d.ts +1 -0
  39. package/dist/types/modes/components/tool-execution.d.ts +18 -0
  40. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  41. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  42. package/dist/types/modes/index.d.ts +5 -4
  43. package/dist/types/modes/interactive-mode.d.ts +2 -2
  44. package/dist/types/modes/setup-version.d.ts +11 -0
  45. package/dist/types/modes/setup-wizard/index.d.ts +2 -1
  46. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +2 -1
  47. package/dist/types/modes/types.d.ts +2 -2
  48. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  49. package/dist/types/sdk.d.ts +1 -1
  50. package/dist/types/task/executor.d.ts +7 -0
  51. package/dist/types/telemetry-export.d.ts +1 -1
  52. package/dist/types/tools/eval-render.d.ts +1 -0
  53. package/dist/types/tools/fetch.d.ts +15 -7
  54. package/dist/types/tools/render-utils.d.ts +33 -0
  55. package/dist/types/tools/renderers.d.ts +16 -2
  56. package/dist/types/tools/search.d.ts +1 -1
  57. package/dist/types/tools/write.d.ts +2 -0
  58. package/dist/types/tui/code-cell.d.ts +6 -0
  59. package/dist/types/tui/output-block.d.ts +11 -0
  60. package/dist/types/web/scrapers/github.d.ts +22 -0
  61. package/dist/types/web/search/providers/perplexity.d.ts +8 -1
  62. package/dist/types/web/search/types.d.ts +1 -1
  63. package/package.json +9 -9
  64. package/scripts/dev-launch +42 -0
  65. package/scripts/dev-launch-preload.ts +19 -0
  66. package/src/autoresearch/dashboard.ts +11 -21
  67. package/src/cli/args.ts +2 -2
  68. package/src/cli/claude-trace-cli.ts +13 -1
  69. package/src/cli/gallery-cli.ts +223 -0
  70. package/src/cli/gallery-fixtures/agentic.ts +292 -0
  71. package/src/cli/gallery-fixtures/codeintel.ts +188 -0
  72. package/src/cli/gallery-fixtures/edit.ts +194 -0
  73. package/src/cli/gallery-fixtures/fs.ts +153 -0
  74. package/src/cli/gallery-fixtures/index.ts +40 -0
  75. package/src/cli/gallery-fixtures/interaction.ts +49 -0
  76. package/src/cli/gallery-fixtures/memory.ts +81 -0
  77. package/src/cli/gallery-fixtures/misc.ts +221 -0
  78. package/src/cli/gallery-fixtures/search.ts +213 -0
  79. package/src/cli/gallery-fixtures/shell.ts +167 -0
  80. package/src/cli/gallery-fixtures/types.ts +41 -0
  81. package/src/cli/gallery-fixtures/web.ts +158 -0
  82. package/src/cli/gallery-screenshot.ts +279 -0
  83. package/src/cli-commands.ts +1 -0
  84. package/src/commands/gallery.ts +52 -0
  85. package/src/commands/launch.ts +1 -1
  86. package/src/config/keybindings.ts +68 -2
  87. package/src/config/model-equivalence.ts +35 -12
  88. package/src/config/model-id-affixes.ts +39 -22
  89. package/src/config/model-registry.ts +16 -16
  90. package/src/config/settings-schema.ts +29 -6
  91. package/src/config/settings.ts +11 -0
  92. package/src/dap/client.ts +14 -16
  93. package/src/debug/raw-sse.ts +18 -4
  94. package/src/edit/file-snapshot-store.ts +1 -1
  95. package/src/edit/index.ts +1 -1
  96. package/src/edit/renderer.ts +43 -55
  97. package/src/edit/streaming.ts +1 -1
  98. package/src/eval/__tests__/agent-bridge.test.ts +102 -58
  99. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  100. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  101. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  102. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  103. package/src/eval/agent-bridge.ts +38 -12
  104. package/src/eval/backend.ts +6 -6
  105. package/src/eval/bridge-timeout.ts +44 -0
  106. package/src/eval/idle-timeout.ts +33 -15
  107. package/src/eval/js/executor.ts +10 -10
  108. package/src/eval/llm-bridge.ts +4 -5
  109. package/src/eval/py/executor.ts +6 -6
  110. package/src/eval/py/kernel.ts +11 -1
  111. package/src/eval/py/spawn-options.ts +126 -0
  112. package/src/export/ttsr.ts +9 -0
  113. package/src/extensibility/extensions/runner.ts +3 -0
  114. package/src/extensibility/plugins/doctor.ts +0 -1
  115. package/src/extensibility/plugins/marketplace-auto-update.ts +49 -0
  116. package/src/goals/tools/goal-tool.ts +2 -2
  117. package/src/internal-urls/docs-index.generated.ts +7 -6
  118. package/src/lsp/client.ts +179 -52
  119. package/src/lsp/index.ts +38 -4
  120. package/src/lsp/render.ts +3 -3
  121. package/src/lsp/types.ts +10 -0
  122. package/src/main.ts +47 -52
  123. package/src/memory-backend/index.ts +13 -1
  124. package/src/memory-backend/resolve.ts +3 -5
  125. package/src/memory-backend/types.ts +1 -1
  126. package/src/modes/components/agent-dashboard.ts +13 -4
  127. package/src/modes/components/assistant-message.ts +22 -1
  128. package/src/modes/components/copy-selector.ts +249 -0
  129. package/src/modes/components/custom-editor.ts +10 -1
  130. package/src/modes/components/extensions/extension-list.ts +17 -8
  131. package/src/modes/components/history-search.ts +19 -11
  132. package/src/modes/components/model-selector.ts +125 -29
  133. package/src/modes/components/oauth-selector.ts +28 -12
  134. package/src/modes/components/session-observer-overlay.ts +13 -15
  135. package/src/modes/components/session-selector.ts +24 -13
  136. package/src/modes/components/status-line.ts +3 -5
  137. package/src/modes/components/tool-execution.ts +83 -24
  138. package/src/modes/components/tree-selector.ts +19 -7
  139. package/src/modes/components/user-message-selector.ts +25 -14
  140. package/src/modes/controllers/command-controller.ts +13 -118
  141. package/src/modes/controllers/event-controller.ts +26 -10
  142. package/src/modes/controllers/input-controller.ts +11 -3
  143. package/src/modes/controllers/selector-controller.ts +40 -3
  144. package/src/modes/index.ts +5 -4
  145. package/src/modes/interactive-mode.ts +21 -7
  146. package/src/modes/setup-version.ts +11 -0
  147. package/src/modes/setup-wizard/index.ts +3 -2
  148. package/src/modes/setup-wizard/scenes/web-search.ts +3 -2
  149. package/src/modes/theme/theme.ts +46 -10
  150. package/src/modes/types.ts +2 -2
  151. package/src/modes/utils/context-usage.ts +10 -6
  152. package/src/modes/utils/copy-targets.ts +254 -0
  153. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  154. package/src/prompts/tools/ast-edit.md +1 -1
  155. package/src/prompts/tools/ast-grep.md +1 -1
  156. package/src/prompts/tools/read.md +1 -1
  157. package/src/prompts/tools/search.md +1 -1
  158. package/src/sdk.ts +21 -23
  159. package/src/session/agent-session.ts +13 -9
  160. package/src/slash-commands/builtin-registry.ts +4 -12
  161. package/src/slash-commands/helpers/usage-report.ts +2 -0
  162. package/src/task/executor.ts +20 -2
  163. package/src/task/render.ts +37 -11
  164. package/src/telemetry-export.ts +25 -7
  165. package/src/tools/bash.ts +18 -8
  166. package/src/tools/browser/render.ts +5 -4
  167. package/src/tools/debug.ts +3 -3
  168. package/src/tools/eval-backends.ts +6 -17
  169. package/src/tools/eval-render.ts +28 -10
  170. package/src/tools/eval.ts +19 -23
  171. package/src/tools/fetch.ts +99 -89
  172. package/src/tools/read.ts +7 -7
  173. package/src/tools/render-utils.ts +63 -3
  174. package/src/tools/renderers.ts +16 -1
  175. package/src/tools/report-tool-issue.ts +1 -1
  176. package/src/tools/search.ts +173 -81
  177. package/src/tools/ssh.ts +21 -8
  178. package/src/tools/todo.ts +20 -7
  179. package/src/tools/write.ts +39 -9
  180. package/src/tui/code-cell.ts +19 -4
  181. package/src/tui/output-block.ts +14 -0
  182. package/src/web/scrapers/github.ts +255 -3
  183. package/src/web/scrapers/youtube.ts +3 -2
  184. package/src/web/search/providers/perplexity.ts +199 -51
  185. package/src/web/search/render.ts +42 -57
  186. package/src/web/search/types.ts +5 -1
  187. package/dist/types/eval/heartbeat.d.ts +0 -45
  188. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  189. package/src/eval/__tests__/shared-executors.test.ts +0 -609
  190. package/src/eval/heartbeat.ts +0 -74
  191. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
  192. /package/dist/types/eval/__tests__/{shared-executors.test.d.ts → kernel-spawn.test.d.ts} +0 -0
@@ -18,7 +18,7 @@ import { formatContextUsage } from "../modes/components/status-line/context-thre
18
18
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
19
19
  import { shimmerEnabled } from "../modes/theme/shimmer";
20
20
  import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
21
- import { borderShimmerTick, renderCodeCell } from "../tui";
21
+ import { borderShimmerTick, markFramedBlockComponent, renderCodeCell } from "../tui";
22
22
  import {
23
23
  JSON_TREE_MAX_DEPTH_COLLAPSED,
24
24
  JSON_TREE_MAX_DEPTH_EXPANDED,
@@ -39,7 +39,6 @@ import {
39
39
  truncateToWidth,
40
40
  wrapBrackets,
41
41
  } from "./render-utils";
42
-
43
42
  export const EVAL_DEFAULT_PREVIEW_LINES = 10;
44
43
 
45
44
  function languageForHighlighter(language: EvalLanguage | undefined): "python" | "javascript" {
@@ -490,10 +489,10 @@ export const evalToolRenderer = {
490
489
 
491
490
  let cached: { key: string; width: number; result: string[] } | undefined;
492
491
 
493
- return {
492
+ return markFramedBlockComponent({
494
493
  render: (width: number): string[] => {
495
494
  const animate = options.isPartial && shimmerEnabled();
496
- const key = `${animate ? borderShimmerTick() : 0}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
495
+ const key = `${animate ? borderShimmerTick() : 0}|${options.expanded ? 1 : 0}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
497
496
  if (cached && cached.key === key && cached.width === width) {
498
497
  return cached.result;
499
498
  }
@@ -510,8 +509,13 @@ export const evalToolRenderer = {
510
509
  title: cell.title,
511
510
  status: "pending",
512
511
  width,
513
- codeMaxLines: EVAL_DEFAULT_PREVIEW_LINES,
514
- expanded: 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,
518
+ expanded: options.expanded,
515
519
  animate,
516
520
  },
517
521
  uiTheme,
@@ -527,7 +531,7 @@ export const evalToolRenderer = {
527
531
  invalidate: () => {
528
532
  cached = undefined;
529
533
  },
530
- };
534
+ });
531
535
  },
532
536
 
533
537
  renderResult(
@@ -571,7 +575,7 @@ export const evalToolRenderer = {
571
575
  if (cellResults && cellResults.length > 0) {
572
576
  let cached: { key: string; width: number; result: string[] } | undefined;
573
577
 
574
- return {
578
+ return markFramedBlockComponent({
575
579
  render: (width: number): string[] => {
576
580
  const expanded = options.renderContext?.expanded ?? options.expanded;
577
581
  const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES;
@@ -613,7 +617,9 @@ export const evalToolRenderer = {
613
617
  duration: cell.durationMs,
614
618
  output: outputLines.length > 0 ? outputLines.join("\n") : undefined,
615
619
  outputMaxLines: outputLines.length,
616
- 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,
617
623
  expanded,
618
624
  width,
619
625
  animate,
@@ -649,7 +655,7 @@ export const evalToolRenderer = {
649
655
  invalidate: () => {
650
656
  cached = undefined;
651
657
  },
652
- };
658
+ });
653
659
  }
654
660
 
655
661
  const displayOutput = output;
@@ -745,6 +751,18 @@ export const evalToolRenderer = {
745
751
  },
746
752
  };
747
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
+ },
748
766
  mergeCallAndResult: true,
749
767
  inline: true,
750
768
  };
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";
@@ -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
 
@@ -313,16 +314,13 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
313
314
  for (let i = 0; i < cells.length; i++) {
314
315
  const cell = cells[i];
315
316
  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.
317
+ // The per-cell `timeout` is a budget on the cell runtime's *own*
318
+ // work. Host-side `agent()`/`parallel()`/`llm()` bridge calls suspend
319
+ // that budget entirely and restart a fresh timeout window when control
320
+ // returns to Python/JS. Compute, stdout, `log()`/`phase()`, and
321
+ // ordinary tool calls all count against the budget. The watchdog drives
322
+ // `combinedSignal`; we pass no wall-clock deadline downstream so the
323
+ // backends never arm a competing fixed timer.
326
324
  const idleTimeoutMs = timeoutSecondsFromMs(cell.timeoutMs) * 1000;
327
325
  const idle = new IdleTimeout(idleTimeoutMs);
328
326
  const combinedSignal = signal
@@ -355,14 +353,12 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
355
353
  outputSink!.push(chunk);
356
354
  },
357
355
  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();
356
+ if (event.op === EVAL_TIMEOUT_PAUSE_OP) {
357
+ idle.pause();
358
+ return;
359
+ }
360
+ if (event.op === EVAL_TIMEOUT_RESUME_OP) {
361
+ idle.resume();
366
362
  return;
367
363
  }
368
364
  cellResult.statusEvents ??= [];
@@ -14,7 +14,7 @@ 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
16
  import { renderStatusLine, urlHyperlink } from "../tui";
17
- import { CachedOutputBlock } from "../tui/output-block";
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";
@@ -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,
@@ -1488,11 +1498,11 @@ export function renderReadUrlResult(
1488
1498
  const header = renderStatusLine({ icon: "error", title: "Read", description }, uiTheme);
1489
1499
  const errorLines = errorText.split("\n").map(line => uiTheme.fg("error", replaceTabs(line)));
1490
1500
  const outputBlock = new CachedOutputBlock();
1491
- return {
1501
+ return markFramedBlockComponent({
1492
1502
  render: (width: number) =>
1493
1503
  outputBlock.render({ header, state: "error", sections: [{ lines: errorLines }], width }, uiTheme),
1494
1504
  invalidate: () => outputBlock.invalidate(),
1495
- };
1505
+ });
1496
1506
  }
1497
1507
 
1498
1508
  const description = formatReadUrlDescription(details.finalUrl);
@@ -1542,7 +1552,7 @@ export function renderReadUrlResult(
1542
1552
  let lastExpanded: boolean | undefined;
1543
1553
  let contentPreviewLines: string[] | undefined;
1544
1554
 
1545
- return {
1555
+ return markFramedBlockComponent({
1546
1556
  render: (width: number) => {
1547
1557
  const { expanded } = options;
1548
1558
 
@@ -1582,5 +1592,5 @@ export function renderReadUrlResult(
1582
1592
  contentPreviewLines = undefined;
1583
1593
  lastExpanded = undefined;
1584
1594
  },
1585
- };
1595
+ });
1586
1596
  }
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";
@@ -2413,11 +2413,11 @@ export const readToolRenderer = {
2413
2413
  const header = renderStatusLine({ icon: "error", title }, uiTheme);
2414
2414
  const errorLines = errorText.split("\n").map(line => uiTheme.fg("error", replaceTabs(line)));
2415
2415
  const outputBlock = new CachedOutputBlock();
2416
- return {
2416
+ return markFramedBlockComponent({
2417
2417
  render: (width: number) =>
2418
2418
  outputBlock.render({ header, state: "error", sections: [{ lines: errorLines }], width }, uiTheme),
2419
2419
  invalidate: () => outputBlock.invalidate(),
2420
- };
2420
+ });
2421
2421
  }
2422
2422
  const details = result.details;
2423
2423
  const rawText = result.content?.find(c => c.type === "text")?.text ?? "";
@@ -2465,7 +2465,7 @@ export const readToolRenderer = {
2465
2465
  const detailLines = contentText ? contentText.split("\n").map(line => uiTheme.fg("toolOutput", line)) : [];
2466
2466
  const lines = [...detailLines, ...warningLines];
2467
2467
  const outputBlock = new CachedOutputBlock();
2468
- return {
2468
+ return markFramedBlockComponent({
2469
2469
  render: (width: number) =>
2470
2470
  outputBlock.render(
2471
2471
  {
@@ -2482,7 +2482,7 @@ export const readToolRenderer = {
2482
2482
  uiTheme,
2483
2483
  ),
2484
2484
  invalidate: () => outputBlock.invalidate(),
2485
- };
2485
+ });
2486
2486
  }
2487
2487
 
2488
2488
  const suffix = details?.suffixResolution;
@@ -2514,7 +2514,7 @@ export const readToolRenderer = {
2514
2514
  let cachedWidth: number | undefined;
2515
2515
  let cachedExpanded: boolean | undefined;
2516
2516
  let cachedLines: string[] | undefined;
2517
- return {
2517
+ return markFramedBlockComponent({
2518
2518
  render: (width: number) => {
2519
2519
  const expanded = options.expanded;
2520
2520
  if (cachedLines && cachedWidth === width && cachedExpanded === expanded) return cachedLines;
@@ -2551,7 +2551,7 @@ export const readToolRenderer = {
2551
2551
  cachedExpanded = undefined;
2552
2552
  cachedLines = undefined;
2553
2553
  },
2554
- };
2554
+ });
2555
2555
  },
2556
2556
  mergeCallAndResult: true,
2557
2557
  };
@@ -171,14 +171,74 @@ 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
  }
177
223
 
178
- export function formatErrorMessage(message: string | undefined, theme: Theme): string {
224
+ function sanitizeErrorText(message: string | undefined): string {
179
225
  const clean = (message ?? "").replace(/^Error:\s*/, "").trim();
180
- const safe = clean ? replaceTabs(truncateToWidth(clean, TRUNCATE_LENGTHS.LINE)) : "Unknown error";
181
- 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))}`;
182
242
  }
183
243
 
184
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
  // ───────────────────────────────────────────────────────────────────────────