@oh-my-pi/pi-coding-agent 14.5.12 → 14.5.14

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 (112) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +18 -10
  3. package/src/cli/jupyter-cli.ts +1 -1
  4. package/src/commit/pipeline.ts +4 -3
  5. package/src/config/model-equivalence.ts +49 -16
  6. package/src/config/model-registry.ts +100 -25
  7. package/src/config/model-resolver.ts +29 -15
  8. package/src/config/settings-schema.ts +20 -6
  9. package/src/config/settings.ts +9 -8
  10. package/src/config.ts +18 -6
  11. package/src/eval/backend.ts +43 -0
  12. package/src/eval/eval.lark +43 -0
  13. package/src/eval/index.ts +5 -0
  14. package/src/eval/js/context-manager.ts +717 -0
  15. package/src/eval/js/executor.ts +131 -0
  16. package/src/eval/js/index.ts +46 -0
  17. package/src/eval/js/prelude.ts +2 -0
  18. package/src/eval/js/prelude.txt +84 -0
  19. package/src/eval/js/tool-bridge.ts +124 -0
  20. package/src/eval/parse.ts +337 -0
  21. package/src/{ipy → eval/py}/executor.ts +2 -180
  22. package/src/{ipy → eval/py}/gateway-coordinator.ts +2 -2
  23. package/src/eval/py/index.ts +58 -0
  24. package/src/{ipy → eval/py}/kernel.ts +9 -45
  25. package/src/{ipy → eval/py}/prelude.py +39 -227
  26. package/src/eval/types.ts +48 -0
  27. package/src/export/html/template.generated.ts +1 -1
  28. package/src/export/html/template.js +8 -10
  29. package/src/extensibility/extensions/types.ts +2 -3
  30. package/src/internal-urls/docs-index.generated.ts +5 -5
  31. package/src/lsp/client.ts +9 -0
  32. package/src/lsp/index.ts +395 -0
  33. package/src/lsp/types.ts +15 -4
  34. package/src/main.ts +35 -14
  35. package/src/mcp/manager.ts +22 -0
  36. package/src/mcp/oauth-flow.ts +1 -1
  37. package/src/memories/index.ts +1 -1
  38. package/src/modes/acp/acp-event-mapper.ts +1 -1
  39. package/src/modes/components/{python-execution.ts → eval-execution.ts} +11 -4
  40. package/src/modes/components/login-dialog.ts +1 -1
  41. package/src/modes/components/oauth-selector.ts +2 -1
  42. package/src/modes/components/tool-execution.ts +3 -4
  43. package/src/modes/controllers/command-controller.ts +28 -8
  44. package/src/modes/controllers/input-controller.ts +4 -4
  45. package/src/modes/controllers/selector-controller.ts +2 -1
  46. package/src/modes/interactive-mode.ts +4 -5
  47. package/src/modes/rpc/rpc-client.ts +9 -0
  48. package/src/modes/rpc/rpc-mode.ts +6 -0
  49. package/src/modes/rpc/rpc-types.ts +9 -0
  50. package/src/modes/types.ts +3 -3
  51. package/src/modes/utils/ui-helpers.ts +2 -2
  52. package/src/prompts/system/system-prompt.md +3 -3
  53. package/src/prompts/tools/eval.md +92 -0
  54. package/src/prompts/tools/lsp.md +7 -3
  55. package/src/sdk.ts +64 -35
  56. package/src/session/agent-session.ts +152 -46
  57. package/src/session/messages.ts +1 -1
  58. package/src/slash-commands/builtin-registry.ts +1 -1
  59. package/src/system-prompt.ts +34 -66
  60. package/src/task/agents.ts +4 -5
  61. package/src/task/executor.ts +5 -9
  62. package/src/tools/archive-reader.ts +9 -3
  63. package/src/tools/browser/launch.ts +22 -0
  64. package/src/tools/browser/readable.ts +11 -6
  65. package/src/tools/browser/registry.ts +25 -244
  66. package/src/tools/browser/render.ts +1 -1
  67. package/src/tools/browser/tab-protocol.ts +101 -0
  68. package/src/tools/browser/tab-supervisor.ts +429 -0
  69. package/src/tools/browser/tab-worker-entry.ts +21 -0
  70. package/src/tools/browser/tab-worker.ts +1006 -0
  71. package/src/tools/browser.ts +17 -32
  72. package/src/tools/checkpoint.ts +2 -2
  73. package/src/tools/{python.ts → eval.ts} +324 -315
  74. package/src/tools/exit-plan-mode.ts +1 -1
  75. package/src/tools/image-gen.ts +2 -2
  76. package/src/tools/index.ts +62 -100
  77. package/src/tools/read.ts +0 -6
  78. package/src/tools/recipe/runners/pkg.ts +34 -32
  79. package/src/tools/renderers.ts +2 -2
  80. package/src/tools/resolve.ts +7 -2
  81. package/src/tools/todo-write.ts +0 -1
  82. package/src/tools/tool-timeouts.ts +2 -2
  83. package/src/tools/write.ts +8 -1
  84. package/src/utils/markit.ts +15 -7
  85. package/src/utils/tools-manager.ts +5 -5
  86. package/src/web/scrapers/crossref.ts +3 -3
  87. package/src/web/scrapers/devto.ts +1 -1
  88. package/src/web/scrapers/discourse.ts +5 -5
  89. package/src/web/scrapers/firefox-addons.ts +1 -1
  90. package/src/web/scrapers/flathub.ts +2 -2
  91. package/src/web/scrapers/gitlab.ts +1 -1
  92. package/src/web/scrapers/go-pkg.ts +2 -2
  93. package/src/web/scrapers/jetbrains-marketplace.ts +1 -1
  94. package/src/web/scrapers/mastodon.ts +9 -9
  95. package/src/web/scrapers/mdn.ts +11 -7
  96. package/src/web/scrapers/pub-dev.ts +1 -1
  97. package/src/web/scrapers/rawg.ts +3 -3
  98. package/src/web/scrapers/readthedocs.ts +1 -1
  99. package/src/web/scrapers/spdx.ts +1 -1
  100. package/src/web/scrapers/stackoverflow.ts +2 -2
  101. package/src/web/scrapers/types.ts +53 -39
  102. package/src/web/scrapers/w3c.ts +1 -1
  103. package/src/web/search/index.ts +5 -5
  104. package/src/web/search/provider.ts +121 -39
  105. package/src/web/search/providers/gemini.ts +4 -4
  106. package/src/web/search/render.ts +2 -2
  107. package/src/ipy/modules.ts +0 -144
  108. package/src/prompts/tools/python.md +0 -57
  109. package/src/tools/browser/vm.ts +0 -792
  110. /package/src/{ipy → eval/py}/cancellation.ts +0 -0
  111. /package/src/{ipy → eval/py}/prelude.ts +0 -0
  112. /package/src/{ipy → eval/py}/runtime.ts +0 -0
@@ -1,89 +1,42 @@
1
- import * as path from "node:path";
2
1
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
2
  import type { ImageContent } from "@oh-my-pi/pi-ai";
4
3
  import type { Component } from "@oh-my-pi/pi-tui";
5
4
  import { Markdown, Text } from "@oh-my-pi/pi-tui";
6
- import { getProjectDir, prompt } from "@oh-my-pi/pi-utils";
5
+ import { prompt } from "@oh-my-pi/pi-utils";
7
6
  import { type Static, Type } from "@sinclair/typebox";
7
+ import { jsBackend, parseEvalInput, pythonBackend } from "../eval";
8
+ import type { ExecutorBackend } from "../eval/backend";
9
+ import evalGrammar from "../eval/eval.lark" with { type: "text" };
10
+ import type { ParsedEvalCell } from "../eval/parse";
11
+ import type { EvalCellResult, EvalLanguage, EvalStatusEvent, EvalToolDetails } from "../eval/types";
8
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
- import { executePython, getPreludeDocs, type PythonExecutorOptions, warmPythonEnvironment } from "../ipy/executor";
10
- import type { PreludeHelper, PythonStatusEvent } from "../ipy/kernel";
11
13
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
12
14
  import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
13
- import pythonDescription from "../prompts/tools/python.md" with { type: "text" };
15
+ import evalDescription from "../prompts/tools/eval.md" with { type: "text" };
14
16
  import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary, TailBuffer } from "../session/streaming-output";
15
17
  import { getTreeBranch, getTreeContinuePrefix, renderCodeCell } from "../tui";
16
- import type { ToolSession } from ".";
17
- import { formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
18
+ import { resolveEvalBackends, type ToolSession } from ".";
19
+ import { formatStyledTruncationWarning } from "./output-meta";
18
20
  import { formatTitle, replaceTabs, shortenPath, truncateToWidth, wrapBrackets } from "./render-utils";
19
21
  import { ToolAbortError, ToolError } from "./tool-errors";
20
22
  import { toolResult } from "./tool-result";
21
23
  import { clampTimeout } from "./tool-timeouts";
22
24
 
23
- export const PYTHON_DEFAULT_PREVIEW_LINES = 10;
25
+ export const EVAL_DEFAULT_PREVIEW_LINES = 10;
24
26
 
25
- type PreludeCategory = {
26
- name: string;
27
- functions: PreludeHelper[];
28
- };
29
-
30
- function groupPreludeHelpers(helpers: PreludeHelper[]): PreludeCategory[] {
31
- const categories: PreludeCategory[] = [];
32
- const byName = new Map<string, PreludeHelper[]>();
33
- for (const helper of helpers) {
34
- let bucket = byName.get(helper.category);
35
- if (!bucket) {
36
- bucket = [];
37
- byName.set(helper.category, bucket);
38
- categories.push({ name: helper.category, functions: bucket });
39
- }
40
- bucket.push(helper);
41
- }
42
- return categories;
43
- }
44
-
45
- export const pythonSchema = Type.Object({
46
- cells: Type.Array(
47
- Type.Object({
48
- code: Type.String({ description: "python code", examples: ["print('hello')", "import json"] }),
49
- title: Type.String({ description: "cell label", examples: ["imports", "helper"] }),
50
- }),
51
- { description: "cells to execute" },
52
- ),
53
- timeout: Type.Optional(Type.Number({ description: "timeout in seconds", default: 30 })),
54
- reset: Type.Optional(Type.Boolean({ description: "restart kernel" })),
27
+ export const evalSchema = Type.Object({
28
+ input: Type.String({
29
+ description: "atom-style eval input containing CELL sections, fenced code, and optional RESET directive",
30
+ }),
55
31
  });
56
- export type PythonToolParams = Static<typeof pythonSchema>;
32
+ export type EvalToolParams = Static<typeof evalSchema>;
57
33
 
58
- export type PythonToolResult = {
34
+ export type EvalToolResult = {
59
35
  content: Array<{ type: "text"; text: string }>;
60
- details: PythonToolDetails | undefined;
36
+ details: EvalToolDetails | undefined;
61
37
  };
62
38
 
63
- export type PythonProxyExecutor = (params: PythonToolParams, signal?: AbortSignal) => Promise<PythonToolResult>;
64
-
65
- export interface PythonCellResult {
66
- index: number;
67
- title?: string;
68
- code: string;
69
- output: string;
70
- status: "pending" | "running" | "complete" | "error";
71
- durationMs?: number;
72
- exitCode?: number;
73
- statusEvents?: PythonStatusEvent[];
74
- hasMarkdown?: boolean;
75
- }
76
-
77
- export interface PythonToolDetails {
78
- cells?: PythonCellResult[];
79
- jsonOutputs?: unknown[];
80
- images?: ImageContent[];
81
- /** Structured status events from prelude helpers */
82
- statusEvents?: PythonStatusEvent[];
83
- isError?: boolean;
84
- /** Structured output metadata for notices */
85
- meta?: OutputMeta;
86
- }
39
+ export type EvalProxyExecutor = (params: EvalToolParams, signal?: AbortSignal) => Promise<EvalToolResult>;
87
40
 
88
41
  function formatJsonScalar(value: unknown): string {
89
42
  if (value === null) return "null";
@@ -129,61 +82,189 @@ function renderJsonTree(value: unknown, theme: Theme, expanded: boolean, maxDept
129
82
  return renderNode(value, "", 0, true);
130
83
  }
131
84
 
132
- export function getPythonToolDescription(): string {
133
- const helpers = getPreludeDocs();
134
- const categories = groupPreludeHelpers(helpers);
135
- return prompt.render(pythonDescription, { categories });
85
+ export interface EvalToolDescriptionOptions {
86
+ py?: boolean;
87
+ js?: boolean;
88
+ }
89
+
90
+ export function getEvalToolDescription(options: EvalToolDescriptionOptions = {}): string {
91
+ const py = options.py ?? true;
92
+ const js = options.js ?? true;
93
+ return prompt.render(evalDescription, { py, js });
94
+ }
95
+
96
+ export interface EvalToolOptions {
97
+ proxyExecutor?: EvalProxyExecutor;
98
+ }
99
+
100
+ interface ResolvedBackend {
101
+ backend: ExecutorBackend;
102
+ fallback: boolean;
103
+ notice?: string;
104
+ }
105
+
106
+ interface ResolvedEvalCell {
107
+ index: number;
108
+ title?: string;
109
+ code: string;
110
+ timeoutMs: number;
111
+ reset: boolean;
112
+ resolved: ResolvedBackend;
113
+ }
114
+
115
+ function uniqueEvalLanguages(cells: ResolvedEvalCell[]): EvalLanguage[] {
116
+ return [...new Set(cells.map(cell => cell.resolved.backend.id))];
136
117
  }
137
118
 
138
- export interface PythonToolOptions {
139
- proxyExecutor?: PythonProxyExecutor;
119
+ function detailsNotice(cells: ResolvedEvalCell[]): string | undefined {
120
+ const notices = [
121
+ ...new Set(cells.map(cell => cell.resolved.notice).filter((notice): notice is string => Boolean(notice))),
122
+ ];
123
+ return notices.length > 0 ? notices.join(" ") : undefined;
140
124
  }
141
125
 
142
- export class PythonTool implements AgentTool<typeof pythonSchema> {
143
- readonly name = "python";
144
- readonly label = "Python";
126
+ function languageForHighlighter(language: EvalLanguage | undefined): "python" | "javascript" {
127
+ return language === "js" ? "javascript" : "python";
128
+ }
129
+
130
+ function timeoutSecondsFromMs(timeoutMs: number): number {
131
+ return clampTimeout("eval", timeoutMs / 1000);
132
+ }
133
+
134
+ /**
135
+ * Best-effort language sniff for cells with no explicit `language`.
136
+ *
137
+ * Order:
138
+ * 1. Shebang on first line (`#!/usr/bin/env python`, `#!/usr/bin/env node`, etc.)
139
+ * 2. Strong syntactic markers unique to one language. We bias false negatives over
140
+ * false positives — anything ambiguous returns `undefined` and the caller falls
141
+ * back to the default-backend rules.
142
+ */
143
+ function sniffLanguage(code: string): EvalLanguage | undefined {
144
+ const stripped = code.replace(/^\s+/, "");
145
+ if (stripped.startsWith("#!")) {
146
+ const firstLine = stripped.split("\n", 1)[0]!.toLowerCase();
147
+ if (/(\bpython\d?\b|\bipython\b)/.test(firstLine)) return "python";
148
+ if (/(\bnode\b|\bbun\b|\bdeno\b|\bjavascript\b|\bjs\b)/.test(firstLine)) return "js";
149
+ }
150
+ const jsMarkers =
151
+ /(^|\n)\s*(const|let|var|async\s+function|function\s*\*?\s*[\w$]*\s*\(|import\s+[^\n]+\sfrom\s|export\s+(default|const|let|function|class|async)|require\s*\(|console\.\w+\s*\(|=>|;\s*$)/m;
152
+ const pyMarkers =
153
+ /(^|\n)\s*(def\s+\w+\s*\(|from\s+[\w.]+\s+import|import\s+\w+(\s+as\s+\w+)?\s*$|class\s+\w+\s*[(:]|print\s*\(|elif\s+[^\n]*:|with\s+[^\n]+:\s*$|@[\w.]+\s*$)/m;
154
+ const hasJs = jsMarkers.test(code);
155
+ const hasPy = pyMarkers.test(code);
156
+ if (hasJs && !hasPy) return "js";
157
+ if (hasPy && !hasJs) return "python";
158
+ return undefined;
159
+ }
160
+
161
+ async function resolveBackend(
162
+ session: ToolSession,
163
+ requested: EvalLanguage | undefined,
164
+ code: string,
165
+ ): Promise<ResolvedBackend> {
166
+ const allowPy = (session.settings.get("eval.py") as boolean | undefined) ?? true;
167
+ const allowJs = (session.settings.get("eval.js") as boolean | undefined) ?? true;
168
+
169
+ if (requested === "python") {
170
+ if (!allowPy) throw new ToolError("Python backend is disabled (eval.py = false).");
171
+ if (!(await pythonBackend.isAvailable(session))) {
172
+ throw new ToolError(
173
+ 'Python backend is unavailable in this session. Pass language: "js" or install the python kernel.',
174
+ );
175
+ }
176
+ return { backend: pythonBackend, fallback: false };
177
+ }
178
+ if (requested === "js") {
179
+ if (!allowJs) throw new ToolError("JavaScript backend is disabled (eval.js = false).");
180
+ return { backend: jsBackend, fallback: false };
181
+ }
182
+ // Auto-detect.
183
+ const sniffed = sniffLanguage(code);
184
+ if (sniffed === "python" && allowPy && (await pythonBackend.isAvailable(session))) {
185
+ return { backend: pythonBackend, fallback: false };
186
+ }
187
+ if (sniffed === "js" && allowJs) {
188
+ return { backend: jsBackend, fallback: false };
189
+ }
190
+
191
+ // Sniffer returned undefined or the preferred backend was disabled. Prefer
192
+ // python when its kernel is up, else fall back to js.
193
+ if (allowPy && (await pythonBackend.isAvailable(session))) {
194
+ const notice =
195
+ sniffed === "js" ? "JavaScript markers detected but eval.js is disabled; using Python." : undefined;
196
+ return { backend: pythonBackend, fallback: false, notice };
197
+ }
198
+ if (allowJs) {
199
+ const notice =
200
+ sniffed === "python"
201
+ ? "Python markers detected but the python kernel is unavailable; using JavaScript."
202
+ : undefined;
203
+ return { backend: jsBackend, fallback: true, notice };
204
+ }
205
+ throw new ToolError("No eval backend is available; enable eval.py or eval.js.");
206
+ }
207
+
208
+ export class EvalTool implements AgentTool<typeof evalSchema> {
209
+ readonly name = "eval";
210
+ readonly label = "Eval";
145
211
  get description(): string {
146
- return getPythonToolDescription();
212
+ if (!this.session) return getEvalToolDescription();
213
+ const backends = resolveEvalBackends(this.session);
214
+ return getEvalToolDescription({ py: backends.python, js: backends.js });
147
215
  }
148
- readonly parameters = pythonSchema;
216
+ readonly parameters = evalSchema;
149
217
  readonly concurrency = "exclusive";
150
218
  readonly strict = true;
151
219
 
152
- readonly #proxyExecutor?: PythonProxyExecutor;
220
+ get customFormat(): { syntax: "lark"; definition: string } {
221
+ return { syntax: "lark", definition: evalGrammar };
222
+ }
223
+
224
+ readonly #proxyExecutor?: EvalProxyExecutor;
153
225
 
154
226
  constructor(
155
227
  private readonly session: ToolSession | null,
156
- options?: PythonToolOptions,
228
+ options?: EvalToolOptions,
157
229
  ) {
158
230
  this.#proxyExecutor = options?.proxyExecutor;
159
231
  }
160
232
 
161
233
  async execute(
162
234
  _toolCallId: string,
163
- params: Static<typeof pythonSchema>,
235
+ params: Static<typeof evalSchema>,
164
236
  signal?: AbortSignal,
165
237
  onUpdate?: AgentToolUpdateCallback,
166
238
  _ctx?: AgentToolContext,
167
- ): Promise<AgentToolResult<PythonToolDetails | undefined>> {
239
+ ): Promise<AgentToolResult<EvalToolDetails | undefined>> {
168
240
  if (this.#proxyExecutor) {
169
241
  return this.#proxyExecutor(params, signal);
170
242
  }
171
243
 
172
244
  if (!this.session) {
173
- throw new ToolError("Python tool requires a session when not using proxy executor");
245
+ throw new ToolError("Eval tool requires a session when not using proxy executor");
174
246
  }
175
247
  const session = this.session;
176
248
 
177
- const { cells, timeout: rawTimeout = 30, reset } = params;
178
- // Clamp to reasonable range: 1s - 600s (10 min)
179
- const timeoutSec = clampTimeout("python", rawTimeout);
180
- const timeoutMs = timeoutSec * 1000;
181
- const deadlineMs = Date.now() + timeoutMs;
182
- const timeoutSignal = AbortSignal.timeout(Math.max(0, deadlineMs - Date.now()));
249
+ const parsedInput = parseEvalInput(params.input);
250
+ let previousRuntimeLanguage: EvalLanguage | undefined;
251
+ const cells: ResolvedEvalCell[] = [];
252
+ for (const cell of parsedInput.cells) {
253
+ const requested = cell.languageOrigin === "fence" ? cell.language : (previousRuntimeLanguage ?? undefined);
254
+ const resolved = await resolveBackend(session, requested, cell.code);
255
+ previousRuntimeLanguage = resolved.backend.id;
256
+ cells.push({
257
+ index: cell.index,
258
+ title: cell.title,
259
+ code: cell.code,
260
+ timeoutMs: cell.timeoutMs,
261
+ reset: cell.reset,
262
+ resolved,
263
+ });
264
+ }
265
+ const languages = uniqueEvalLanguages(cells);
266
+ const notice = detailsNotice(cells);
183
267
  const sessionAbortController = new AbortController();
184
- const combinedSignal = signal
185
- ? AbortSignal.any([signal, timeoutSignal, sessionAbortController.signal])
186
- : AbortSignal.any([timeoutSignal, sessionAbortController.signal]);
187
268
  let outputSink: OutputSink | undefined;
188
269
  let outputSummary: OutputSummary | undefined;
189
270
  let outputDumped = false;
@@ -194,22 +275,23 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
194
275
  return outputSummary;
195
276
  };
196
277
 
197
- const execution = (async (): Promise<AgentToolResult<PythonToolDetails | undefined>> => {
278
+ const execution = (async (): Promise<AgentToolResult<EvalToolDetails | undefined>> => {
198
279
  try {
199
280
  if (signal?.aborted) {
200
281
  throw new ToolAbortError();
201
282
  }
202
- session.assertPythonExecutionAllowed?.();
283
+ session.assertEvalExecutionAllowed?.();
203
284
 
204
285
  const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES * 2);
205
286
  const jsonOutputs: unknown[] = [];
206
287
  const images: ImageContent[] = [];
207
- const statusEvents: PythonStatusEvent[] = [];
288
+ const statusEvents: EvalStatusEvent[] = [];
208
289
 
209
- const cellResults: PythonCellResult[] = cells.map((cell, index) => ({
210
- index,
290
+ const cellResults: EvalCellResult[] = cells.map(cell => ({
291
+ index: cell.index,
211
292
  title: cell.title,
212
293
  code: cell.code,
294
+ language: cell.resolved.backend.id,
213
295
  output: "",
214
296
  status: "pending",
215
297
  }));
@@ -219,8 +301,10 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
219
301
  tailBuffer.append(text);
220
302
  };
221
303
 
222
- const buildUpdateDetails = (): PythonToolDetails => {
223
- const details: PythonToolDetails = {
304
+ const buildUpdateDetails = (): EvalToolDetails => {
305
+ const details: EvalToolDetails = {
306
+ language: languages[0],
307
+ languages,
224
308
  cells: cellResults.map(cell => ({
225
309
  ...cell,
226
310
  statusEvents: cell.statusEvents ? [...cell.statusEvents] : undefined,
@@ -235,6 +319,9 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
235
319
  if (statusEvents.length > 0) {
236
320
  details.statusEvents = statusEvents;
237
321
  }
322
+ if (notice) {
323
+ details.notice = notice;
324
+ }
238
325
  return details;
239
326
  };
240
327
 
@@ -248,9 +335,9 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
248
335
  };
249
336
 
250
337
  const sessionFile = session.getSessionFile?.() ?? undefined;
251
- const kernelOwnerId = session.getPythonKernelOwnerId?.() ?? undefined;
252
- const { path: artifactPath, id: artifactId } = (await session.allocateOutputArtifact?.("python")) ?? {};
253
- session.assertPythonExecutionAllowed?.();
338
+ const kernelOwnerId = session.getEvalKernelOwnerId?.() ?? undefined;
339
+ const { path: artifactPath, id: artifactId } = (await session.allocateOutputArtifact?.("eval")) ?? {};
340
+ session.assertEvalExecutionAllowed?.();
254
341
  outputSink = new OutputSink({
255
342
  artifactPath,
256
343
  artifactId,
@@ -261,36 +348,16 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
261
348
  });
262
349
  const sessionId = sessionFile ? `session:${sessionFile}:cwd:${session.cwd}` : `cwd:${session.cwd}`;
263
350
 
264
- if (getPreludeDocs().length === 0) {
265
- const warmup = await warmPythonEnvironment(
266
- session.cwd,
267
- sessionId,
268
- session.settings.get("python.sharedGateway"),
269
- sessionFile ?? undefined,
270
- kernelOwnerId,
271
- combinedSignal,
272
- );
273
- if (!warmup.ok) {
274
- if (combinedSignal.aborted) throw new ToolAbortError();
275
- throw new ToolError(warmup.reason ?? "Python prelude helpers unavailable");
276
- }
277
- session.assertPythonExecutionAllowed?.();
278
- }
279
-
280
- const baseExecutorOptions = {
281
- cwd: session.cwd,
282
- deadlineMs,
283
- signal: combinedSignal,
284
- sessionId,
285
- kernelMode: session.settings.get("python.kernelMode"),
286
- useSharedGateway: session.settings.get("python.sharedGateway"),
287
- sessionFile: sessionFile ?? undefined,
288
- kernelOwnerId,
289
- };
290
-
291
351
  for (let i = 0; i < cells.length; i++) {
292
352
  const cell = cells[i];
293
- const isFirstCell = i === 0;
353
+ const backend = cell.resolved.backend;
354
+ const timeoutSec = timeoutSecondsFromMs(cell.timeoutMs);
355
+ const deadlineMs = Date.now() + timeoutSec * 1000;
356
+ const timeoutSignal = AbortSignal.timeout(Math.max(0, deadlineMs - Date.now()));
357
+ const combinedSignal = signal
358
+ ? AbortSignal.any([signal, timeoutSignal, sessionAbortController.signal])
359
+ : AbortSignal.any([timeoutSignal, sessionAbortController.signal]);
360
+
294
361
  const cellResult = cellResults[i];
295
362
  cellResult.status = "running";
296
363
  cellResult.output = "";
@@ -299,19 +366,25 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
299
366
  cellResult.durationMs = undefined;
300
367
  pushUpdate();
301
368
 
302
- const executorOptions: PythonExecutorOptions = {
303
- ...baseExecutorOptions,
304
- reset: isFirstCell ? reset : false,
369
+ const startTime = Date.now();
370
+ const result = await backend.execute(cell.code, {
371
+ cwd: session.cwd,
372
+ sessionId,
373
+ sessionFile: sessionFile ?? undefined,
374
+ kernelOwnerId,
375
+ signal: combinedSignal,
376
+ session,
377
+ deadlineMs,
378
+ reset: cell.reset,
379
+ artifactPath,
380
+ artifactId,
305
381
  onChunk: chunk => {
306
382
  outputSink!.push(chunk);
307
383
  },
308
- };
309
-
310
- const startTime = Date.now();
311
- const result = await executePython(cell.code, executorOptions);
384
+ });
312
385
  const durationMs = Date.now() - startTime;
313
386
 
314
- const cellStatusEvents: PythonStatusEvent[] = [];
387
+ const cellStatusEvents: EvalStatusEvent[] = [];
315
388
  let cellHasMarkdown = false;
316
389
  for (const output of result.displayOutputs) {
317
390
  if (output.type === "json") {
@@ -366,35 +439,17 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
366
439
  ? `${combinedOutput}\n\nCell ${i + 1} aborted: ${errorMsg}`
367
440
  : combinedOutput || errorMsg;
368
441
 
369
- const rawSummary = (await finalizeOutput()) ?? {
370
- output: "",
371
- truncated: false,
372
- totalLines: 0,
373
- totalBytes: 0,
374
- outputLines: 0,
375
- outputBytes: 0,
376
- };
377
- const outputLines = combinedOutput.length > 0 ? combinedOutput.split("\n").length : 0;
378
- const outputBytes = Buffer.byteLength(combinedOutput, "utf-8");
379
- const missingLines = Math.max(0, rawSummary.totalLines - rawSummary.outputLines);
380
- const missingBytes = Math.max(0, rawSummary.totalBytes - rawSummary.outputBytes);
381
- const summaryForMeta: OutputSummary = {
382
- output: combinedOutput,
383
- truncated: rawSummary.truncated,
384
- totalLines: outputLines + missingLines,
385
- totalBytes: outputBytes + missingBytes,
386
- outputLines,
387
- outputBytes,
388
- artifactId: rawSummary.artifactId,
389
- };
390
-
391
- const details: PythonToolDetails = {
442
+ const summaryForMeta = await summarizeFinal(combinedOutput, finalizeOutput);
443
+ const details: EvalToolDetails = {
444
+ language: languages[0],
445
+ languages,
392
446
  cells: cellResults,
393
447
  jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
394
448
  images: images.length > 0 ? images : undefined,
395
449
  statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
396
450
  isError: true,
397
451
  };
452
+ if (notice) details.notice = notice;
398
453
 
399
454
  return toolResult(details)
400
455
  .text(outputText)
@@ -413,35 +468,17 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
413
468
  ? `${combinedOutput}\n\nCommand exited with code ${result.exitCode}`
414
469
  : `Command exited with code ${result.exitCode}`;
415
470
 
416
- const rawSummary = (await finalizeOutput()) ?? {
417
- output: "",
418
- truncated: false,
419
- totalLines: 0,
420
- totalBytes: 0,
421
- outputLines: 0,
422
- outputBytes: 0,
423
- };
424
- const outputLines = combinedOutput.length > 0 ? combinedOutput.split("\n").length : 0;
425
- const outputBytes = Buffer.byteLength(combinedOutput, "utf-8");
426
- const missingLines = Math.max(0, rawSummary.totalLines - rawSummary.outputLines);
427
- const missingBytes = Math.max(0, rawSummary.totalBytes - rawSummary.outputBytes);
428
- const summaryForMeta: OutputSummary = {
429
- output: combinedOutput,
430
- truncated: rawSummary.truncated,
431
- totalLines: outputLines + missingLines,
432
- totalBytes: outputBytes + missingBytes,
433
- outputLines,
434
- outputBytes,
435
- artifactId: rawSummary.artifactId,
436
- };
437
-
438
- const details: PythonToolDetails = {
471
+ const summaryForMeta = await summarizeFinal(combinedOutput, finalizeOutput);
472
+ const details: EvalToolDetails = {
473
+ language: languages[0],
474
+ languages,
439
475
  cells: cellResults,
440
476
  jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
441
477
  images: images.length > 0 ? images : undefined,
442
478
  statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
443
479
  isError: true,
444
480
  };
481
+ if (notice) details.notice = notice;
445
482
 
446
483
  return toolResult(details)
447
484
  .text(outputText)
@@ -456,40 +493,22 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
456
493
  const combinedOutput = cellOutputs.join("\n\n");
457
494
  const outputText =
458
495
  combinedOutput || (jsonOutputs.length > 0 || images.length > 0 ? "(no text output)" : "(no output)");
459
- const rawSummary = (await finalizeOutput()) ?? {
460
- output: "",
461
- truncated: false,
462
- totalLines: 0,
463
- totalBytes: 0,
464
- outputLines: 0,
465
- outputBytes: 0,
466
- };
467
- const outputLines = combinedOutput.length > 0 ? combinedOutput.split("\n").length : 0;
468
- const outputBytes = Buffer.byteLength(combinedOutput, "utf-8");
469
- const missingLines = Math.max(0, rawSummary.totalLines - rawSummary.outputLines);
470
- const missingBytes = Math.max(0, rawSummary.totalBytes - rawSummary.outputBytes);
471
- const summaryForMeta: OutputSummary = {
472
- output: combinedOutput,
473
- truncated: rawSummary.truncated,
474
- totalLines: outputLines + missingLines,
475
- totalBytes: outputBytes + missingBytes,
476
- outputLines,
477
- outputBytes,
478
- artifactId: rawSummary.artifactId,
479
- };
496
+ const summaryForMeta = await summarizeFinal(combinedOutput, finalizeOutput);
480
497
 
481
- const details: PythonToolDetails = {
498
+ const details: EvalToolDetails = {
499
+ language: languages[0],
500
+ languages,
482
501
  cells: cellResults,
483
502
  jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
484
503
  images: images.length > 0 ? images : undefined,
485
504
  statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
486
505
  };
506
+ if (notice) details.notice = notice;
487
507
 
488
- const resultBuilder = toolResult(details)
508
+ return toolResult(details)
489
509
  .text(outputText)
490
- .truncationFromSummary(summaryForMeta, { direction: "tail" });
491
-
492
- return resultBuilder.done();
510
+ .truncationFromSummary(summaryForMeta, { direction: "tail" })
511
+ .done();
493
512
  } finally {
494
513
  if (!outputDumped) {
495
514
  try {
@@ -498,57 +517,95 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
498
517
  }
499
518
  }
500
519
  })();
501
- return await (session.trackPythonExecution?.(execution, sessionAbortController) ?? execution);
520
+
521
+ return await (session.trackEvalExecution?.(execution, sessionAbortController) ?? execution);
502
522
  }
503
523
  }
504
524
 
505
- interface PythonRenderArgs {
506
- cells?: Array<{ code: string; title?: string }>;
507
- timeout?: number;
508
- cwd?: string;
525
+ async function summarizeFinal(
526
+ combinedOutput: string,
527
+ finalizeOutput: () => Promise<OutputSummary | undefined>,
528
+ ): Promise<OutputSummary> {
529
+ const rawSummary = (await finalizeOutput()) ?? {
530
+ output: "",
531
+ truncated: false,
532
+ totalLines: 0,
533
+ totalBytes: 0,
534
+ outputLines: 0,
535
+ outputBytes: 0,
536
+ };
537
+ const outputLines = combinedOutput.length > 0 ? combinedOutput.split("\n").length : 0;
538
+ const outputBytes = Buffer.byteLength(combinedOutput, "utf-8");
539
+ const missingLines = Math.max(0, rawSummary.totalLines - rawSummary.outputLines);
540
+ const missingBytes = Math.max(0, rawSummary.totalBytes - rawSummary.outputBytes);
541
+ return {
542
+ output: combinedOutput,
543
+ truncated: rawSummary.truncated,
544
+ totalLines: outputLines + missingLines,
545
+ totalBytes: outputBytes + missingBytes,
546
+ outputLines,
547
+ outputBytes,
548
+ artifactId: rawSummary.artifactId,
549
+ };
509
550
  }
510
551
 
511
- interface PythonRenderContext {
552
+ interface EvalRenderArgs {
553
+ input?: string;
554
+ __partialJson?: string;
555
+ }
556
+
557
+ interface EvalRenderContext {
512
558
  output?: string;
513
559
  expanded?: boolean;
514
560
  previewLines?: number;
515
561
  timeout?: number;
516
562
  }
517
563
 
564
+ function decodePartialJsonStringFragment(fragment: string): string {
565
+ let text = fragment.replace(/\\u[0-9a-fA-F]{0,3}$/, "");
566
+ const trailingBackslashes = text.match(/\\+$/)?.[0].length ?? 0;
567
+ if (trailingBackslashes % 2 === 1) text = text.slice(0, -1);
568
+ try {
569
+ return JSON.parse(`"${text}"`) as string;
570
+ } catch {
571
+ return text;
572
+ }
573
+ }
574
+
575
+ function extractPartialJsonString(partialJson: string | undefined, key: string): string | undefined {
576
+ if (!partialJson) return undefined;
577
+ const pattern = new RegExp(`"${key}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)`, "u");
578
+ const match = pattern.exec(partialJson);
579
+ if (!match) return undefined;
580
+ return decodePartialJsonStringFragment(match[1]);
581
+ }
582
+
583
+ function getRenderInput(args: EvalRenderArgs | undefined): string | undefined {
584
+ return args?.input ?? extractPartialJsonString(args?.__partialJson, "input");
585
+ }
586
+
518
587
  /** Format a status event as a single line for display. */
519
- function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string {
588
+ function formatStatusEvent(event: EvalStatusEvent, theme: Theme): string {
520
589
  const { op, ...data } = event;
521
590
 
522
- // Map operations to available theme icons
523
591
  type AvailableIcon = "icon.file" | "icon.folder" | "icon.git" | "icon.package";
524
592
  const opIcons: Record<string, AvailableIcon> = {
525
- // File I/O
526
593
  read: "icon.file",
527
594
  write: "icon.file",
528
595
  append: "icon.file",
529
596
  cat: "icon.file",
530
597
  touch: "icon.file",
531
- lines: "icon.file",
532
- // Navigation/Directory
533
598
  ls: "icon.folder",
534
599
  cd: "icon.folder",
535
600
  pwd: "icon.folder",
536
601
  mkdir: "icon.folder",
537
602
  tree: "icon.folder",
538
603
  stat: "icon.folder",
539
- // Search (use file icon since no search icon)
540
604
  find: "icon.file",
541
605
  grep: "icon.file",
542
606
  rgrep: "icon.file",
543
607
  glob: "icon.file",
544
- // Edit operations (use file icon)
545
- replace: "icon.file",
546
608
  sed: "icon.file",
547
- rsed: "icon.file",
548
- delete_lines: "icon.file",
549
- delete_matching: "icon.file",
550
- insert_at: "icon.file",
551
- // Git
552
609
  git_status: "icon.git",
553
610
  git_diff: "icon.git",
554
611
  git_log: "icon.git",
@@ -556,7 +613,6 @@ function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string {
556
613
  git_branch: "icon.git",
557
614
  git_file_at: "icon.git",
558
615
  git_has_changes: "icon.git",
559
- // Shell/batch (use package icon)
560
616
  run: "icon.package",
561
617
  sh: "icon.package",
562
618
  env: "icon.package",
@@ -566,23 +622,20 @@ function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string {
566
622
  const iconKey = opIcons[op] ?? "icon.file";
567
623
  const icon = theme.styledSymbol(iconKey, "muted");
568
624
 
569
- // Format the status message based on operation type
570
625
  const parts: string[] = [];
571
626
 
572
- // Error handling
573
627
  if (data.error) {
574
628
  return `${icon} ${theme.fg("warning", op)}: ${theme.fg("dim", String(data.error))}`;
575
629
  }
576
630
 
577
- // Build description based on common fields
578
631
  switch (op) {
579
632
  case "read":
580
- parts.push(`${data.chars} chars`);
633
+ parts.push(`${data.chars ?? data.bytes ?? 0} chars`);
581
634
  if (data.path) parts.push(`from ${shortenPath(String(data.path))}`);
582
635
  break;
583
636
  case "write":
584
637
  case "append":
585
- parts.push(`${data.chars} chars`);
638
+ parts.push(`${data.chars ?? data.bytes ?? 0} chars`);
586
639
  if (data.path) parts.push(`to ${shortenPath(String(data.path))}`);
587
640
  break;
588
641
  case "cat":
@@ -622,15 +675,10 @@ function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string {
622
675
  }
623
676
  if (data.path) parts.push(shortenPath(String(data.path)));
624
677
  break;
625
- case "replace":
626
678
  case "sed":
627
679
  parts.push(`${data.count} replacement${(data.count as number) !== 1 ? "s" : ""}`);
628
680
  if (data.path) parts.push(`in ${shortenPath(String(data.path))}`);
629
681
  break;
630
- case "rsed":
631
- parts.push(`${data.count} replacement${(data.count as number) !== 1 ? "s" : ""}`);
632
- if (data.files) parts.push(`in ${data.files} file${(data.files as number) !== 1 ? "s" : ""}`);
633
- break;
634
682
  case "git_status":
635
683
  if (data.clean) {
636
684
  parts.push("clean");
@@ -663,31 +711,13 @@ function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string {
663
711
  case "wc":
664
712
  parts.push(`${data.lines}L ${data.words}W ${data.chars}C`);
665
713
  break;
666
- case "lines":
667
- parts.push(`${data.count} line${(data.count as number) !== 1 ? "s" : ""}`);
668
- if (data.start && data.end) parts.push(`(${data.start}-${data.end})`);
669
- break;
670
- case "delete_lines":
671
- case "delete_matching":
672
- parts.push(`${data.count} line${(data.count as number) !== 1 ? "s" : ""} deleted`);
673
- break;
674
- case "insert_at":
675
- parts.push(`${data.lines_inserted} line${(data.lines_inserted as number) !== 1 ? "s" : ""} inserted`);
676
- break;
677
714
  case "cd":
678
715
  case "pwd":
679
716
  case "mkdir":
680
717
  case "touch":
681
718
  if (data.path) parts.push(shortenPath(String(data.path)));
682
719
  break;
683
- case "rm":
684
- case "mv":
685
- case "cp":
686
- if (data.src) parts.push(`${shortenPath(String(data.src))} → ${shortenPath(String(data.dst))}`);
687
- else if (data.path) parts.push(shortenPath(String(data.path)));
688
- break;
689
720
  default:
690
- // Generic formatting for other operations
691
721
  if (data.count !== undefined) {
692
722
  parts.push(String(data.count));
693
723
  }
@@ -701,14 +731,12 @@ function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string {
701
731
  }
702
732
 
703
733
  /** Format status event with expanded detail lines. */
704
- function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme): string[] {
734
+ function formatStatusEventExpanded(event: EvalStatusEvent, theme: Theme): string[] {
705
735
  const lines: string[] = [];
706
736
  const { op, ...data } = event;
707
737
 
708
- // Main status line
709
738
  lines.push(formatStatusEvent(event, theme));
710
739
 
711
- // Add detail lines for operations with list data
712
740
  const addItems = (items: unknown[], formatter: (item: unknown) => string, max = 5) => {
713
741
  const arr = Array.isArray(items) ? items : [];
714
742
  for (let i = 0; i < Math.min(arr.length, max); i++) {
@@ -719,7 +747,6 @@ function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme): stri
719
747
  }
720
748
  };
721
749
 
722
- // Add preview lines (truncated content)
723
750
  const addPreview = (preview: string, maxLines = 3) => {
724
751
  const previewLines = String(preview).split("\n").slice(0, maxLines);
725
752
  for (const line of previewLines) {
@@ -755,14 +782,6 @@ function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme): stri
755
782
  });
756
783
  }
757
784
  break;
758
- case "rsed":
759
- if (data.changed) {
760
- addItems(data.changed as unknown[], c => {
761
- const change = c as { file: string; count: number };
762
- return `${shortenPath(change.file)}: ${change.count} replacement${change.count !== 1 ? "s" : ""}`;
763
- });
764
- }
765
- break;
766
785
  case "env":
767
786
  if (data.keys) addItems(data.keys as unknown[], k => String(k), 10);
768
787
  break;
@@ -786,7 +805,6 @@ function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme): stri
786
805
  case "tail":
787
806
  case "tree":
788
807
  case "diff":
789
- case "lines":
790
808
  case "git_diff":
791
809
  case "sh":
792
810
  if (data.preview) addPreview(String(data.preview));
@@ -797,7 +815,7 @@ function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme): stri
797
815
  }
798
816
 
799
817
  /** Render status events as tree lines. */
800
- function renderStatusEvents(events: PythonStatusEvent[], theme: Theme, expanded: boolean): string[] {
818
+ function renderStatusEvents(events: EvalStatusEvent[], theme: Theme, expanded: boolean): string[] {
801
819
  if (events.length === 0) return [];
802
820
 
803
821
  const maxCollapsed = 3;
@@ -810,7 +828,6 @@ function renderStatusEvents(events: PythonStatusEvent[], theme: Theme, expanded:
810
828
  const branch = isLast ? theme.tree.last : theme.tree.branch;
811
829
 
812
830
  if (expanded) {
813
- // Show expanded details for each event
814
831
  const eventLines = formatStatusEventExpanded(events[i], theme);
815
832
  lines.push(`${theme.fg("dim", branch)} ${eventLines[0]}`);
816
833
  const continueBranch = isLast ? " " : `${theme.tree.vertical} `;
@@ -832,7 +849,7 @@ function renderStatusEvents(events: PythonStatusEvent[], theme: Theme, expanded:
832
849
  }
833
850
 
834
851
  function formatCellOutputLines(
835
- cell: PythonCellResult,
852
+ cell: EvalCellResult,
836
853
  expanded: boolean,
837
854
  previewLines: number,
838
855
  theme: Theme,
@@ -861,60 +878,46 @@ function formatCellOutputLines(
861
878
  return { lines: outputLines, hiddenCount };
862
879
  }
863
880
 
864
- export const pythonToolRenderer = {
865
- renderCall(args: PythonRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
866
- const cells = args.cells ?? [];
867
- const cwd = getProjectDir();
868
- let displayWorkdir = args.cwd;
869
-
870
- if (displayWorkdir) {
871
- const resolvedCwd = path.resolve(cwd);
872
- const resolvedWorkdir = path.resolve(displayWorkdir);
873
- if (resolvedWorkdir === resolvedCwd) {
874
- displayWorkdir = undefined;
875
- } else {
876
- const relativePath = path.relative(resolvedCwd, resolvedWorkdir);
877
- const isWithinCwd =
878
- relativePath && !relativePath.startsWith("..") && !relativePath.startsWith(`..${path.sep}`);
879
- if (isWithinCwd) {
880
- displayWorkdir = relativePath;
881
- }
881
+ export const evalToolRenderer = {
882
+ renderCall(args: EvalRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
883
+ const input = getRenderInput(args);
884
+ let cells: ParsedEvalCell[] = [];
885
+ if (input) {
886
+ try {
887
+ cells = parseEvalInput(input).cells;
888
+ } catch {
889
+ cells = [];
882
890
  }
883
891
  }
884
892
 
885
- const workdirLabel = displayWorkdir ? `cd ${displayWorkdir}` : undefined;
886
893
  if (cells.length === 0) {
887
- const prompt = uiTheme.fg("accent", ">>>");
888
- const prefix = workdirLabel ? `${uiTheme.fg("dim", `${workdirLabel} && `)}` : "";
889
- const text = formatTitle(`${prompt} ${prefix}…`, uiTheme);
894
+ const promptSym = uiTheme.fg("accent", ">>>");
895
+ const text = formatTitle(`${promptSym} …`, uiTheme);
890
896
  return new Text(text, 0, 0);
891
897
  }
892
898
 
893
- // Cache state - cells don't change, only width varies
894
- let cached: { width: number; result: string[] } | undefined;
899
+ let cached: { key: string; width: number; result: string[] } | undefined;
895
900
 
896
901
  return {
897
902
  render: (width: number): string[] => {
898
- if (cached && cached.width === width) {
903
+ const key = `${input?.length ?? 0}`;
904
+ if (cached && cached.key === key && cached.width === width) {
899
905
  return cached.result;
900
906
  }
901
907
 
902
908
  const lines: string[] = [];
903
909
  for (let i = 0; i < cells.length; i++) {
904
910
  const cell = cells[i];
905
- const cellTitle = cell.title;
906
- const combinedTitle =
907
- cellTitle && workdirLabel ? `${workdirLabel} · ${cellTitle}` : (cellTitle ?? workdirLabel);
908
911
  const cellLines = renderCodeCell(
909
912
  {
910
913
  code: cell.code,
911
- language: "python",
914
+ language: languageForHighlighter(cell.language),
912
915
  index: i,
913
916
  total: cells.length,
914
- title: combinedTitle,
917
+ title: cell.title,
915
918
  status: "pending",
916
919
  width,
917
- codeMaxLines: PYTHON_DEFAULT_PREVIEW_LINES,
920
+ codeMaxLines: EVAL_DEFAULT_PREVIEW_LINES,
918
921
  expanded: true,
919
922
  },
920
923
  uiTheme,
@@ -924,7 +927,7 @@ export const pythonToolRenderer = {
924
927
  lines.push("");
925
928
  }
926
929
  }
927
- cached = { width, result: lines };
930
+ cached = { key, width, result: lines };
928
931
  return lines;
929
932
  },
930
933
  invalidate: () => {
@@ -934,9 +937,10 @@ export const pythonToolRenderer = {
934
937
  },
935
938
 
936
939
  renderResult(
937
- result: { content: Array<{ type: string; text?: string }>; details?: PythonToolDetails },
938
- options: RenderResultOptions & { renderContext?: PythonRenderContext },
940
+ result: { content: Array<{ type: string; text?: string }>; details?: EvalToolDetails },
941
+ options: RenderResultOptions & { renderContext?: EvalRenderContext },
939
942
  uiTheme: Theme,
943
+ _args?: EvalRenderArgs,
940
944
  ): Component {
941
945
  const details = result.details;
942
946
 
@@ -959,17 +963,16 @@ export const pythonToolRenderer = {
959
963
  if (details?.meta?.truncation) {
960
964
  warningLine = formatStyledTruncationWarning(details.meta, uiTheme) ?? undefined;
961
965
  }
966
+ const noticeLine = details?.notice ? uiTheme.fg("dim", wrapBrackets(details.notice, uiTheme)) : undefined;
962
967
 
963
968
  const cellResults = details?.cells;
964
969
  if (cellResults && cellResults.length > 0) {
965
- // Cache state following Box pattern
966
970
  let cached: { key: string; width: number; result: string[] } | undefined;
967
971
 
968
972
  return {
969
973
  render: (width: number): string[] => {
970
- // Read mutable state at render time
971
974
  const expanded = options.renderContext?.expanded ?? options.expanded;
972
- const previewLines = options.renderContext?.previewLines ?? PYTHON_DEFAULT_PREVIEW_LINES;
975
+ const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES;
973
976
  const key = `${expanded}|${previewLines}|${options.spinnerFrame}`;
974
977
  if (cached && cached.key === key && cached.width === width) {
975
978
  return cached.result;
@@ -995,7 +998,7 @@ export const pythonToolRenderer = {
995
998
  const cellLines = renderCodeCell(
996
999
  {
997
1000
  code: cell.code,
998
- language: "python",
1001
+ language: languageForHighlighter(cell.language ?? details?.language),
999
1002
  index: i,
1000
1003
  total: cellResults.length,
1001
1004
  title: cell.title,
@@ -1004,7 +1007,7 @@ export const pythonToolRenderer = {
1004
1007
  duration: cell.durationMs,
1005
1008
  output: outputLines.length > 0 ? outputLines.join("\n") : undefined,
1006
1009
  outputMaxLines: outputLines.length,
1007
- codeMaxLines: expanded ? Number.POSITIVE_INFINITY : PYTHON_DEFAULT_PREVIEW_LINES,
1010
+ codeMaxLines: expanded ? Number.POSITIVE_INFINITY : EVAL_DEFAULT_PREVIEW_LINES,
1008
1011
  expanded,
1009
1012
  width,
1010
1013
  },
@@ -1024,6 +1027,9 @@ export const pythonToolRenderer = {
1024
1027
  if (timeoutLine) {
1025
1028
  lines.push(timeoutLine);
1026
1029
  }
1030
+ if (noticeLine) {
1031
+ lines.push(noticeLine);
1032
+ }
1027
1033
  if (warningLine) {
1028
1034
  lines.push(warningLine);
1029
1035
  }
@@ -1047,12 +1053,12 @@ export const pythonToolRenderer = {
1047
1053
  );
1048
1054
 
1049
1055
  if (!combinedOutput && statusLines.length === 0) {
1050
- const lines = [timeoutLine, warningLine].filter(Boolean) as string[];
1056
+ const lines = [timeoutLine, noticeLine, warningLine].filter(Boolean) as string[];
1051
1057
  return new Text(lines.join("\n"), 0, 0);
1052
1058
  }
1053
1059
 
1054
1060
  if (!combinedOutput && statusLines.length > 0) {
1055
- const lines = [uiTheme.fg("dim", "Status"), ...statusLines, timeoutLine, warningLine].filter(
1061
+ const lines = [uiTheme.fg("dim", "Status"), ...statusLines, timeoutLine, noticeLine, warningLine].filter(
1056
1062
  Boolean,
1057
1063
  ) as string[];
1058
1064
  return new Text(lines.join("\n"), 0, 0);
@@ -1067,6 +1073,7 @@ export const pythonToolRenderer = {
1067
1073
  styledOutput,
1068
1074
  ...(statusLines.length > 0 ? [uiTheme.fg("dim", "Status"), ...statusLines] : []),
1069
1075
  timeoutLine,
1076
+ noticeLine,
1070
1077
  warningLine,
1071
1078
  ].filter(Boolean) as string[];
1072
1079
  return new Text(lines.join("\n"), 0, 0);
@@ -1085,8 +1092,7 @@ export const pythonToolRenderer = {
1085
1092
 
1086
1093
  return {
1087
1094
  render: (width: number): string[] => {
1088
- // Read mutable state at render time
1089
- const previewLines = options.renderContext?.previewLines ?? PYTHON_DEFAULT_PREVIEW_LINES;
1095
+ const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES;
1090
1096
  if (cachedLines === undefined || cachedWidth !== width || cachedPreviewLines !== previewLines) {
1091
1097
  const result = truncateToVisualLines(textContent, previewLines, width);
1092
1098
  cachedLines = result.visualLines;
@@ -1113,6 +1119,9 @@ export const pythonToolRenderer = {
1113
1119
  if (timeoutLine) {
1114
1120
  outputLines.push(truncateToWidth(timeoutLine, width));
1115
1121
  }
1122
+ if (noticeLine) {
1123
+ outputLines.push(truncateToWidth(noticeLine, width));
1124
+ }
1116
1125
  if (warningLine) {
1117
1126
  outputLines.push(truncateToWidth(warningLine, width));
1118
1127
  }