@oh-my-pi/pi-coding-agent 6.9.0 → 7.0.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 (143) hide show
  1. package/CHANGELOG.md +173 -51
  2. package/examples/sdk/04-skills.ts +1 -1
  3. package/package.json +6 -5
  4. package/src/cli/stats-cli.ts +191 -0
  5. package/src/core/agent-session.ts +214 -4
  6. package/src/core/auth-storage.ts +524 -202
  7. package/src/core/bash-executor.ts +1 -1
  8. package/src/core/extensions/index.ts +2 -0
  9. package/src/core/extensions/runner.ts +31 -0
  10. package/src/core/extensions/types.ts +24 -0
  11. package/src/core/messages.ts +48 -0
  12. package/src/core/model-registry.ts +7 -0
  13. package/src/core/python-executor.ts +29 -8
  14. package/src/core/python-gateway-coordinator.ts +55 -1
  15. package/src/core/python-prelude.py +201 -8
  16. package/src/core/session-manager.ts +10 -1
  17. package/src/core/tools/bash.ts +5 -7
  18. package/src/core/tools/find.ts +18 -5
  19. package/src/core/tools/index.ts +1 -1
  20. package/src/core/tools/lsp/index.ts +13 -2
  21. package/src/core/tools/patch/applicator.ts +115 -17
  22. package/src/core/tools/patch/index.ts +1 -1
  23. package/src/core/tools/patch/normalize.ts +185 -10
  24. package/src/core/tools/python.ts +445 -86
  25. package/src/core/tools/read.ts +4 -4
  26. package/src/core/tools/task/executor.ts +2 -6
  27. package/src/core/tools/task/index.ts +30 -12
  28. package/src/core/tools/task/render.ts +163 -30
  29. package/src/core/tools/task/template.ts +37 -0
  30. package/src/core/tools/task/types.ts +6 -2
  31. package/src/core/tools/task/worker.ts +1 -1
  32. package/src/index.ts +2 -0
  33. package/src/main.ts +12 -0
  34. package/src/modes/interactive/components/python-execution.ts +180 -0
  35. package/src/modes/interactive/components/welcome.ts +1 -0
  36. package/src/modes/interactive/controllers/command-controller.ts +395 -0
  37. package/src/modes/interactive/controllers/input-controller.ts +83 -8
  38. package/src/modes/interactive/interactive-mode.ts +16 -1
  39. package/src/modes/interactive/theme/dark.json +2 -9
  40. package/src/modes/interactive/theme/defaults/alabaster.json +2 -8
  41. package/src/modes/interactive/theme/defaults/amethyst.json +2 -9
  42. package/src/modes/interactive/theme/defaults/anthracite.json +2 -9
  43. package/src/modes/interactive/theme/defaults/basalt.json +89 -88
  44. package/src/modes/interactive/theme/defaults/birch.json +2 -8
  45. package/src/modes/interactive/theme/defaults/dark-abyss.json +2 -8
  46. package/src/modes/interactive/theme/defaults/dark-arctic.json +2 -9
  47. package/src/modes/interactive/theme/defaults/dark-aurora.json +3 -2
  48. package/src/modes/interactive/theme/defaults/dark-catppuccin.json +2 -1
  49. package/src/modes/interactive/theme/defaults/dark-cavern.json +2 -8
  50. package/src/modes/interactive/theme/defaults/dark-copper.json +3 -2
  51. package/src/modes/interactive/theme/defaults/dark-cosmos.json +2 -8
  52. package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +2 -9
  53. package/src/modes/interactive/theme/defaults/dark-dracula.json +2 -9
  54. package/src/modes/interactive/theme/defaults/dark-eclipse.json +2 -8
  55. package/src/modes/interactive/theme/defaults/dark-ember.json +3 -2
  56. package/src/modes/interactive/theme/defaults/dark-equinox.json +2 -8
  57. package/src/modes/interactive/theme/defaults/dark-forest.json +2 -9
  58. package/src/modes/interactive/theme/defaults/dark-github.json +2 -9
  59. package/src/modes/interactive/theme/defaults/dark-gruvbox.json +2 -9
  60. package/src/modes/interactive/theme/defaults/dark-lavender.json +3 -2
  61. package/src/modes/interactive/theme/defaults/dark-lunar.json +2 -8
  62. package/src/modes/interactive/theme/defaults/dark-midnight.json +3 -2
  63. package/src/modes/interactive/theme/defaults/dark-monochrome.json +2 -9
  64. package/src/modes/interactive/theme/defaults/dark-monokai.json +2 -9
  65. package/src/modes/interactive/theme/defaults/dark-nebula.json +2 -8
  66. package/src/modes/interactive/theme/defaults/dark-nord.json +2 -9
  67. package/src/modes/interactive/theme/defaults/dark-ocean.json +2 -9
  68. package/src/modes/interactive/theme/defaults/dark-one.json +2 -9
  69. package/src/modes/interactive/theme/defaults/dark-rainforest.json +2 -8
  70. package/src/modes/interactive/theme/defaults/dark-reef.json +2 -8
  71. package/src/modes/interactive/theme/defaults/dark-retro.json +2 -9
  72. package/src/modes/interactive/theme/defaults/dark-rose-pine.json +2 -1
  73. package/src/modes/interactive/theme/defaults/dark-sakura.json +3 -2
  74. package/src/modes/interactive/theme/defaults/dark-slate.json +3 -2
  75. package/src/modes/interactive/theme/defaults/dark-solarized.json +2 -1
  76. package/src/modes/interactive/theme/defaults/dark-solstice.json +2 -8
  77. package/src/modes/interactive/theme/defaults/dark-starfall.json +2 -8
  78. package/src/modes/interactive/theme/defaults/dark-sunset.json +2 -9
  79. package/src/modes/interactive/theme/defaults/dark-swamp.json +2 -8
  80. package/src/modes/interactive/theme/defaults/dark-synthwave.json +2 -1
  81. package/src/modes/interactive/theme/defaults/dark-taiga.json +2 -8
  82. package/src/modes/interactive/theme/defaults/dark-terminal.json +3 -2
  83. package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +2 -9
  84. package/src/modes/interactive/theme/defaults/dark-tundra.json +2 -8
  85. package/src/modes/interactive/theme/defaults/dark-twilight.json +2 -8
  86. package/src/modes/interactive/theme/defaults/dark-volcanic.json +2 -8
  87. package/src/modes/interactive/theme/defaults/graphite.json +2 -9
  88. package/src/modes/interactive/theme/defaults/light-arctic.json +2 -1
  89. package/src/modes/interactive/theme/defaults/light-aurora-day.json +2 -8
  90. package/src/modes/interactive/theme/defaults/light-canyon.json +2 -8
  91. package/src/modes/interactive/theme/defaults/light-catppuccin.json +2 -1
  92. package/src/modes/interactive/theme/defaults/light-cirrus.json +2 -8
  93. package/src/modes/interactive/theme/defaults/light-coral.json +3 -2
  94. package/src/modes/interactive/theme/defaults/light-cyberpunk.json +2 -9
  95. package/src/modes/interactive/theme/defaults/light-dawn.json +2 -8
  96. package/src/modes/interactive/theme/defaults/light-dunes.json +2 -8
  97. package/src/modes/interactive/theme/defaults/light-eucalyptus.json +3 -2
  98. package/src/modes/interactive/theme/defaults/light-forest.json +2 -9
  99. package/src/modes/interactive/theme/defaults/light-frost.json +3 -2
  100. package/src/modes/interactive/theme/defaults/light-github.json +2 -1
  101. package/src/modes/interactive/theme/defaults/light-glacier.json +2 -8
  102. package/src/modes/interactive/theme/defaults/light-gruvbox.json +2 -9
  103. package/src/modes/interactive/theme/defaults/light-haze.json +2 -8
  104. package/src/modes/interactive/theme/defaults/light-honeycomb.json +3 -2
  105. package/src/modes/interactive/theme/defaults/light-lagoon.json +2 -8
  106. package/src/modes/interactive/theme/defaults/light-lavender.json +3 -2
  107. package/src/modes/interactive/theme/defaults/light-meadow.json +2 -8
  108. package/src/modes/interactive/theme/defaults/light-mint.json +3 -2
  109. package/src/modes/interactive/theme/defaults/light-monochrome.json +2 -1
  110. package/src/modes/interactive/theme/defaults/light-ocean.json +2 -9
  111. package/src/modes/interactive/theme/defaults/light-one.json +2 -8
  112. package/src/modes/interactive/theme/defaults/light-opal.json +2 -8
  113. package/src/modes/interactive/theme/defaults/light-orchard.json +2 -8
  114. package/src/modes/interactive/theme/defaults/light-paper.json +3 -2
  115. package/src/modes/interactive/theme/defaults/light-prism.json +2 -8
  116. package/src/modes/interactive/theme/defaults/light-retro.json +2 -9
  117. package/src/modes/interactive/theme/defaults/light-sand.json +3 -2
  118. package/src/modes/interactive/theme/defaults/light-savanna.json +2 -8
  119. package/src/modes/interactive/theme/defaults/light-solarized.json +2 -1
  120. package/src/modes/interactive/theme/defaults/light-soleil.json +2 -8
  121. package/src/modes/interactive/theme/defaults/light-sunset.json +2 -9
  122. package/src/modes/interactive/theme/defaults/light-synthwave.json +2 -9
  123. package/src/modes/interactive/theme/defaults/light-tokyo-night.json +2 -9
  124. package/src/modes/interactive/theme/defaults/light-wetland.json +2 -8
  125. package/src/modes/interactive/theme/defaults/light-zenith.json +2 -8
  126. package/src/modes/interactive/theme/defaults/limestone.json +2 -8
  127. package/src/modes/interactive/theme/defaults/mahogany.json +2 -9
  128. package/src/modes/interactive/theme/defaults/marble.json +2 -8
  129. package/src/modes/interactive/theme/defaults/obsidian.json +89 -88
  130. package/src/modes/interactive/theme/defaults/onyx.json +89 -88
  131. package/src/modes/interactive/theme/defaults/pearl.json +2 -8
  132. package/src/modes/interactive/theme/defaults/porcelain.json +89 -88
  133. package/src/modes/interactive/theme/defaults/quartz.json +2 -8
  134. package/src/modes/interactive/theme/defaults/sandstone.json +2 -8
  135. package/src/modes/interactive/theme/defaults/titanium.json +88 -87
  136. package/src/modes/interactive/theme/light.json +2 -8
  137. package/src/modes/interactive/theme/theme-schema.json +5 -0
  138. package/src/modes/interactive/theme/theme.ts +7 -0
  139. package/src/modes/interactive/types.ts +7 -1
  140. package/src/modes/interactive/utils/ui-helpers.ts +20 -0
  141. package/src/prompts/system/system-prompt.md +88 -78
  142. package/src/prompts/tools/python.md +39 -2
  143. package/src/prompts/tools/task.md +8 -13
@@ -2,10 +2,10 @@ import { relative, resolve, sep } from "node:path";
2
2
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
3
  import type { ImageContent } from "@oh-my-pi/pi-ai";
4
4
  import type { Component } from "@oh-my-pi/pi-tui";
5
- import { Text, truncateToWidth } from "@oh-my-pi/pi-tui";
5
+ import { Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
6
6
  import { type Static, Type } from "@sinclair/typebox";
7
7
  import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate";
8
- import type { Theme } from "../../modes/interactive/theme/theme";
8
+ import { highlightCode, type Theme } from "../../modes/interactive/theme/theme";
9
9
  import pythonDescription from "../../prompts/tools/python.md" with { type: "text" };
10
10
  import type { RenderResultOptions } from "../custom-tools/types";
11
11
  import { renderPromptTemplate } from "../prompt-templates";
@@ -39,15 +39,26 @@ function groupPreludeHelpers(helpers: PreludeHelper[]): PreludeCategory[] {
39
39
  }
40
40
 
41
41
  export const pythonSchema = Type.Object({
42
- code: Type.String({ description: "Python code to execute" }),
43
- timeoutMs: Type.Optional(Type.Number({ description: "Timeout in milliseconds (default: 30000)" })),
44
- workdir: Type.Optional(
45
- Type.String({ description: "Working directory for the command (default: current directory)" }),
42
+ cells: Type.Array(
43
+ Type.Object({
44
+ code: Type.String({
45
+ description:
46
+ "Python code for this cell. Keep it focused (imports, helper, test, use). No narrative text—put explanations in the assistant message or in the cell title.",
47
+ }),
48
+ title: Type.Optional(
49
+ Type.String({ description: "Short label for the cell (e.g., 'imports', 'parse helper')." }),
50
+ ),
51
+ }),
52
+ {
53
+ description:
54
+ "Python cells to execute sequentially. Each cell runs in the same kernel—imports and variables persist. Keep cells small: one logical step each (import, define, test, use). If a cell fails, fix only that cell; earlier cells' state remains.",
55
+ },
46
56
  ),
57
+ timeoutMs: Type.Optional(Type.Number({ description: "Timeout in milliseconds (default: 30000)" })),
58
+ cwd: Type.Optional(Type.String({ description: "Working directory for the command (default: current directory)" })),
47
59
  reset: Type.Optional(Type.Boolean({ description: "Restart the kernel before executing this code" })),
48
60
  });
49
-
50
- export type PythonToolParams = { code: string; timeout?: number; workdir?: string; reset?: boolean };
61
+ export type PythonToolParams = Static<typeof pythonSchema>;
51
62
 
52
63
  export type PythonToolResult = {
53
64
  content: Array<{ type: "text"; text: string }>;
@@ -56,7 +67,19 @@ export type PythonToolResult = {
56
67
 
57
68
  export type PythonProxyExecutor = (params: PythonToolParams, signal?: AbortSignal) => Promise<PythonToolResult>;
58
69
 
70
+ export interface PythonCellResult {
71
+ index: number;
72
+ title?: string;
73
+ code: string;
74
+ output: string;
75
+ status: "pending" | "running" | "complete" | "error";
76
+ durationMs?: number;
77
+ exitCode?: number;
78
+ statusEvents?: PythonStatusEvent[];
79
+ }
80
+
59
81
  export interface PythonToolDetails {
82
+ cells?: PythonCellResult[];
60
83
  truncation?: TruncationResult;
61
84
  fullOutputPath?: string;
62
85
  fullOutput?: string;
@@ -151,7 +174,7 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
151
174
  throw new Error("Python tool requires a session when not using proxy executor");
152
175
  }
153
176
 
154
- const { code, timeoutMs = 30000, workdir, reset } = params;
177
+ const { cells, timeoutMs = 30000, cwd, reset } = params;
155
178
  const controller = new AbortController();
156
179
  const onAbort = () => controller.abort();
157
180
  signal?.addEventListener("abort", onAbort, { once: true });
@@ -161,7 +184,7 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
161
184
  throw new Error("Aborted");
162
185
  }
163
186
 
164
- const commandCwd = workdir ? resolveToCwd(workdir, this.session.cwd) : this.session.cwd;
187
+ const commandCwd = cwd ? resolveToCwd(cwd, this.session.cwd) : this.session.cwd;
165
188
  let cwdStat: Awaited<ReturnType<Bun.BunFile["stat"]>>;
166
189
  try {
167
190
  cwdStat = await Bun.file(commandCwd).stat();
@@ -177,89 +200,185 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
177
200
  let tailBytes = 0;
178
201
  const jsonOutputs: unknown[] = [];
179
202
  const images: ImageContent[] = [];
203
+ const statusEvents: PythonStatusEvent[] = [];
204
+
205
+ const cellResults: PythonCellResult[] = cells.map((cell, index) => ({
206
+ index,
207
+ title: cell.title,
208
+ code: cell.code,
209
+ output: "",
210
+ status: "pending",
211
+ }));
212
+ const cellOutputs: string[] = [];
213
+ let lastFullOutputPath: string | undefined;
214
+
215
+ const appendTail = (text: string) => {
216
+ if (!text) return;
217
+ const chunkBytes = Buffer.byteLength(text, "utf-8");
218
+ tailChunks.push({ text, bytes: chunkBytes });
219
+ tailBytes += chunkBytes;
220
+ while (tailBytes > maxTailBytes && tailChunks.length > 1) {
221
+ const removed = tailChunks.shift();
222
+ if (removed) {
223
+ tailBytes -= removed.bytes;
224
+ }
225
+ }
226
+ };
227
+
228
+ const buildUpdateDetails = (truncation?: TruncationResult): PythonToolDetails => {
229
+ const details: PythonToolDetails = {
230
+ cells: cellResults.map((cell) => ({
231
+ ...cell,
232
+ statusEvents: cell.statusEvents ? [...cell.statusEvents] : undefined,
233
+ })),
234
+ };
235
+ if (truncation) {
236
+ details.truncation = truncation;
237
+ }
238
+ if (lastFullOutputPath) {
239
+ details.fullOutputPath = lastFullOutputPath;
240
+ }
241
+ if (jsonOutputs.length > 0) {
242
+ details.jsonOutputs = jsonOutputs;
243
+ }
244
+ if (images.length > 0) {
245
+ details.images = images;
246
+ }
247
+ if (statusEvents.length > 0) {
248
+ details.statusEvents = statusEvents;
249
+ }
250
+ return details;
251
+ };
252
+
253
+ const pushUpdate = () => {
254
+ if (!onUpdate) return;
255
+ const tailText = tailChunks.map((entry) => entry.text).join("");
256
+ const truncation = truncateTail(tailText);
257
+ onUpdate({
258
+ content: [{ type: "text", text: truncation.content || "" }],
259
+ details: buildUpdateDetails(truncation.truncated ? truncation : undefined),
260
+ });
261
+ };
180
262
 
181
263
  const sessionFile = this.session.getSessionFile?.() ?? undefined;
182
- const sessionId = sessionFile ? `session:${sessionFile}:workdir:${commandCwd}` : `cwd:${commandCwd}`;
183
- const executorOptions: PythonExecutorOptions = {
264
+ const sessionId = sessionFile ? `session:${sessionFile}:cwd:${commandCwd}` : `cwd:${commandCwd}`;
265
+ const baseExecutorOptions: Omit<PythonExecutorOptions, "reset"> = {
184
266
  cwd: commandCwd,
185
267
  timeoutMs,
186
268
  signal: controller.signal,
187
269
  sessionId,
188
270
  kernelMode: this.session.settings?.getPythonKernelMode?.() ?? "session",
189
271
  useSharedGateway: this.session.settings?.getPythonSharedGateway?.() ?? true,
190
- reset,
191
- onChunk: (chunk) => {
192
- const chunkBytes = Buffer.byteLength(chunk, "utf-8");
193
- tailChunks.push({ text: chunk, bytes: chunkBytes });
194
- tailBytes += chunkBytes;
195
- while (tailBytes > maxTailBytes && tailChunks.length > 1) {
196
- const removed = tailChunks.shift();
197
- if (removed) {
198
- tailBytes -= removed.bytes;
199
- }
272
+ sessionFile: sessionFile ?? undefined,
273
+ };
274
+
275
+ for (let i = 0; i < cells.length; i++) {
276
+ const cell = cells[i];
277
+ const isFirstCell = i === 0;
278
+ const cellResult = cellResults[i];
279
+ cellResult.status = "running";
280
+ cellResult.output = "";
281
+ cellResult.statusEvents = undefined;
282
+ cellResult.exitCode = undefined;
283
+ cellResult.durationMs = undefined;
284
+ pushUpdate();
285
+
286
+ const executorOptions: PythonExecutorOptions = {
287
+ ...baseExecutorOptions,
288
+ reset: isFirstCell ? reset : false,
289
+ };
290
+
291
+ const startTime = Date.now();
292
+ const result = await executePython(cell.code, executorOptions);
293
+ const durationMs = Date.now() - startTime;
294
+
295
+ const cellStatusEvents: PythonStatusEvent[] = [];
296
+ for (const output of result.displayOutputs) {
297
+ if (output.type === "json") {
298
+ jsonOutputs.push(output.data);
200
299
  }
201
- if (onUpdate) {
202
- const tailText = tailChunks.map((entry) => entry.text).join("");
203
- const truncation = truncateTail(tailText);
204
- onUpdate({
205
- content: [{ type: "text", text: truncation.content || "" }],
206
- details: truncation.truncated ? { truncation } : undefined,
207
- });
300
+ if (output.type === "image") {
301
+ images.push({ type: "image", data: output.data, mimeType: output.mimeType });
208
302
  }
209
- },
210
- };
303
+ if (output.type === "status") {
304
+ statusEvents.push(output.event);
305
+ cellStatusEvents.push(output.event);
306
+ }
307
+ }
211
308
 
212
- const result = await executePython(code, executorOptions);
309
+ if (result.fullOutputPath) {
310
+ lastFullOutputPath = result.fullOutputPath;
311
+ }
213
312
 
214
- const statusEvents: PythonStatusEvent[] = [];
215
- for (const output of result.displayOutputs) {
216
- if (output.type === "json") {
217
- jsonOutputs.push(output.data);
313
+ const cellOutput = result.output.trim();
314
+ cellResult.output = cellOutput;
315
+ cellResult.exitCode = result.exitCode;
316
+ cellResult.durationMs = durationMs;
317
+ cellResult.statusEvents = cellStatusEvents.length > 0 ? cellStatusEvents : undefined;
318
+
319
+ let combinedCellOutput = "";
320
+ if (cells.length > 1) {
321
+ const cellHeader = `[${i + 1}/${cells.length}]`;
322
+ const cellTitle = cell.title ? ` ${cell.title}` : "";
323
+ if (cellOutput) {
324
+ combinedCellOutput = `${cellHeader}${cellTitle}\n${cellOutput}`;
325
+ } else {
326
+ combinedCellOutput = `${cellHeader}${cellTitle} (ok)`;
327
+ }
328
+ cellOutputs.push(combinedCellOutput);
329
+ } else if (cellOutput) {
330
+ combinedCellOutput = cellOutput;
331
+ cellOutputs.push(combinedCellOutput);
218
332
  }
219
- if (output.type === "image") {
220
- images.push({ type: "image", data: output.data, mimeType: output.mimeType });
333
+
334
+ if (combinedCellOutput) {
335
+ const prefix = cellOutputs.length > 1 ? "\n\n" : "";
336
+ appendTail(`${prefix}${combinedCellOutput}`);
221
337
  }
222
- if (output.type === "status") {
223
- statusEvents.push(output.event);
338
+
339
+ if (result.cancelled) {
340
+ cellResult.status = "error";
341
+ pushUpdate();
342
+ const errorMsg = result.output || "Command aborted";
343
+ throw new Error(cells.length > 1 ? `Cell ${i + 1} aborted: ${errorMsg}` : errorMsg);
344
+ }
345
+
346
+ if (result.exitCode !== 0 && result.exitCode !== undefined) {
347
+ cellResult.status = "error";
348
+ pushUpdate();
349
+ const combinedOutput = cellOutputs.join("\n\n");
350
+ throw new Error(
351
+ cells.length > 1
352
+ ? `${combinedOutput}\n\nCell ${i + 1} failed (exit code ${result.exitCode}). Earlier cells succeeded—their state persists. Fix only cell ${i + 1}.`
353
+ : `${combinedOutput}\n\nCommand exited with code ${result.exitCode}`,
354
+ );
224
355
  }
225
- }
226
356
 
227
- if (result.cancelled) {
228
- throw new Error(result.output || "Command aborted");
357
+ cellResult.status = "complete";
358
+ pushUpdate();
229
359
  }
230
360
 
231
- const truncation = truncateTail(result.output);
361
+ const combinedOutput = cellOutputs.join("\n\n");
362
+ const truncation = truncateTail(combinedOutput);
232
363
  let outputText =
233
364
  truncation.content || (jsonOutputs.length > 0 || images.length > 0 ? "(no text output)" : "(no output)");
234
- let details: PythonToolDetails | undefined;
365
+
366
+ const details: PythonToolDetails = {
367
+ cells: cellResults,
368
+ fullOutputPath: lastFullOutputPath,
369
+ jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
370
+ images: images.length > 0 ? images : undefined,
371
+ statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
372
+ };
235
373
 
236
374
  if (truncation.truncated) {
237
- details = {
238
- truncation,
239
- fullOutputPath: result.fullOutputPath,
240
- jsonOutputs: jsonOutputs,
241
- images,
242
- statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
243
- };
375
+ details.truncation = truncation;
244
376
  outputText += formatTailTruncationNotice(truncation, {
245
- fullOutputPath: result.fullOutputPath,
246
- originalContent: result.output,
377
+ fullOutputPath: lastFullOutputPath,
378
+ originalContent: combinedOutput,
247
379
  });
248
380
  }
249
381
 
250
- if (!details && (jsonOutputs.length > 0 || images.length > 0 || statusEvents.length > 0)) {
251
- details = {
252
- jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
253
- images: images.length > 0 ? images : undefined,
254
- statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
255
- };
256
- }
257
-
258
- if (result.exitCode !== 0 && result.exitCode !== undefined) {
259
- outputText += `\n\nCommand exited with code ${result.exitCode}`;
260
- throw new Error(outputText);
261
- }
262
-
263
382
  return { content: [{ type: "text", text: outputText }], details };
264
383
  } finally {
265
384
  signal?.removeEventListener("abort", onAbort);
@@ -268,9 +387,9 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
268
387
  }
269
388
 
270
389
  interface PythonRenderArgs {
271
- code?: string;
390
+ cells?: Array<{ code: string; title?: string }>;
272
391
  timeout?: number;
273
- workdir?: string;
392
+ cwd?: string;
274
393
  }
275
394
 
276
395
  interface PythonRenderContext {
@@ -600,13 +719,175 @@ function renderStatusEvents(events: PythonStatusEvent[], theme: Theme, expanded:
600
719
  return lines;
601
720
  }
602
721
 
722
+ function applyCellBackground(line: string, width: number, bgFn?: (text: string) => string): string {
723
+ if (!bgFn) return line;
724
+ if (width <= 0) return bgFn(line);
725
+ const paddingNeeded = Math.max(0, width - visibleWidth(line));
726
+ const padded = line + " ".repeat(paddingNeeded);
727
+ return bgFn(padded);
728
+ }
729
+
730
+ function highlightPythonCode(code?: string): string[] {
731
+ return highlightCode(code ?? "", "python");
732
+ }
733
+
734
+ function formatCellStatus(cell: PythonCellResult, ui: ToolUIKit, spinnerFrame?: number): string | undefined {
735
+ switch (cell.status) {
736
+ case "pending":
737
+ return `${ui.statusIcon("pending")} ${ui.theme.fg("muted", "pending")}`;
738
+ case "running":
739
+ return `${ui.statusIcon("running", spinnerFrame)} ${ui.theme.fg("muted", "running")}`;
740
+ case "complete":
741
+ return ui.statusIcon("success");
742
+ case "error":
743
+ return ui.statusIcon("error");
744
+ }
745
+ }
746
+
747
+ function formatCellHeader(
748
+ cell: PythonCellResult,
749
+ index: number,
750
+ total: number,
751
+ ui: ToolUIKit,
752
+ spinnerFrame?: number,
753
+ workdirLabel?: string,
754
+ ): string {
755
+ const indexLabel = ui.theme.fg("accent", `[${index + 1}/${total}]`);
756
+ const title = cell.title ? ` ${cell.title}` : "";
757
+ const metaParts: string[] = [];
758
+ if (workdirLabel) {
759
+ metaParts.push(ui.theme.fg("dim", workdirLabel));
760
+ }
761
+ if (cell.durationMs !== undefined) {
762
+ metaParts.push(ui.theme.fg("dim", `(${ui.formatDuration(cell.durationMs)})`));
763
+ }
764
+ const statusLabel = formatCellStatus(cell, ui, spinnerFrame);
765
+ if (statusLabel) {
766
+ metaParts.push(statusLabel);
767
+ }
768
+ const meta = metaParts.length > 0 ? ` ${metaParts.join(ui.theme.fg("dim", ui.theme.sep.dot))}` : "";
769
+ return `${indexLabel}${title}${meta}`;
770
+ }
771
+
772
+ function formatCellOutputLines(
773
+ cell: PythonCellResult,
774
+ expanded: boolean,
775
+ previewLines: number,
776
+ theme: Theme,
777
+ ): { lines: string[]; hiddenCount: number } {
778
+ const rawLines = cell.output ? cell.output.split("\n") : [];
779
+ const displayLines = expanded ? rawLines : rawLines.slice(-previewLines);
780
+ const hiddenCount = rawLines.length - displayLines.length;
781
+ const outputLines = displayLines.map((line) => theme.fg("toolOutput", line));
782
+
783
+ if (outputLines.length === 0) {
784
+ return { lines: [], hiddenCount: 0 };
785
+ }
786
+
787
+ return { lines: outputLines, hiddenCount };
788
+ }
789
+
790
+ function renderCellBlock(
791
+ cell: PythonCellResult,
792
+ index: number,
793
+ total: number,
794
+ ui: ToolUIKit,
795
+ options: {
796
+ expanded: boolean;
797
+ previewLines: number;
798
+ spinnerFrame?: number;
799
+ showOutput: boolean;
800
+ workdirLabel?: string;
801
+ width: number;
802
+ bgFn?: (text: string) => string;
803
+ },
804
+ ): string[] {
805
+ const { expanded, previewLines, spinnerFrame, showOutput, workdirLabel, width, bgFn } = options;
806
+ const h = ui.theme.boxSharp.horizontal;
807
+ const v = ui.theme.boxSharp.vertical;
808
+ const cap = h.repeat(3);
809
+ const border = (text: string) => ui.theme.fg("dim", text);
810
+ const lineWidth = Math.max(0, width);
811
+
812
+ const buildBarLine = (leftChar: string, label?: string): string => {
813
+ const left = border(`${leftChar}${cap}`);
814
+ if (lineWidth <= 0) return left;
815
+ const rawLabel = label ? ` ${label} ` : " ";
816
+ const maxLabelWidth = Math.max(0, lineWidth - visibleWidth(left));
817
+ const trimmedLabel = truncateToWidth(rawLabel, maxLabelWidth, ui.theme.format.ellipsis);
818
+ const fillCount = Math.max(0, lineWidth - visibleWidth(left + trimmedLabel));
819
+ return `${left}${trimmedLabel}${border(h.repeat(fillCount))}`;
820
+ };
821
+
822
+ const lines: string[] = [];
823
+ lines.push(
824
+ applyCellBackground(
825
+ buildBarLine(ui.theme.boxSharp.topLeft, formatCellHeader(cell, index, total, ui, spinnerFrame, workdirLabel)),
826
+ lineWidth,
827
+ bgFn,
828
+ ),
829
+ );
830
+
831
+ const codePrefix = border(`${v} `);
832
+ const codeWidth = Math.max(0, lineWidth - visibleWidth(codePrefix));
833
+ const codeLines = highlightPythonCode(cell.code);
834
+ for (const line of codeLines) {
835
+ const text = truncateToWidth(line, codeWidth, ui.theme.format.ellipsis);
836
+ lines.push(applyCellBackground(`${codePrefix}${text}`, lineWidth, bgFn));
837
+ }
838
+
839
+ const statusLines = renderStatusEvents(cell.statusEvents ?? [], ui.theme, expanded);
840
+ const outputContent = formatCellOutputLines(cell, expanded, previewLines, ui.theme);
841
+ const hasOutput = outputContent.lines.length > 0;
842
+ const hasStatus = statusLines.length > 0;
843
+ const showOutputSection = showOutput && (hasOutput || hasStatus);
844
+
845
+ if (showOutputSection) {
846
+ lines.push(
847
+ applyCellBackground(
848
+ buildBarLine(ui.theme.boxSharp.teeRight, ui.theme.fg("toolTitle", "Output")),
849
+ lineWidth,
850
+ bgFn,
851
+ ),
852
+ );
853
+
854
+ for (const line of outputContent.lines) {
855
+ const text = truncateToWidth(line, codeWidth, ui.theme.format.ellipsis);
856
+ lines.push(applyCellBackground(`${codePrefix}${text}`, lineWidth, bgFn));
857
+ }
858
+ if (!expanded && outputContent.hiddenCount > 0) {
859
+ const hint = ui.theme.fg(
860
+ "dim",
861
+ `${ui.theme.format.ellipsis} ${outputContent.hiddenCount} more lines (ctrl+o to expand)`,
862
+ );
863
+ lines.push(
864
+ applyCellBackground(
865
+ `${codePrefix}${truncateToWidth(hint, codeWidth, ui.theme.format.ellipsis)}`,
866
+ lineWidth,
867
+ bgFn,
868
+ ),
869
+ );
870
+ }
871
+
872
+ for (const line of statusLines) {
873
+ const text = truncateToWidth(line, codeWidth, ui.theme.format.ellipsis);
874
+ lines.push(applyCellBackground(`${codePrefix}${text}`, lineWidth, bgFn));
875
+ }
876
+ }
877
+
878
+ const bottomLeft = border(`${ui.theme.boxSharp.bottomLeft}${cap}`);
879
+ const bottomFillCount = Math.max(0, lineWidth - visibleWidth(bottomLeft));
880
+ const bottomLine = `${bottomLeft}${border(h.repeat(bottomFillCount))}`;
881
+ lines.push(applyCellBackground(bottomLine, lineWidth, bgFn));
882
+ return lines;
883
+ }
884
+
603
885
  export const pythonToolRenderer = {
604
886
  renderCall(args: PythonRenderArgs, uiTheme: Theme): Component {
605
887
  const ui = new ToolUIKit(uiTheme);
606
- const code = args.code || uiTheme.format.ellipsis;
607
- const prompt = uiTheme.fg("accent", ">>>");
888
+ const cells = args.cells ?? [];
608
889
  const cwd = process.cwd();
609
- let displayWorkdir = args.workdir;
890
+ let displayWorkdir = args.cwd;
610
891
 
611
892
  if (displayWorkdir) {
612
893
  const resolvedCwd = resolve(cwd);
@@ -622,11 +903,44 @@ export const pythonToolRenderer = {
622
903
  }
623
904
  }
624
905
 
625
- const cmdText = displayWorkdir
626
- ? `${prompt} ${uiTheme.fg("dim", `cd ${displayWorkdir} &&`)} ${code}`
627
- : `${prompt} ${code}`;
628
- const text = ui.title(cmdText);
629
- return new Text(text, 0, 0);
906
+ const workdirLabel = displayWorkdir ? `cd ${displayWorkdir}` : undefined;
907
+ if (cells.length === 0) {
908
+ const prompt = uiTheme.fg("accent", ">>>");
909
+ const prefix = workdirLabel ? `${uiTheme.fg("dim", `${workdirLabel} && `)}` : "";
910
+ const text = ui.title(`${prompt} ${prefix}${uiTheme.format.ellipsis}`);
911
+ return new Text(text, 0, 0);
912
+ }
913
+
914
+ return {
915
+ render: (width: number): string[] => {
916
+ const lines: string[] = [];
917
+ for (let i = 0; i < cells.length; i++) {
918
+ const cell = cells[i];
919
+ const cellResult: PythonCellResult = {
920
+ index: i,
921
+ title: cell.title,
922
+ code: cell.code,
923
+ output: "",
924
+ status: "pending",
925
+ };
926
+ lines.push(
927
+ ...renderCellBlock(cellResult, i, cells.length, ui, {
928
+ expanded: true,
929
+ previewLines: PYTHON_DEFAULT_PREVIEW_LINES,
930
+ showOutput: false,
931
+ workdirLabel: i === 0 ? workdirLabel : undefined,
932
+ width,
933
+ bgFn: (text: string) => uiTheme.bg("toolPendingBg", text),
934
+ }),
935
+ );
936
+ if (i < cells.length - 1) {
937
+ lines.push("");
938
+ }
939
+ }
940
+ return lines;
941
+ },
942
+ invalidate: () => {},
943
+ };
630
944
  },
631
945
 
632
946
  renderResult(
@@ -642,7 +956,6 @@ export const pythonToolRenderer = {
642
956
  const previewLines = renderContext?.previewLines ?? PYTHON_DEFAULT_PREVIEW_LINES;
643
957
  const output = renderContext?.output ?? (result.content?.find((c) => c.type === "text")?.text ?? "").trim();
644
958
  const fullOutput = details?.fullOutput;
645
- const displayOutput = expanded ? (fullOutput ?? output) : output;
646
959
  const showingFullOutput = expanded && fullOutput !== undefined;
647
960
 
648
961
  const jsonOutputs = details?.jsonOutputs ?? [];
@@ -652,12 +965,6 @@ export const pythonToolRenderer = {
652
965
  return [header, ...treeLines];
653
966
  });
654
967
 
655
- // Render status events
656
- const statusEvents = details?.statusEvents ?? [];
657
- const statusLines = renderStatusEvents(statusEvents, uiTheme, expanded);
658
-
659
- const combinedOutput = [displayOutput, ...jsonLines].filter(Boolean).join("\n");
660
-
661
968
  const truncation = details?.truncation;
662
969
  const fullOutputPath = details?.fullOutputPath;
663
970
  const timeoutSeconds = renderContext?.timeout;
@@ -685,12 +992,63 @@ export const pythonToolRenderer = {
685
992
  }
686
993
  }
687
994
 
995
+ const cellResults = details?.cells;
996
+ if (cellResults && cellResults.length > 0) {
997
+ return {
998
+ render: (width: number): string[] => {
999
+ const lines: string[] = [];
1000
+ for (let i = 0; i < cellResults.length; i++) {
1001
+ const cell = cellResults[i];
1002
+ const showOutput = cell.status !== "pending";
1003
+ const bgColor =
1004
+ cell.status === "error"
1005
+ ? "toolErrorBg"
1006
+ : cell.status === "complete"
1007
+ ? "toolSuccessBg"
1008
+ : "toolPendingBg";
1009
+ lines.push(
1010
+ ...renderCellBlock(cell, i, cellResults.length, ui, {
1011
+ expanded,
1012
+ previewLines,
1013
+ spinnerFrame: options.spinnerFrame,
1014
+ showOutput,
1015
+ width,
1016
+ bgFn: (text: string) => uiTheme.bg(bgColor, text),
1017
+ }),
1018
+ );
1019
+ if (i < cellResults.length - 1) {
1020
+ lines.push("");
1021
+ }
1022
+ }
1023
+ if (jsonLines.length > 0) {
1024
+ if (lines.length > 0) {
1025
+ lines.push("");
1026
+ }
1027
+ lines.push(...jsonLines);
1028
+ }
1029
+ if (timeoutLine) {
1030
+ lines.push(timeoutLine);
1031
+ }
1032
+ if (warningLine) {
1033
+ lines.push(warningLine);
1034
+ }
1035
+ return lines;
1036
+ },
1037
+ invalidate: () => {},
1038
+ };
1039
+ }
1040
+
1041
+ const displayOutput = expanded ? (fullOutput ?? output) : output;
1042
+ const combinedOutput = [displayOutput, ...jsonLines].filter(Boolean).join("\n");
1043
+
1044
+ const statusEvents = details?.statusEvents ?? [];
1045
+ const statusLines = renderStatusEvents(statusEvents, uiTheme, expanded);
1046
+
688
1047
  if (!combinedOutput && statusLines.length === 0) {
689
1048
  const lines = [timeoutLine, warningLine].filter(Boolean) as string[];
690
1049
  return new Text(lines.join("\n"), 0, 0);
691
1050
  }
692
1051
 
693
- // If only status events (no text output), show them directly
694
1052
  if (!combinedOutput && statusLines.length > 0) {
695
1053
  const lines = [...statusLines, timeoutLine, warningLine].filter(Boolean) as string[];
696
1054
  return new Text(lines.join("\n"), 0, 0);
@@ -733,7 +1091,6 @@ export const pythonToolRenderer = {
733
1091
  outputLines.push(truncateToWidth(skippedLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
734
1092
  }
735
1093
  outputLines.push(...cachedLines);
736
- // Add status events below the output
737
1094
  for (const statusLine of statusLines) {
738
1095
  outputLines.push(truncateToWidth(statusLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
739
1096
  }
@@ -752,4 +1109,6 @@ export const pythonToolRenderer = {
752
1109
  },
753
1110
  };
754
1111
  },
1112
+ mergeCallAndResult: true,
1113
+ inline: true,
755
1114
  };
@@ -223,11 +223,11 @@ async function listCandidateFiles(
223
223
  const { stdout, stderr, exitCode } = await runFd(fdPath, args, signal);
224
224
  const output = stdout.trim();
225
225
 
226
- if (exitCode !== 0 && !output) {
227
- return { files: [], truncated: false, error: stderr.trim() || `fd exited with code ${exitCode ?? -1}` };
228
- }
229
-
230
226
  if (!output) {
227
+ // fd exit codes: 0 = found, 1 = no matches, other = error
228
+ if (exitCode !== 0 && exitCode !== 1) {
229
+ return { files: [], truncated: false, error: stderr.trim() || `fd failed (exit ${exitCode})` };
230
+ }
231
231
  return { files: [], truncated: false };
232
232
  }
233
233