@oh-my-pi/pi-coding-agent 16.1.14 → 16.1.16

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 (111) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/dist/cli.js +3705 -4015
  3. package/dist/types/advisor/runtime.d.ts +3 -0
  4. package/dist/types/cli/args.d.ts +2 -5
  5. package/dist/types/cli/flag-tables.d.ts +2 -2
  6. package/dist/types/cli/session-picker.d.ts +4 -2
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/settings-schema.d.ts +32 -1
  9. package/dist/types/eval/agent-bridge.d.ts +19 -0
  10. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  11. package/dist/types/eval/js/shared/types.d.ts +1 -1
  12. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  13. package/dist/types/eval/py/executor.d.ts +1 -1
  14. package/dist/types/export/share.d.ts +8 -1
  15. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  16. package/dist/types/main.d.ts +2 -0
  17. package/dist/types/mcp/transports/stdio.d.ts +12 -1
  18. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  19. package/dist/types/modes/components/session-selector.d.ts +25 -0
  20. package/dist/types/modes/components/status-line/context-thresholds.d.ts +4 -3
  21. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  22. package/dist/types/secrets/obfuscator.d.ts +3 -3
  23. package/dist/types/task/isolation-runner.d.ts +128 -0
  24. package/dist/types/task/worktree.d.ts +14 -1
  25. package/dist/types/thinking.d.ts +15 -0
  26. package/dist/types/tools/eval-render.d.ts +3 -0
  27. package/dist/types/tools/eval.d.ts +11 -17
  28. package/dist/types/tools/todo.d.ts +26 -28
  29. package/dist/types/tui/output-block.d.ts +8 -0
  30. package/dist/types/utils/image-resize.d.ts +2 -0
  31. package/dist/types/utils/shell-snapshot.d.ts +10 -0
  32. package/dist/types/web/search/providers/exa.d.ts +2 -0
  33. package/dist/types/web/search/providers/perplexity.d.ts +17 -3
  34. package/package.json +12 -12
  35. package/scripts/build-binary.ts +18 -4
  36. package/src/advisor/__tests__/advisor.test.ts +114 -0
  37. package/src/advisor/runtime.ts +129 -1
  38. package/src/cli/args.ts +4 -5
  39. package/src/cli/flag-tables.ts +3 -3
  40. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  41. package/src/cli/gallery-fixtures/shell.ts +15 -23
  42. package/src/cli/session-picker.ts +17 -3
  43. package/src/commands/launch.ts +3 -3
  44. package/src/config/model-registry.ts +12 -4
  45. package/src/config/settings-schema.ts +37 -1
  46. package/src/edit/renderer.ts +34 -12
  47. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  48. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  49. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  50. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  51. package/src/eval/agent-bridge.ts +301 -71
  52. package/src/eval/jl/prelude.jl +32 -227
  53. package/src/eval/jl/runner.jl +38 -12
  54. package/src/eval/js/shared/helpers.ts +1 -114
  55. package/src/eval/js/shared/prelude.txt +13 -27
  56. package/src/eval/js/shared/runtime.ts +0 -6
  57. package/src/eval/js/shared/types.ts +1 -1
  58. package/src/eval/js/worker-protocol.ts +1 -1
  59. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  60. package/src/eval/py/executor.ts +1 -1
  61. package/src/eval/py/prelude.py +47 -105
  62. package/src/eval/py/runner.py +0 -6
  63. package/src/eval/rb/prelude.rb +21 -189
  64. package/src/eval/rb/runner.rb +116 -9
  65. package/src/exec/bash-executor.ts +44 -0
  66. package/src/export/html/tool-views.generated.js +29 -29
  67. package/src/export/share.ts +51 -28
  68. package/src/internal-urls/docs-index.generated.txt +1 -1
  69. package/src/internal-urls/local-protocol.ts +100 -53
  70. package/src/main.ts +15 -4
  71. package/src/mcp/transports/stdio.ts +20 -4
  72. package/src/modes/acp/acp-event-mapper.ts +7 -2
  73. package/src/modes/components/custom-editor.test.ts +22 -0
  74. package/src/modes/components/custom-editor.ts +10 -1
  75. package/src/modes/components/footer.ts +4 -3
  76. package/src/modes/components/plugin-settings.ts +7 -1
  77. package/src/modes/components/session-selector.ts +143 -29
  78. package/src/modes/components/status-line/component.ts +5 -1
  79. package/src/modes/components/status-line/context-thresholds.ts +11 -3
  80. package/src/modes/components/status-line/segments.ts +1 -1
  81. package/src/modes/components/status-line/types.ts +1 -0
  82. package/src/modes/controllers/command-controller.ts +6 -0
  83. package/src/modes/rpc/rpc-mode.ts +6 -0
  84. package/src/modes/utils/copy-targets.ts +7 -2
  85. package/src/prompts/system/system-prompt.md +6 -6
  86. package/src/prompts/system/workflow-notice.md +3 -3
  87. package/src/prompts/tools/bash.md +18 -2
  88. package/src/prompts/tools/eval.md +19 -19
  89. package/src/prompts/tools/search.md +1 -0
  90. package/src/prompts/tools/todo.md +1 -1
  91. package/src/secrets/obfuscator.ts +3 -9
  92. package/src/session/agent-session.ts +264 -52
  93. package/src/slash-commands/builtin-registry.ts +2 -1
  94. package/src/task/index.ts +61 -207
  95. package/src/task/isolation-runner.ts +354 -0
  96. package/src/task/worktree.ts +46 -9
  97. package/src/thinking.ts +20 -0
  98. package/src/tools/ask.ts +44 -38
  99. package/src/tools/bash.ts +9 -2
  100. package/src/tools/browser/tab-worker.ts +1 -1
  101. package/src/tools/eval-render.ts +34 -27
  102. package/src/tools/eval.ts +100 -103
  103. package/src/tools/index.ts +8 -1
  104. package/src/tools/read.ts +136 -60
  105. package/src/tools/todo.ts +60 -64
  106. package/src/tui/code-cell.ts +1 -1
  107. package/src/tui/output-block.ts +11 -0
  108. package/src/utils/image-resize.ts +30 -0
  109. package/src/utils/shell-snapshot.ts +63 -1
  110. package/src/web/search/providers/exa.ts +85 -1
  111. package/src/web/search/providers/perplexity.ts +18 -6
@@ -194,6 +194,19 @@ export async function captureDeltaPatch(isolationDir: string, baseline: Worktree
194
194
 
195
195
  /**
196
196
  * Apply nested repo patches directly to their working directories after parent merge.
197
+ *
198
+ * Pre-existing dirty state in a nested repo is stashed before the patch is
199
+ * applied and popped back (with `--index` so staged WIP stays staged) after
200
+ * the commit, so unrelated user edits never get folded into the agent's
201
+ * commit. A failing `git stash pop` (e.g. user edits collide with the patched
202
+ * lines) leaves the stash entry intact, emits a `logger.warn`, and is
203
+ * returned to the caller as a human-readable warning string — the agent
204
+ * commit already landed, so this is a partial success the workflow needs to
205
+ * see, not a thrown failure.
206
+ *
207
+ * Returns the collected stash-restore warnings (empty when every nested repo
208
+ * was restored cleanly). Throws when the patch apply itself fails.
209
+ *
197
210
  * @param commitMessage Optional async function to generate a commit message from the combined diff.
198
211
  * If omitted or returns null, falls back to a generic message.
199
212
  */
@@ -201,7 +214,8 @@ export async function applyNestedPatches(
201
214
  repoRoot: string,
202
215
  patches: NestedRepoPatch[],
203
216
  commitMessage?: (diff: string) => Promise<string | null>,
204
- ): Promise<void> {
217
+ ): Promise<string[]> {
218
+ const warnings: string[] = [];
205
219
  // Group patches by target repo to apply all at once and commit
206
220
  const byRepo = new Map<string, NestedRepoPatch[]>();
207
221
  for (const p of patches) {
@@ -220,17 +234,40 @@ export async function applyNestedPatches(
220
234
  }
221
235
 
222
236
  const combinedDiff = repoPatches.map(p => p.patch).join("\n");
223
- for (const { patch } of repoPatches) {
224
- await git.patch.applyText(nestedDir, patch);
225
- }
226
237
 
227
- // Commit so nested repo history reflects the task changes
228
- if ((await git.status(nestedDir)).trim().length > 0) {
229
- const msg = (await commitMessage?.(combinedDiff)) ?? "changes from isolated task(s)";
230
- await git.stage.files(nestedDir);
231
- await git.commit(nestedDir, msg);
238
+ // Preserve any pre-existing dirty state (tracked + untracked) so we
239
+ // commit only the agent delta, not the user's in-flight work.
240
+ const stashed =
241
+ (await git.status(nestedDir)).trim().length > 0
242
+ ? await git.stash.push(nestedDir, `omp-isolation-${Snowflake.next()}`)
243
+ : false;
244
+ try {
245
+ for (const { patch } of repoPatches) {
246
+ await git.patch.applyText(nestedDir, patch);
247
+ }
248
+ if ((await git.status(nestedDir)).trim().length > 0) {
249
+ const msg = (await commitMessage?.(combinedDiff)) ?? "changes from isolated task(s)";
250
+ await git.stage.files(nestedDir);
251
+ await git.commit(nestedDir, msg);
252
+ }
253
+ } finally {
254
+ if (stashed) {
255
+ try {
256
+ await git.stash.pop(nestedDir, { index: true });
257
+ } catch (popErr) {
258
+ const message = popErr instanceof Error ? popErr.message : String(popErr);
259
+ logger.warn("Pre-existing nested-repo dirty state could not be auto-restored", {
260
+ nestedDir,
261
+ error: message,
262
+ });
263
+ warnings.push(
264
+ `Pre-existing dirty state in nested repo \`${relativePath}\` could not be auto-restored after the agent commit; stash entry preserved (${message}).`,
265
+ );
266
+ }
267
+ }
232
268
  }
233
269
  }
270
+ return warnings;
234
271
  }
235
272
 
236
273
  // ═══════════════════════════════════════════════════════════════════════════
package/src/thinking.ts CHANGED
@@ -153,6 +153,26 @@ export function getConfiguredThinkingLevelMetadata(level: ConfiguredThinkingLeve
153
153
  return level === AUTO_THINKING ? AUTO_THINKING_METADATA : getThinkingLevelMetadata(level);
154
154
  }
155
155
 
156
+ /**
157
+ * Thinking selectors accepted by the `--thinking` CLI flag, in display order:
158
+ * `off`, every concrete effort (`minimal`..`xhigh`), then `auto`. Single source
159
+ * for the flag's `options` list, shell completions, and the "invalid level"
160
+ * warning so all three stay in sync.
161
+ */
162
+ export const CLI_THINKING_LEVELS: readonly string[] = [ThinkingLevel.Off, ...THINKING_EFFORTS, AUTO_THINKING];
163
+
164
+ /**
165
+ * Parses a `--thinking` CLI value. Accepts every {@link parseConfiguredThinkingLevel}
166
+ * selector (`off`, `auto`, `minimal`..`xhigh`, plus the `max` alias) but rejects
167
+ * `inherit`: an explicit `inherit` on the command line would suppress the
168
+ * settings/scoped-model fallback during startup resolution only to resolve back
169
+ * to the provider default, which is never what the user means.
170
+ */
171
+ export function parseCliThinkingLevel(value: string | null | undefined): ConfiguredThinkingLevel | undefined {
172
+ const level = parseConfiguredThinkingLevel(value);
173
+ return level === ThinkingLevel.Inherit ? undefined : level;
174
+ }
175
+
156
176
  /**
157
177
  * Resolves an auto-classified effort against the active model's supported
158
178
  * range. Unlike {@link clampThinkingLevelForModel}, `auto` never resolves below
package/src/tools/ask.ts CHANGED
@@ -308,7 +308,7 @@ async function askSingleQuestion(
308
308
  }
309
309
  const customResult = await promptForCustomInput();
310
310
  if (customResult.input === undefined) {
311
- break;
311
+ continue;
312
312
  }
313
313
  customInput = customResult.input;
314
314
  break;
@@ -332,51 +332,57 @@ async function askSingleQuestion(
332
332
  }
333
333
  selectedOptions = Array.from(selected);
334
334
  } else {
335
- const displayOptions = addRecommendedSuffix(questionOptions, recommended);
336
- const optionsWithNavigation: ExtensionUISelectItem[] = [...displayOptions, OTHER_OPTION];
337
-
338
- let initialIndex = recommended;
339
- const previouslySelected = selectedOptions[0];
340
- if (previouslySelected) {
341
- const selectedIndex = questionOptions.findIndex(option => option.label === previouslySelected);
342
- if (selectedIndex >= 0) initialIndex = selectedIndex;
343
- } else if (customInput !== undefined) {
344
- initialIndex = displayOptions.length;
345
- }
346
- if (initialIndex !== undefined) {
347
- const maxIndex = Math.max(optionsWithNavigation.length - 1, 0);
348
- initialIndex = Math.max(0, Math.min(initialIndex, maxIndex));
349
- }
335
+ while (true) {
336
+ const displayOptions = addRecommendedSuffix(questionOptions, recommended);
337
+ const optionsWithNavigation: ExtensionUISelectItem[] = [...displayOptions, OTHER_OPTION];
338
+
339
+ let initialIndex = recommended;
340
+ const previouslySelected = selectedOptions[0];
341
+ if (previouslySelected) {
342
+ const selectedIndex = questionOptions.findIndex(option => option.label === previouslySelected);
343
+ if (selectedIndex >= 0) initialIndex = selectedIndex;
344
+ } else if (customInput !== undefined) {
345
+ initialIndex = displayOptions.length;
346
+ }
347
+ if (initialIndex !== undefined) {
348
+ const maxIndex = Math.max(optionsWithNavigation.length - 1, 0);
349
+ initialIndex = Math.max(0, Math.min(initialIndex, maxIndex));
350
+ }
350
351
 
351
- const {
352
- choice,
353
- timedOut: selectTimedOut,
354
- navigation: arrowNavigation,
355
- } = await selectOption(promptWithProgress, optionsWithNavigation, initialIndex, {
356
- selectionMarker: "radio",
357
- markableCount: displayOptions.length,
358
- });
359
- timedOut = selectTimedOut;
352
+ const {
353
+ choice,
354
+ timedOut: selectTimedOut,
355
+ navigation: arrowNavigation,
356
+ } = await selectOption(promptWithProgress, optionsWithNavigation, initialIndex, {
357
+ selectionMarker: "radio",
358
+ markableCount: displayOptions.length,
359
+ });
360
+ timedOut = selectTimedOut;
360
361
 
361
- if (arrowNavigation) {
362
- return { selectedOptions, customInput, timedOut, navigation: arrowNavigation };
363
- }
364
- if (choice === undefined) {
365
- if (!timedOut) {
366
- return { selectedOptions, customInput, timedOut, cancelled: true };
362
+ if (arrowNavigation) {
363
+ return { selectedOptions, customInput, timedOut, navigation: arrowNavigation };
364
+ }
365
+ if (choice === undefined) {
366
+ if (!timedOut) {
367
+ return { selectedOptions, customInput, timedOut, cancelled: true };
368
+ }
369
+ break;
367
370
  }
368
- } else if (choice === OTHER_OPTION) {
369
- if (!selectTimedOut) {
371
+ if (choice === OTHER_OPTION) {
372
+ if (selectTimedOut) {
373
+ break;
374
+ }
370
375
  const customResult = await promptForCustomInput();
371
- if (customResult.input !== undefined) {
372
- customInput = customResult.input;
373
- selectedOptions = [];
376
+ if (customResult.input === undefined) {
377
+ continue;
374
378
  }
375
- // If editor was dismissed (undefined), keep prior selectedOptions/customInput intact
379
+ customInput = customResult.input;
380
+ selectedOptions = [];
381
+ break;
376
382
  }
377
- } else {
378
383
  selectedOptions = [stripRecommendedSuffix(choice)];
379
384
  customInput = undefined;
385
+ break;
380
386
  }
381
387
  if (navigation?.allowForward) {
382
388
  return { selectedOptions, customInput, timedOut, navigation: "forward" };
package/src/tools/bash.ts CHANGED
@@ -19,7 +19,7 @@ import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
19
19
  import type { ClientBridgeTerminalExitStatus, ClientBridgeTerminalOutput } from "../session/client-bridge";
20
20
  import { DEFAULT_MAX_BYTES, enforceInlineByteCap, streamTailUpdates, TailBuffer } from "../session/streaming-output";
21
21
  import { renderStatusLine } from "../tui";
22
- import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
22
+ import { CachedOutputBlock, markFramedBlockComponent, outputBlockContentWidth } from "../tui/output-block";
23
23
  import { getSixelLineMask } from "../utils/sixel";
24
24
  import type { ToolSession } from ".";
25
25
  import { truncateForPrompt } from "./approval";
@@ -1334,7 +1334,14 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1334
1334
  .map(line => uiTheme.fg("toolOutput", replaceTabs(line)))
1335
1335
  .join("\n");
1336
1336
  const textContent = styledOutput;
1337
- const result = truncateToVisualLines(textContent, previewLines, width);
1337
+ // Cap the collapsed/streaming output to a viewport-sized tail and
1338
+ // measure it at the box's INNER width. Otherwise a growing tail
1339
+ // window scrolls its (mutating) rows above the live-region window
1340
+ // and the engine re-commits a fresh snapshot every frame —
1341
+ // spraying duplicate "… ctrl+o to expand" banners into native
1342
+ // scrollback (the box never overflows the viewport now).
1343
+ const previewBudget = Math.min(previewLines, previewWindow);
1344
+ const result = truncateToVisualLines(textContent, previewBudget, outputBlockContentWidth(width));
1338
1345
  if (result.skippedCount > 0) {
1339
1346
  outputLines.push(
1340
1347
  uiTheme.fg(
@@ -800,7 +800,7 @@ export class WorkerCore {
800
800
  displays.push({ type: "text", text: safeJsonStringify(output.data) });
801
801
  return;
802
802
  }
803
- // status — surface as compact JSON so helper side effects (read/write/tree) appear in
803
+ // status — surface as compact JSON so helper side effects (read/write/env) appear in
804
804
  // the cell result alongside explicit display() output.
805
805
  displays.push({ type: "text", text: safeJsonStringify(output.event) });
806
806
  }
@@ -18,7 +18,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
18
18
  import { formatContextUsage } from "../modes/components/status-line/context-thresholds";
19
19
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
20
20
  import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
21
- import { markFramedBlockComponent, renderCodeCell } from "../tui";
21
+ import { markFramedBlockComponent, outputBlockContentWidth, renderCodeCell } from "../tui";
22
22
  import {
23
23
  JSON_TREE_MAX_DEPTH_COLLAPSED,
24
24
  JSON_TREE_MAX_DEPTH_EXPANDED,
@@ -56,6 +56,9 @@ interface EvalRenderCellArg {
56
56
  }
57
57
 
58
58
  interface EvalRenderArgs {
59
+ language?: string;
60
+ code?: string;
61
+ title?: string;
59
62
  cells?: EvalRenderCellArg[];
60
63
  __partialJson?: string;
61
64
  }
@@ -81,8 +84,8 @@ function normalizeRenderLanguage(value: string | undefined): EvalLanguage {
81
84
  }
82
85
 
83
86
  function getRenderCells(args: EvalRenderArgs | undefined): EvalRenderCell[] {
84
- const raw = args?.cells;
85
- if (!Array.isArray(raw)) return [];
87
+ if (!args) return [];
88
+ const raw = Array.isArray(args.cells) ? args.cells : typeof args.code === "string" ? [args] : [];
86
89
  const out: EvalRenderCell[] = [];
87
90
  for (const cell of raw) {
88
91
  if (!cell || typeof cell !== "object") continue;
@@ -238,14 +241,12 @@ function formatStatusEvent(event: EvalStatusEvent, theme: Theme): string {
238
241
  const opIcons: Record<string, AvailableIcon> = {
239
242
  read: "icon.file",
240
243
  write: "icon.file",
241
- append: "icon.file",
242
244
  cat: "icon.file",
243
245
  touch: "icon.file",
244
246
  ls: "icon.folder",
245
247
  cd: "icon.folder",
246
248
  pwd: "icon.folder",
247
249
  mkdir: "icon.folder",
248
- tree: "icon.folder",
249
250
  git_status: "icon.git",
250
251
  git_diff: "icon.git",
251
252
  git_log: "icon.git",
@@ -277,7 +278,6 @@ function formatStatusEvent(event: EvalStatusEvent, theme: Theme): string {
277
278
  if (data.path) parts.push(`from ${shortenPath(String(data.path))}`);
278
279
  break;
279
280
  case "write":
280
- case "append":
281
281
  parts.push(`${data.chars ?? data.bytes ?? 0} chars`);
282
282
  if (data.path) parts.push(`to ${shortenPath(String(data.path))}`);
283
283
  break;
@@ -316,13 +316,6 @@ function formatStatusEvent(event: EvalStatusEvent, theme: Theme): string {
316
316
  parts.push(`${data.lines} line${(data.lines as number) !== 1 ? "s" : ""}`);
317
317
  if (data.staged) parts.push("(staged)");
318
318
  break;
319
- case "diff":
320
- if (data.identical) {
321
- parts.push("files identical");
322
- } else {
323
- parts.push("files differ");
324
- }
325
- break;
326
319
  case "batch":
327
320
  parts.push(`${data.files} file${(data.files as number) !== 1 ? "s" : ""} processed`);
328
321
  break;
@@ -412,8 +405,6 @@ function formatStatusEventExpanded(event: EvalStatusEvent, theme: Theme): string
412
405
  case "cat":
413
406
  case "head":
414
407
  case "tail":
415
- case "tree":
416
- case "diff":
417
408
  case "git_diff":
418
409
  case "sh":
419
410
  if (data.preview) addPreview(String(data.preview));
@@ -468,23 +459,33 @@ function formatCellOutputLines(
468
459
  return { lines: [], hiddenCount: 0 };
469
460
  }
470
461
 
462
+ // Cell output lands in renderCodeCell → renderOutputBlock, which re-wraps it
463
+ // at the box's inner content width. Bound the collapsed tail by VISUAL rows
464
+ // at that width so a long-line tail can't wrap into more rows than budgeted
465
+ // and scroll its mutating preview above the live-region window — the
466
+ // duplicate "ctrl+o to expand" scrollback spray.
467
+ const innerWidth = outputBlockContentWidth(width);
468
+
471
469
  if (cell.hasMarkdown && cell.status !== "error") {
472
470
  const md = new Markdown(cell.output, 0, 0, getMarkdownTheme());
473
- const allLines = md.render(width);
471
+ const allLines = md.render(innerWidth);
474
472
  const displayLines = expanded ? allLines : allLines.slice(-previewLines);
475
473
  const hiddenCount = allLines.length - displayLines.length;
476
474
  return { lines: displayLines, hiddenCount };
477
475
  }
478
476
 
479
- const rawLines = cell.output.split("\n");
480
- const displayLines = expanded ? rawLines : rawLines.slice(-previewLines);
481
- const hiddenCount = rawLines.length - displayLines.length;
482
- const outputLines = displayLines.map(line => {
483
- const cleaned = replaceTabs(line);
484
- return cell.status === "error" ? theme.fg("error", cleaned) : theme.fg("toolOutput", cleaned);
485
- });
486
-
487
- return { lines: outputLines, hiddenCount };
477
+ const styledOutput = cell.output
478
+ .split("\n")
479
+ .map(line => {
480
+ const cleaned = replaceTabs(line);
481
+ return cell.status === "error" ? theme.fg("error", cleaned) : theme.fg("toolOutput", cleaned);
482
+ })
483
+ .join("\n");
484
+ if (expanded) {
485
+ return { lines: styledOutput.split("\n"), hiddenCount: 0 };
486
+ }
487
+ const { visualLines, skippedCount } = truncateToVisualLines(styledOutput, previewLines, innerWidth);
488
+ return { lines: visualLines, hiddenCount: skippedCount };
488
489
  }
489
490
 
490
491
  export const evalToolRenderer = {
@@ -586,7 +587,10 @@ export const evalToolRenderer = {
586
587
  return markFramedBlockComponent({
587
588
  render: (width: number): readonly string[] => {
588
589
  const expanded = options.renderContext?.expanded ?? options.expanded;
589
- const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES;
590
+ const previewLines = Math.min(
591
+ options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES,
592
+ previewWindowRows(),
593
+ );
590
594
  const key = `${expanded}|${previewLines}|${options.spinnerFrame}|${previewWindowRows()}`;
591
595
  if (cached && cached.key === key && cached.width === width) {
592
596
  return cached.result;
@@ -717,7 +721,10 @@ export const evalToolRenderer = {
717
721
 
718
722
  return {
719
723
  render: (width: number): readonly string[] => {
720
- const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES;
724
+ const previewLines = Math.min(
725
+ options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES,
726
+ previewWindowRows(),
727
+ );
721
728
  if (cachedLines === undefined || cachedWidth !== width || cachedPreviewLines !== previewLines) {
722
729
  const result = truncateToVisualLines(textContent, previewLines, width);
723
730
  cachedLines = result.visualLines;