@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.
@@ -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
- container.addChild(
325
- new Text(
326
- [
327
- theme.fg("toolTitle", theme.bold("CodeMode")),
328
- theme.fg("accent", operation),
329
- theme.fg("muted", sessionId === undefined ? "new" : formatSessionPrefix(sessionId)),
330
- !expanded && oneLineSource !== undefined
331
- ? highlightCode(oneLineSource, "typescript")[0]
332
- : undefined,
333
- !expanded && oneLineSource !== undefined
334
- ? theme.fg("dim", `· ${expansionHint}`)
335
- : undefined,
336
- ]
337
- .filter((part): part is string => part !== undefined)
338
- .join(" "),
339
- 0,
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
- appendHighlightedCodeModeSource(container, visibleSource);
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
- for (let expectedIndex = 0; expectedIndex < expected.length; expectedIndex += 1) {
136
- const expectedKey = expected[expectedIndex];
137
- if (expectedKey === undefined) return false;
138
- let found = false;
139
- for (let keyIndex = 0; keyIndex < keys.length; keyIndex += 1) {
140
- if (keys[keyIndex] === expectedKey) {
141
- found = true;
142
- break;
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 (!found) return false;
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 true;
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
- return decoded.resultJson === undefined
420
- ? { ok: true, value }
421
- : { ok: true, value: { ...value, resultJson: decoded.resultJson } };
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
- decoded.type === "cell-error" &&
425
- hasExactKeys(decoded, ["cellId", "error", "sessionId", "type", "version"]) &&
426
- isNonEmptyString(decoded.cellId)
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
- if (error !== undefined && ["script", "serialization", "runtime"].includes(error.code)) {
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
- const message = jsonStringify(response);
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" ||