@ian-pascoe/pi-codemode 0.2.0 → 0.3.1
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.
- package/README.md +50 -13
- package/package.json +1 -1
- package/src/codemode-console-output.ts +11 -0
- package/src/codemode-observer-ui.ts +15 -36
- package/src/codemode-session-coordinator.ts +127 -90
- package/src/codemode-tool-catalog.ts +4 -6
- package/src/codemode-tool-contract.ts +37 -7
- package/src/codemode-tool-rendering.ts +71 -27
- package/src/codemode-worker-protocol.ts +141 -33
- package/src/codemode-worker.ts +283 -18
- package/src/pi-codemode-extension.ts +17 -21
- package/src/pi-tool-bridge.ts +5 -38
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { Type } from "typebox";
|
|
22
22
|
import { Value } from "typebox/value";
|
|
23
23
|
import { formatCodeModePresentationData } from "./codemode-presentation-output.js";
|
|
24
|
+
import { formatCodeModeDuration } from "./codemode-session-coordinator.js";
|
|
24
25
|
import {
|
|
25
26
|
CodeModeCancelParametersSchema,
|
|
26
27
|
CodeModeExecuteParametersSchema,
|
|
@@ -116,13 +117,6 @@ function shortCodeModeSessionId(sessionId: string): string {
|
|
|
116
117
|
/** Resolves one Session ID to the shortest unambiguous CodeMode Transcript label. */
|
|
117
118
|
export type CodeModeSessionPrefixFormatter = (sessionId: string) => string;
|
|
118
119
|
|
|
119
|
-
function formatCodeModeDuration(elapsedMs: number): string {
|
|
120
|
-
if (elapsedMs < 1_000) return `${elapsedMs}ms`;
|
|
121
|
-
if (elapsedMs < 60_000) return `${(elapsedMs / 1_000).toFixed(1)}s`;
|
|
122
|
-
const seconds = Math.floor(elapsedMs / 1_000);
|
|
123
|
-
return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
120
|
function pluralizedCodeModeCount(count: number, noun: string): string {
|
|
127
121
|
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
128
122
|
}
|
|
@@ -206,6 +200,47 @@ function highlightedCodeModeSource(source: string): string {
|
|
|
206
200
|
.join("\n");
|
|
207
201
|
}
|
|
208
202
|
|
|
203
|
+
function truncateHighlightedCodeModeSourceLine(line: string, width: number): string {
|
|
204
|
+
if (visibleWidth(line) <= width) return line;
|
|
205
|
+
const prefix = sliceByColumn(line, 0, Math.max(0, width - 1), true);
|
|
206
|
+
return `${prefix}\u001b[22;23;24;25;27;28;29;39m…`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
class CodeModeCollapsedSourceComponent implements Component {
|
|
210
|
+
private readonly lines: readonly string[];
|
|
211
|
+
|
|
212
|
+
constructor(source: string) {
|
|
213
|
+
this.lines = highlightedCodeModeSource(source).split("\n");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
render(width: number): string[] {
|
|
217
|
+
if (width <= 0) return [];
|
|
218
|
+
return this.lines.map((line) => truncateHighlightedCodeModeSourceLine(line, width));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
invalidate(): void {}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
class CodeModeCollapsedInlineSourceComponent implements Component {
|
|
225
|
+
constructor(
|
|
226
|
+
private readonly prefix: string,
|
|
227
|
+
private readonly source: string,
|
|
228
|
+
private readonly suffix: string,
|
|
229
|
+
) {}
|
|
230
|
+
|
|
231
|
+
render(width: number): string[] {
|
|
232
|
+
if (width <= 0) return [];
|
|
233
|
+
const sourceWidth = width - visibleWidth(this.prefix) - visibleWidth(this.suffix);
|
|
234
|
+
if (visibleWidth(this.source) === 0 || sourceWidth <= 0)
|
|
235
|
+
return new Text(`${this.prefix.trimEnd()}${this.suffix}`, 0, 0).render(width);
|
|
236
|
+
return [
|
|
237
|
+
`${this.prefix}${truncateHighlightedCodeModeSourceLine(this.source, sourceWidth)}${this.suffix}`,
|
|
238
|
+
];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
invalidate(): void {}
|
|
242
|
+
}
|
|
243
|
+
|
|
209
244
|
function appendHighlightedCodeModeSource(container: Container, source: string): void {
|
|
210
245
|
container.addChild(new Text(highlightedCodeModeSource(source), 0, 0));
|
|
211
246
|
}
|
|
@@ -252,6 +287,7 @@ function renderCodeModeSummary(
|
|
|
252
287
|
formatSessionPrefix: CodeModeSessionPrefixFormatter,
|
|
253
288
|
): string {
|
|
254
289
|
const presentation = details.presentation;
|
|
290
|
+
const consoleEntries = details.result === "pending" ? undefined : details.console;
|
|
255
291
|
const state = codeModeCellState(toolName, details);
|
|
256
292
|
const status =
|
|
257
293
|
details.result === "failed" && details.error.code === "eviction"
|
|
@@ -279,6 +315,9 @@ function renderCodeModeSummary(
|
|
|
279
315
|
: undefined,
|
|
280
316
|
details.result === "failed" ? theme.fg("muted", details.error.code) : undefined,
|
|
281
317
|
details.result === "failed" ? boundedCodeModePreview(details.error.message, 64) : undefined,
|
|
318
|
+
consoleEntries === undefined
|
|
319
|
+
? undefined
|
|
320
|
+
: theme.fg("muted", pluralizedCodeModeCount(consoleEntries.length, "console call")),
|
|
282
321
|
state !== "running" && presentation !== undefined && presentation.nested_tool_count > 0
|
|
283
322
|
? theme.fg("muted", pluralizedCodeModeCount(presentation.nested_tool_count, "tool"))
|
|
284
323
|
: undefined,
|
|
@@ -321,30 +360,27 @@ export function renderCodeModeToolCall(
|
|
|
321
360
|
const oneLineSource = source !== undefined && !source.includes("\n") ? source : undefined;
|
|
322
361
|
const expansionHint = `${keyText("app.tools.expand")} to expand`;
|
|
323
362
|
const container = new Container();
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
0,
|
|
341
|
-
),
|
|
342
|
-
);
|
|
363
|
+
const header = [
|
|
364
|
+
theme.fg("toolTitle", theme.bold("CodeMode")),
|
|
365
|
+
theme.fg("accent", operation),
|
|
366
|
+
theme.fg("muted", sessionId === undefined ? "new" : formatSessionPrefix(sessionId)),
|
|
367
|
+
].join(" ");
|
|
368
|
+
if (!expanded && oneLineSource !== undefined) {
|
|
369
|
+
container.addChild(
|
|
370
|
+
new CodeModeCollapsedInlineSourceComponent(
|
|
371
|
+
`${header} `,
|
|
372
|
+
highlightCode(oneLineSource, "typescript")[0] ?? "",
|
|
373
|
+
` ${theme.fg("dim", `· ${expansionHint}`)}`,
|
|
374
|
+
),
|
|
375
|
+
);
|
|
376
|
+
} else {
|
|
377
|
+
container.addChild(new Text(header, 0, 0));
|
|
378
|
+
}
|
|
343
379
|
if (!expanded) {
|
|
344
380
|
if (source === undefined || oneLineSource !== undefined) return container;
|
|
345
381
|
const sourceLines = source.split("\n");
|
|
346
382
|
const visibleSource = sourceLines.slice(0, CODEMODE_COLLAPSED_SCRIPT_LINES).join("\n");
|
|
347
|
-
|
|
383
|
+
container.addChild(new CodeModeCollapsedSourceComponent(visibleSource));
|
|
348
384
|
const omittedLines = Math.max(0, sourceLines.length - CODEMODE_COLLAPSED_SCRIPT_LINES);
|
|
349
385
|
const omitted =
|
|
350
386
|
omittedLines === 0 ? "" : `… ${pluralizedCodeModeCount(omittedLines, "line")} omitted · `;
|
|
@@ -414,6 +450,7 @@ export function renderCodeModeToolResult(
|
|
|
414
450
|
return renderCodeModeFallback(result, options, theme, isError);
|
|
415
451
|
}
|
|
416
452
|
const details = result.details;
|
|
453
|
+
const consoleEntries = details.result === "pending" ? undefined : details.console;
|
|
417
454
|
const summary = renderCodeModeSummary(details, toolName, theme, formatSessionPrefix);
|
|
418
455
|
if (!options.expanded) {
|
|
419
456
|
const hint = options.isPartial ? "" : ` · ${keyText("app.tools.expand")} to expand`;
|
|
@@ -462,6 +499,13 @@ export function renderCodeModeToolResult(
|
|
|
462
499
|
}
|
|
463
500
|
}
|
|
464
501
|
|
|
502
|
+
if (consoleEntries !== undefined) {
|
|
503
|
+
container.addChild(new Spacer(1));
|
|
504
|
+
container.addChild(new Text(theme.fg("muted", theme.bold("Console")), 0, 0));
|
|
505
|
+
const output = consoleEntries.map((entry) => `${entry.method}: ${entry.text}`).join("\n");
|
|
506
|
+
container.addChild(new Text(theme.fg("toolOutput", boundedCodeModeText(output, 2_000)), 0, 0));
|
|
507
|
+
}
|
|
508
|
+
|
|
465
509
|
if (details.result === "success") {
|
|
466
510
|
container.addChild(new Spacer(1));
|
|
467
511
|
container.addChild(new Text(theme.fg("muted", theme.bold("Result")), 0, 0));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
|
+
import { CODEMODE_CONSOLE_METHODS, type CodeModeConsoleEntry } from "./codemode-console-output.ts";
|
|
2
3
|
|
|
3
4
|
/** Maximum UTF-8 bytes in one CodeMode worker protocol line, excluding its newline. */
|
|
4
5
|
export const CODEMODE_WORKER_MESSAGE_LIMIT_BYTES = 8 * 1024 * 1024;
|
|
@@ -76,6 +77,7 @@ export type CodeModeWorkerResponse =
|
|
|
76
77
|
readonly sessionId: string;
|
|
77
78
|
readonly cellId: string;
|
|
78
79
|
readonly resultJson?: string;
|
|
80
|
+
readonly console?: readonly CodeModeConsoleEntry[];
|
|
79
81
|
}
|
|
80
82
|
| {
|
|
81
83
|
readonly version: 1;
|
|
@@ -83,6 +85,7 @@ export type CodeModeWorkerResponse =
|
|
|
83
85
|
readonly sessionId: string;
|
|
84
86
|
readonly cellId: string;
|
|
85
87
|
readonly error: { readonly code: CodeModeWorkerCellErrorCode; readonly message: string };
|
|
88
|
+
readonly console?: readonly CodeModeConsoleEntry[];
|
|
86
89
|
}
|
|
87
90
|
| {
|
|
88
91
|
readonly version: 1;
|
|
@@ -132,19 +135,61 @@ function hasString(values: readonly string[], candidate: string): boolean {
|
|
|
132
135
|
function hasExactKeys(value: CodeModeProtocolObject, expected: readonly string[]): boolean {
|
|
133
136
|
const keys = objectKeys(value);
|
|
134
137
|
if (keys.length !== expected.length) return false;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
138
|
+
return expected.every((expectedKey) => expectedKey !== undefined && keys.includes(expectedKey));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function hasDuplicateJsonObjectKeys(message: string): boolean {
|
|
142
|
+
const objectKeysByDepth: (string[] | undefined)[] = [];
|
|
143
|
+
for (let index = 0; index < message.length; index += 1) {
|
|
144
|
+
const character = message[index];
|
|
145
|
+
if (character === "{") {
|
|
146
|
+
objectKeysByDepth.push([]);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (character === "[") {
|
|
150
|
+
objectKeysByDepth.push(undefined);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (character === "}" || character === "]") {
|
|
154
|
+
objectKeysByDepth.pop();
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (character !== '"') continue;
|
|
158
|
+
|
|
159
|
+
let endIndex = index + 1;
|
|
160
|
+
for (; endIndex < message.length; endIndex += 1) {
|
|
161
|
+
if (message[endIndex] === "\\") {
|
|
162
|
+
endIndex += 1;
|
|
163
|
+
continue;
|
|
143
164
|
}
|
|
165
|
+
if (message[endIndex] === '"') break;
|
|
144
166
|
}
|
|
145
|
-
if (
|
|
167
|
+
if (endIndex >= message.length) return false;
|
|
168
|
+
let nextIndex = endIndex + 1;
|
|
169
|
+
while (
|
|
170
|
+
message[nextIndex] === " " ||
|
|
171
|
+
message[nextIndex] === "\n" ||
|
|
172
|
+
message[nextIndex] === "\r" ||
|
|
173
|
+
message[nextIndex] === "\t"
|
|
174
|
+
) {
|
|
175
|
+
nextIndex += 1;
|
|
176
|
+
}
|
|
177
|
+
const keys = objectKeysByDepth[objectKeysByDepth.length - 1];
|
|
178
|
+
if (message[nextIndex] === ":" && keys !== undefined) {
|
|
179
|
+
try {
|
|
180
|
+
// SAFETY: This slice is one syntactically bounded JSON string token; the string refinement below rejects every other JSON value.
|
|
181
|
+
const key = jsonParse(message.slice(index, endIndex + 1)) as CodeModeProtocolValue;
|
|
182
|
+
if (isString(key)) {
|
|
183
|
+
if (hasString(keys, key)) return true;
|
|
184
|
+
keys.push(key);
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
index = endIndex;
|
|
146
191
|
}
|
|
147
|
-
return
|
|
192
|
+
return false;
|
|
148
193
|
}
|
|
149
194
|
|
|
150
195
|
function parseProtocolJson(
|
|
@@ -158,6 +203,9 @@ function parseProtocolJson(
|
|
|
158
203
|
if (Buffer.byteLength(message, "utf8") > CODEMODE_WORKER_MESSAGE_LIMIT_BYTES) {
|
|
159
204
|
return { ok: false, message: `CodeMode worker ${subject} exceeds 8 MiB` };
|
|
160
205
|
}
|
|
206
|
+
if (hasDuplicateJsonObjectKeys(message)) {
|
|
207
|
+
return { ok: false, message: `CodeMode worker ${subject} contains duplicate object keys` };
|
|
208
|
+
}
|
|
161
209
|
try {
|
|
162
210
|
// SAFETY: Successful JSON.parse output is exactly the recursive JSON representation modeled by CodeModeProtocolValue.
|
|
163
211
|
return { ok: true, value: jsonParse(message) as CodeModeProtocolValue };
|
|
@@ -308,6 +356,30 @@ function parseWorkerError(
|
|
|
308
356
|
return { code: value.code, message: value.message };
|
|
309
357
|
}
|
|
310
358
|
|
|
359
|
+
function parseConsoleOutput(
|
|
360
|
+
value: CodeModeProtocolSlot,
|
|
361
|
+
): readonly CodeModeConsoleEntry[] | undefined {
|
|
362
|
+
if (!arrayIsArray(value) || value.length === 0) return undefined;
|
|
363
|
+
const entries: CodeModeConsoleEntry[] = [];
|
|
364
|
+
for (const candidate of value) {
|
|
365
|
+
if (
|
|
366
|
+
!isRecord(candidate) ||
|
|
367
|
+
!hasExactKeys(candidate, ["method", "text"]) ||
|
|
368
|
+
!isString(candidate.method) ||
|
|
369
|
+
!hasString(CODEMODE_CONSOLE_METHODS, candidate.method) ||
|
|
370
|
+
!isString(candidate.text)
|
|
371
|
+
) {
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
entries.push({
|
|
375
|
+
// SAFETY: The literal-membership check above refines the protocol string to a supported Console method.
|
|
376
|
+
method: candidate.method as CodeModeConsoleEntry["method"],
|
|
377
|
+
text: candidate.text,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return entries;
|
|
381
|
+
}
|
|
382
|
+
|
|
311
383
|
function parseToolBatchResponse(
|
|
312
384
|
decoded: CodeModeProtocolObject,
|
|
313
385
|
): CodeModeWorkerResponse | undefined {
|
|
@@ -401,43 +473,58 @@ export function parseCodeModeWorkerResponse(
|
|
|
401
473
|
}
|
|
402
474
|
if (
|
|
403
475
|
decoded.type === "cell-result" &&
|
|
404
|
-
hasExactKeys(
|
|
405
|
-
decoded,
|
|
406
|
-
decoded.resultJson === undefined
|
|
407
|
-
? ["cellId", "sessionId", "type", "version"]
|
|
408
|
-
: ["cellId", "resultJson", "sessionId", "type", "version"],
|
|
409
|
-
) &&
|
|
410
476
|
isNonEmptyString(decoded.cellId) &&
|
|
411
477
|
(decoded.resultJson === undefined || isString(decoded.resultJson))
|
|
412
478
|
) {
|
|
479
|
+
const expectedKeys = ["cellId", "sessionId", "type", "version"];
|
|
480
|
+
if (decoded.resultJson !== undefined) expectedKeys.push("resultJson");
|
|
481
|
+
if (decoded.console !== undefined) expectedKeys.push("console");
|
|
482
|
+
if (!hasExactKeys(decoded, expectedKeys)) {
|
|
483
|
+
return { ok: false, message: "CodeMode worker response has an invalid protocol shape" };
|
|
484
|
+
}
|
|
485
|
+
const consoleEntries =
|
|
486
|
+
decoded.console === undefined ? undefined : parseConsoleOutput(decoded.console);
|
|
487
|
+
if (decoded.console !== undefined && consoleEntries === undefined) {
|
|
488
|
+
return { ok: false, message: "CodeMode worker response has an invalid protocol shape" };
|
|
489
|
+
}
|
|
413
490
|
const value = {
|
|
414
491
|
version: CODEMODE_WORKER_PROTOCOL_VERSION,
|
|
415
492
|
type: "cell-result",
|
|
416
493
|
sessionId: decoded.sessionId,
|
|
417
494
|
cellId: decoded.cellId,
|
|
418
495
|
} as const;
|
|
419
|
-
|
|
420
|
-
?
|
|
421
|
-
|
|
496
|
+
const resultValue =
|
|
497
|
+
decoded.resultJson === undefined ? value : { ...value, resultJson: decoded.resultJson };
|
|
498
|
+
return consoleEntries === undefined
|
|
499
|
+
? { ok: true, value: resultValue }
|
|
500
|
+
: { ok: true, value: { ...resultValue, console: consoleEntries } };
|
|
422
501
|
}
|
|
423
|
-
if (
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
502
|
+
if (decoded.type === "cell-error" && isNonEmptyString(decoded.cellId)) {
|
|
503
|
+
const expectedKeys = ["cellId", "error", "sessionId", "type", "version"];
|
|
504
|
+
if (decoded.console !== undefined) expectedKeys.push("console");
|
|
505
|
+
if (!hasExactKeys(decoded, expectedKeys)) {
|
|
506
|
+
return { ok: false, message: "CodeMode worker response has an invalid protocol shape" };
|
|
507
|
+
}
|
|
428
508
|
const error = parseWorkerError(decoded.error);
|
|
429
|
-
|
|
509
|
+
const consoleEntries =
|
|
510
|
+
decoded.console === undefined ? undefined : parseConsoleOutput(decoded.console);
|
|
511
|
+
if (
|
|
512
|
+
error !== undefined &&
|
|
513
|
+
["script", "serialization", "runtime"].includes(error.code) &&
|
|
514
|
+
(decoded.console === undefined || consoleEntries !== undefined)
|
|
515
|
+
) {
|
|
430
516
|
// SAFETY: The literal-membership check above refines the protocol string to the closed worker error code union.
|
|
431
517
|
const code = error.code as CodeModeWorkerCellErrorCode;
|
|
518
|
+
const value = {
|
|
519
|
+
version: CODEMODE_WORKER_PROTOCOL_VERSION,
|
|
520
|
+
type: "cell-error",
|
|
521
|
+
sessionId: decoded.sessionId,
|
|
522
|
+
cellId: decoded.cellId,
|
|
523
|
+
error: { code, message: error.message },
|
|
524
|
+
} as const;
|
|
432
525
|
return {
|
|
433
526
|
ok: true,
|
|
434
|
-
value: {
|
|
435
|
-
version: CODEMODE_WORKER_PROTOCOL_VERSION,
|
|
436
|
-
type: "cell-error",
|
|
437
|
-
sessionId: decoded.sessionId,
|
|
438
|
-
cellId: decoded.cellId,
|
|
439
|
-
error: { code, message: error.message },
|
|
440
|
-
},
|
|
527
|
+
value: consoleEntries === undefined ? value : { ...value, console: consoleEntries },
|
|
441
528
|
};
|
|
442
529
|
}
|
|
443
530
|
}
|
|
@@ -456,7 +543,28 @@ export function serializeCodeModeWorkerRequest(
|
|
|
456
543
|
|
|
457
544
|
/** Serializes one worker response, replacing oversized Cell output with a bounded error. */
|
|
458
545
|
export function serializeCodeModeWorkerResponse(response: CodeModeWorkerResponse): string {
|
|
459
|
-
|
|
546
|
+
let message: string;
|
|
547
|
+
if (response.type === "cell-result" && response.console?.length === 0) {
|
|
548
|
+
const responseBase = {
|
|
549
|
+
version: response.version,
|
|
550
|
+
type: response.type,
|
|
551
|
+
sessionId: response.sessionId,
|
|
552
|
+
cellId: response.cellId,
|
|
553
|
+
} as const;
|
|
554
|
+
message = jsonStringify(
|
|
555
|
+
response.resultJson === undefined
|
|
556
|
+
? responseBase
|
|
557
|
+
: { ...responseBase, resultJson: response.resultJson },
|
|
558
|
+
);
|
|
559
|
+
} else if (response.type === "cell-error" && response.console?.length === 0) {
|
|
560
|
+
message = jsonStringify({
|
|
561
|
+
version: response.version,
|
|
562
|
+
type: response.type,
|
|
563
|
+
sessionId: response.sessionId,
|
|
564
|
+
cellId: response.cellId,
|
|
565
|
+
error: response.error,
|
|
566
|
+
});
|
|
567
|
+
} else message = jsonStringify(response);
|
|
460
568
|
if (Buffer.byteLength(message, "utf8") <= CODEMODE_WORKER_MESSAGE_LIMIT_BYTES) return message;
|
|
461
569
|
if (
|
|
462
570
|
response.type === "cell-result" ||
|