@oh-my-pi/pi-coding-agent 16.1.15 → 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 (85) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/cli.js +3725 -4029
  3. package/dist/types/cli/args.d.ts +2 -5
  4. package/dist/types/cli/flag-tables.d.ts +2 -2
  5. package/dist/types/cli/session-picker.d.ts +4 -2
  6. package/dist/types/commands/launch.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +12 -1
  8. package/dist/types/eval/agent-bridge.d.ts +19 -0
  9. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  10. package/dist/types/eval/js/shared/types.d.ts +1 -1
  11. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  12. package/dist/types/eval/py/executor.d.ts +1 -1
  13. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  14. package/dist/types/main.d.ts +2 -0
  15. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  16. package/dist/types/modes/components/session-selector.d.ts +25 -0
  17. package/dist/types/task/isolation-runner.d.ts +128 -0
  18. package/dist/types/task/worktree.d.ts +14 -1
  19. package/dist/types/thinking.d.ts +15 -0
  20. package/dist/types/tools/eval-render.d.ts +3 -0
  21. package/dist/types/tools/eval.d.ts +11 -17
  22. package/dist/types/tools/todo.d.ts +26 -28
  23. package/dist/types/tui/output-block.d.ts +8 -0
  24. package/dist/types/utils/image-resize.d.ts +2 -0
  25. package/dist/types/web/search/providers/exa.d.ts +2 -0
  26. package/package.json +12 -12
  27. package/scripts/build-binary.ts +18 -4
  28. package/src/cli/args.ts +4 -5
  29. package/src/cli/flag-tables.ts +3 -3
  30. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  31. package/src/cli/gallery-fixtures/shell.ts +15 -23
  32. package/src/cli/session-picker.ts +17 -3
  33. package/src/commands/launch.ts +3 -3
  34. package/src/config/settings-schema.ts +13 -1
  35. package/src/edit/renderer.ts +34 -12
  36. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  37. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  38. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  39. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  40. package/src/eval/agent-bridge.ts +301 -71
  41. package/src/eval/jl/prelude.jl +32 -227
  42. package/src/eval/jl/runner.jl +38 -12
  43. package/src/eval/js/shared/helpers.ts +1 -114
  44. package/src/eval/js/shared/prelude.txt +13 -27
  45. package/src/eval/js/shared/runtime.ts +0 -6
  46. package/src/eval/js/shared/types.ts +1 -1
  47. package/src/eval/js/worker-protocol.ts +1 -1
  48. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  49. package/src/eval/py/executor.ts +1 -1
  50. package/src/eval/py/prelude.py +47 -105
  51. package/src/eval/py/runner.py +0 -6
  52. package/src/eval/rb/prelude.rb +21 -189
  53. package/src/eval/rb/runner.rb +116 -9
  54. package/src/export/html/tool-views.generated.js +29 -29
  55. package/src/internal-urls/docs-index.generated.txt +1 -1
  56. package/src/internal-urls/local-protocol.ts +100 -53
  57. package/src/main.ts +15 -4
  58. package/src/modes/acp/acp-event-mapper.ts +7 -2
  59. package/src/modes/components/plugin-settings.ts +7 -1
  60. package/src/modes/components/session-selector.ts +143 -29
  61. package/src/modes/controllers/command-controller.ts +5 -0
  62. package/src/modes/rpc/rpc-mode.ts +6 -0
  63. package/src/modes/utils/copy-targets.ts +7 -2
  64. package/src/prompts/system/system-prompt.md +3 -3
  65. package/src/prompts/system/workflow-notice.md +3 -3
  66. package/src/prompts/tools/bash.md +16 -0
  67. package/src/prompts/tools/eval.md +19 -19
  68. package/src/prompts/tools/todo.md +1 -1
  69. package/src/session/agent-session.ts +231 -50
  70. package/src/task/index.ts +61 -207
  71. package/src/task/isolation-runner.ts +354 -0
  72. package/src/task/worktree.ts +46 -9
  73. package/src/thinking.ts +20 -0
  74. package/src/tools/ask.ts +44 -38
  75. package/src/tools/bash.ts +9 -2
  76. package/src/tools/browser/tab-worker.ts +1 -1
  77. package/src/tools/eval-render.ts +34 -27
  78. package/src/tools/eval.ts +100 -103
  79. package/src/tools/index.ts +8 -1
  80. package/src/tools/read.ts +136 -60
  81. package/src/tools/todo.ts +60 -64
  82. package/src/tui/code-cell.ts +1 -1
  83. package/src/tui/output-block.ts +11 -0
  84. package/src/utils/image-resize.ts +30 -0
  85. package/src/web/search/providers/exa.ts +85 -1
@@ -10,6 +10,8 @@ import { type AuthStorage, type FetchImpl } from "@oh-my-pi/pi-ai";
10
10
  import type { SearchResponse } from "../../../web/search/types";
11
11
  import type { SearchParams } from "./base";
12
12
  import { SearchProvider } from "./base";
13
+ /** Reset Exa request pacing state for isolated provider tests. */
14
+ export declare function resetExaSearchThrottleForTest(): void;
13
15
  type ExaSearchType = "neural" | "fast" | "auto" | "deep";
14
16
  type ExaSearchParamType = ExaSearchType | "keyword";
15
17
  export interface ExaSearchParams {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "16.1.15",
4
+ "version": "16.1.16",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -48,17 +48,17 @@
48
48
  "@agentclientprotocol/sdk": "0.25.0",
49
49
  "@babel/parser": "^7.29.7",
50
50
  "@mozilla/readability": "^0.6.0",
51
- "@oh-my-pi/hashline": "16.1.15",
52
- "@oh-my-pi/omp-stats": "16.1.15",
53
- "@oh-my-pi/pi-agent-core": "16.1.15",
54
- "@oh-my-pi/pi-ai": "16.1.15",
55
- "@oh-my-pi/pi-catalog": "16.1.15",
56
- "@oh-my-pi/pi-mnemopi": "16.1.15",
57
- "@oh-my-pi/pi-natives": "16.1.15",
58
- "@oh-my-pi/pi-tui": "16.1.15",
59
- "@oh-my-pi/pi-utils": "16.1.15",
60
- "@oh-my-pi/pi-wire": "16.1.15",
61
- "@oh-my-pi/snapcompact": "16.1.15",
51
+ "@oh-my-pi/hashline": "16.1.16",
52
+ "@oh-my-pi/omp-stats": "16.1.16",
53
+ "@oh-my-pi/pi-agent-core": "16.1.16",
54
+ "@oh-my-pi/pi-ai": "16.1.16",
55
+ "@oh-my-pi/pi-catalog": "16.1.16",
56
+ "@oh-my-pi/pi-mnemopi": "16.1.16",
57
+ "@oh-my-pi/pi-natives": "16.1.16",
58
+ "@oh-my-pi/pi-tui": "16.1.16",
59
+ "@oh-my-pi/pi-utils": "16.1.16",
60
+ "@oh-my-pi/pi-wire": "16.1.16",
61
+ "@oh-my-pi/snapcompact": "16.1.16",
62
62
  "@opentelemetry/api": "^1.9.1",
63
63
  "@opentelemetry/context-async-hooks": "^2.7.1",
64
64
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -5,7 +5,15 @@ import * as path from "node:path";
5
5
 
6
6
  const packageDir = path.join(import.meta.dir, "..");
7
7
  const repoRoot = path.join(packageDir, "..", "..");
8
- const outputPath = path.join(packageDir, "dist", "omp");
8
+ // Optional cross-compile target, e.g. CROSS_TARGET=linux-arm64 → bun build
9
+ // --target=bun-linux-arm64, embeds the matching native, outputs dist/omp-<target>.
10
+ const crossTarget = Bun.env.CROSS_TARGET || null;
11
+ const [crossPlatform, crossArch] = crossTarget ? crossTarget.split("-") : [null, null];
12
+ // x64 uses the baseline bun runtime so it runs under Rosetta / pre-AVX2 CPUs
13
+ // (the modern bun-linux-x64 target SIGILLs under Apple-Silicon Rosetta).
14
+ const bunTarget = crossTarget ? (crossTarget === "linux-x64" ? "bun-linux-x64-baseline" : `bun-${crossTarget}`) : null;
15
+ const outName = crossTarget ? `omp-${crossTarget}` : "omp";
16
+ const outputPath = path.join(packageDir, "dist", outName);
9
17
 
10
18
  // Transformers.js is an optional, native-heavy dependency that is never bundled
11
19
  // into the binary; the tiny-model worker `bun install`s it into a runtime cache
@@ -17,7 +25,7 @@ const transformersVersion = (
17
25
  ).version;
18
26
 
19
27
  function shouldAdhocSignDarwinBinary(): boolean {
20
- return process.platform === "darwin";
28
+ return process.platform === "darwin" && !crossTarget;
21
29
  }
22
30
 
23
31
  async function runCommand(
@@ -43,7 +51,12 @@ async function main(): Promise<void> {
43
51
  try {
44
52
  await runCommand(["bun", "--cwd=../stats", "scripts/generate-client-bundle.ts", "--generate"]);
45
53
  await runCommand(["bun", "scripts/generate-docs-index.ts", "--generate"]);
46
- await runCommand(["bun", "--cwd=../natives", "run", "embed:native"]);
54
+ await runCommand(
55
+ ["bun", "--cwd=../natives", "run", "embed:native"],
56
+ crossTarget
57
+ ? { ...Bun.env, TARGET_PLATFORM: crossPlatform as string, TARGET_ARCH: crossArch as string }
58
+ : Bun.env,
59
+ );
47
60
  await runCommand(["bun", "scripts/embed-mupdf-wasm.ts", "--generate"]);
48
61
  try {
49
62
  const buildEnv = shouldAdhocSignDarwinBinary() ? { ...Bun.env, BUN_NO_CODESIGN_MACHO_BINARY: "1" } : Bun.env;
@@ -52,6 +65,7 @@ async function main(): Promise<void> {
52
65
  "bun",
53
66
  "build",
54
67
  "--compile",
68
+ ...(bunTarget ? ["--target", bunTarget] : []),
55
69
  "--no-compile-autoload-bunfig",
56
70
  "--no-compile-autoload-dotenv",
57
71
  "--no-compile-autoload-tsconfig",
@@ -85,7 +99,7 @@ async function main(): Promise<void> {
85
99
  "./packages/coding-agent/src/extensibility/legacy-pi-ai-shim.ts",
86
100
  "./packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts",
87
101
  "--outfile",
88
- "packages/coding-agent/dist/omp",
102
+ `packages/coding-agent/dist/${outName}`,
89
103
  ],
90
104
  buildEnv,
91
105
  repoRoot,
package/src/cli/args.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * CLI argument parsing and help display
3
3
  */
4
- import { type Effort, THINKING_EFFORTS } from "@oh-my-pi/pi-catalog/effort";
5
4
  import { APP_NAME, CONFIG_DIR_NAME, logger } from "@oh-my-pi/pi-utils";
6
5
  import chalk from "chalk";
7
- import { parseEffort } from "../thinking";
6
+ import { CLI_THINKING_LEVELS, type ConfiguredThinkingLevel, parseCliThinkingLevel } from "../thinking";
8
7
  import { BUILTIN_TOOL_NAMES } from "../tools/builtin-names";
9
8
  import {
10
9
  OPTIONAL_FLAGS,
@@ -32,7 +31,7 @@ export interface Args {
32
31
  apiKey?: string;
33
32
  systemPrompt?: string;
34
33
  appendSystemPrompt?: string;
35
- thinking?: Effort;
34
+ thinking?: ConfiguredThinkingLevel;
36
35
  hideThinking?: boolean;
37
36
  advisor?: boolean;
38
37
  continue?: boolean;
@@ -89,9 +88,9 @@ export interface Args {
89
88
  */
90
89
  const PARSE_DEPS: ParseDeps = {
91
90
  logger,
92
- parseEffort,
91
+ parseThinking: parseCliThinkingLevel,
93
92
  builtinToolNames: BUILTIN_TOOL_NAMES,
94
- thinkingEfforts: THINKING_EFFORTS,
93
+ thinkingEfforts: CLI_THINKING_LEVELS,
95
94
  };
96
95
 
97
96
  export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { type: "boolean" | "string" }>): Args {
@@ -30,7 +30,7 @@
30
30
  * real implementations at the dispatch site.
31
31
  */
32
32
 
33
- import type { Effort } from "@oh-my-pi/pi-ai";
33
+ import type { ConfiguredThinkingLevel } from "../thinking";
34
34
  import type { Args } from "./args";
35
35
 
36
36
  /**
@@ -44,7 +44,7 @@ import type { Args } from "./args";
44
44
  */
45
45
  export interface ParseDeps {
46
46
  logger: { warn: (message: string, meta?: Record<string, unknown>) => void };
47
- parseEffort: (value: string | null | undefined) => Effort | undefined;
47
+ parseThinking: (value: string | null | undefined) => ConfiguredThinkingLevel | undefined;
48
48
  builtinToolNames: readonly string[];
49
49
  thinkingEfforts: readonly string[];
50
50
  }
@@ -165,7 +165,7 @@ export const STRING_SETTERS: Record<string, StringSetter> = {
165
165
  result.tools = valid;
166
166
  },
167
167
  "--thinking": (result, value, deps) => {
168
- const thinking = deps.parseEffort(value);
168
+ const thinking = deps.parseThinking(value);
169
169
  if (thinking !== undefined) {
170
170
  result.thinking = thinking;
171
171
  } else {
@@ -5,17 +5,14 @@ export const interactionFixtures: Record<string, GalleryFixture> = {
5
5
  todo: {
6
6
  label: "Todo",
7
7
  streamingArgs: {
8
- ops: [{ op: "init", list: [{ phase: "Foundation", items: ["Scaffold crate"] }] }],
8
+ op: "init",
9
+ list: [{ phase: "Foundation", items: ["Scaffold crate"] }],
9
10
  },
10
11
  args: {
11
- ops: [
12
- {
13
- op: "init",
14
- list: [
15
- { phase: "Foundation", items: ["Scaffold crate", "Wire workspace"] },
16
- { phase: "Auth", items: ["Port credential store", "Wire OAuth providers"] },
17
- ],
18
- },
12
+ op: "init",
13
+ list: [
14
+ { phase: "Foundation", items: ["Scaffold crate", "Wire workspace"] },
15
+ { phase: "Auth", items: ["Port credential store", "Wire OAuth providers"] },
19
16
  ],
20
17
  },
21
18
  result: {
@@ -59,31 +59,23 @@ export const shellFixtures: Record<string, GalleryFixture> = {
59
59
  eval: {
60
60
  label: "Eval",
61
61
  streamingArgs: {
62
- cells: [
63
- {
64
- language: "py",
65
- code: 'import json\nfrom pathlib import Path\n\ndata = json.loads(Path("package.js',
66
- title: "load config",
67
- },
68
- ],
62
+ language: "py",
63
+ code: 'import json\nfrom pathlib import Path\n\ndata = json.loads(Path("package.js',
64
+ title: "load config",
69
65
  },
70
66
  args: {
71
- cells: [
72
- {
73
- language: "py",
74
- title: "load config",
75
- code: [
76
- "import json",
77
- "from pathlib import Path",
78
- "",
79
- 'data = json.loads(Path("package.json").read_text())',
80
- 'deps = data.get("dependencies", {})',
81
- 'print(f"{data[\\"name\\"]} v{data[\\"version\\"]}")',
82
- 'print(f"{len(deps)} dependencies")',
83
- "display(sorted(deps)[:3])",
84
- ].join("\n"),
85
- },
86
- ],
67
+ language: "py",
68
+ title: "load config",
69
+ code: [
70
+ "import json",
71
+ "from pathlib import Path",
72
+ "",
73
+ 'data = json.loads(Path("package.json").read_text())',
74
+ 'deps = data.get("dependencies", {})',
75
+ 'print(f"{data[\\"name\\"]} v{data[\\"version\\"]}")',
76
+ 'print(f"{len(deps)} dependencies")',
77
+ "display(sorted(deps)[:3])",
78
+ ].join("\n"),
87
79
  },
88
80
  result: {
89
81
  content: [
@@ -8,8 +8,10 @@ import { FileSessionStorage } from "../session/session-storage";
8
8
 
9
9
  /**
10
10
  * Show the TUI session selector and return the selected session, or null if
11
- * cancelled. Tab toggles between current-folder and all-projects scope; the
12
- * all-projects list is loaded lazily via `SessionManager.listAll`.
11
+ * cancelled. Rendered as a fullscreen overlay on the terminal's alternate
12
+ * screen, so the list scrolls and rows are clickable with the mouse. Tab
13
+ * toggles between current-folder and all-projects scope; the all-projects list
14
+ * is loaded lazily via `SessionManager.listAll`.
13
15
  */
14
16
  export async function selectSession(
15
17
  sessions: SessionInfo[],
@@ -65,6 +67,7 @@ export async function selectSession(
65
67
  loadAllSessions: () => SessionManager.listAll(storage),
66
68
  allSessions: options?.allSessions,
67
69
  getTerminalRows: () => ui.terminal.rows,
70
+ fillHeight: true,
68
71
  },
69
72
  );
70
73
  return selector;
@@ -72,7 +75,18 @@ export async function selectSession(
72
75
 
73
76
  const selector = showSelector();
74
77
  selector.setOnRequestRender(() => ui.requestRender());
75
- ui.addChild(selector);
78
+ // Present as a fullscreen overlay so the picker borrows the terminal's
79
+ // alternate screen buffer (vim/less idiom): the list scrolls and rows are
80
+ // clickable via the mouse tracking the overlay enables for its lifetime.
81
+ // Anchored top-left at full size so a mouse row maps directly to a rendered
82
+ // line (the overlay paints from screen row 0).
83
+ ui.showOverlay(selector, {
84
+ anchor: "top-left",
85
+ width: "100%",
86
+ maxHeight: "100%",
87
+ margin: 0,
88
+ fullscreen: true,
89
+ });
76
90
  ui.setFocus(selector);
77
91
  ui.start();
78
92
  return promise;
@@ -2,12 +2,12 @@
2
2
  * Root command for the coding agent CLI.
3
3
  */
4
4
 
5
- import { THINKING_EFFORTS } from "@oh-my-pi/pi-catalog/effort";
6
5
  import { APP_NAME } from "@oh-my-pi/pi-utils";
7
6
  import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
8
7
  import { parseArgs } from "../cli/args";
9
8
  import { runRootCommand } from "../main";
10
9
  import { prepareAcpTerminalAuthArgs } from "../modes/acp/terminal-auth";
10
+ import { CLI_THINKING_LEVELS } from "../thinking";
11
11
 
12
12
  export default class Index extends Command {
13
13
  static description = "AI coding assistant";
@@ -100,8 +100,8 @@ export default class Index extends Command {
100
100
  description: "Comma-separated list of tools to enable (default: all)",
101
101
  }),
102
102
  thinking: Flags.string({
103
- description: `Set thinking level: ${THINKING_EFFORTS.join(", ")}`,
104
- options: [...THINKING_EFFORTS],
103
+ description: `Set thinking level: ${CLI_THINKING_LEVELS.join(", ")}`,
104
+ options: [...CLI_THINKING_LEVELS],
105
105
  }),
106
106
  "hide-thinking": Flags.boolean({
107
107
  description: "Hide thinking blocks in TUI output (display only, does not disable model thinking)",
@@ -3572,7 +3572,7 @@ export const SETTINGS_SCHEMA = {
3572
3572
  group: "Discovery & MCP",
3573
3573
  label: "Essential Tools Override",
3574
3574
  description:
3575
- "Override the always-loaded built-in tools (default: read, bash, edit). Leave empty to use defaults.",
3575
+ "Override the always-loaded built-in tools (default: read, bash, edit, write, find, eval). Leave empty to use defaults.",
3576
3576
  },
3577
3577
  },
3578
3578
 
@@ -4479,6 +4479,17 @@ export const SETTINGS_SCHEMA = {
4479
4479
  },
4480
4480
  },
4481
4481
 
4482
+ "exa.searchDelayMs": {
4483
+ type: "number",
4484
+ default: 1_000,
4485
+ ui: {
4486
+ tab: "providers",
4487
+ group: "Services",
4488
+ label: "Exa Search Delay",
4489
+ description: "Minimum delay between Exa web search requests in milliseconds; set 0 to disable pacing",
4490
+ },
4491
+ },
4492
+
4482
4493
  "exa.enableResearcher": {
4483
4494
  type: "boolean",
4484
4495
  default: false,
@@ -4785,6 +4796,7 @@ export interface TtsrSettings {
4785
4796
  export interface ExaSettings {
4786
4797
  enabled: boolean;
4787
4798
  enableSearch: boolean;
4799
+ searchDelayMs: number;
4788
4800
  enableResearcher: boolean;
4789
4801
  enableWebsets: boolean;
4790
4802
  }
@@ -22,6 +22,7 @@ import {
22
22
  invalidateRenderedStringCache,
23
23
  type LspBatchRequest,
24
24
  PREVIEW_LIMITS,
25
+ previewWindowRows,
25
26
  type RenderedStringCache,
26
27
  replaceTabs,
27
28
  shortenPath,
@@ -340,6 +341,7 @@ function renderPlainTextPreview(text: string, uiTheme: Theme, _filePath?: string
340
341
  function formatStreamingDiff(
341
342
  diff: string,
342
343
  rawPath: string,
344
+ width: number,
343
345
  uiTheme: Theme,
344
346
  expanded: boolean,
345
347
  label = "streaming",
@@ -347,15 +349,32 @@ function formatStreamingDiff(
347
349
  cache?: RenderedStringCache,
348
350
  ): string {
349
351
  if (!diff) return "";
350
- let text = cachedRenderedString(cache, uiTheme, expanded, rawPath, diff, () => {
351
- // Collapsed uses a "Cursor" tail window: pin the last
352
- // EDIT_STREAMING_PREVIEW_LINES rows to the bottom so freshly streamed changes
353
- // stay on screen. The whole-file diff is recomputed on every streamed chunk
354
- // and its Myers alignment is not monotonic in payload length, so a hunk-aware
355
- // window stutters as rows move between hunks. Expanded deliberately lifts that
356
- // cap for the approval-time full view.
352
+ // Clamp the collapsed tail to the viewport so a tall or fast-growing diff
353
+ // cannot outgrow the live window. Otherwise its mutating tail scrolls above
354
+ // the native-scrollback commit boundary and the engine re-commits a fresh
355
+ // snapshot every streamed frame, stacking duplicate "… more lines above"
356
+ // previews in history. The budget is VISUAL rows (a long wrapped line counts
357
+ // for more than one) at the framed block's inner width (border only —
358
+ // contentPaddingLeft is 0); only the visible suffix is syntax-colored, so the
359
+ // cheap raw-line wrap walk keeps the per-chunk cost bounded. innerWidth/budget
360
+ // are in the cache salt so a resize re-slices.
361
+ const innerWidth = Math.max(1, width - 2);
362
+ const budget = expanded ? Number.POSITIVE_INFINITY : Math.min(EDIT_STREAMING_PREVIEW_LINES, previewWindowRows());
363
+ let text = cachedRenderedString(cache, uiTheme, expanded, `${rawPath}:${innerWidth}:${budget}`, diff, () => {
364
+ // "Cursor" tail window: pin the last rows to the bottom so freshly streamed
365
+ // changes stay on screen. The whole-file diff is recomputed every chunk and
366
+ // its Myers alignment is not monotonic in payload length, so a hunk-aware
367
+ // window stutters as rows move between hunks. Expanded lifts the cap.
357
368
  const allLines = diff.replace(/\n+$/u, "").split("\n");
358
- const hiddenLines = expanded ? 0 : Math.max(0, allLines.length - EDIT_STREAMING_PREVIEW_LINES);
369
+ let visualUsed = 0;
370
+ let cut = allLines.length;
371
+ for (let i = allLines.length - 1; i >= 0; i--) {
372
+ const lineRows = Math.max(1, wrapTextWithAnsi(replaceTabs(allLines[i]!), innerWidth).length);
373
+ if (visualUsed + lineRows > budget && visualUsed > 0) break;
374
+ visualUsed += lineRows;
375
+ cut = i;
376
+ }
377
+ const hiddenLines = cut;
359
378
  const visible = hiddenLines > 0 ? allLines.slice(hiddenLines) : allLines;
360
379
  let rendered = "\n\n";
361
380
  if (hiddenLines > 0) {
@@ -384,6 +403,7 @@ function formatStreamingDiff(
384
403
 
385
404
  function formatMultiFileStreamingDiff(
386
405
  previews: PerFileDiffPreview[],
406
+ width: number,
387
407
  uiTheme: Theme,
388
408
  expanded: boolean,
389
409
  spinnerFrame?: number,
@@ -405,7 +425,7 @@ function formatMultiFileStreamingDiff(
405
425
  const isLast = index === previews.length - 1;
406
426
  const cache = previewCacheAt(caches, index);
407
427
  parts.push(
408
- `${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, expanded, "preview", isLast ? spinnerFrame : undefined, cache)}`,
428
+ `${header}${formatStreamingDiff(preview.diff, preview.path, width, uiTheme, expanded, "preview", isLast ? spinnerFrame : undefined, cache)}`,
409
429
  );
410
430
  }
411
431
  }
@@ -415,6 +435,7 @@ function formatMultiFileStreamingDiff(
415
435
  function getCallPreview(
416
436
  args: EditRenderArgs,
417
437
  rawPath: string,
438
+ width: number,
418
439
  uiTheme: Theme,
419
440
  renderContext: EditRenderContext | undefined,
420
441
  expanded: boolean,
@@ -423,14 +444,14 @@ function getCallPreview(
423
444
  ): string {
424
445
  const multi = renderContext?.perFileDiffPreview;
425
446
  if (multi && multi.length > 1 && multi.some(p => p.diff || p.error)) {
426
- return formatMultiFileStreamingDiff(multi, uiTheme, expanded, spinnerFrame, caches);
447
+ return formatMultiFileStreamingDiff(multi, width, uiTheme, expanded, spinnerFrame, caches);
427
448
  }
428
449
  const cache = previewCacheAt(caches, 0);
429
450
  if (args.previewDiff) {
430
- return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, expanded, "preview", spinnerFrame, cache);
451
+ return formatStreamingDiff(args.previewDiff, rawPath, width, uiTheme, expanded, "preview", spinnerFrame, cache);
431
452
  }
432
453
  if (args.diff && args.op) {
433
- return formatStreamingDiff(args.diff, rawPath, uiTheme, expanded, "streaming", spinnerFrame, cache);
454
+ return formatStreamingDiff(args.diff, rawPath, width, uiTheme, expanded, "streaming", spinnerFrame, cache);
434
455
  }
435
456
  if (args.diff) {
436
457
  return renderPlainTextPreview(args.diff, uiTheme, rawPath);
@@ -628,6 +649,7 @@ export const editToolRenderer = {
628
649
  let body = getCallPreview(
629
650
  editArgs,
630
651
  rawPath,
652
+ width,
631
653
  uiTheme,
632
654
  renderContext,
633
655
  options.expanded,