@oh-my-pi/pi-coding-agent 5.5.0 → 5.6.7

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 (96) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/docs/python-repl.md +77 -0
  3. package/examples/hooks/snake.ts +7 -7
  4. package/package.json +5 -5
  5. package/src/bun-imports.d.ts +6 -0
  6. package/src/cli/args.ts +7 -0
  7. package/src/cli/setup-cli.ts +231 -0
  8. package/src/cli.ts +2 -0
  9. package/src/core/agent-session.ts +118 -15
  10. package/src/core/bash-executor.ts +3 -84
  11. package/src/core/compaction/compaction.ts +10 -5
  12. package/src/core/extensions/index.ts +2 -0
  13. package/src/core/extensions/loader.ts +13 -1
  14. package/src/core/extensions/runner.ts +50 -2
  15. package/src/core/extensions/types.ts +67 -2
  16. package/src/core/keybindings.ts +51 -1
  17. package/src/core/prompt-templates.ts +15 -0
  18. package/src/core/python-executor-display.test.ts +42 -0
  19. package/src/core/python-executor-lifecycle.test.ts +99 -0
  20. package/src/core/python-executor-mapping.test.ts +41 -0
  21. package/src/core/python-executor-per-call.test.ts +49 -0
  22. package/src/core/python-executor-session.test.ts +103 -0
  23. package/src/core/python-executor-streaming.test.ts +77 -0
  24. package/src/core/python-executor-timeout.test.ts +35 -0
  25. package/src/core/python-executor.lifecycle.test.ts +139 -0
  26. package/src/core/python-executor.result.test.ts +49 -0
  27. package/src/core/python-executor.test.ts +180 -0
  28. package/src/core/python-executor.ts +313 -0
  29. package/src/core/python-gateway-coordinator.ts +832 -0
  30. package/src/core/python-kernel-display.test.ts +54 -0
  31. package/src/core/python-kernel-env.test.ts +138 -0
  32. package/src/core/python-kernel-session.test.ts +87 -0
  33. package/src/core/python-kernel-ws.test.ts +104 -0
  34. package/src/core/python-kernel.lifecycle.test.ts +249 -0
  35. package/src/core/python-kernel.test.ts +549 -0
  36. package/src/core/python-kernel.ts +1178 -0
  37. package/src/core/python-prelude.py +889 -0
  38. package/src/core/python-prelude.test.ts +140 -0
  39. package/src/core/python-prelude.ts +3 -0
  40. package/src/core/sdk.ts +24 -6
  41. package/src/core/session-manager.ts +174 -82
  42. package/src/core/settings-manager-python.test.ts +23 -0
  43. package/src/core/settings-manager.ts +202 -0
  44. package/src/core/streaming-output.test.ts +26 -0
  45. package/src/core/streaming-output.ts +100 -0
  46. package/src/core/system-prompt.python.test.ts +17 -0
  47. package/src/core/system-prompt.ts +3 -1
  48. package/src/core/timings.ts +1 -1
  49. package/src/core/tools/bash.ts +13 -2
  50. package/src/core/tools/edit-diff.ts +9 -1
  51. package/src/core/tools/index.test.ts +50 -23
  52. package/src/core/tools/index.ts +83 -1
  53. package/src/core/tools/python-execution.test.ts +68 -0
  54. package/src/core/tools/python-fallback.test.ts +72 -0
  55. package/src/core/tools/python-renderer.test.ts +36 -0
  56. package/src/core/tools/python-tool-mode.test.ts +43 -0
  57. package/src/core/tools/python.test.ts +121 -0
  58. package/src/core/tools/python.ts +760 -0
  59. package/src/core/tools/renderers.ts +2 -0
  60. package/src/core/tools/schema-validation.test.ts +1 -0
  61. package/src/core/tools/task/executor.ts +146 -3
  62. package/src/core/tools/task/worker-protocol.ts +32 -2
  63. package/src/core/tools/task/worker.ts +182 -15
  64. package/src/index.ts +6 -0
  65. package/src/main.ts +136 -40
  66. package/src/modes/interactive/components/custom-editor.ts +16 -31
  67. package/src/modes/interactive/components/extensions/extension-dashboard.ts +5 -16
  68. package/src/modes/interactive/components/extensions/extension-list.ts +5 -13
  69. package/src/modes/interactive/components/history-search.ts +5 -8
  70. package/src/modes/interactive/components/hook-editor.ts +3 -4
  71. package/src/modes/interactive/components/hook-input.ts +3 -3
  72. package/src/modes/interactive/components/hook-selector.ts +5 -15
  73. package/src/modes/interactive/components/index.ts +1 -0
  74. package/src/modes/interactive/components/keybinding-hints.ts +66 -0
  75. package/src/modes/interactive/components/model-selector.ts +53 -66
  76. package/src/modes/interactive/components/oauth-selector.ts +5 -5
  77. package/src/modes/interactive/components/session-selector.ts +29 -23
  78. package/src/modes/interactive/components/settings-defs.ts +404 -196
  79. package/src/modes/interactive/components/settings-selector.ts +14 -10
  80. package/src/modes/interactive/components/status-line-segment-editor.ts +7 -7
  81. package/src/modes/interactive/components/tool-execution.ts +8 -0
  82. package/src/modes/interactive/components/tree-selector.ts +29 -23
  83. package/src/modes/interactive/components/user-message-selector.ts +6 -17
  84. package/src/modes/interactive/controllers/command-controller.ts +86 -37
  85. package/src/modes/interactive/controllers/event-controller.ts +8 -0
  86. package/src/modes/interactive/controllers/extension-ui-controller.ts +51 -0
  87. package/src/modes/interactive/controllers/input-controller.ts +42 -6
  88. package/src/modes/interactive/interactive-mode.ts +56 -30
  89. package/src/modes/interactive/theme/theme-schema.json +2 -2
  90. package/src/modes/interactive/types.ts +6 -1
  91. package/src/modes/interactive/utils/ui-helpers.ts +2 -1
  92. package/src/modes/print-mode.ts +23 -0
  93. package/src/modes/rpc/rpc-mode.ts +21 -0
  94. package/src/prompts/agents/reviewer.md +1 -1
  95. package/src/prompts/system/system-prompt.md +32 -1
  96. package/src/prompts/tools/python.md +91 -0
@@ -0,0 +1,760 @@
1
+ import { relative, resolve, sep } from "node:path";
2
+ import type { AgentTool, AgentToolContext } from "@oh-my-pi/pi-agent-core";
3
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
4
+ import type { Component } from "@oh-my-pi/pi-tui";
5
+ import { Text, truncateToWidth } from "@oh-my-pi/pi-tui";
6
+ import { Type } from "@sinclair/typebox";
7
+ import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate";
8
+ import type { Theme } from "../../modes/interactive/theme/theme";
9
+ import pythonDescription from "../../prompts/tools/python.md" with { type: "text" };
10
+ import type { RenderResultOptions } from "../custom-tools/types";
11
+ import { renderPromptTemplate } from "../prompt-templates";
12
+ import { executePython, getPreludeDocs, type PythonExecutorOptions } from "../python-executor";
13
+ import type { PreludeHelper, PythonStatusEvent } from "../python-kernel";
14
+ import type { ToolSession } from "./index";
15
+ import { resolveToCwd } from "./path-utils";
16
+ import { createToolUIKit, getTreeBranch, getTreeContinuePrefix, shortenPath, truncate } from "./render-utils";
17
+ import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate";
18
+
19
+ export const PYTHON_DEFAULT_PREVIEW_LINES = 10;
20
+
21
+ type PreludeCategory = {
22
+ name: string;
23
+ functions: PreludeHelper[];
24
+ };
25
+
26
+ function groupPreludeHelpers(helpers: PreludeHelper[]): PreludeCategory[] {
27
+ const categories: PreludeCategory[] = [];
28
+ const byName = new Map<string, PreludeHelper[]>();
29
+ for (const helper of helpers) {
30
+ let bucket = byName.get(helper.category);
31
+ if (!bucket) {
32
+ bucket = [];
33
+ byName.set(helper.category, bucket);
34
+ categories.push({ name: helper.category, functions: bucket });
35
+ }
36
+ bucket.push(helper);
37
+ }
38
+ return categories;
39
+ }
40
+
41
+ export const pythonSchema = Type.Object({
42
+ code: Type.String({ description: "Python code to execute" }),
43
+ timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
44
+ workdir: Type.Optional(
45
+ Type.String({ description: "Working directory for the command (default: current directory)" }),
46
+ ),
47
+ reset: Type.Optional(Type.Boolean({ description: "Restart the kernel before executing this code" })),
48
+ });
49
+
50
+ export type PythonToolParams = { code: string; timeout?: number; workdir?: string; reset?: boolean };
51
+
52
+ export type PythonToolResult = {
53
+ content: Array<{ type: "text"; text: string }>;
54
+ details: PythonToolDetails | undefined;
55
+ };
56
+
57
+ export type PythonProxyExecutor = (params: PythonToolParams, signal?: AbortSignal) => Promise<PythonToolResult>;
58
+
59
+ export interface PythonToolDetails {
60
+ truncation?: TruncationResult;
61
+ fullOutputPath?: string;
62
+ fullOutput?: string;
63
+ jsonOutputs?: unknown[];
64
+ images?: ImageContent[];
65
+ /** Structured status events from prelude helpers */
66
+ statusEvents?: PythonStatusEvent[];
67
+ }
68
+
69
+ function formatJsonScalar(value: unknown): string {
70
+ if (value === null) return "null";
71
+ if (value === undefined) return "undefined";
72
+ if (typeof value === "string") return JSON.stringify(value);
73
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
74
+ if (typeof value === "function") return "[function]";
75
+ return "[object]";
76
+ }
77
+
78
+ function renderJsonTree(value: unknown, theme: Theme, expanded: boolean, maxDepth = expanded ? 6 : 2): string[] {
79
+ const maxItems = expanded ? 20 : 5;
80
+
81
+ const renderNode = (node: unknown, prefix: string, depth: number, isLast: boolean, label?: string): string[] => {
82
+ const branch = getTreeBranch(isLast, theme);
83
+ const displayLabel = label ? `${label}: ` : "";
84
+
85
+ if (depth >= maxDepth || node === null || typeof node !== "object") {
86
+ return [`${prefix}${branch} ${displayLabel}${formatJsonScalar(node)}`];
87
+ }
88
+
89
+ const isArray = Array.isArray(node);
90
+ const entries = isArray
91
+ ? node.map((val, index) => [String(index), val] as const)
92
+ : Object.entries(node as object);
93
+ const header = `${prefix}${branch} ${displayLabel}${isArray ? `Array(${entries.length})` : `Object(${entries.length})`}`;
94
+ const lines = [header];
95
+
96
+ const childPrefix = prefix + getTreeContinuePrefix(isLast, theme);
97
+ const visible = entries.slice(0, maxItems);
98
+ for (let i = 0; i < visible.length; i++) {
99
+ const [key, val] = visible[i];
100
+ const childLast = i === visible.length - 1 && (expanded || entries.length <= maxItems);
101
+ lines.push(...renderNode(val, childPrefix, depth + 1, childLast, isArray ? `[${key}]` : key));
102
+ }
103
+ if (!expanded && entries.length > maxItems) {
104
+ const moreBranch = theme.tree.last;
105
+ lines.push(`${childPrefix}${moreBranch} ${entries.length - maxItems} more item(s)`);
106
+ }
107
+ return lines;
108
+ };
109
+
110
+ return renderNode(value, "", 0, true);
111
+ }
112
+
113
+ export function getPythonToolDescription(): string {
114
+ const helpers = getPreludeDocs();
115
+ const categories = groupPreludeHelpers(helpers);
116
+ return renderPromptTemplate(pythonDescription, { categories });
117
+ }
118
+
119
+ interface CreatePythonToolOptions {
120
+ proxyExecutor?: PythonProxyExecutor;
121
+ }
122
+
123
+ export function createPythonTool(
124
+ session: ToolSession | null,
125
+ options?: CreatePythonToolOptions,
126
+ ): AgentTool<typeof pythonSchema> {
127
+ const { proxyExecutor } = options ?? {};
128
+
129
+ return {
130
+ name: "python",
131
+ label: "Python",
132
+ description: getPythonToolDescription(),
133
+ parameters: pythonSchema,
134
+ execute: async (
135
+ _toolCallId: string,
136
+ params: PythonToolParams,
137
+ signal?: AbortSignal,
138
+ onUpdate?,
139
+ _ctx?: AgentToolContext,
140
+ ) => {
141
+ if (proxyExecutor) {
142
+ return proxyExecutor(params, signal);
143
+ }
144
+
145
+ if (!session) {
146
+ throw new Error("Python tool requires a session when not using proxy executor");
147
+ }
148
+
149
+ const { code, timeout, workdir, reset } = params;
150
+ const controller = new AbortController();
151
+ const onAbort = () => controller.abort();
152
+ signal?.addEventListener("abort", onAbort, { once: true });
153
+
154
+ try {
155
+ if (signal?.aborted) {
156
+ throw new Error("Aborted");
157
+ }
158
+
159
+ const commandCwd = workdir ? resolveToCwd(workdir, session.cwd) : session.cwd;
160
+ let cwdStat: Awaited<ReturnType<Bun.BunFile["stat"]>>;
161
+ try {
162
+ cwdStat = await Bun.file(commandCwd).stat();
163
+ } catch {
164
+ throw new Error(`Working directory does not exist: ${commandCwd}`);
165
+ }
166
+ if (!cwdStat.isDirectory()) {
167
+ throw new Error(`Working directory is not a directory: ${commandCwd}`);
168
+ }
169
+
170
+ const maxTailBytes = DEFAULT_MAX_BYTES * 2;
171
+ const tailChunks: Array<{ text: string; bytes: number }> = [];
172
+ let tailBytes = 0;
173
+ const jsonOutputs: unknown[] = [];
174
+ const images: ImageContent[] = [];
175
+
176
+ const sessionFile = session.getSessionFile?.() ?? undefined;
177
+ const sessionId = sessionFile ? `session:${sessionFile}:workdir:${commandCwd}` : `cwd:${commandCwd}`;
178
+ const executorOptions: PythonExecutorOptions = {
179
+ cwd: commandCwd,
180
+ timeout: timeout ? timeout * 1000 : undefined,
181
+ signal: controller.signal,
182
+ sessionId,
183
+ kernelMode: session.settings?.getPythonKernelMode?.() ?? "session",
184
+ useSharedGateway: session.settings?.getPythonSharedGateway?.() ?? true,
185
+ reset,
186
+ onChunk: (chunk) => {
187
+ const chunkBytes = Buffer.byteLength(chunk, "utf-8");
188
+ tailChunks.push({ text: chunk, bytes: chunkBytes });
189
+ tailBytes += chunkBytes;
190
+ while (tailBytes > maxTailBytes && tailChunks.length > 1) {
191
+ const removed = tailChunks.shift();
192
+ if (removed) {
193
+ tailBytes -= removed.bytes;
194
+ }
195
+ }
196
+ if (onUpdate) {
197
+ const tailText = tailChunks.map((entry) => entry.text).join("");
198
+ const truncation = truncateTail(tailText);
199
+ onUpdate({
200
+ content: [{ type: "text", text: truncation.content || "" }],
201
+ details: truncation.truncated ? { truncation } : undefined,
202
+ });
203
+ }
204
+ },
205
+ };
206
+
207
+ const result = await executePython(code, executorOptions);
208
+
209
+ const statusEvents: PythonStatusEvent[] = [];
210
+ for (const output of result.displayOutputs) {
211
+ if (output.type === "json") {
212
+ jsonOutputs.push(output.data);
213
+ }
214
+ if (output.type === "image") {
215
+ images.push({ type: "image", data: output.data, mimeType: output.mimeType });
216
+ }
217
+ if (output.type === "status") {
218
+ statusEvents.push(output.event);
219
+ }
220
+ }
221
+
222
+ if (result.cancelled) {
223
+ throw new Error(result.output || "Command aborted");
224
+ }
225
+
226
+ const truncation = truncateTail(result.output);
227
+ let outputText =
228
+ truncation.content || (jsonOutputs.length > 0 || images.length > 0 ? "(no text output)" : "(no output)");
229
+ let details: PythonToolDetails | undefined;
230
+
231
+ if (truncation.truncated) {
232
+ const fullOutputSuffix = result.fullOutputPath ? ` Full output: ${result.fullOutputPath}` : "";
233
+ details = {
234
+ truncation,
235
+ fullOutputPath: result.fullOutputPath,
236
+ jsonOutputs: jsonOutputs,
237
+ images,
238
+ statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
239
+ };
240
+
241
+ const startLine = truncation.totalLines - truncation.outputLines + 1;
242
+ const endLine = truncation.totalLines;
243
+
244
+ if (truncation.lastLinePartial) {
245
+ const lastLineSize = formatSize(Buffer.byteLength(result.output.split("\n").pop() || "", "utf-8"));
246
+ outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize})${fullOutputSuffix}]`;
247
+ } else if (truncation.truncatedBy === "lines") {
248
+ outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}${fullOutputSuffix}]`;
249
+ } else {
250
+ outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit)${fullOutputSuffix}]`;
251
+ }
252
+ }
253
+
254
+ if (!details && (jsonOutputs.length > 0 || images.length > 0 || statusEvents.length > 0)) {
255
+ details = {
256
+ jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
257
+ images: images.length > 0 ? images : undefined,
258
+ statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
259
+ };
260
+ }
261
+
262
+ if (result.exitCode !== 0 && result.exitCode !== undefined) {
263
+ outputText += `\n\nCommand exited with code ${result.exitCode}`;
264
+ throw new Error(outputText);
265
+ }
266
+
267
+ return { content: [{ type: "text", text: outputText }], details };
268
+ } finally {
269
+ signal?.removeEventListener("abort", onAbort);
270
+ }
271
+ },
272
+ };
273
+ }
274
+
275
+ interface PythonRenderArgs {
276
+ code?: string;
277
+ timeout?: number;
278
+ workdir?: string;
279
+ }
280
+
281
+ interface PythonRenderContext {
282
+ output?: string;
283
+ expanded?: boolean;
284
+ previewLines?: number;
285
+ timeout?: number;
286
+ }
287
+
288
+ /** Format a status event as a single line for display. */
289
+ function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string {
290
+ const { op, ...data } = event;
291
+
292
+ // Map operations to available theme icons
293
+ type AvailableIcon = "icon.file" | "icon.folder" | "icon.git" | "icon.package";
294
+ const opIcons: Record<string, AvailableIcon> = {
295
+ // File I/O
296
+ read: "icon.file",
297
+ write: "icon.file",
298
+ append: "icon.file",
299
+ cat: "icon.file",
300
+ touch: "icon.file",
301
+ lines: "icon.file",
302
+ // Navigation/Directory
303
+ ls: "icon.folder",
304
+ cd: "icon.folder",
305
+ pwd: "icon.folder",
306
+ mkdir: "icon.folder",
307
+ tree: "icon.folder",
308
+ stat: "icon.folder",
309
+ // Search (use file icon since no search icon)
310
+ find: "icon.file",
311
+ grep: "icon.file",
312
+ rgrep: "icon.file",
313
+ glob: "icon.file",
314
+ // Edit operations (use file icon)
315
+ replace: "icon.file",
316
+ sed: "icon.file",
317
+ rsed: "icon.file",
318
+ delete_lines: "icon.file",
319
+ delete_matching: "icon.file",
320
+ insert_at: "icon.file",
321
+ // Git
322
+ git_status: "icon.git",
323
+ git_diff: "icon.git",
324
+ git_log: "icon.git",
325
+ git_show: "icon.git",
326
+ git_branch: "icon.git",
327
+ git_file_at: "icon.git",
328
+ git_has_changes: "icon.git",
329
+ // Shell/batch (use package icon)
330
+ run: "icon.package",
331
+ sh: "icon.package",
332
+ env: "icon.package",
333
+ batch: "icon.package",
334
+ };
335
+
336
+ const iconKey = opIcons[op] ?? "icon.file";
337
+ const icon = theme.styledSymbol(iconKey, "muted");
338
+
339
+ // Format the status message based on operation type
340
+ const parts: string[] = [];
341
+
342
+ // Error handling
343
+ if (data.error) {
344
+ return `${icon} ${theme.fg("warning", op)}: ${theme.fg("dim", String(data.error))}`;
345
+ }
346
+
347
+ // Build description based on common fields
348
+ switch (op) {
349
+ case "read":
350
+ parts.push(`${data.chars} chars`);
351
+ if (data.path) parts.push(`from ${shortenPath(String(data.path))}`);
352
+ break;
353
+ case "write":
354
+ case "append":
355
+ parts.push(`${data.chars} chars`);
356
+ if (data.path) parts.push(`to ${shortenPath(String(data.path))}`);
357
+ break;
358
+ case "cat":
359
+ parts.push(`${data.files} file${(data.files as number) !== 1 ? "s" : ""}`);
360
+ parts.push(`${data.chars} chars`);
361
+ break;
362
+ case "find":
363
+ case "glob":
364
+ parts.push(`${data.count} match${(data.count as number) !== 1 ? "es" : ""}`);
365
+ if (data.pattern) parts.push(`for "${truncate(String(data.pattern), 20, theme.format.ellipsis)}"`);
366
+ break;
367
+ case "grep":
368
+ parts.push(`${data.count} match${(data.count as number) !== 1 ? "es" : ""}`);
369
+ if (data.path) parts.push(`in ${shortenPath(String(data.path))}`);
370
+ break;
371
+ case "rgrep":
372
+ parts.push(`${data.count} match${(data.count as number) !== 1 ? "es" : ""}`);
373
+ if (data.pattern) parts.push(`for "${truncate(String(data.pattern), 20, theme.format.ellipsis)}"`);
374
+ break;
375
+ case "ls":
376
+ parts.push(`${data.count} entr${(data.count as number) !== 1 ? "ies" : "y"}`);
377
+ break;
378
+ case "env":
379
+ if (data.action === "set") {
380
+ parts.push(`set ${data.key}=${truncate(String(data.value ?? ""), 30, theme.format.ellipsis)}`);
381
+ } else if (data.action === "get") {
382
+ parts.push(`${data.key}=${truncate(String(data.value ?? ""), 30, theme.format.ellipsis)}`);
383
+ } else {
384
+ parts.push(`${data.count} variable${(data.count as number) !== 1 ? "s" : ""}`);
385
+ }
386
+ break;
387
+ case "stat":
388
+ if (data.is_dir) {
389
+ parts.push("directory");
390
+ } else {
391
+ parts.push(`${data.size} bytes`);
392
+ }
393
+ if (data.path) parts.push(shortenPath(String(data.path)));
394
+ break;
395
+ case "replace":
396
+ case "sed":
397
+ parts.push(`${data.count} replacement${(data.count as number) !== 1 ? "s" : ""}`);
398
+ if (data.path) parts.push(`in ${shortenPath(String(data.path))}`);
399
+ break;
400
+ case "rsed":
401
+ parts.push(`${data.count} replacement${(data.count as number) !== 1 ? "s" : ""}`);
402
+ if (data.files) parts.push(`in ${data.files} file${(data.files as number) !== 1 ? "s" : ""}`);
403
+ break;
404
+ case "git_status":
405
+ if (data.clean) {
406
+ parts.push("clean");
407
+ } else {
408
+ const statusParts: string[] = [];
409
+ if (data.staged) statusParts.push(`${data.staged} staged`);
410
+ if (data.modified) statusParts.push(`${data.modified} modified`);
411
+ if (data.untracked) statusParts.push(`${data.untracked} untracked`);
412
+ parts.push(statusParts.join(", ") || "unknown");
413
+ }
414
+ if (data.branch) parts.push(`on ${data.branch}`);
415
+ break;
416
+ case "git_log":
417
+ parts.push(`${data.commits} commit${(data.commits as number) !== 1 ? "s" : ""}`);
418
+ break;
419
+ case "git_diff":
420
+ parts.push(`${data.lines} line${(data.lines as number) !== 1 ? "s" : ""}`);
421
+ if (data.staged) parts.push("(staged)");
422
+ break;
423
+ case "diff":
424
+ if (data.identical) {
425
+ parts.push("files identical");
426
+ } else {
427
+ parts.push("files differ");
428
+ }
429
+ break;
430
+ case "batch":
431
+ parts.push(`${data.files} file${(data.files as number) !== 1 ? "s" : ""} processed`);
432
+ break;
433
+ case "wc":
434
+ parts.push(`${data.lines}L ${data.words}W ${data.chars}C`);
435
+ break;
436
+ case "lines":
437
+ parts.push(`${data.count} line${(data.count as number) !== 1 ? "s" : ""}`);
438
+ if (data.start && data.end) parts.push(`(${data.start}-${data.end})`);
439
+ break;
440
+ case "delete_lines":
441
+ case "delete_matching":
442
+ parts.push(`${data.count} line${(data.count as number) !== 1 ? "s" : ""} deleted`);
443
+ break;
444
+ case "insert_at":
445
+ parts.push(`${data.lines_inserted} line${(data.lines_inserted as number) !== 1 ? "s" : ""} inserted`);
446
+ break;
447
+ case "cd":
448
+ case "pwd":
449
+ case "mkdir":
450
+ case "touch":
451
+ if (data.path) parts.push(shortenPath(String(data.path)));
452
+ break;
453
+ case "rm":
454
+ case "mv":
455
+ case "cp":
456
+ if (data.src) parts.push(`${shortenPath(String(data.src))} → ${shortenPath(String(data.dst))}`);
457
+ else if (data.path) parts.push(shortenPath(String(data.path)));
458
+ break;
459
+ default:
460
+ // Generic formatting for other operations
461
+ if (data.count !== undefined) {
462
+ parts.push(String(data.count));
463
+ }
464
+ if (data.path) {
465
+ parts.push(shortenPath(String(data.path)));
466
+ }
467
+ }
468
+
469
+ const desc = parts.length > 0 ? parts.join(" · ") : "";
470
+ return `${icon} ${theme.fg("muted", op)}${desc ? ` ${theme.fg("dim", desc)}` : ""}`;
471
+ }
472
+
473
+ /** Format status event with expanded detail lines. */
474
+ function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme): string[] {
475
+ const lines: string[] = [];
476
+ const { op, ...data } = event;
477
+
478
+ // Main status line
479
+ lines.push(formatStatusEvent(event, theme));
480
+
481
+ // Add detail lines for operations with list data
482
+ const addItems = (items: unknown[], formatter: (item: unknown) => string, max = 5) => {
483
+ const arr = Array.isArray(items) ? items : [];
484
+ for (let i = 0; i < Math.min(arr.length, max); i++) {
485
+ lines.push(` ${theme.fg("dim", formatter(arr[i]))}`);
486
+ }
487
+ if (arr.length > max) {
488
+ lines.push(` ${theme.fg("dim", `${theme.format.ellipsis} ${arr.length - max} more`)}`);
489
+ }
490
+ };
491
+
492
+ // Add preview lines (truncated content)
493
+ const addPreview = (preview: string, maxLines = 3) => {
494
+ const previewLines = String(preview).split("\n").slice(0, maxLines);
495
+ for (const line of previewLines) {
496
+ lines.push(` ${theme.fg("toolOutput", truncate(line, 80, theme.format.ellipsis))}`);
497
+ }
498
+ const totalLines = String(preview).split("\n").length;
499
+ if (totalLines > maxLines) {
500
+ lines.push(` ${theme.fg("dim", `${theme.format.ellipsis} ${totalLines - maxLines} more lines`)}`);
501
+ }
502
+ };
503
+
504
+ switch (op) {
505
+ case "find":
506
+ case "glob":
507
+ if (data.matches) addItems(data.matches as unknown[], (m) => String(m));
508
+ break;
509
+ case "ls":
510
+ if (data.items) addItems(data.items as unknown[], (m) => String(m));
511
+ break;
512
+ case "grep":
513
+ if (data.hits) {
514
+ addItems(data.hits as unknown[], (h) => {
515
+ const hit = h as { line: number; text: string };
516
+ return `${hit.line}: ${truncate(hit.text, 60, theme.format.ellipsis)}`;
517
+ });
518
+ }
519
+ break;
520
+ case "rgrep":
521
+ if (data.hits) {
522
+ addItems(data.hits as unknown[], (h) => {
523
+ const hit = h as { file: string; line: number; text: string };
524
+ return `${shortenPath(hit.file)}:${hit.line}: ${truncate(hit.text, 50, theme.format.ellipsis)}`;
525
+ });
526
+ }
527
+ break;
528
+ case "rsed":
529
+ if (data.changed) {
530
+ addItems(data.changed as unknown[], (c) => {
531
+ const change = c as { file: string; count: number };
532
+ return `${shortenPath(change.file)}: ${change.count} replacement${change.count !== 1 ? "s" : ""}`;
533
+ });
534
+ }
535
+ break;
536
+ case "env":
537
+ if (data.keys) addItems(data.keys as unknown[], (k) => String(k), 10);
538
+ break;
539
+ case "git_log":
540
+ if (data.entries) {
541
+ addItems(data.entries as unknown[], (e) => {
542
+ const entry = e as { sha: string; subject: string };
543
+ return `${entry.sha} ${truncate(entry.subject, 50, theme.format.ellipsis)}`;
544
+ });
545
+ }
546
+ break;
547
+ case "git_status":
548
+ if (data.files) addItems(data.files as unknown[], (f) => String(f));
549
+ break;
550
+ case "git_branch":
551
+ if (data.branches) addItems(data.branches as unknown[], (b) => String(b));
552
+ break;
553
+ case "read":
554
+ case "cat":
555
+ case "head":
556
+ case "tail":
557
+ case "tree":
558
+ case "diff":
559
+ case "lines":
560
+ case "git_diff":
561
+ case "sh":
562
+ if (data.preview) addPreview(String(data.preview));
563
+ break;
564
+ }
565
+
566
+ return lines;
567
+ }
568
+
569
+ /** Render status events as tree lines. */
570
+ function renderStatusEvents(events: PythonStatusEvent[], theme: Theme, expanded: boolean): string[] {
571
+ if (events.length === 0) return [];
572
+
573
+ const maxCollapsed = 3;
574
+ const maxExpanded = 10;
575
+ const displayCount = expanded ? Math.min(events.length, maxExpanded) : Math.min(events.length, maxCollapsed);
576
+
577
+ const lines: string[] = [];
578
+ for (let i = 0; i < displayCount; i++) {
579
+ const isLast = i === displayCount - 1 && (expanded || events.length <= maxCollapsed);
580
+ const branch = isLast ? theme.tree.last : theme.tree.branch;
581
+
582
+ if (expanded) {
583
+ // Show expanded details for each event
584
+ const eventLines = formatStatusEventExpanded(events[i], theme);
585
+ lines.push(`${theme.fg("dim", branch)} ${eventLines[0]}`);
586
+ const continueBranch = isLast ? " " : `${theme.tree.vertical} `;
587
+ for (let j = 1; j < eventLines.length; j++) {
588
+ lines.push(`${theme.fg("dim", continueBranch)}${eventLines[j]}`);
589
+ }
590
+ } else {
591
+ lines.push(`${theme.fg("dim", branch)} ${formatStatusEvent(events[i], theme)}`);
592
+ }
593
+ }
594
+
595
+ if (!expanded && events.length > maxCollapsed) {
596
+ lines.push(
597
+ `${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", `${theme.format.ellipsis} ${events.length - maxCollapsed} more`)}`,
598
+ );
599
+ } else if (expanded && events.length > maxExpanded) {
600
+ lines.push(
601
+ `${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", `${theme.format.ellipsis} ${events.length - maxExpanded} more`)}`,
602
+ );
603
+ }
604
+
605
+ return lines;
606
+ }
607
+
608
+ export const pythonToolRenderer = {
609
+ renderCall(args: PythonRenderArgs, uiTheme: Theme): Component {
610
+ const ui = createToolUIKit(uiTheme);
611
+ const code = args.code || uiTheme.format.ellipsis;
612
+ const prompt = uiTheme.fg("accent", ">>>");
613
+ const cwd = process.cwd();
614
+ let displayWorkdir = args.workdir;
615
+
616
+ if (displayWorkdir) {
617
+ const resolvedCwd = resolve(cwd);
618
+ const resolvedWorkdir = resolve(displayWorkdir);
619
+ if (resolvedWorkdir === resolvedCwd) {
620
+ displayWorkdir = undefined;
621
+ } else {
622
+ const relativePath = relative(resolvedCwd, resolvedWorkdir);
623
+ const isWithinCwd = relativePath && !relativePath.startsWith("..") && !relativePath.startsWith(`..${sep}`);
624
+ if (isWithinCwd) {
625
+ displayWorkdir = relativePath;
626
+ }
627
+ }
628
+ }
629
+
630
+ const cmdText = displayWorkdir
631
+ ? `${prompt} ${uiTheme.fg("dim", `cd ${displayWorkdir} &&`)} ${code}`
632
+ : `${prompt} ${code}`;
633
+ const text = ui.title(cmdText);
634
+ return new Text(text, 0, 0);
635
+ },
636
+
637
+ renderResult(
638
+ result: { content: Array<{ type: string; text?: string }>; details?: PythonToolDetails },
639
+ options: RenderResultOptions & { renderContext?: PythonRenderContext },
640
+ uiTheme: Theme,
641
+ ): Component {
642
+ const ui = createToolUIKit(uiTheme);
643
+ const { renderContext } = options;
644
+ const details = result.details;
645
+
646
+ const expanded = renderContext?.expanded ?? options.expanded;
647
+ const previewLines = renderContext?.previewLines ?? PYTHON_DEFAULT_PREVIEW_LINES;
648
+ const output = renderContext?.output ?? (result.content?.find((c) => c.type === "text")?.text ?? "").trim();
649
+ const fullOutput = details?.fullOutput;
650
+ const displayOutput = expanded ? (fullOutput ?? output) : output;
651
+ const showingFullOutput = expanded && fullOutput !== undefined;
652
+
653
+ const jsonOutputs = details?.jsonOutputs ?? [];
654
+ const jsonLines = jsonOutputs.flatMap((value, index) => {
655
+ const header = `JSON output ${index + 1}`;
656
+ const treeLines = renderJsonTree(value, uiTheme, expanded);
657
+ return [header, ...treeLines];
658
+ });
659
+
660
+ // Render status events
661
+ const statusEvents = details?.statusEvents ?? [];
662
+ const statusLines = renderStatusEvents(statusEvents, uiTheme, expanded);
663
+
664
+ const combinedOutput = [displayOutput, ...jsonLines].filter(Boolean).join("\n");
665
+
666
+ const truncation = details?.truncation;
667
+ const fullOutputPath = details?.fullOutputPath;
668
+ const timeoutSeconds = renderContext?.timeout;
669
+ const timeoutLine =
670
+ typeof timeoutSeconds === "number"
671
+ ? uiTheme.fg("dim", ui.wrapBrackets(`Timeout: ${timeoutSeconds}s`))
672
+ : undefined;
673
+ let warningLine: string | undefined;
674
+ if (fullOutputPath || (truncation?.truncated && !showingFullOutput)) {
675
+ const warnings: string[] = [];
676
+ if (fullOutputPath) {
677
+ warnings.push(`Full output: ${fullOutputPath}`);
678
+ }
679
+ if (truncation?.truncated && !showingFullOutput) {
680
+ if (truncation.truncatedBy === "lines") {
681
+ warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
682
+ } else {
683
+ warnings.push(
684
+ `Truncated: ${truncation.outputLines} lines shown (${ui.formatBytes(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
685
+ );
686
+ }
687
+ }
688
+ if (warnings.length > 0) {
689
+ warningLine = uiTheme.fg("warning", ui.wrapBrackets(warnings.join(". ")));
690
+ }
691
+ }
692
+
693
+ if (!combinedOutput && statusLines.length === 0) {
694
+ const lines = [timeoutLine, warningLine].filter(Boolean) as string[];
695
+ return new Text(lines.join("\n"), 0, 0);
696
+ }
697
+
698
+ // If only status events (no text output), show them directly
699
+ if (!combinedOutput && statusLines.length > 0) {
700
+ const lines = [...statusLines, timeoutLine, warningLine].filter(Boolean) as string[];
701
+ return new Text(lines.join("\n"), 0, 0);
702
+ }
703
+
704
+ if (expanded) {
705
+ const styledOutput = combinedOutput
706
+ .split("\n")
707
+ .map((line) => uiTheme.fg("toolOutput", line))
708
+ .join("\n");
709
+ const lines = [styledOutput, ...statusLines, timeoutLine, warningLine].filter(Boolean) as string[];
710
+ return new Text(lines.join("\n"), 0, 0);
711
+ }
712
+
713
+ const styledOutput = combinedOutput
714
+ .split("\n")
715
+ .map((line) => uiTheme.fg("toolOutput", line))
716
+ .join("\n");
717
+ const textContent = `\n${styledOutput}`;
718
+
719
+ let cachedWidth: number | undefined;
720
+ let cachedLines: string[] | undefined;
721
+ let cachedSkipped: number | undefined;
722
+
723
+ return {
724
+ render: (width: number): string[] => {
725
+ if (cachedLines === undefined || cachedWidth !== width) {
726
+ const result = truncateToVisualLines(textContent, previewLines, width);
727
+ cachedLines = result.visualLines;
728
+ cachedSkipped = result.skippedCount;
729
+ cachedWidth = width;
730
+ }
731
+ const outputLines: string[] = [];
732
+ if (cachedSkipped && cachedSkipped > 0) {
733
+ outputLines.push("");
734
+ const skippedLine = uiTheme.fg(
735
+ "dim",
736
+ `${uiTheme.format.ellipsis} (${cachedSkipped} earlier lines, showing ${cachedLines.length} of ${cachedSkipped + cachedLines.length}) (ctrl+o to expand)`,
737
+ );
738
+ outputLines.push(truncateToWidth(skippedLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
739
+ }
740
+ outputLines.push(...cachedLines);
741
+ // Add status events below the output
742
+ for (const statusLine of statusLines) {
743
+ outputLines.push(truncateToWidth(statusLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
744
+ }
745
+ if (timeoutLine) {
746
+ outputLines.push(truncateToWidth(timeoutLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
747
+ }
748
+ if (warningLine) {
749
+ outputLines.push(truncateToWidth(warningLine, width, uiTheme.fg("warning", uiTheme.format.ellipsis)));
750
+ }
751
+ return outputLines;
752
+ },
753
+ invalidate: () => {
754
+ cachedWidth = undefined;
755
+ cachedLines = undefined;
756
+ cachedSkipped = undefined;
757
+ },
758
+ };
759
+ },
760
+ };