@oh-my-pi/pi-coding-agent 6.9.0 → 6.9.69

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 (133) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/package.json +6 -5
  3. package/src/cli/stats-cli.ts +191 -0
  4. package/src/core/agent-session.ts +103 -1
  5. package/src/core/extensions/index.ts +2 -0
  6. package/src/core/extensions/runner.ts +31 -0
  7. package/src/core/extensions/types.ts +24 -0
  8. package/src/core/messages.ts +48 -0
  9. package/src/core/session-manager.ts +10 -1
  10. package/src/core/tools/bash.ts +5 -7
  11. package/src/core/tools/index.ts +1 -1
  12. package/src/core/tools/patch/applicator.ts +115 -17
  13. package/src/core/tools/patch/index.ts +1 -1
  14. package/src/core/tools/patch/normalize.ts +185 -10
  15. package/src/core/tools/python.ts +444 -86
  16. package/src/core/tools/task/executor.ts +2 -6
  17. package/src/core/tools/task/index.ts +30 -12
  18. package/src/core/tools/task/render.ts +163 -30
  19. package/src/core/tools/task/template.ts +37 -0
  20. package/src/core/tools/task/types.ts +6 -2
  21. package/src/core/tools/task/worker.ts +1 -1
  22. package/src/index.ts +2 -0
  23. package/src/main.ts +12 -0
  24. package/src/modes/interactive/components/python-execution.ts +180 -0
  25. package/src/modes/interactive/components/welcome.ts +1 -0
  26. package/src/modes/interactive/controllers/command-controller.ts +46 -0
  27. package/src/modes/interactive/controllers/input-controller.ts +28 -1
  28. package/src/modes/interactive/interactive-mode.ts +10 -0
  29. package/src/modes/interactive/theme/dark.json +2 -9
  30. package/src/modes/interactive/theme/defaults/alabaster.json +2 -8
  31. package/src/modes/interactive/theme/defaults/amethyst.json +2 -9
  32. package/src/modes/interactive/theme/defaults/anthracite.json +2 -9
  33. package/src/modes/interactive/theme/defaults/basalt.json +89 -88
  34. package/src/modes/interactive/theme/defaults/birch.json +2 -8
  35. package/src/modes/interactive/theme/defaults/dark-abyss.json +2 -8
  36. package/src/modes/interactive/theme/defaults/dark-arctic.json +2 -9
  37. package/src/modes/interactive/theme/defaults/dark-aurora.json +3 -2
  38. package/src/modes/interactive/theme/defaults/dark-catppuccin.json +2 -1
  39. package/src/modes/interactive/theme/defaults/dark-cavern.json +2 -8
  40. package/src/modes/interactive/theme/defaults/dark-copper.json +3 -2
  41. package/src/modes/interactive/theme/defaults/dark-cosmos.json +2 -8
  42. package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +2 -9
  43. package/src/modes/interactive/theme/defaults/dark-dracula.json +2 -9
  44. package/src/modes/interactive/theme/defaults/dark-eclipse.json +2 -8
  45. package/src/modes/interactive/theme/defaults/dark-ember.json +3 -2
  46. package/src/modes/interactive/theme/defaults/dark-equinox.json +2 -8
  47. package/src/modes/interactive/theme/defaults/dark-forest.json +2 -9
  48. package/src/modes/interactive/theme/defaults/dark-github.json +2 -9
  49. package/src/modes/interactive/theme/defaults/dark-gruvbox.json +2 -9
  50. package/src/modes/interactive/theme/defaults/dark-lavender.json +3 -2
  51. package/src/modes/interactive/theme/defaults/dark-lunar.json +2 -8
  52. package/src/modes/interactive/theme/defaults/dark-midnight.json +3 -2
  53. package/src/modes/interactive/theme/defaults/dark-monochrome.json +2 -9
  54. package/src/modes/interactive/theme/defaults/dark-monokai.json +2 -9
  55. package/src/modes/interactive/theme/defaults/dark-nebula.json +2 -8
  56. package/src/modes/interactive/theme/defaults/dark-nord.json +2 -9
  57. package/src/modes/interactive/theme/defaults/dark-ocean.json +2 -9
  58. package/src/modes/interactive/theme/defaults/dark-one.json +2 -9
  59. package/src/modes/interactive/theme/defaults/dark-rainforest.json +2 -8
  60. package/src/modes/interactive/theme/defaults/dark-reef.json +2 -8
  61. package/src/modes/interactive/theme/defaults/dark-retro.json +2 -9
  62. package/src/modes/interactive/theme/defaults/dark-rose-pine.json +2 -1
  63. package/src/modes/interactive/theme/defaults/dark-sakura.json +3 -2
  64. package/src/modes/interactive/theme/defaults/dark-slate.json +3 -2
  65. package/src/modes/interactive/theme/defaults/dark-solarized.json +2 -1
  66. package/src/modes/interactive/theme/defaults/dark-solstice.json +2 -8
  67. package/src/modes/interactive/theme/defaults/dark-starfall.json +2 -8
  68. package/src/modes/interactive/theme/defaults/dark-sunset.json +2 -9
  69. package/src/modes/interactive/theme/defaults/dark-swamp.json +2 -8
  70. package/src/modes/interactive/theme/defaults/dark-synthwave.json +2 -1
  71. package/src/modes/interactive/theme/defaults/dark-taiga.json +2 -8
  72. package/src/modes/interactive/theme/defaults/dark-terminal.json +3 -2
  73. package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +2 -9
  74. package/src/modes/interactive/theme/defaults/dark-tundra.json +2 -8
  75. package/src/modes/interactive/theme/defaults/dark-twilight.json +2 -8
  76. package/src/modes/interactive/theme/defaults/dark-volcanic.json +2 -8
  77. package/src/modes/interactive/theme/defaults/graphite.json +2 -9
  78. package/src/modes/interactive/theme/defaults/light-arctic.json +2 -1
  79. package/src/modes/interactive/theme/defaults/light-aurora-day.json +2 -8
  80. package/src/modes/interactive/theme/defaults/light-canyon.json +2 -8
  81. package/src/modes/interactive/theme/defaults/light-catppuccin.json +2 -1
  82. package/src/modes/interactive/theme/defaults/light-cirrus.json +2 -8
  83. package/src/modes/interactive/theme/defaults/light-coral.json +3 -2
  84. package/src/modes/interactive/theme/defaults/light-cyberpunk.json +2 -9
  85. package/src/modes/interactive/theme/defaults/light-dawn.json +2 -8
  86. package/src/modes/interactive/theme/defaults/light-dunes.json +2 -8
  87. package/src/modes/interactive/theme/defaults/light-eucalyptus.json +3 -2
  88. package/src/modes/interactive/theme/defaults/light-forest.json +2 -9
  89. package/src/modes/interactive/theme/defaults/light-frost.json +3 -2
  90. package/src/modes/interactive/theme/defaults/light-github.json +2 -1
  91. package/src/modes/interactive/theme/defaults/light-glacier.json +2 -8
  92. package/src/modes/interactive/theme/defaults/light-gruvbox.json +2 -9
  93. package/src/modes/interactive/theme/defaults/light-haze.json +2 -8
  94. package/src/modes/interactive/theme/defaults/light-honeycomb.json +3 -2
  95. package/src/modes/interactive/theme/defaults/light-lagoon.json +2 -8
  96. package/src/modes/interactive/theme/defaults/light-lavender.json +3 -2
  97. package/src/modes/interactive/theme/defaults/light-meadow.json +2 -8
  98. package/src/modes/interactive/theme/defaults/light-mint.json +3 -2
  99. package/src/modes/interactive/theme/defaults/light-monochrome.json +2 -1
  100. package/src/modes/interactive/theme/defaults/light-ocean.json +2 -9
  101. package/src/modes/interactive/theme/defaults/light-one.json +2 -8
  102. package/src/modes/interactive/theme/defaults/light-opal.json +2 -8
  103. package/src/modes/interactive/theme/defaults/light-orchard.json +2 -8
  104. package/src/modes/interactive/theme/defaults/light-paper.json +3 -2
  105. package/src/modes/interactive/theme/defaults/light-prism.json +2 -8
  106. package/src/modes/interactive/theme/defaults/light-retro.json +2 -9
  107. package/src/modes/interactive/theme/defaults/light-sand.json +3 -2
  108. package/src/modes/interactive/theme/defaults/light-savanna.json +2 -8
  109. package/src/modes/interactive/theme/defaults/light-solarized.json +2 -1
  110. package/src/modes/interactive/theme/defaults/light-soleil.json +2 -8
  111. package/src/modes/interactive/theme/defaults/light-sunset.json +2 -9
  112. package/src/modes/interactive/theme/defaults/light-synthwave.json +2 -9
  113. package/src/modes/interactive/theme/defaults/light-tokyo-night.json +2 -9
  114. package/src/modes/interactive/theme/defaults/light-wetland.json +2 -8
  115. package/src/modes/interactive/theme/defaults/light-zenith.json +2 -8
  116. package/src/modes/interactive/theme/defaults/limestone.json +2 -8
  117. package/src/modes/interactive/theme/defaults/mahogany.json +2 -9
  118. package/src/modes/interactive/theme/defaults/marble.json +2 -8
  119. package/src/modes/interactive/theme/defaults/obsidian.json +89 -88
  120. package/src/modes/interactive/theme/defaults/onyx.json +89 -88
  121. package/src/modes/interactive/theme/defaults/pearl.json +2 -8
  122. package/src/modes/interactive/theme/defaults/porcelain.json +89 -88
  123. package/src/modes/interactive/theme/defaults/quartz.json +2 -8
  124. package/src/modes/interactive/theme/defaults/sandstone.json +2 -8
  125. package/src/modes/interactive/theme/defaults/titanium.json +88 -87
  126. package/src/modes/interactive/theme/light.json +2 -8
  127. package/src/modes/interactive/theme/theme-schema.json +5 -0
  128. package/src/modes/interactive/theme/theme.ts +7 -0
  129. package/src/modes/interactive/types.ts +5 -0
  130. package/src/modes/interactive/utils/ui-helpers.ts +20 -0
  131. package/src/prompts/system/system-prompt.md +8 -0
  132. package/src/prompts/tools/python.md +40 -2
  133. 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,184 @@ 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
+ };
273
+
274
+ for (let i = 0; i < cells.length; i++) {
275
+ const cell = cells[i];
276
+ const isFirstCell = i === 0;
277
+ const cellResult = cellResults[i];
278
+ cellResult.status = "running";
279
+ cellResult.output = "";
280
+ cellResult.statusEvents = undefined;
281
+ cellResult.exitCode = undefined;
282
+ cellResult.durationMs = undefined;
283
+ pushUpdate();
284
+
285
+ const executorOptions: PythonExecutorOptions = {
286
+ ...baseExecutorOptions,
287
+ reset: isFirstCell ? reset : false,
288
+ };
289
+
290
+ const startTime = Date.now();
291
+ const result = await executePython(cell.code, executorOptions);
292
+ const durationMs = Date.now() - startTime;
293
+
294
+ const cellStatusEvents: PythonStatusEvent[] = [];
295
+ for (const output of result.displayOutputs) {
296
+ if (output.type === "json") {
297
+ jsonOutputs.push(output.data);
200
298
  }
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
- });
299
+ if (output.type === "image") {
300
+ images.push({ type: "image", data: output.data, mimeType: output.mimeType });
208
301
  }
209
- },
210
- };
302
+ if (output.type === "status") {
303
+ statusEvents.push(output.event);
304
+ cellStatusEvents.push(output.event);
305
+ }
306
+ }
211
307
 
212
- const result = await executePython(code, executorOptions);
308
+ if (result.fullOutputPath) {
309
+ lastFullOutputPath = result.fullOutputPath;
310
+ }
213
311
 
214
- const statusEvents: PythonStatusEvent[] = [];
215
- for (const output of result.displayOutputs) {
216
- if (output.type === "json") {
217
- jsonOutputs.push(output.data);
312
+ const cellOutput = result.output.trim();
313
+ cellResult.output = cellOutput;
314
+ cellResult.exitCode = result.exitCode;
315
+ cellResult.durationMs = durationMs;
316
+ cellResult.statusEvents = cellStatusEvents.length > 0 ? cellStatusEvents : undefined;
317
+
318
+ let combinedCellOutput = "";
319
+ if (cells.length > 1) {
320
+ const cellHeader = `[${i + 1}/${cells.length}]`;
321
+ const cellTitle = cell.title ? ` ${cell.title}` : "";
322
+ if (cellOutput) {
323
+ combinedCellOutput = `${cellHeader}${cellTitle}\n${cellOutput}`;
324
+ } else {
325
+ combinedCellOutput = `${cellHeader}${cellTitle} (ok)`;
326
+ }
327
+ cellOutputs.push(combinedCellOutput);
328
+ } else if (cellOutput) {
329
+ combinedCellOutput = cellOutput;
330
+ cellOutputs.push(combinedCellOutput);
218
331
  }
219
- if (output.type === "image") {
220
- images.push({ type: "image", data: output.data, mimeType: output.mimeType });
332
+
333
+ if (combinedCellOutput) {
334
+ const prefix = cellOutputs.length > 1 ? "\n\n" : "";
335
+ appendTail(`${prefix}${combinedCellOutput}`);
221
336
  }
222
- if (output.type === "status") {
223
- statusEvents.push(output.event);
337
+
338
+ if (result.cancelled) {
339
+ cellResult.status = "error";
340
+ pushUpdate();
341
+ const errorMsg = result.output || "Command aborted";
342
+ throw new Error(cells.length > 1 ? `Cell ${i + 1} aborted: ${errorMsg}` : errorMsg);
343
+ }
344
+
345
+ if (result.exitCode !== 0 && result.exitCode !== undefined) {
346
+ cellResult.status = "error";
347
+ pushUpdate();
348
+ const combinedOutput = cellOutputs.join("\n\n");
349
+ throw new Error(
350
+ cells.length > 1
351
+ ? `${combinedOutput}\n\nCell ${i + 1} failed (exit code ${result.exitCode}). Earlier cells succeeded—their state persists. Fix only cell ${i + 1}.`
352
+ : `${combinedOutput}\n\nCommand exited with code ${result.exitCode}`,
353
+ );
224
354
  }
225
- }
226
355
 
227
- if (result.cancelled) {
228
- throw new Error(result.output || "Command aborted");
356
+ cellResult.status = "complete";
357
+ pushUpdate();
229
358
  }
230
359
 
231
- const truncation = truncateTail(result.output);
360
+ const combinedOutput = cellOutputs.join("\n\n");
361
+ const truncation = truncateTail(combinedOutput);
232
362
  let outputText =
233
363
  truncation.content || (jsonOutputs.length > 0 || images.length > 0 ? "(no text output)" : "(no output)");
234
- let details: PythonToolDetails | undefined;
364
+
365
+ const details: PythonToolDetails = {
366
+ cells: cellResults,
367
+ fullOutputPath: lastFullOutputPath,
368
+ jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
369
+ images: images.length > 0 ? images : undefined,
370
+ statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
371
+ };
235
372
 
236
373
  if (truncation.truncated) {
237
- details = {
238
- truncation,
239
- fullOutputPath: result.fullOutputPath,
240
- jsonOutputs: jsonOutputs,
241
- images,
242
- statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
243
- };
374
+ details.truncation = truncation;
244
375
  outputText += formatTailTruncationNotice(truncation, {
245
- fullOutputPath: result.fullOutputPath,
246
- originalContent: result.output,
376
+ fullOutputPath: lastFullOutputPath,
377
+ originalContent: combinedOutput,
247
378
  });
248
379
  }
249
380
 
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
381
  return { content: [{ type: "text", text: outputText }], details };
264
382
  } finally {
265
383
  signal?.removeEventListener("abort", onAbort);
@@ -268,9 +386,9 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
268
386
  }
269
387
 
270
388
  interface PythonRenderArgs {
271
- code?: string;
389
+ cells?: Array<{ code: string; title?: string }>;
272
390
  timeout?: number;
273
- workdir?: string;
391
+ cwd?: string;
274
392
  }
275
393
 
276
394
  interface PythonRenderContext {
@@ -600,13 +718,175 @@ function renderStatusEvents(events: PythonStatusEvent[], theme: Theme, expanded:
600
718
  return lines;
601
719
  }
602
720
 
721
+ function applyCellBackground(line: string, width: number, bgFn?: (text: string) => string): string {
722
+ if (!bgFn) return line;
723
+ if (width <= 0) return bgFn(line);
724
+ const paddingNeeded = Math.max(0, width - visibleWidth(line));
725
+ const padded = line + " ".repeat(paddingNeeded);
726
+ return bgFn(padded);
727
+ }
728
+
729
+ function highlightPythonCode(code?: string): string[] {
730
+ return highlightCode(code ?? "", "python");
731
+ }
732
+
733
+ function formatCellStatus(cell: PythonCellResult, ui: ToolUIKit, spinnerFrame?: number): string | undefined {
734
+ switch (cell.status) {
735
+ case "pending":
736
+ return `${ui.statusIcon("pending")} ${ui.theme.fg("muted", "pending")}`;
737
+ case "running":
738
+ return `${ui.statusIcon("running", spinnerFrame)} ${ui.theme.fg("muted", "running")}`;
739
+ case "complete":
740
+ return ui.statusIcon("success");
741
+ case "error":
742
+ return ui.statusIcon("error");
743
+ }
744
+ }
745
+
746
+ function formatCellHeader(
747
+ cell: PythonCellResult,
748
+ index: number,
749
+ total: number,
750
+ ui: ToolUIKit,
751
+ spinnerFrame?: number,
752
+ workdirLabel?: string,
753
+ ): string {
754
+ const indexLabel = ui.theme.fg("accent", `[${index + 1}/${total}]`);
755
+ const title = cell.title ? ` ${cell.title}` : "";
756
+ const metaParts: string[] = [];
757
+ if (workdirLabel) {
758
+ metaParts.push(ui.theme.fg("dim", workdirLabel));
759
+ }
760
+ if (cell.durationMs !== undefined) {
761
+ metaParts.push(ui.theme.fg("dim", `(${ui.formatDuration(cell.durationMs)})`));
762
+ }
763
+ const statusLabel = formatCellStatus(cell, ui, spinnerFrame);
764
+ if (statusLabel) {
765
+ metaParts.push(statusLabel);
766
+ }
767
+ const meta = metaParts.length > 0 ? ` ${metaParts.join(ui.theme.fg("dim", ui.theme.sep.dot))}` : "";
768
+ return `${indexLabel}${title}${meta}`;
769
+ }
770
+
771
+ function formatCellOutputLines(
772
+ cell: PythonCellResult,
773
+ expanded: boolean,
774
+ previewLines: number,
775
+ theme: Theme,
776
+ ): { lines: string[]; hiddenCount: number } {
777
+ const rawLines = cell.output ? cell.output.split("\n") : [];
778
+ const displayLines = expanded ? rawLines : rawLines.slice(-previewLines);
779
+ const hiddenCount = rawLines.length - displayLines.length;
780
+ const outputLines = displayLines.map((line) => theme.fg("toolOutput", line));
781
+
782
+ if (outputLines.length === 0) {
783
+ return { lines: [], hiddenCount: 0 };
784
+ }
785
+
786
+ return { lines: outputLines, hiddenCount };
787
+ }
788
+
789
+ function renderCellBlock(
790
+ cell: PythonCellResult,
791
+ index: number,
792
+ total: number,
793
+ ui: ToolUIKit,
794
+ options: {
795
+ expanded: boolean;
796
+ previewLines: number;
797
+ spinnerFrame?: number;
798
+ showOutput: boolean;
799
+ workdirLabel?: string;
800
+ width: number;
801
+ bgFn?: (text: string) => string;
802
+ },
803
+ ): string[] {
804
+ const { expanded, previewLines, spinnerFrame, showOutput, workdirLabel, width, bgFn } = options;
805
+ const h = ui.theme.boxSharp.horizontal;
806
+ const v = ui.theme.boxSharp.vertical;
807
+ const cap = h.repeat(3);
808
+ const border = (text: string) => ui.theme.fg("dim", text);
809
+ const lineWidth = Math.max(0, width);
810
+
811
+ const buildBarLine = (leftChar: string, label?: string): string => {
812
+ const left = border(`${leftChar}${cap}`);
813
+ if (lineWidth <= 0) return left;
814
+ const rawLabel = label ? ` ${label} ` : " ";
815
+ const maxLabelWidth = Math.max(0, lineWidth - visibleWidth(left));
816
+ const trimmedLabel = truncateToWidth(rawLabel, maxLabelWidth, ui.theme.format.ellipsis);
817
+ const fillCount = Math.max(0, lineWidth - visibleWidth(left + trimmedLabel));
818
+ return `${left}${trimmedLabel}${border(h.repeat(fillCount))}`;
819
+ };
820
+
821
+ const lines: string[] = [];
822
+ lines.push(
823
+ applyCellBackground(
824
+ buildBarLine(ui.theme.boxSharp.topLeft, formatCellHeader(cell, index, total, ui, spinnerFrame, workdirLabel)),
825
+ lineWidth,
826
+ bgFn,
827
+ ),
828
+ );
829
+
830
+ const codePrefix = border(`${v} `);
831
+ const codeWidth = Math.max(0, lineWidth - visibleWidth(codePrefix));
832
+ const codeLines = highlightPythonCode(cell.code);
833
+ for (const line of codeLines) {
834
+ const text = truncateToWidth(line, codeWidth, ui.theme.format.ellipsis);
835
+ lines.push(applyCellBackground(`${codePrefix}${text}`, lineWidth, bgFn));
836
+ }
837
+
838
+ const statusLines = renderStatusEvents(cell.statusEvents ?? [], ui.theme, expanded);
839
+ const outputContent = formatCellOutputLines(cell, expanded, previewLines, ui.theme);
840
+ const hasOutput = outputContent.lines.length > 0;
841
+ const hasStatus = statusLines.length > 0;
842
+ const showOutputSection = showOutput && (hasOutput || hasStatus);
843
+
844
+ if (showOutputSection) {
845
+ lines.push(
846
+ applyCellBackground(
847
+ buildBarLine(ui.theme.boxSharp.teeRight, ui.theme.fg("toolTitle", "Output")),
848
+ lineWidth,
849
+ bgFn,
850
+ ),
851
+ );
852
+
853
+ for (const line of outputContent.lines) {
854
+ const text = truncateToWidth(line, codeWidth, ui.theme.format.ellipsis);
855
+ lines.push(applyCellBackground(`${codePrefix}${text}`, lineWidth, bgFn));
856
+ }
857
+ if (!expanded && outputContent.hiddenCount > 0) {
858
+ const hint = ui.theme.fg(
859
+ "dim",
860
+ `${ui.theme.format.ellipsis} ${outputContent.hiddenCount} more lines (ctrl+o to expand)`,
861
+ );
862
+ lines.push(
863
+ applyCellBackground(
864
+ `${codePrefix}${truncateToWidth(hint, codeWidth, ui.theme.format.ellipsis)}`,
865
+ lineWidth,
866
+ bgFn,
867
+ ),
868
+ );
869
+ }
870
+
871
+ for (const line of statusLines) {
872
+ const text = truncateToWidth(line, codeWidth, ui.theme.format.ellipsis);
873
+ lines.push(applyCellBackground(`${codePrefix}${text}`, lineWidth, bgFn));
874
+ }
875
+ }
876
+
877
+ const bottomLeft = border(`${ui.theme.boxSharp.bottomLeft}${cap}`);
878
+ const bottomFillCount = Math.max(0, lineWidth - visibleWidth(bottomLeft));
879
+ const bottomLine = `${bottomLeft}${border(h.repeat(bottomFillCount))}`;
880
+ lines.push(applyCellBackground(bottomLine, lineWidth, bgFn));
881
+ return lines;
882
+ }
883
+
603
884
  export const pythonToolRenderer = {
604
885
  renderCall(args: PythonRenderArgs, uiTheme: Theme): Component {
605
886
  const ui = new ToolUIKit(uiTheme);
606
- const code = args.code || uiTheme.format.ellipsis;
607
- const prompt = uiTheme.fg("accent", ">>>");
887
+ const cells = args.cells ?? [];
608
888
  const cwd = process.cwd();
609
- let displayWorkdir = args.workdir;
889
+ let displayWorkdir = args.cwd;
610
890
 
611
891
  if (displayWorkdir) {
612
892
  const resolvedCwd = resolve(cwd);
@@ -622,11 +902,44 @@ export const pythonToolRenderer = {
622
902
  }
623
903
  }
624
904
 
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);
905
+ const workdirLabel = displayWorkdir ? `cd ${displayWorkdir}` : undefined;
906
+ if (cells.length === 0) {
907
+ const prompt = uiTheme.fg("accent", ">>>");
908
+ const prefix = workdirLabel ? `${uiTheme.fg("dim", `${workdirLabel} && `)}` : "";
909
+ const text = ui.title(`${prompt} ${prefix}${uiTheme.format.ellipsis}`);
910
+ return new Text(text, 0, 0);
911
+ }
912
+
913
+ return {
914
+ render: (width: number): string[] => {
915
+ const lines: string[] = [];
916
+ for (let i = 0; i < cells.length; i++) {
917
+ const cell = cells[i];
918
+ const cellResult: PythonCellResult = {
919
+ index: i,
920
+ title: cell.title,
921
+ code: cell.code,
922
+ output: "",
923
+ status: "pending",
924
+ };
925
+ lines.push(
926
+ ...renderCellBlock(cellResult, i, cells.length, ui, {
927
+ expanded: true,
928
+ previewLines: PYTHON_DEFAULT_PREVIEW_LINES,
929
+ showOutput: false,
930
+ workdirLabel: i === 0 ? workdirLabel : undefined,
931
+ width,
932
+ bgFn: (text: string) => uiTheme.bg("toolPendingBg", text),
933
+ }),
934
+ );
935
+ if (i < cells.length - 1) {
936
+ lines.push("");
937
+ }
938
+ }
939
+ return lines;
940
+ },
941
+ invalidate: () => {},
942
+ };
630
943
  },
631
944
 
632
945
  renderResult(
@@ -642,7 +955,6 @@ export const pythonToolRenderer = {
642
955
  const previewLines = renderContext?.previewLines ?? PYTHON_DEFAULT_PREVIEW_LINES;
643
956
  const output = renderContext?.output ?? (result.content?.find((c) => c.type === "text")?.text ?? "").trim();
644
957
  const fullOutput = details?.fullOutput;
645
- const displayOutput = expanded ? (fullOutput ?? output) : output;
646
958
  const showingFullOutput = expanded && fullOutput !== undefined;
647
959
 
648
960
  const jsonOutputs = details?.jsonOutputs ?? [];
@@ -652,12 +964,6 @@ export const pythonToolRenderer = {
652
964
  return [header, ...treeLines];
653
965
  });
654
966
 
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
967
  const truncation = details?.truncation;
662
968
  const fullOutputPath = details?.fullOutputPath;
663
969
  const timeoutSeconds = renderContext?.timeout;
@@ -685,12 +991,63 @@ export const pythonToolRenderer = {
685
991
  }
686
992
  }
687
993
 
994
+ const cellResults = details?.cells;
995
+ if (cellResults && cellResults.length > 0) {
996
+ return {
997
+ render: (width: number): string[] => {
998
+ const lines: string[] = [];
999
+ for (let i = 0; i < cellResults.length; i++) {
1000
+ const cell = cellResults[i];
1001
+ const showOutput = cell.status !== "pending";
1002
+ const bgColor =
1003
+ cell.status === "error"
1004
+ ? "toolErrorBg"
1005
+ : cell.status === "complete"
1006
+ ? "toolSuccessBg"
1007
+ : "toolPendingBg";
1008
+ lines.push(
1009
+ ...renderCellBlock(cell, i, cellResults.length, ui, {
1010
+ expanded,
1011
+ previewLines,
1012
+ spinnerFrame: options.spinnerFrame,
1013
+ showOutput,
1014
+ width,
1015
+ bgFn: (text: string) => uiTheme.bg(bgColor, text),
1016
+ }),
1017
+ );
1018
+ if (i < cellResults.length - 1) {
1019
+ lines.push("");
1020
+ }
1021
+ }
1022
+ if (jsonLines.length > 0) {
1023
+ if (lines.length > 0) {
1024
+ lines.push("");
1025
+ }
1026
+ lines.push(...jsonLines);
1027
+ }
1028
+ if (timeoutLine) {
1029
+ lines.push(timeoutLine);
1030
+ }
1031
+ if (warningLine) {
1032
+ lines.push(warningLine);
1033
+ }
1034
+ return lines;
1035
+ },
1036
+ invalidate: () => {},
1037
+ };
1038
+ }
1039
+
1040
+ const displayOutput = expanded ? (fullOutput ?? output) : output;
1041
+ const combinedOutput = [displayOutput, ...jsonLines].filter(Boolean).join("\n");
1042
+
1043
+ const statusEvents = details?.statusEvents ?? [];
1044
+ const statusLines = renderStatusEvents(statusEvents, uiTheme, expanded);
1045
+
688
1046
  if (!combinedOutput && statusLines.length === 0) {
689
1047
  const lines = [timeoutLine, warningLine].filter(Boolean) as string[];
690
1048
  return new Text(lines.join("\n"), 0, 0);
691
1049
  }
692
1050
 
693
- // If only status events (no text output), show them directly
694
1051
  if (!combinedOutput && statusLines.length > 0) {
695
1052
  const lines = [...statusLines, timeoutLine, warningLine].filter(Boolean) as string[];
696
1053
  return new Text(lines.join("\n"), 0, 0);
@@ -733,7 +1090,6 @@ export const pythonToolRenderer = {
733
1090
  outputLines.push(truncateToWidth(skippedLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
734
1091
  }
735
1092
  outputLines.push(...cachedLines);
736
- // Add status events below the output
737
1093
  for (const statusLine of statusLines) {
738
1094
  outputLines.push(truncateToWidth(statusLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
739
1095
  }
@@ -752,4 +1108,6 @@ export const pythonToolRenderer = {
752
1108
  },
753
1109
  };
754
1110
  },
1111
+ mergeCallAndResult: true,
1112
+ inline: true,
755
1113
  };
@@ -16,7 +16,7 @@ import { checkPythonKernelAvailability } from "../../python-kernel";
16
16
  import type { ToolSession } from "..";
17
17
  import { LspTool } from "../lsp/index";
18
18
  import type { LspParams } from "../lsp/types";
19
- import { PythonTool } from "../python";
19
+ import { PythonTool, type PythonToolParams } from "../python";
20
20
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
21
21
  import {
22
22
  type AgentDefinition,
@@ -789,11 +789,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
789
789
  const timeoutSignal = createTimeoutSignal(timeoutMs);
790
790
  const combinedSignal = combineSignals([signal, callController.signal, timeoutSignal]);
791
791
  try {
792
- const result = await pythonTool.execute(
793
- request.callId,
794
- request.params as { code: string; timeout?: number; workdir?: string; reset?: boolean },
795
- combinedSignal,
796
- );
792
+ const result = await pythonTool.execute(request.callId, request.params as PythonToolParams, combinedSignal);
797
793
  postMessageSafe({
798
794
  type: "python_tool_result",
799
795
  callId: request.callId,