pi-studio 0.8.3 → 0.9.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.
package/index.ts CHANGED
@@ -35,6 +35,7 @@ type StudioSourceKind = "file" | "last-response" | "blank";
35
35
  type TerminalActivityPhase = "idle" | "running" | "tool" | "responding";
36
36
  type StudioPromptMode = "response" | "run" | "effective";
37
37
  type StudioPromptTriggerKind = "run" | "steer";
38
+ type StudioReplRuntime = "shell" | "python" | "ipython" | "julia" | "r" | "ghci" | "clojure";
38
39
 
39
40
  const STUDIO_CSS_URL = new URL("./client/studio.css", import.meta.url);
40
41
  const STUDIO_ANNOTATION_HELPERS_URL = new URL("./client/studio-annotation-helpers.js", import.meta.url);
@@ -111,6 +112,14 @@ interface StudioContextUsageSnapshot {
111
112
  percent: number | null;
112
113
  }
113
114
 
115
+ interface StudioReplSessionInfo {
116
+ sessionName: string;
117
+ target: string;
118
+ runtime: StudioReplRuntime | "unknown";
119
+ label: string;
120
+ source: "studio" | "pi-repl" | "tmux";
121
+ }
122
+
114
123
  interface PreparedStudioPdfExport {
115
124
  pdf: Buffer;
116
125
  filename: string;
@@ -196,6 +205,14 @@ interface StudioTraceAssistantEntry {
196
205
  stopReason: string | null;
197
206
  }
198
207
 
208
+ interface StudioTraceImage {
209
+ id: string;
210
+ mimeType: string;
211
+ data: string;
212
+ byteLength: number | null;
213
+ label: string | null;
214
+ }
215
+
199
216
  interface StudioTraceToolEntry {
200
217
  id: string;
201
218
  type: "tool";
@@ -204,6 +221,7 @@ interface StudioTraceToolEntry {
204
221
  label: string | null;
205
222
  argsSummary: string | null;
206
223
  output: string;
224
+ images: StudioTraceImage[];
207
225
  startedAt: number;
208
226
  updatedAt: number;
209
227
  status: StudioTraceEntryStatus;
@@ -258,6 +276,41 @@ interface SendRunRequestMessage {
258
276
  text: string;
259
277
  }
260
278
 
279
+ interface ReplListRequestMessage {
280
+ type: "repl_list_request";
281
+ }
282
+
283
+ interface ReplCaptureRequestMessage {
284
+ type: "repl_capture_request";
285
+ sessionName?: string;
286
+ }
287
+
288
+ interface ReplStartRequestMessage {
289
+ type: "repl_start_request";
290
+ requestId: string;
291
+ runtime: StudioReplRuntime;
292
+ newSession?: boolean;
293
+ }
294
+
295
+ interface ReplStopRequestMessage {
296
+ type: "repl_stop_request";
297
+ requestId: string;
298
+ sessionName: string;
299
+ }
300
+
301
+ interface ReplSendRequestMessage {
302
+ type: "repl_send_request";
303
+ requestId: string;
304
+ sessionName: string;
305
+ text: string;
306
+ }
307
+
308
+ interface ReplInterruptRequestMessage {
309
+ type: "repl_interrupt_request";
310
+ requestId: string;
311
+ sessionName: string;
312
+ }
313
+
261
314
  interface CompactRequestMessage {
262
315
  type: "compact_request";
263
316
  requestId: string;
@@ -322,6 +375,12 @@ type IncomingStudioMessage =
322
375
  | CritiqueRequestMessage
323
376
  | AnnotationRequestMessage
324
377
  | SendRunRequestMessage
378
+ | ReplListRequestMessage
379
+ | ReplCaptureRequestMessage
380
+ | ReplStartRequestMessage
381
+ | ReplStopRequestMessage
382
+ | ReplSendRequestMessage
383
+ | ReplInterruptRequestMessage
325
384
  | CompactRequestMessage
326
385
  | SaveAsRequestMessage
327
386
  | SaveOverRequestMessage
@@ -345,6 +404,22 @@ const MAX_PREPARED_PDF_EXPORTS = 8;
345
404
  const MAX_PREPARED_HTML_EXPORTS = 8;
346
405
  const STUDIO_TRACE_SNAPSHOT_MAX_ENTRIES = 80;
347
406
  const STUDIO_TRACE_SNAPSHOT_MAX_FIELD_CHARS = 20_000;
407
+ const STUDIO_TRACE_IMAGE_MAX_COUNT = 8;
408
+ const STUDIO_TRACE_IMAGE_MAX_BASE64_CHARS = 2_500_000;
409
+ const STUDIO_TRACE_SNAPSHOT_MAX_IMAGES = 12;
410
+ const STUDIO_TRACE_SNAPSHOT_MAX_IMAGE_BASE64_CHARS = 6_000_000;
411
+ const STUDIO_TRACE_IMAGE_SAFE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
412
+ const STUDIO_REPL_CAPTURE_LINES = 800;
413
+ const STUDIO_REPL_SEND_MAX_CHARS = 200_000;
414
+ const STUDIO_REPL_RUNTIME_LABELS: Record<StudioReplRuntime, string> = {
415
+ shell: "Shell",
416
+ python: "Python",
417
+ ipython: "IPython",
418
+ julia: "Julia",
419
+ r: "R",
420
+ ghci: "GHCi",
421
+ clojure: "Clojure",
422
+ };
348
423
  const MAX_STUDIO_TRACE_SNAPSHOTS = RESPONSE_HISTORY_LIMIT;
349
424
  const TRANSIENT_STUDIO_DOCUMENT_TTL_MS = 30 * 60 * 1000;
350
425
  const MAX_TRANSIENT_STUDIO_DOCUMENTS = 16;
@@ -6397,6 +6472,54 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
6397
6472
  };
6398
6473
  }
6399
6474
 
6475
+ if (msg.type === "repl_list_request") {
6476
+ return { type: "repl_list_request" };
6477
+ }
6478
+
6479
+ if (msg.type === "repl_capture_request") {
6480
+ return {
6481
+ type: "repl_capture_request",
6482
+ sessionName: typeof msg.sessionName === "string" ? msg.sessionName : undefined,
6483
+ };
6484
+ }
6485
+
6486
+ if (msg.type === "repl_start_request" && typeof msg.requestId === "string") {
6487
+ const runtime = normalizeStudioReplRuntime(msg.runtime);
6488
+ if (runtime) {
6489
+ return {
6490
+ type: "repl_start_request",
6491
+ requestId: msg.requestId,
6492
+ runtime,
6493
+ newSession: Boolean(msg.newSession),
6494
+ };
6495
+ }
6496
+ }
6497
+
6498
+ if (msg.type === "repl_stop_request" && typeof msg.requestId === "string" && typeof msg.sessionName === "string") {
6499
+ return {
6500
+ type: "repl_stop_request",
6501
+ requestId: msg.requestId,
6502
+ sessionName: msg.sessionName,
6503
+ };
6504
+ }
6505
+
6506
+ if (msg.type === "repl_send_request" && typeof msg.requestId === "string" && typeof msg.sessionName === "string" && typeof msg.text === "string") {
6507
+ return {
6508
+ type: "repl_send_request",
6509
+ requestId: msg.requestId,
6510
+ sessionName: msg.sessionName,
6511
+ text: msg.text,
6512
+ };
6513
+ }
6514
+
6515
+ if (msg.type === "repl_interrupt_request" && typeof msg.requestId === "string" && typeof msg.sessionName === "string") {
6516
+ return {
6517
+ type: "repl_interrupt_request",
6518
+ requestId: msg.requestId,
6519
+ sessionName: msg.sessionName,
6520
+ };
6521
+ }
6522
+
6400
6523
  if (
6401
6524
  msg.type === "compact_request" &&
6402
6525
  typeof msg.requestId === "string" &&
@@ -6649,9 +6772,44 @@ function truncateStudioTraceSnapshotText(text: string, maxChars = STUDIO_TRACE_S
6649
6772
  };
6650
6773
  }
6651
6774
 
6775
+ function copyStudioTraceImagesForSnapshot(
6776
+ images: StudioTraceImage[] | undefined,
6777
+ budget: { remainingImages: number; remainingBase64Chars: number },
6778
+ ): { images: StudioTraceImage[]; omitted: number } {
6779
+ const copied: StudioTraceImage[] = [];
6780
+ let omitted = 0;
6781
+ for (const image of Array.isArray(images) ? images : []) {
6782
+ if (!image || typeof image !== "object") continue;
6783
+ const mimeType = normalizeStudioTraceImageMimeType(image.mimeType);
6784
+ const data = typeof image.data === "string" ? image.data : "";
6785
+ if (!data || !isStudioTraceSafeImageMimeType(mimeType)) {
6786
+ omitted += 1;
6787
+ continue;
6788
+ }
6789
+ if (budget.remainingImages <= 0 || data.length > budget.remainingBase64Chars) {
6790
+ omitted += 1;
6791
+ continue;
6792
+ }
6793
+ copied.push({
6794
+ id: typeof image.id === "string" && image.id.trim() ? image.id : `trace-image-snapshot-${copied.length + 1}`,
6795
+ mimeType,
6796
+ data,
6797
+ byteLength: typeof image.byteLength === "number" && Number.isFinite(image.byteLength) ? image.byteLength : estimateStudioTraceBase64ByteLength(data),
6798
+ label: typeof image.label === "string" && image.label.trim() ? image.label : null,
6799
+ });
6800
+ budget.remainingImages -= 1;
6801
+ budget.remainingBase64Chars -= data.length;
6802
+ }
6803
+ return { images: copied, omitted };
6804
+ }
6805
+
6652
6806
  function createStudioTraceSnapshot(source: StudioTraceState): { traceState: StudioTraceState; truncated: boolean } {
6653
6807
  let truncated = false;
6654
6808
  const sourceEntries = Array.isArray(source.entries) ? source.entries : [];
6809
+ const imageBudget = {
6810
+ remainingImages: STUDIO_TRACE_SNAPSHOT_MAX_IMAGES,
6811
+ remainingBase64Chars: STUDIO_TRACE_SNAPSHOT_MAX_IMAGE_BASE64_CHARS,
6812
+ };
6655
6813
  const entries = sourceEntries.slice(-STUDIO_TRACE_SNAPSHOT_MAX_ENTRIES).map((entry) => {
6656
6814
  if (entry.type === "assistant") {
6657
6815
  const thinking = truncateStudioTraceSnapshotText(entry.thinking);
@@ -6665,11 +6823,16 @@ function createStudioTraceSnapshot(source: StudioTraceState): { traceState: Stud
6665
6823
  }
6666
6824
  const argsSummary = truncateStudioTraceSnapshotText(entry.argsSummary ?? "");
6667
6825
  const output = truncateStudioTraceSnapshotText(entry.output);
6668
- truncated = truncated || argsSummary.truncated || output.truncated;
6826
+ const snapshotImages = copyStudioTraceImagesForSnapshot(entry.images, imageBudget);
6827
+ truncated = truncated || argsSummary.truncated || output.truncated || snapshotImages.omitted > 0;
6828
+ const omittedImageNote = snapshotImages.omitted > 0
6829
+ ? `[${snapshotImages.omitted} image preview${snapshotImages.omitted === 1 ? "" : "s"} omitted from saved Working view to keep history bounded.]`
6830
+ : "";
6669
6831
  return {
6670
6832
  ...entry,
6671
6833
  argsSummary: argsSummary.text || null,
6672
- output: output.text,
6834
+ output: [output.text, omittedImageNote].filter(Boolean).join("\n"),
6835
+ images: snapshotImages.images,
6673
6836
  };
6674
6837
  });
6675
6838
  if (sourceEntries.length > entries.length) truncated = true;
@@ -6706,29 +6869,102 @@ function sanitizeStudioTraceOutputText(text: string): string {
6706
6869
  .replace(/\b[A-Za-z0-9+/]{3000,}={0,2}\b/g, "[base64 data omitted]");
6707
6870
  }
6708
6871
 
6872
+ function normalizeStudioTraceImageMimeType(value: unknown): string {
6873
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
6874
+ }
6875
+
6876
+ function getStudioTraceImageMimeType(block: unknown): string {
6877
+ if (!block || typeof block !== "object") return "";
6878
+ const payload = block as Record<string, unknown>;
6879
+ const source = payload.source && typeof payload.source === "object" ? payload.source as Record<string, unknown> : null;
6880
+ return normalizeStudioTraceImageMimeType(
6881
+ payload.mimeType
6882
+ ?? payload.mediaType
6883
+ ?? payload.media_type
6884
+ ?? source?.mimeType
6885
+ ?? source?.mediaType
6886
+ ?? source?.media_type,
6887
+ );
6888
+ }
6889
+
6709
6890
  function isStudioTraceImageBlock(block: unknown): boolean {
6710
6891
  if (!block || typeof block !== "object") return false;
6711
6892
  const payload = block as Record<string, unknown>;
6712
6893
  const type = typeof payload.type === "string" ? payload.type.toLowerCase() : "";
6713
6894
  if (type.includes("image")) return true;
6714
- const mime = typeof payload.mimeType === "string"
6715
- ? payload.mimeType
6716
- : (typeof payload.media_type === "string" ? payload.media_type : "");
6717
- if (mime.toLowerCase().startsWith("image/")) return true;
6895
+ return getStudioTraceImageMimeType(block).startsWith("image/");
6896
+ }
6897
+
6898
+ function isStudioTraceSafeImageMimeType(mimeType: string): boolean {
6899
+ return STUDIO_TRACE_IMAGE_SAFE_MIME_TYPES.has(normalizeStudioTraceImageMimeType(mimeType));
6900
+ }
6901
+
6902
+ function getStudioTraceImageData(block: unknown): string | null {
6903
+ if (!block || typeof block !== "object") return null;
6904
+ const payload = block as Record<string, unknown>;
6905
+ if (typeof payload.data === "string") return payload.data;
6718
6906
  const source = payload.source && typeof payload.source === "object" ? payload.source as Record<string, unknown> : null;
6719
- const sourceMime = source && typeof source.media_type === "string" ? source.media_type : "";
6720
- return sourceMime.toLowerCase().startsWith("image/");
6907
+ if (source && typeof source.data === "string") return source.data;
6908
+ return null;
6909
+ }
6910
+
6911
+ function normalizeStudioTraceBase64Data(data: string): string | null {
6912
+ const compact = String(data || "").replace(/\s+/g, "");
6913
+ if (!compact || !/^[A-Za-z0-9+/]*={0,2}$/.test(compact)) return null;
6914
+ return compact;
6721
6915
  }
6722
6916
 
6723
- function describeStudioTraceImageBlock(block: unknown): string {
6917
+ function estimateStudioTraceBase64ByteLength(data: string): number | null {
6918
+ const compact = normalizeStudioTraceBase64Data(data);
6919
+ if (!compact) return null;
6920
+ const padding = compact.endsWith("==") ? 2 : (compact.endsWith("=") ? 1 : 0);
6921
+ return Math.max(0, Math.floor((compact.length * 3) / 4) - padding);
6922
+ }
6923
+
6924
+ function formatStudioTraceByteSize(bytes: number | null): string {
6925
+ if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0) return "unknown size";
6926
+ if (bytes < 1024) return `${Math.round(bytes)} B`;
6927
+ const kib = bytes / 1024;
6928
+ if (kib < 1024) return `${kib.toFixed(kib >= 100 ? 0 : 1).replace(/\.0$/, "")} KB`;
6929
+ const mib = kib / 1024;
6930
+ return `${mib.toFixed(mib >= 100 ? 0 : 1).replace(/\.0$/, "")} MB`;
6931
+ }
6932
+
6933
+ function describeStudioTraceImageBlock(block: unknown, reason?: string): string {
6934
+ const mime = getStudioTraceImageMimeType(block) || "image";
6935
+ return `[Image: ${mime}${reason ? ` ${reason}` : ""}]`;
6936
+ }
6937
+
6938
+ function collectStudioTraceImageBlock(block: unknown, images: StudioTraceImage[]): string {
6939
+ const mimeType = getStudioTraceImageMimeType(block) || "image/unknown";
6940
+ if (!isStudioTraceSafeImageMimeType(mimeType)) {
6941
+ return describeStudioTraceImageBlock(block, "omitted from Working view: unsupported image type");
6942
+ }
6943
+ if (images.length >= STUDIO_TRACE_IMAGE_MAX_COUNT) {
6944
+ return describeStudioTraceImageBlock(block, "omitted from Working view: image count limit reached");
6945
+ }
6946
+ const data = getStudioTraceImageData(block);
6947
+ const normalizedData = data ? normalizeStudioTraceBase64Data(data) : null;
6948
+ if (!normalizedData) {
6949
+ return describeStudioTraceImageBlock(block, "omitted from Working view: no base64 data");
6950
+ }
6951
+ if (normalizedData.length > STUDIO_TRACE_IMAGE_MAX_BASE64_CHARS) {
6952
+ const estimatedBytes = estimateStudioTraceBase64ByteLength(normalizedData);
6953
+ return describeStudioTraceImageBlock(block, `omitted from Working view: ${formatStudioTraceByteSize(estimatedBytes)} exceeds image preview limit`);
6954
+ }
6724
6955
  const payload = (block && typeof block === "object") ? block as Record<string, unknown> : {};
6725
- const source = payload.source && typeof payload.source === "object" ? payload.source as Record<string, unknown> : null;
6726
- const mime = typeof payload.mimeType === "string"
6727
- ? payload.mimeType
6728
- : (typeof payload.media_type === "string"
6729
- ? payload.media_type
6730
- : (source && typeof source.media_type === "string" ? source.media_type : "image"));
6731
- return `[Image: ${mime || "image"} output omitted from Working view]`;
6956
+ const hash = createHash("sha256").update(mimeType).update(normalizedData).digest("hex").slice(0, 16);
6957
+ const image: StudioTraceImage = {
6958
+ id: `trace-image-${hash}-${images.length + 1}`,
6959
+ mimeType,
6960
+ data: normalizedData,
6961
+ byteLength: estimateStudioTraceBase64ByteLength(normalizedData),
6962
+ label: typeof payload.label === "string" && payload.label.trim()
6963
+ ? payload.label.trim()
6964
+ : (typeof payload.alt === "string" && payload.alt.trim() ? payload.alt.trim() : null),
6965
+ };
6966
+ images.push(image);
6967
+ return "";
6732
6968
  }
6733
6969
 
6734
6970
  function stringifyStudioTraceObject(value: unknown): string {
@@ -6745,19 +6981,19 @@ function stringifyStudioTraceObject(value: unknown): string {
6745
6981
  }
6746
6982
  }
6747
6983
 
6748
- function formatStudioTraceOutput(result: unknown): string {
6984
+ function formatStudioTraceOutputPart(result: unknown, images: StudioTraceImage[]): string {
6749
6985
  if (result == null) return "";
6750
6986
  if (typeof result === "string") return sanitizeStudioTraceOutputText(result);
6751
6987
  if (Array.isArray(result)) {
6752
- return result.map((item) => formatStudioTraceOutput(item)).filter(Boolean).join("\n");
6988
+ return result.map((item) => formatStudioTraceOutputPart(item, images)).filter(Boolean).join("\n");
6753
6989
  }
6754
6990
  if (typeof result === "object") {
6755
- if (isStudioTraceImageBlock(result)) return describeStudioTraceImageBlock(result);
6991
+ if (isStudioTraceImageBlock(result)) return collectStudioTraceImageBlock(result, images);
6756
6992
  const payload = result as { content?: Array<{ type?: string; text?: string }> };
6757
6993
  if (Array.isArray(payload.content)) {
6758
6994
  return payload.content
6759
6995
  .map((block) => {
6760
- if (isStudioTraceImageBlock(block)) return describeStudioTraceImageBlock(block);
6996
+ if (isStudioTraceImageBlock(block)) return collectStudioTraceImageBlock(block, images);
6761
6997
  if (block && block.type === "text" && typeof block.text === "string") return sanitizeStudioTraceOutputText(block.text);
6762
6998
  return stringifyStudioTraceObject(block);
6763
6999
  })
@@ -6769,6 +7005,18 @@ function formatStudioTraceOutput(result: unknown): string {
6769
7005
  return sanitizeStudioTraceOutputText(String(result));
6770
7006
  }
6771
7007
 
7008
+ function formatStudioTraceToolResult(result: unknown): { output: string; images: StudioTraceImage[] } {
7009
+ const images: StudioTraceImage[] = [];
7010
+ return {
7011
+ output: formatStudioTraceOutputPart(result, images),
7012
+ images,
7013
+ };
7014
+ }
7015
+
7016
+ function formatStudioTraceOutput(result: unknown): string {
7017
+ return formatStudioTraceToolResult(result).output;
7018
+ }
7019
+
6772
7020
  function summarizeStudioTraceToolArgs(toolName: string, args: unknown): string | null {
6773
7021
  const normalizedTool = String(toolName || "").trim().toLowerCase();
6774
7022
  const payload = (args && typeof args === "object") ? (args as Record<string, unknown>) : {};
@@ -6793,6 +7041,218 @@ function summarizeStudioTraceToolArgs(toolName: string, args: unknown): string |
6793
7041
  }
6794
7042
  }
6795
7043
 
7044
+ function isStudioReplRuntime(value: unknown): value is StudioReplRuntime {
7045
+ return value === "shell"
7046
+ || value === "python"
7047
+ || value === "ipython"
7048
+ || value === "julia"
7049
+ || value === "r"
7050
+ || value === "ghci"
7051
+ || value === "clojure";
7052
+ }
7053
+
7054
+ function normalizeStudioReplRuntime(value: unknown): StudioReplRuntime | null {
7055
+ const normalized = String(value || "").trim().toLowerCase();
7056
+ if (normalized === "r") return "r";
7057
+ return isStudioReplRuntime(normalized) ? normalized : null;
7058
+ }
7059
+
7060
+ function getStudioReplRuntimeCommand(runtime: StudioReplRuntime): string {
7061
+ if (runtime === "shell") return String(process.env.SHELL || "bash").trim() || "bash";
7062
+ if (runtime === "python") return "python3";
7063
+ if (runtime === "ipython") return "ipython";
7064
+ if (runtime === "julia") return "julia";
7065
+ if (runtime === "r") return "R";
7066
+ if (runtime === "ghci") return "ghci";
7067
+ return "clojure";
7068
+ }
7069
+
7070
+ function getStudioReplSessionName(runtime: StudioReplRuntime): string {
7071
+ return `pi-studio-repl-${runtime}`;
7072
+ }
7073
+
7074
+ function getNewStudioReplSessionName(runtime: StudioReplRuntime): string {
7075
+ const suffix = `${Date.now().toString(36)}-${randomUUID().slice(0, 6)}`;
7076
+ return `pi-studio-repl-${runtime}-${suffix}`;
7077
+ }
7078
+
7079
+ function getStudioReplPaneTarget(sessionName: string): string {
7080
+ return `${sessionName}:0.0`;
7081
+ }
7082
+
7083
+ function inferStudioReplSessionRuntime(sessionName: string): { runtime: StudioReplRuntime | "unknown"; source: StudioReplSessionInfo["source"] } {
7084
+ const studioMatch = sessionName.match(/^pi-studio-repl-([a-z0-9-]+)$/i);
7085
+ if (studioMatch) {
7086
+ const raw = (studioMatch[1] || "").toLowerCase();
7087
+ const runtime = (["clojure", "python", "ipython", "julia", "shell", "ghci", "r"] as StudioReplRuntime[])
7088
+ .find((candidate) => raw === candidate || raw.startsWith(`${candidate}-`));
7089
+ return { runtime: runtime ?? "unknown", source: "studio" };
7090
+ }
7091
+ const piReplMatch = sessionName.match(/^pi-repl-([a-z0-9-]+)$/i);
7092
+ if (piReplMatch) {
7093
+ const raw = piReplMatch[1]?.toLowerCase() || "";
7094
+ const runtime = raw === "python" ? "python" : normalizeStudioReplRuntime(raw);
7095
+ return { runtime: runtime ?? "unknown", source: "pi-repl" };
7096
+ }
7097
+ return { runtime: "unknown", source: "tmux" };
7098
+ }
7099
+
7100
+ function shouldShowStudioReplTmuxSession(sessionName: string): boolean {
7101
+ return /^pi-studio-repl-/i.test(sessionName) || /^pi-repl-/i.test(sessionName);
7102
+ }
7103
+
7104
+ function formatStudioReplSessionLabel(sessionName: string, runtime: StudioReplRuntime | "unknown", source: StudioReplSessionInfo["source"]): string {
7105
+ const runtimeLabel = runtime === "unknown" ? "REPL" : STUDIO_REPL_RUNTIME_LABELS[runtime];
7106
+ if (source === "pi-repl") return `${runtimeLabel} (${sessionName})`;
7107
+ if (source === "studio") return `${runtimeLabel} (${sessionName})`;
7108
+ return sessionName;
7109
+ }
7110
+
7111
+ function isTmuxAvailable(): boolean {
7112
+ const result = spawnSync("tmux", ["-V"], { encoding: "utf8", timeout: 3_000 });
7113
+ return result.status === 0;
7114
+ }
7115
+
7116
+ function runStudioTmux(args: string[], options?: { cwd?: string; input?: string; timeout?: number }): { ok: true; stdout: string; stderr: string } | { ok: false; message: string; stdout: string; stderr: string } {
7117
+ const result = spawnSync("tmux", args, {
7118
+ cwd: options?.cwd,
7119
+ input: options?.input,
7120
+ encoding: "utf8",
7121
+ timeout: options?.timeout ?? 5_000,
7122
+ maxBuffer: 10 * 1024 * 1024,
7123
+ });
7124
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
7125
+ const stderr = typeof result.stderr === "string" ? result.stderr : "";
7126
+ if (result.error) {
7127
+ const message = result.error.message || String(result.error);
7128
+ return { ok: false, message, stdout, stderr };
7129
+ }
7130
+ if (result.status !== 0) {
7131
+ return { ok: false, message: (stderr || stdout || `tmux exited with code ${result.status}`).trim(), stdout, stderr };
7132
+ }
7133
+ return { ok: true, stdout, stderr };
7134
+ }
7135
+
7136
+ function listStudioReplSessions(): { tmuxAvailable: boolean; sessions: StudioReplSessionInfo[]; error?: string } {
7137
+ if (!isTmuxAvailable()) return { tmuxAvailable: false, sessions: [], error: "tmux is not available." };
7138
+ const result = runStudioTmux(["list-sessions", "-F", "#{session_name}"], { timeout: 3_000 });
7139
+ if (!result.ok) {
7140
+ const message = result.message.toLowerCase().includes("no server running") ? "No tmux sessions are running." : result.message;
7141
+ return { tmuxAvailable: true, sessions: [], error: message };
7142
+ }
7143
+ const sessions = result.stdout
7144
+ .split(/\r?\n/)
7145
+ .map((line) => line.trim())
7146
+ .filter(Boolean)
7147
+ .filter(shouldShowStudioReplTmuxSession)
7148
+ .map((sessionName) => {
7149
+ const inferred = inferStudioReplSessionRuntime(sessionName);
7150
+ return {
7151
+ sessionName,
7152
+ target: getStudioReplPaneTarget(sessionName),
7153
+ runtime: inferred.runtime,
7154
+ label: formatStudioReplSessionLabel(sessionName, inferred.runtime, inferred.source),
7155
+ source: inferred.source,
7156
+ };
7157
+ });
7158
+ return { tmuxAvailable: true, sessions };
7159
+ }
7160
+
7161
+ function captureStudioReplSession(sessionName: string): { ok: true; transcript: string; session: StudioReplSessionInfo } | { ok: false; message: string } {
7162
+ if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
7163
+ const inferred = inferStudioReplSessionRuntime(sessionName);
7164
+ const session: StudioReplSessionInfo = {
7165
+ sessionName,
7166
+ target: getStudioReplPaneTarget(sessionName),
7167
+ runtime: inferred.runtime,
7168
+ label: formatStudioReplSessionLabel(sessionName, inferred.runtime, inferred.source),
7169
+ source: inferred.source,
7170
+ };
7171
+ const result = runStudioTmux(["capture-pane", "-p", "-t", session.target, "-S", `-${STUDIO_REPL_CAPTURE_LINES}`], { timeout: 3_000 });
7172
+ if (!result.ok) return { ok: false, message: result.message };
7173
+ return { ok: true, transcript: result.stdout.replace(/[\t ]+$/gm, "").trimEnd(), session };
7174
+ }
7175
+
7176
+ function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options?: { newSession?: boolean }): { ok: true; session: StudioReplSessionInfo; message: string } | { ok: false; message: string } {
7177
+ if (!isTmuxAvailable()) return { ok: false, message: "tmux is not available. Install tmux to use Studio REPL sessions." };
7178
+ const sessionName = options?.newSession ? getNewStudioReplSessionName(runtime) : getStudioReplSessionName(runtime);
7179
+ const existing = runStudioTmux(["has-session", "-t", sessionName], { timeout: 3_000 });
7180
+ if (existing.ok) {
7181
+ const inferred = inferStudioReplSessionRuntime(sessionName);
7182
+ return {
7183
+ ok: true,
7184
+ session: {
7185
+ sessionName,
7186
+ target: getStudioReplPaneTarget(sessionName),
7187
+ runtime: inferred.runtime,
7188
+ label: formatStudioReplSessionLabel(sessionName, inferred.runtime, inferred.source),
7189
+ source: inferred.source,
7190
+ },
7191
+ message: `${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL is already running.`,
7192
+ };
7193
+ }
7194
+ const command = getStudioReplRuntimeCommand(runtime);
7195
+ const result = runStudioTmux(["new-session", "-d", "-s", sessionName, "-c", cwd || process.cwd(), command], { timeout: 5_000 });
7196
+ if (!result.ok) return { ok: false, message: result.message || `Failed to start ${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL.` };
7197
+ return {
7198
+ ok: true,
7199
+ session: {
7200
+ sessionName,
7201
+ target: getStudioReplPaneTarget(sessionName),
7202
+ runtime,
7203
+ label: formatStudioReplSessionLabel(sessionName, runtime, "studio"),
7204
+ source: "studio",
7205
+ },
7206
+ message: `Started ${options?.newSession ? "new " : ""}${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL.`,
7207
+ };
7208
+ }
7209
+
7210
+ function stopStudioReplSession(sessionName: string): { ok: true; message: string } | { ok: false; message: string } {
7211
+ if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
7212
+ const inferred = inferStudioReplSessionRuntime(sessionName);
7213
+ if (inferred.source !== "studio") {
7214
+ return { ok: false, message: "Studio can only stop Studio-owned REPL sessions. Use tmux or pi-repl to stop external sessions." };
7215
+ }
7216
+ const result = runStudioTmux(["kill-session", "-t", sessionName], { timeout: 5_000 });
7217
+ if (!result.ok) return { ok: false, message: result.message || "Failed to stop REPL session." };
7218
+ return { ok: true, message: `Stopped ${sessionName}.` };
7219
+ }
7220
+
7221
+ function prepareTextForStudioReplSend(sessionName: string, source: string): string {
7222
+ const normalized = String(source || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
7223
+ const runtime = inferStudioReplSessionRuntime(sessionName).runtime;
7224
+ if ((runtime === "python" || runtime === "ipython") && normalized.includes("\n")) {
7225
+ // The standard Python prompt needs a final blank line to close pasted suites
7226
+ // such as `for`/`if`/`def` blocks. Without it the prompt can remain in
7227
+ // continuation mode, making the next send look like an unexpected indent.
7228
+ return `${normalized.replace(/\n+$/, "")}\n\n`;
7229
+ }
7230
+ return normalized.endsWith("\n") ? normalized : `${normalized}\n`;
7231
+ }
7232
+
7233
+ function sendTextToStudioReplSession(sessionName: string, text: string): { ok: true; message: string } | { ok: false; message: string } {
7234
+ if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
7235
+ const source = String(text || "");
7236
+ if (!source.trim()) return { ok: false, message: "Editor text is empty." };
7237
+ if (source.length > STUDIO_REPL_SEND_MAX_CHARS) {
7238
+ return { ok: false, message: `REPL input is too large (${source.length} chars; max ${STUDIO_REPL_SEND_MAX_CHARS}).` };
7239
+ }
7240
+ const bufferName = `pi-studio-repl-${randomUUID().replace(/-/g, "")}`;
7241
+ const input = prepareTextForStudioReplSend(sessionName, source);
7242
+ const loadResult = runStudioTmux(["load-buffer", "-b", bufferName, "-"], { input, timeout: 5_000 });
7243
+ if (!loadResult.ok) return { ok: false, message: loadResult.message || "Failed to load text into tmux buffer." };
7244
+ const pasteResult = runStudioTmux(["paste-buffer", "-d", "-b", bufferName, "-t", getStudioReplPaneTarget(sessionName)], { timeout: 5_000 });
7245
+ if (!pasteResult.ok) return { ok: false, message: pasteResult.message || "Failed to paste text into REPL session." };
7246
+ return { ok: true, message: `Sent ${source.length} chars to ${sessionName}.` };
7247
+ }
7248
+
7249
+ function interruptStudioReplSession(sessionName: string): { ok: true; message: string } | { ok: false; message: string } {
7250
+ if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
7251
+ const result = runStudioTmux(["send-keys", "-t", getStudioReplPaneTarget(sessionName), "C-c"], { timeout: 5_000 });
7252
+ if (!result.ok) return { ok: false, message: result.message || "Failed to interrupt REPL session." };
7253
+ return { ok: true, message: `Interrupted ${sessionName}.` };
7254
+ }
7255
+
6796
7256
  function isAllowedOrigin(_origin: string | undefined, _port: number): boolean {
6797
7257
  // For local-only studio, token auth is the primary guard. In practice,
6798
7258
  // browser origin headers can vary (or be omitted) across wrappers/browsers,
@@ -7283,6 +7743,11 @@ ${cssVarsBlock}
7283
7743
  <div class="source-actions-row">
7284
7744
  <button id="sendRunBtn" type="button" title="Run editor text. While a direct run is active, this button becomes Stop. Cmd/Ctrl+Enter queues steering from the current editor text. Stop the active request with Esc.">Run editor text</button>
7285
7745
  <button id="queueSteerBtn" type="button" title="Queue steering is available while Run editor text is active." disabled>Queue steering</button>
7746
+ <button id="sendReplBtn" type="button" hidden title="Send the current selection, or the full editor text, to the active REPL session shown in the right pane.">Send to REPL</button>
7747
+ <select id="replSendModeSelect" hidden aria-label="REPL send mode" title="Choose how Send to REPL interprets the editor text.">
7748
+ <option value="scratch" selected>Scratch send</option>
7749
+ <option value="literate">Literate send</option>
7750
+ </select>
7286
7751
  <button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy text</button>
7287
7752
  <button id="openCompanionBtn" type="button" title="Open a detached copy of the current editor text in a new editor-only Studio tab.">Open new editor</button>
7288
7753
  <button id="sendEditorBtn" type="button">Send to pi editor</button>
@@ -7425,6 +7890,7 @@ ${cssVarsBlock}
7425
7890
  <option value="preview" selected>Response (Preview)</option>
7426
7891
  <option value="editor-preview">Editor (Preview)</option>
7427
7892
  <option value="trace">Working</option>
7893
+ <option value="repl">REPL</option>
7428
7894
  </select>
7429
7895
  </div>
7430
7896
  <div class="section-header-actions">
@@ -7562,6 +8028,7 @@ export default function (pi: ExtensionAPI) {
7562
8028
  contextWindow: null,
7563
8029
  percent: null,
7564
8030
  };
8031
+ let studioReplActiveSessionName: string | null = null;
7565
8032
  let compactInProgress = false;
7566
8033
  let compactRequestId: string | null = null;
7567
8034
 
@@ -7997,6 +8464,57 @@ export default function (pi: ExtensionAPI) {
7997
8464
  }
7998
8465
  };
7999
8466
 
8467
+ const sendReplStateToClient = (client: WebSocket, extra?: Record<string, unknown>) => {
8468
+ const state = listStudioReplSessions();
8469
+ if (studioReplActiveSessionName && !state.sessions.some((session) => session.sessionName === studioReplActiveSessionName)) {
8470
+ studioReplActiveSessionName = state.sessions[0]?.sessionName ?? null;
8471
+ } else if (!studioReplActiveSessionName && state.sessions.length > 0) {
8472
+ studioReplActiveSessionName = state.sessions[0].sessionName;
8473
+ }
8474
+ sendToClient(client, {
8475
+ type: "repl_state",
8476
+ tmuxAvailable: state.tmuxAvailable,
8477
+ sessions: state.sessions,
8478
+ activeSessionName: studioReplActiveSessionName,
8479
+ error: state.error ?? null,
8480
+ ...extra,
8481
+ });
8482
+ };
8483
+
8484
+ const sendReplCaptureToClient = (client: WebSocket, sessionName?: string | null, extra?: Record<string, unknown>) => {
8485
+ const targetSession = (typeof sessionName === "string" && sessionName.trim())
8486
+ ? sessionName.trim()
8487
+ : studioReplActiveSessionName;
8488
+ if (!targetSession) {
8489
+ sendReplStateToClient(client, {
8490
+ transcript: "",
8491
+ capturedAt: Date.now(),
8492
+ ...extra,
8493
+ });
8494
+ return;
8495
+ }
8496
+ const captured = captureStudioReplSession(targetSession);
8497
+ if (!captured.ok) {
8498
+ sendReplStateToClient(client, {
8499
+ activeSessionName: targetSession,
8500
+ transcript: "",
8501
+ captureError: captured.message,
8502
+ capturedAt: Date.now(),
8503
+ ...extra,
8504
+ });
8505
+ return;
8506
+ }
8507
+ studioReplActiveSessionName = captured.session.sessionName;
8508
+ sendToClient(client, {
8509
+ type: "repl_capture",
8510
+ session: captured.session,
8511
+ activeSessionName: captured.session.sessionName,
8512
+ transcript: captured.transcript,
8513
+ capturedAt: Date.now(),
8514
+ ...extra,
8515
+ });
8516
+ };
8517
+
8000
8518
  const emitDebugEvent = (event: string, details?: Record<string, unknown>) => {
8001
8519
  broadcast({
8002
8520
  type: "debug_event",
@@ -8151,6 +8669,7 @@ export default function (pi: ExtensionAPI) {
8151
8669
  label: deriveToolActivityLabel(toolName, args),
8152
8670
  argsSummary: summarizeStudioTraceToolArgs(toolName, args),
8153
8671
  output: "",
8672
+ images: [],
8154
8673
  startedAt: now,
8155
8674
  updatedAt: now,
8156
8675
  status: "pending",
@@ -8168,9 +8687,11 @@ export default function (pi: ExtensionAPI) {
8168
8687
  output: string,
8169
8688
  status: StudioTraceEntryStatus,
8170
8689
  isError: boolean,
8690
+ images?: StudioTraceImage[],
8171
8691
  ) => {
8172
8692
  const entry = ensureStudioTraceToolEntry(toolCallId, toolName, args);
8173
8693
  entry.output = output;
8694
+ if (Array.isArray(images)) entry.images = images;
8174
8695
  entry.status = status;
8175
8696
  entry.isError = isError;
8176
8697
  entry.updatedAt = Date.now();
@@ -8818,6 +9339,82 @@ export default function (pi: ExtensionAPI) {
8818
9339
  return;
8819
9340
  }
8820
9341
 
9342
+ if (msg.type === "repl_list_request") {
9343
+ sendReplStateToClient(client);
9344
+ return;
9345
+ }
9346
+
9347
+ if (msg.type === "repl_capture_request") {
9348
+ sendReplCaptureToClient(client, msg.sessionName ?? null);
9349
+ return;
9350
+ }
9351
+
9352
+ if (msg.type === "repl_start_request") {
9353
+ if (!isValidRequestId(msg.requestId)) {
9354
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
9355
+ return;
9356
+ }
9357
+ const started = startStudioReplSession(msg.runtime, studioCwd, { newSession: msg.newSession });
9358
+ if (!started.ok) {
9359
+ sendReplStateToClient(client, { requestId: msg.requestId, replError: started.message });
9360
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: started.message });
9361
+ return;
9362
+ }
9363
+ studioReplActiveSessionName = started.session.sessionName;
9364
+ sendReplStateToClient(client, { requestId: msg.requestId, replMessage: started.message });
9365
+ sendReplCaptureToClient(client, started.session.sessionName, { requestId: msg.requestId, replMessage: started.message });
9366
+ return;
9367
+ }
9368
+
9369
+ if (msg.type === "repl_stop_request") {
9370
+ if (!isValidRequestId(msg.requestId)) {
9371
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
9372
+ return;
9373
+ }
9374
+ const stopped = stopStudioReplSession(msg.sessionName);
9375
+ if (!stopped.ok) {
9376
+ sendReplStateToClient(client, { requestId: msg.requestId, replError: stopped.message });
9377
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: stopped.message });
9378
+ return;
9379
+ }
9380
+ if (studioReplActiveSessionName === msg.sessionName) studioReplActiveSessionName = null;
9381
+ sendReplStateToClient(client, { requestId: msg.requestId, replMessage: stopped.message, transcript: "", capturedAt: Date.now() });
9382
+ return;
9383
+ }
9384
+
9385
+ if (msg.type === "repl_send_request") {
9386
+ if (!isValidRequestId(msg.requestId)) {
9387
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
9388
+ return;
9389
+ }
9390
+ const sent = sendTextToStudioReplSession(msg.sessionName, msg.text);
9391
+ if (!sent.ok) {
9392
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: sent.message });
9393
+ sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replError: sent.message });
9394
+ return;
9395
+ }
9396
+ studioReplActiveSessionName = msg.sessionName;
9397
+ sendToClient(client, { type: "repl_send_ack", requestId: msg.requestId, sessionName: msg.sessionName, message: sent.message });
9398
+ setTimeout(() => sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId }), 150);
9399
+ return;
9400
+ }
9401
+
9402
+ if (msg.type === "repl_interrupt_request") {
9403
+ if (!isValidRequestId(msg.requestId)) {
9404
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
9405
+ return;
9406
+ }
9407
+ const interrupted = interruptStudioReplSession(msg.sessionName);
9408
+ if (!interrupted.ok) {
9409
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: interrupted.message });
9410
+ sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replError: interrupted.message });
9411
+ return;
9412
+ }
9413
+ studioReplActiveSessionName = msg.sessionName;
9414
+ sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replMessage: interrupted.message });
9415
+ return;
9416
+ }
9417
+
8821
9418
  if (msg.type === "compact_request") {
8822
9419
  if (!isValidRequestId(msg.requestId)) {
8823
9420
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
@@ -10233,25 +10830,29 @@ export default function (pi: ExtensionAPI) {
10233
10830
 
10234
10831
  pi.on("tool_execution_update", async (event) => {
10235
10832
  if (!agentBusy) return;
10833
+ const formatted = formatStudioTraceToolResult(event.partialResult);
10236
10834
  updateStudioTraceToolEntry(
10237
10835
  event.toolCallId,
10238
10836
  event.toolName,
10239
10837
  event.args,
10240
- formatStudioTraceOutput(event.partialResult),
10838
+ formatted.output,
10241
10839
  "streaming",
10242
10840
  false,
10841
+ formatted.images,
10243
10842
  );
10244
10843
  });
10245
10844
 
10246
10845
  pi.on("tool_execution_end", async (event) => {
10247
10846
  if (!agentBusy) return;
10847
+ const formatted = formatStudioTraceToolResult(event.result);
10248
10848
  updateStudioTraceToolEntry(
10249
10849
  event.toolCallId,
10250
10850
  event.toolName,
10251
10851
  undefined,
10252
- formatStudioTraceOutput(event.result),
10852
+ formatted.output,
10253
10853
  event.isError ? "error" : "complete",
10254
10854
  Boolean(event.isError),
10855
+ formatted.images,
10255
10856
  );
10256
10857
  emitDebugEvent("tool_execution_end", { toolName: event.toolName, activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
10257
10858
  // Keep tool phase visible until the next tool call, assistant response phase,