@oh-my-pi/pi-coding-agent 16.1.15 → 16.1.17

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 (143) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/cli.js +3531 -3812
  3. package/dist/types/auto-thinking/classifier.d.ts +4 -2
  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/model-discovery.d.ts +1 -0
  9. package/dist/types/config/model-registry.d.ts +4 -0
  10. package/dist/types/config/settings-schema.d.ts +12 -1
  11. package/dist/types/eval/agent-bridge.d.ts +19 -0
  12. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  13. package/dist/types/eval/js/shared/types.d.ts +1 -1
  14. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  15. package/dist/types/eval/py/executor.d.ts +1 -1
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
  17. package/dist/types/extensibility/plugins/loader.d.ts +16 -9
  18. package/dist/types/extensibility/tool-event-input.d.ts +2 -0
  19. package/dist/types/hindsight/content.d.ts +2 -10
  20. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  21. package/dist/types/main.d.ts +2 -0
  22. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  23. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  24. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  25. package/dist/types/modes/components/session-selector.d.ts +25 -0
  26. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  27. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  28. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
  30. package/dist/types/session/agent-session.d.ts +6 -6
  31. package/dist/types/session/provider-image-budget.d.ts +3 -0
  32. package/dist/types/session/session-context.d.ts +6 -5
  33. package/dist/types/task/isolation-runner.d.ts +128 -0
  34. package/dist/types/task/parallel.d.ts +4 -0
  35. package/dist/types/task/worktree.d.ts +14 -1
  36. package/dist/types/thinking.d.ts +23 -1
  37. package/dist/types/tiny/title-client.d.ts +2 -2
  38. package/dist/types/tools/eval-render.d.ts +3 -0
  39. package/dist/types/tools/eval.d.ts +11 -17
  40. package/dist/types/tools/todo.d.ts +26 -28
  41. package/dist/types/tui/output-block.d.ts +8 -0
  42. package/dist/types/utils/image-resize.d.ts +2 -0
  43. package/dist/types/utils/tools-manager.d.ts +2 -0
  44. package/dist/types/web/search/providers/exa.d.ts +2 -0
  45. package/package.json +12 -12
  46. package/scripts/build-binary.ts +18 -4
  47. package/src/auto-thinking/classifier.ts +7 -2
  48. package/src/cli/args.ts +4 -5
  49. package/src/cli/flag-tables.ts +3 -3
  50. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  51. package/src/cli/gallery-fixtures/shell.ts +15 -23
  52. package/src/cli/profile-alias.ts +38 -7
  53. package/src/cli/session-picker.ts +17 -3
  54. package/src/cli/usage-cli.ts +5 -1
  55. package/src/commands/launch.ts +3 -3
  56. package/src/config/model-discovery.ts +59 -8
  57. package/src/config/model-registry.ts +74 -3
  58. package/src/config/settings-schema.ts +13 -1
  59. package/src/discovery/omp-extension-roots.ts +1 -3
  60. package/src/edit/renderer.ts +34 -12
  61. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  62. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  63. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  64. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  65. package/src/eval/agent-bridge.ts +301 -71
  66. package/src/eval/jl/prelude.jl +32 -227
  67. package/src/eval/jl/runner.jl +38 -12
  68. package/src/eval/js/shared/helpers.ts +1 -114
  69. package/src/eval/js/shared/prelude.txt +13 -27
  70. package/src/eval/js/shared/runtime.ts +0 -6
  71. package/src/eval/js/shared/types.ts +1 -1
  72. package/src/eval/js/worker-protocol.ts +1 -1
  73. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  74. package/src/eval/py/executor.ts +1 -1
  75. package/src/eval/py/prelude.py +47 -105
  76. package/src/eval/py/runner.py +0 -6
  77. package/src/eval/rb/prelude.rb +21 -189
  78. package/src/eval/rb/runner.rb +116 -9
  79. package/src/export/html/tool-views.generated.js +29 -29
  80. package/src/extensibility/extensions/wrapper.ts +3 -2
  81. package/src/extensibility/hooks/tool-wrapper.ts +4 -3
  82. package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
  83. package/src/extensibility/plugins/loader.ts +71 -23
  84. package/src/extensibility/plugins/marketplace/manager.ts +134 -0
  85. package/src/extensibility/tool-event-input.ts +57 -0
  86. package/src/hindsight/content.ts +21 -12
  87. package/src/internal-urls/docs-index.generated.txt +1 -1
  88. package/src/internal-urls/local-protocol.ts +100 -53
  89. package/src/main.ts +15 -4
  90. package/src/mcp/tool-bridge.ts +27 -2
  91. package/src/mnemopi/state.ts +5 -2
  92. package/src/modes/acp/acp-event-mapper.ts +7 -2
  93. package/src/modes/components/agent-transcript-viewer.ts +193 -41
  94. package/src/modes/components/chat-transcript-builder.ts +6 -0
  95. package/src/modes/components/custom-editor.test.ts +18 -1
  96. package/src/modes/components/custom-editor.ts +77 -45
  97. package/src/modes/components/hook-editor.ts +15 -2
  98. package/src/modes/components/plugin-settings.ts +7 -1
  99. package/src/modes/components/session-selector.ts +143 -29
  100. package/src/modes/components/settings-selector.ts +2 -2
  101. package/src/modes/components/status-line/component.ts +52 -8
  102. package/src/modes/components/status-line/segments.ts +5 -1
  103. package/src/modes/components/status-line/types.ts +1 -0
  104. package/src/modes/components/welcome.ts +12 -14
  105. package/src/modes/controllers/command-controller.ts +21 -5
  106. package/src/modes/controllers/input-controller.ts +115 -3
  107. package/src/modes/controllers/selector-controller.ts +19 -1
  108. package/src/modes/interactive-mode.ts +3 -3
  109. package/src/modes/rpc/rpc-mode.ts +6 -0
  110. package/src/modes/utils/copy-targets.ts +7 -2
  111. package/src/modes/utils/ui-helpers.ts +3 -3
  112. package/src/prompts/system/system-prompt.md +3 -3
  113. package/src/prompts/system/workflow-notice.md +3 -3
  114. package/src/prompts/tools/bash.md +16 -0
  115. package/src/prompts/tools/eval.md +19 -19
  116. package/src/prompts/tools/todo.md +1 -1
  117. package/src/sdk.ts +8 -10
  118. package/src/session/agent-session.ts +422 -97
  119. package/src/session/provider-image-budget.ts +86 -0
  120. package/src/session/session-context.ts +14 -7
  121. package/src/session/session-storage.ts +24 -2
  122. package/src/session/snapcompact-inline.ts +19 -3
  123. package/src/slash-commands/builtin-registry.ts +0 -22
  124. package/src/slash-commands/helpers/usage-report.ts +9 -1
  125. package/src/task/index.ts +61 -207
  126. package/src/task/isolation-runner.ts +354 -0
  127. package/src/task/parallel.ts +6 -1
  128. package/src/task/worktree.ts +46 -9
  129. package/src/thinking.ts +29 -2
  130. package/src/tiny/title-client.ts +75 -21
  131. package/src/tools/ask.ts +44 -38
  132. package/src/tools/bash.ts +9 -2
  133. package/src/tools/browser/tab-worker.ts +1 -1
  134. package/src/tools/eval-render.ts +34 -27
  135. package/src/tools/eval.ts +100 -103
  136. package/src/tools/index.ts +8 -1
  137. package/src/tools/read.ts +136 -60
  138. package/src/tools/todo.ts +60 -64
  139. package/src/tui/code-cell.ts +1 -1
  140. package/src/tui/output-block.ts +11 -0
  141. package/src/utils/image-resize.ts +30 -0
  142. package/src/utils/tools-manager.ts +67 -10
  143. package/src/web/search/providers/exa.ts +85 -1
@@ -5,12 +5,12 @@ import {
5
5
  createWorkerHandle,
6
6
  createWorkerSubprocess,
7
7
  logWorkerMessage,
8
+ type RefCountedWorkerHandle,
8
9
  resolveWorkerSpawnCmd,
9
10
  SMOKE_TEST_TIMEOUT_MS,
10
11
  type SpawnedSubprocess,
11
12
  smokeTestWorker,
12
13
  spawnWorkerOrUnavailable,
13
- type WorkerHandle,
14
14
  workerEnvFromParent,
15
15
  } from "../subprocess/worker-client";
16
16
  import { safeSend } from "../utils/ipc";
@@ -128,33 +128,60 @@ export function createTinyTitleSubprocess(): SpawnedSubprocess<TinyTitleWorkerOu
128
128
 
129
129
  function wrapSubprocess(
130
130
  spawned: SpawnedSubprocess<TinyTitleWorkerOutbound>,
131
- ): WorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> {
131
+ ): RefCountedWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> {
132
132
  const { proc } = spawned;
133
- return createWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound>(spawned, message =>
134
- safeSend(proc, message, "tiny-title"),
135
- );
133
+ return {
134
+ ...createWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound>(spawned, message =>
135
+ safeSend(proc, message, "tiny-title"),
136
+ ),
137
+ ref() {
138
+ try {
139
+ proc.ref();
140
+ } catch {
141
+ // Already gone.
142
+ }
143
+ },
144
+ unref() {
145
+ try {
146
+ proc.unref();
147
+ } catch {
148
+ // Already gone.
149
+ }
150
+ },
151
+ };
152
+ }
153
+
154
+ function spawnInlineUnavailableWorker(
155
+ error: unknown,
156
+ ): RefCountedWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> {
157
+ return {
158
+ ...createUnavailableWorker<TinyTitleWorkerInbound, TinyTitleWorkerOutbound>(error),
159
+ ref() {},
160
+ unref() {},
161
+ };
136
162
  }
137
163
 
138
- function spawnTinyTitleWorker(): WorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> {
164
+ function spawnTinyTitleWorker(): RefCountedWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> {
139
165
  return spawnWorkerOrUnavailable(
140
166
  () => wrapSubprocess(createTinyTitleSubprocess()),
141
- createUnavailableWorker<TinyTitleWorkerInbound, TinyTitleWorkerOutbound>,
167
+ spawnInlineUnavailableWorker,
142
168
  "Tiny title worker spawn failed; local titles disabled",
143
169
  );
144
170
  }
145
171
 
146
172
  export class TinyTitleClient {
147
- #worker: WorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> | null = null;
173
+ #worker: RefCountedWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> | null = null;
148
174
  #unsubscribeMessage: (() => void) | null = null;
149
175
  #unsubscribeError: (() => void) | null = null;
150
176
  #pending = new Map<string, PendingRequest>();
151
177
  #failedModels = new Set<TinyLocalModelKey>();
152
178
  #progressListeners = new Set<(event: TinyTitleProgressEvent) => void>();
153
179
  #nextRequestId = 0;
154
- #spawnWorker: () => WorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound>;
180
+ #refed = false;
181
+ #spawnWorker: () => RefCountedWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound>;
155
182
 
156
183
  constructor(
157
- spawnWorker: () => WorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> = spawnTinyTitleWorker,
184
+ spawnWorker: () => RefCountedWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> = spawnTinyTitleWorker,
158
185
  ) {
159
186
  this.#spawnWorker = spawnWorker;
160
187
  }
@@ -179,11 +206,11 @@ export class TinyTitleClient {
179
206
  const worker = this.#ensureWorker();
180
207
  const id = String(++this.#nextRequestId);
181
208
  const { promise, resolve } = Promise.withResolvers<string | null>();
182
- this.#pending.set(id, { kind: "generate", modelKey, resolve });
209
+ this.#addPending(id, { kind: "generate", modelKey, resolve });
183
210
  const abort = (): void => {
184
211
  const pending = this.#pending.get(id);
185
212
  if (pending?.kind !== "generate") return;
186
- this.#pending.delete(id);
213
+ this.#deletePending(id);
187
214
  pending.resolve(null);
188
215
  };
189
216
  options.signal?.addEventListener("abort", abort, { once: true });
@@ -195,7 +222,7 @@ export class TinyTitleClient {
195
222
  return await promise;
196
223
  } finally {
197
224
  options.signal?.removeEventListener("abort", abort);
198
- this.#pending.delete(id);
225
+ this.#deletePending(id);
199
226
  }
200
227
  } catch (error) {
201
228
  logger.debug("tiny-title: local generation failed", {
@@ -218,11 +245,11 @@ export class TinyTitleClient {
218
245
  const worker = this.#ensureWorker();
219
246
  const id = String(++this.#nextRequestId);
220
247
  const { promise, resolve } = Promise.withResolvers<string | null>();
221
- this.#pending.set(id, { kind: "complete", modelKey, resolve });
248
+ this.#addPending(id, { kind: "complete", modelKey, resolve });
222
249
  const abort = (): void => {
223
250
  const pending = this.#pending.get(id);
224
251
  if (pending?.kind !== "complete") return;
225
- this.#pending.delete(id);
252
+ this.#deletePending(id);
226
253
  pending.resolve(null);
227
254
  };
228
255
  options.signal?.addEventListener("abort", abort, { once: true });
@@ -231,7 +258,7 @@ export class TinyTitleClient {
231
258
  return await promise;
232
259
  } finally {
233
260
  options.signal?.removeEventListener("abort", abort);
234
- this.#pending.delete(id);
261
+ this.#deletePending(id);
235
262
  }
236
263
  } catch (error) {
237
264
  logger.debug("tiny-model: local completion failed", {
@@ -251,11 +278,11 @@ export class TinyTitleClient {
251
278
  const worker = this.#ensureWorker();
252
279
  const id = String(++this.#nextRequestId);
253
280
  const { promise, resolve } = Promise.withResolvers<boolean>();
254
- this.#pending.set(id, { kind: "download", modelKey, resolve });
281
+ this.#addPending(id, { kind: "download", modelKey, resolve });
255
282
  const abort = (): void => {
256
283
  const pending = this.#pending.get(id);
257
284
  if (pending?.kind !== "download") return;
258
- this.#pending.delete(id);
285
+ this.#deletePending(id);
259
286
  pending.resolve(false);
260
287
  };
261
288
  options.signal?.addEventListener("abort", abort, { once: true });
@@ -264,7 +291,7 @@ export class TinyTitleClient {
264
291
  return await promise;
265
292
  } finally {
266
293
  options.signal?.removeEventListener("abort", abort);
267
- this.#pending.delete(id);
294
+ this.#deletePending(id);
268
295
  }
269
296
  } catch (error) {
270
297
  logger.debug("tiny-title: local model download failed", {
@@ -290,6 +317,7 @@ export class TinyTitleClient {
290
317
  else pending.resolve(false);
291
318
  }
292
319
  this.#pending.clear();
320
+ this.#refed = false;
293
321
  try {
294
322
  await worker?.terminate();
295
323
  } catch {
@@ -297,7 +325,7 @@ export class TinyTitleClient {
297
325
  }
298
326
  }
299
327
 
300
- #ensureWorker(): WorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> {
328
+ #ensureWorker(): RefCountedWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> {
301
329
  if (this.#worker) return this.#worker;
302
330
  const worker = this.#spawnWorker();
303
331
  this.#worker = worker;
@@ -306,6 +334,32 @@ export class TinyTitleClient {
306
334
  return worker;
307
335
  }
308
336
 
337
+ /** Register a pending request and keep the worker referenced while work is in flight. */
338
+ #addPending(id: string, request: PendingRequest): void {
339
+ this.#pending.set(id, request);
340
+ this.#syncWorkerRef();
341
+ }
342
+
343
+ /** Drop a pending request and unref the worker once nothing is in flight. */
344
+ #deletePending(id: string): void {
345
+ if (this.#pending.delete(id)) this.#syncWorkerRef();
346
+ }
347
+
348
+ /**
349
+ * Tiny-model workers are spawned `unref`'d so idle TUI sessions can exit.
350
+ * Short-lived CLI downloads need the opposite while awaiting worker IPC, or
351
+ * Bun can drain the event loop before the subprocess answers.
352
+ */
353
+ #syncWorkerRef(): void {
354
+ const worker = this.#worker;
355
+ if (!worker) return;
356
+ const shouldRef = this.#pending.size > 0;
357
+ if (shouldRef === this.#refed) return;
358
+ this.#refed = shouldRef;
359
+ if (shouldRef) worker.ref();
360
+ else worker.unref();
361
+ }
362
+
309
363
  #handleMessage(message: TinyTitleWorkerOutbound): void {
310
364
  if (message.type === "log") {
311
365
  logWorkerMessage(message);
@@ -319,7 +373,7 @@ export class TinyTitleClient {
319
373
 
320
374
  const pending = this.#pending.get(message.id);
321
375
  if (!pending) return;
322
- this.#pending.delete(message.id);
376
+ this.#deletePending(message.id);
323
377
  if (message.type === "title") {
324
378
  if (pending.kind === "generate") pending.resolve(message.title);
325
379
  return;
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;