@ian-pascoe/pi-codemode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,487 @@
1
+ import {
2
+ getMarkdownTheme,
3
+ keyText,
4
+ truncateHead,
5
+ type AgentToolResult,
6
+ type Theme,
7
+ type ThemeColor,
8
+ type ToolRenderResultOptions,
9
+ } from "@earendil-works/pi-coding-agent";
10
+ import {
11
+ Container,
12
+ Markdown,
13
+ sliceByColumn,
14
+ Spacer,
15
+ stripTerminalSequences,
16
+ Text,
17
+ visibleWidth,
18
+ type Component,
19
+ } from "@earendil-works/pi-tui";
20
+ import { Type } from "typebox";
21
+ import { Value } from "typebox/value";
22
+ import { formatCodeModePresentationData } from "./codemode-presentation-output.js";
23
+ import {
24
+ CodeModeCancelParametersSchema,
25
+ CodeModeExecuteParametersSchema,
26
+ CodeModeResultDetailsSchema,
27
+ CodeModeResultParametersSchema,
28
+ createCodeModeToolDefinitions,
29
+ type CodeModeCancelParameters,
30
+ type CodeModeErrorCode,
31
+ type CodeModeExecuteParameters,
32
+ isCodeModeJsonObject,
33
+ type CodeModeJsonValue,
34
+ type CodeModePresentationSnapshot,
35
+ type CodeModeResultDetails,
36
+ type CodeModeResultParameters,
37
+ type CodeModeToolOperations,
38
+ } from "./codemode-tool-contract.js";
39
+
40
+ /** Names of the three CodeMode tools with semantic Transcript rendering. */
41
+ export type CodeModeRenderedToolName = "codemode_execute" | "codemode_result" | "codemode_cancel";
42
+
43
+ /** Parsed arguments accepted by one of the three CodeMode Transcript renderers. */
44
+ export type CodeModeRenderedToolParameters =
45
+ | CodeModeExecuteParameters
46
+ | CodeModeResultParameters
47
+ | CodeModeCancelParameters;
48
+
49
+ /** Theme operations used by CodeMode Transcript renderers. */
50
+ export type CodeModeRenderTheme = Pick<Theme, "bold" | "fg">;
51
+
52
+ type CodeModeCellState = CodeModePresentationSnapshot["cell_state"];
53
+ type CodeModeStatusPresentation = {
54
+ readonly color: ThemeColor;
55
+ readonly label: string;
56
+ };
57
+
58
+ const CODEMODE_STATUS_PRESENTATION = {
59
+ running: { color: "accent", label: "◉ running" },
60
+ completed: { color: "success", label: "✓ completed" },
61
+ failed: { color: "error", label: "× failed" },
62
+ cancelled: { color: "warning", label: "■ cancelled" },
63
+ timed_out: { color: "error", label: "! timed out" },
64
+ } satisfies Record<CodeModeCellState, CodeModeStatusPresentation>;
65
+ const CODEMODE_SCRIPT_MAX_LINES = 200;
66
+ const CODEMODE_PRESENTATION_MAX_BYTES = 50 * 1024;
67
+ const CodeModeJsonStringSchema = Type.String();
68
+
69
+ function sanitizeCodeModeText(text: string): string {
70
+ return (
71
+ stripTerminalSequences(text)
72
+ .replaceAll("\r\n", "\n")
73
+ .replaceAll("\r", "\n")
74
+ // oxlint-disable-next-line eslint/no-control-regex -- SAFETY: Transcript text permits tabs/newlines but must remove every remaining C0/C1 terminal control.
75
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
76
+ );
77
+ }
78
+
79
+ function boundedCodeModePreview(text: string, width = 72): string {
80
+ const singleLine = sanitizeCodeModeText(text).replace(/\s+/g, " ").trim();
81
+ if (visibleWidth(singleLine) <= width) return singleLine;
82
+ return `${sliceByColumn(singleLine, 0, width - 1, true).trimEnd()}…`;
83
+ }
84
+
85
+ function firstMeaningfulScriptLine(script: string): string | undefined {
86
+ const line = sanitizeCodeModeText(script)
87
+ .split("\n")
88
+ .map((candidate) => candidate.trim())
89
+ .find(Boolean);
90
+ return line === undefined ? undefined : boundedCodeModePreview(line);
91
+ }
92
+
93
+ function parseCodeModeRenderedToolParameters(
94
+ toolName: CodeModeRenderedToolName,
95
+ parameters: CodeModeJsonValue,
96
+ ): CodeModeRenderedToolParameters | undefined {
97
+ if (toolName === "codemode_execute") {
98
+ return Value.Check(CodeModeExecuteParametersSchema, parameters) ? parameters : undefined;
99
+ }
100
+ if (toolName === "codemode_result") {
101
+ return Value.Check(CodeModeResultParametersSchema, parameters) ? parameters : undefined;
102
+ }
103
+ return Value.Check(CodeModeCancelParametersSchema, parameters) ? parameters : undefined;
104
+ }
105
+
106
+ function shortCodeModeSessionId(sessionId: string): string {
107
+ const safe = sanitizeCodeModeText(sessionId).replace(/\s+/g, "");
108
+ return sliceByColumn(safe, 0, Math.min(8, visibleWidth(safe)), true);
109
+ }
110
+
111
+ /** Resolves one Session ID to the shortest unambiguous CodeMode Transcript label. */
112
+ export type CodeModeSessionPrefixFormatter = (sessionId: string) => string;
113
+
114
+ function formatCodeModeDuration(elapsedMs: number): string {
115
+ if (elapsedMs < 1_000) return `${elapsedMs}ms`;
116
+ if (elapsedMs < 60_000) return `${(elapsedMs / 1_000).toFixed(1)}s`;
117
+ const seconds = Math.floor(elapsedMs / 1_000);
118
+ return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
119
+ }
120
+
121
+ function pluralizedCodeModeCount(count: number, noun: string): string {
122
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
123
+ }
124
+
125
+ function codeModeCellState(
126
+ toolName: CodeModeRenderedToolName,
127
+ details: CodeModeResultDetails,
128
+ ): CodeModeCellState {
129
+ if (details.presentation !== undefined) return details.presentation.cell_state;
130
+ if (toolName === "codemode_cancel" && details.result === "success") return "cancelled";
131
+ if (details.result === "pending") return "running";
132
+ if (details.result === "success") return "completed";
133
+ if (details.error.code === "timeout") return "timed_out";
134
+ if (details.error.code === "cancellation") return "cancelled";
135
+ return "failed";
136
+ }
137
+
138
+ function codeModeSessionLifecycle(
139
+ toolName: CodeModeRenderedToolName,
140
+ details: CodeModeResultDetails,
141
+ ): "Session reusable" | "Session closed" | "No reusable Session" {
142
+ if (details.presentation !== undefined) {
143
+ return details.presentation.session_state === "live" ? "Session reusable" : "Session closed";
144
+ }
145
+ if (toolName === "codemode_cancel") return "Session closed";
146
+ if (
147
+ details.result === "failed" &&
148
+ (details.error.code === "capacity" || details.error.code === "unknown")
149
+ ) {
150
+ return "No reusable Session";
151
+ }
152
+ if (
153
+ details.result !== "failed" ||
154
+ details.error.code === "script" ||
155
+ details.error.code === "serialization" ||
156
+ details.error.code === "busy"
157
+ ) {
158
+ return "Session reusable";
159
+ }
160
+ return "Session closed";
161
+ }
162
+
163
+ function codeModeValueSummary(value: CodeModeJsonValue | undefined): string {
164
+ if (value === undefined) return "(no data)";
165
+ if (value === null) return "null";
166
+ if (Array.isArray(value)) return `array · ${pluralizedCodeModeCount(value.length, "item")}`;
167
+ if (isCodeModeJsonObject(value)) {
168
+ return `object · ${pluralizedCodeModeCount(Object.keys(value).length, "key")}`;
169
+ }
170
+ if (Value.Check(CodeModeJsonStringSchema, value)) {
171
+ return boundedCodeModePreview(JSON.stringify(value), 48);
172
+ }
173
+ return JSON.stringify(value) ?? "";
174
+ }
175
+
176
+ function appendCodeModeField(
177
+ container: Container,
178
+ theme: CodeModeRenderTheme,
179
+ label: string,
180
+ value: string | number,
181
+ ): void {
182
+ container.addChild(
183
+ new Text(`${theme.fg("muted", `${label}:`)} ${sanitizeCodeModeText(String(value))}`, 0, 0),
184
+ );
185
+ }
186
+
187
+ function appendCodeModeBlock(container: Container, language: "ts" | "json", content: string): void {
188
+ const longestFence = Math.max(2, ...[...content.matchAll(/`+/g)].map(([fence]) => fence.length));
189
+ const fence = "`".repeat(longestFence + 1);
190
+ container.addChild(
191
+ new Markdown(`${fence}${language}\n${content}\n${fence}`, 0, 0, getMarkdownTheme()),
192
+ );
193
+ }
194
+
195
+ function boundedCodeModeText(text: string, maxLines: number): string {
196
+ const safe = sanitizeCodeModeText(text);
197
+ const truncated = truncateHead(safe, {
198
+ maxBytes: CODEMODE_PRESENTATION_MAX_BYTES,
199
+ maxLines,
200
+ });
201
+ if (!truncated.truncated) return safe;
202
+ const omittedLines = Math.max(0, truncated.totalLines - truncated.outputLines);
203
+ const notice = `… ${pluralizedCodeModeCount(omittedLines, "line")} omitted`;
204
+ return truncated.content.length === 0 ? notice : `${truncated.content}\n${notice}`;
205
+ }
206
+
207
+ function renderCodeModeFallback(
208
+ result: AgentToolResult<unknown>,
209
+ options: ToolRenderResultOptions,
210
+ theme: CodeModeRenderTheme,
211
+ isError: boolean,
212
+ ): Component {
213
+ const output = sanitizeCodeModeText(
214
+ result.content
215
+ .filter((item) => item.type === "text")
216
+ .map((item) => item.text)
217
+ .join(""),
218
+ );
219
+ const firstLine = output.split("\n").find(Boolean) ?? "CodeMode failed";
220
+ const visible = options.expanded
221
+ ? boundedCodeModeText(output, 2_000)
222
+ : boundedCodeModePreview(firstLine, 160);
223
+ const hint =
224
+ !options.expanded && (output.includes("\n") || visible !== firstLine)
225
+ ? ` · ${keyText("app.tools.expand")} to expand`
226
+ : "";
227
+ return new Text(theme.fg(isError ? "error" : "toolOutput", `${visible}${hint}`), 0, 0);
228
+ }
229
+
230
+ function renderCodeModeSummary(
231
+ details: CodeModeResultDetails,
232
+ toolName: CodeModeRenderedToolName,
233
+ theme: CodeModeRenderTheme,
234
+ formatSessionPrefix: CodeModeSessionPrefixFormatter,
235
+ ): string {
236
+ const presentation = details.presentation;
237
+ const state = codeModeCellState(toolName, details);
238
+ const status = CODEMODE_STATUS_PRESENTATION[state];
239
+ const activeToolNames = presentation?.active_tool_names.slice(0, 3) ?? [];
240
+ const omittedActiveToolCount = Math.max(
241
+ 0,
242
+ (presentation?.active_tool_count ?? 0) - activeToolNames.length,
243
+ );
244
+ const parts = [
245
+ theme.fg(status.color, status.label),
246
+ theme.fg("muted", formatSessionPrefix(details.sessionId)),
247
+ presentation?.cell_ordinal === undefined
248
+ ? undefined
249
+ : theme.fg("muted", `Cell ${presentation.cell_ordinal}`),
250
+ state === "running" && presentation !== undefined && presentation.active_tool_names.length > 0
251
+ ? theme.fg(
252
+ "muted",
253
+ `${activeToolNames.map((name) => sanitizeCodeModeText(name)).join(", ")}${omittedActiveToolCount > 0 ? ` +${omittedActiveToolCount}` : ""}`,
254
+ )
255
+ : undefined,
256
+ details.result === "success"
257
+ ? theme.fg("toolOutput", codeModeValueSummary(details.data))
258
+ : undefined,
259
+ details.result === "failed" ? theme.fg("muted", details.error.code) : undefined,
260
+ details.result === "failed" ? boundedCodeModePreview(details.error.message, 64) : undefined,
261
+ state !== "running" && presentation !== undefined && presentation.nested_tool_count > 0
262
+ ? theme.fg("muted", pluralizedCodeModeCount(presentation.nested_tool_count, "tool"))
263
+ : undefined,
264
+ presentation === undefined
265
+ ? undefined
266
+ : theme.fg("muted", formatCodeModeDuration(presentation.elapsed_ms)),
267
+ ].filter((part): part is string => part !== undefined);
268
+ return parts.join(" ");
269
+ }
270
+
271
+ /** Render one CodeMode tool call as a semantic operation with bounded source detail. */
272
+ export function renderCodeModeToolCall(
273
+ toolName: CodeModeRenderedToolName,
274
+ parameters: CodeModeJsonValue,
275
+ theme: CodeModeRenderTheme,
276
+ expanded: boolean,
277
+ formatSessionPrefix: CodeModeSessionPrefixFormatter = shortCodeModeSessionId,
278
+ ): Component {
279
+ const operation =
280
+ toolName === "codemode_execute"
281
+ ? "Run Cell"
282
+ : toolName === "codemode_result"
283
+ ? "Poll"
284
+ : "Cancel";
285
+ const parsedParameters = parseCodeModeRenderedToolParameters(toolName, parameters);
286
+ const executeParameters =
287
+ toolName === "codemode_execute" &&
288
+ parsedParameters !== undefined &&
289
+ "script" in parsedParameters
290
+ ? parsedParameters
291
+ : undefined;
292
+ const sessionId = parsedParameters?.sessionId;
293
+ const preview =
294
+ executeParameters === undefined
295
+ ? undefined
296
+ : firstMeaningfulScriptLine(executeParameters.script);
297
+ const container = new Container();
298
+ container.addChild(
299
+ new Text(
300
+ [
301
+ theme.fg("toolTitle", theme.bold("CodeMode")),
302
+ theme.fg("accent", operation),
303
+ theme.fg("muted", sessionId === undefined ? "new" : formatSessionPrefix(sessionId)),
304
+ preview === undefined ? undefined : theme.fg("dim", preview),
305
+ ]
306
+ .filter((part): part is string => part !== undefined)
307
+ .join(" "),
308
+ 0,
309
+ 0,
310
+ ),
311
+ );
312
+ if (!expanded) return container;
313
+ container.addChild(new Spacer(1));
314
+ if (sessionId !== undefined) appendCodeModeField(container, theme, "Session", sessionId);
315
+ if (executeParameters === undefined) return container;
316
+ if (executeParameters.wait !== undefined)
317
+ appendCodeModeField(container, theme, "Wait", String(executeParameters.wait));
318
+ if (executeParameters.timeoutMs !== undefined)
319
+ appendCodeModeField(container, theme, "Timeout", `${executeParameters.timeoutMs}ms`);
320
+ container.addChild(new Spacer(1));
321
+ container.addChild(new Text(theme.fg("muted", theme.bold("TypeScript")), 0, 0));
322
+ const script = boundedCodeModeText(executeParameters.script, CODEMODE_SCRIPT_MAX_LINES);
323
+ appendCodeModeBlock(container, "ts", script);
324
+ return container;
325
+ }
326
+
327
+ /** Render one CodeMode result in collapsed, expanded, partial, or historical form. */
328
+ export function renderCodeModeToolResult(
329
+ toolName: CodeModeRenderedToolName,
330
+ result: AgentToolResult<unknown>,
331
+ options: ToolRenderResultOptions,
332
+ theme: CodeModeRenderTheme,
333
+ parameters: CodeModeRenderedToolParameters,
334
+ isError: boolean,
335
+ formatSessionPrefix: CodeModeSessionPrefixFormatter = shortCodeModeSessionId,
336
+ ): Component {
337
+ if (!Value.Check(CodeModeResultDetailsSchema, result.details)) {
338
+ return renderCodeModeFallback(result, options, theme, isError);
339
+ }
340
+ const details = result.details;
341
+ const summary = renderCodeModeSummary(details, toolName, theme, formatSessionPrefix);
342
+ if (!options.expanded) {
343
+ const hint = options.isPartial ? "" : ` · ${keyText("app.tools.expand")} to expand`;
344
+ return new Text(`${summary}${hint}`, 0, 0);
345
+ }
346
+
347
+ const container = new Container();
348
+ container.addChild(new Text(summary, 0, 0));
349
+ container.addChild(new Spacer(1));
350
+ appendCodeModeField(container, theme, "Session", details.sessionId);
351
+ if (details.presentation?.cell_ordinal !== undefined) {
352
+ appendCodeModeField(container, theme, "Cell", details.presentation.cell_ordinal);
353
+ }
354
+ appendCodeModeField(container, theme, "Lifecycle", codeModeSessionLifecycle(toolName, details));
355
+
356
+ const presentation = details.presentation;
357
+ if (presentation !== undefined && presentation.nested_tools.length > 0) {
358
+ container.addChild(new Spacer(1));
359
+ container.addChild(new Text(theme.fg("muted", theme.bold("Tool activity")), 0, 0));
360
+ for (const nested of presentation.nested_tools.slice(0, 20)) {
361
+ const successful = nested.outcome === "success";
362
+ const symbol = successful ? "✓" : nested.outcome === "cancelled" ? "■" : "×";
363
+ const color: ThemeColor = successful
364
+ ? "success"
365
+ : nested.outcome === "cancelled"
366
+ ? "warning"
367
+ : "error";
368
+ container.addChild(
369
+ new Text(
370
+ `${theme.fg(color, symbol)} ${sanitizeCodeModeText(nested.name)} ${theme.fg("muted", formatCodeModeDuration(nested.elapsed_ms))}`,
371
+ 0,
372
+ 0,
373
+ ),
374
+ );
375
+ }
376
+ const omittedNestedToolCount =
377
+ presentation.omitted_nested_tool_count + Math.max(0, presentation.nested_tools.length - 20);
378
+ if (omittedNestedToolCount > 0) {
379
+ container.addChild(
380
+ new Text(
381
+ theme.fg("dim", `… ${pluralizedCodeModeCount(omittedNestedToolCount, "tool")} omitted`),
382
+ 0,
383
+ 0,
384
+ ),
385
+ );
386
+ }
387
+ }
388
+
389
+ if (details.result === "success") {
390
+ container.addChild(new Spacer(1));
391
+ container.addChild(new Text(theme.fg("muted", theme.bold("Result")), 0, 0));
392
+ if (details.data === undefined)
393
+ container.addChild(new Text(theme.fg("dim", "(no data)"), 0, 0));
394
+ else {
395
+ const json = boundedCodeModeText(formatCodeModePresentationData(details.data), 2_000);
396
+ appendCodeModeBlock(container, "json", json);
397
+ }
398
+ } else if (details.result === "failed") {
399
+ container.addChild(new Spacer(1));
400
+ container.addChild(new Text(theme.fg("muted", theme.bold("Error")), 0, 0));
401
+ appendCodeModeField(container, theme, "Code", details.error.code satisfies CodeModeErrorCode);
402
+ container.addChild(
403
+ new Text(theme.fg("error", boundedCodeModeText(details.error.message, 2_000)), 0, 0),
404
+ );
405
+ }
406
+ if (presentation?.spill_path !== undefined) {
407
+ appendCodeModeField(container, theme, "Result Spill", presentation.spill_path);
408
+ }
409
+ return container;
410
+ }
411
+
412
+ /** Create the three CodeMode tools with semantic call and result Transcript renderers. */
413
+ export function createRenderedCodeModeToolDefinitions(
414
+ operations: CodeModeToolOperations,
415
+ executeDescription?: string,
416
+ formatSessionPrefix: CodeModeSessionPrefixFormatter = shortCodeModeSessionId,
417
+ ): ReturnType<typeof createCodeModeToolDefinitions> {
418
+ const [executeTool, resultTool, cancelTool] = createCodeModeToolDefinitions(
419
+ operations,
420
+ executeDescription,
421
+ );
422
+ return [
423
+ {
424
+ ...executeTool,
425
+ renderCall: (args, theme, context) =>
426
+ renderCodeModeToolCall(
427
+ "codemode_execute",
428
+ args,
429
+ theme,
430
+ context.expanded,
431
+ formatSessionPrefix,
432
+ ),
433
+ renderResult: (result, options, theme, context) =>
434
+ renderCodeModeToolResult(
435
+ "codemode_execute",
436
+ result,
437
+ options,
438
+ theme,
439
+ context.args,
440
+ context.isError,
441
+ formatSessionPrefix,
442
+ ),
443
+ },
444
+ {
445
+ ...resultTool,
446
+ renderCall: (args, theme, context) =>
447
+ renderCodeModeToolCall(
448
+ "codemode_result",
449
+ args,
450
+ theme,
451
+ context.expanded,
452
+ formatSessionPrefix,
453
+ ),
454
+ renderResult: (result, options, theme, context) =>
455
+ renderCodeModeToolResult(
456
+ "codemode_result",
457
+ result,
458
+ options,
459
+ theme,
460
+ context.args,
461
+ context.isError,
462
+ formatSessionPrefix,
463
+ ),
464
+ },
465
+ {
466
+ ...cancelTool,
467
+ renderCall: (args, theme, context) =>
468
+ renderCodeModeToolCall(
469
+ "codemode_cancel",
470
+ args,
471
+ theme,
472
+ context.expanded,
473
+ formatSessionPrefix,
474
+ ),
475
+ renderResult: (result, options, theme, context) =>
476
+ renderCodeModeToolResult(
477
+ "codemode_cancel",
478
+ result,
479
+ options,
480
+ theme,
481
+ context.args,
482
+ context.isError,
483
+ formatSessionPrefix,
484
+ ),
485
+ },
486
+ ];
487
+ }