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.
@@ -107,6 +107,8 @@
107
107
  const loadGitDiffBtn = document.getElementById("loadGitDiffBtn");
108
108
  const sendRunBtn = document.getElementById("sendRunBtn");
109
109
  const queueSteerBtn = document.getElementById("queueSteerBtn");
110
+ const sendReplBtn = document.getElementById("sendReplBtn");
111
+ const replSendModeSelect = document.getElementById("replSendModeSelect");
110
112
  const copyDraftBtn = document.getElementById("copyDraftBtn");
111
113
  const saveAnnotatedBtn = document.getElementById("saveAnnotatedBtn");
112
114
  const stripAnnotationsBtn = document.getElementById("stripAnnotationsBtn");
@@ -214,6 +216,48 @@
214
216
  const traceExpandedOutputs = new Set();
215
217
  const TRACE_OUTPUT_PREVIEW_MAX_LINES = 50;
216
218
  const TRACE_OUTPUT_PREVIEW_MAX_CHARS = 8000;
219
+ const TRACE_IMAGE_SAFE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
220
+ const REPL_POLL_INTERVAL_MS = 1000;
221
+ const REPL_TRANSCRIPT_MAX_CHARS = 200_000;
222
+ const REPL_JOURNAL_OUTPUT_MAX_CHARS = 80_000;
223
+ const REPL_JOURNAL_MAX_ENTRIES = 80;
224
+ const EDITOR_TAB_TEXT = " ";
225
+ let replTmuxAvailable = null;
226
+ let replSessions = [];
227
+ let replActiveSessionName = "";
228
+ let replRuntime = (() => {
229
+ try {
230
+ return (window.localStorage && window.localStorage.getItem("piStudio.replRuntime")) || "python";
231
+ } catch {
232
+ return "python";
233
+ }
234
+ })();
235
+ let replTranscript = "";
236
+ let replError = "";
237
+ let replMessage = "";
238
+ let replCapturedAt = 0;
239
+ let replFollow = true;
240
+ let replPollTimer = null;
241
+ let replBusy = false;
242
+ let replSendMode = (() => {
243
+ try {
244
+ return (window.localStorage && window.localStorage.getItem("piStudio.replSendMode")) || "scratch";
245
+ } catch {
246
+ return "scratch";
247
+ }
248
+ })();
249
+ let replJournalEntries = [];
250
+ let activeReplJournalEntryId = "";
251
+ let replJournalCollapsed = (() => {
252
+ try {
253
+ const stored = window.localStorage ? window.localStorage.getItem("piStudio.replJournalCollapsed") : null;
254
+ if (stored === "false") return false;
255
+ if (stored === "true") return true;
256
+ return true;
257
+ } catch {
258
+ return true;
259
+ }
260
+ })();
217
261
  let studioRunChainActive = false;
218
262
  let queuedSteeringCount = 0;
219
263
  let agentBusyFromServer = false;
@@ -287,6 +331,37 @@
287
331
  : "pending";
288
332
  }
289
333
 
334
+ function normalizeTraceImageMimeType(value) {
335
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
336
+ }
337
+
338
+ function isTraceImageSafeMimeType(mimeType) {
339
+ return TRACE_IMAGE_SAFE_MIME_TYPES.has(normalizeTraceImageMimeType(mimeType));
340
+ }
341
+
342
+ function normalizeTraceImage(image, fallbackIndex) {
343
+ if (!image || typeof image !== "object") return null;
344
+ const mimeType = normalizeTraceImageMimeType(image.mimeType);
345
+ if (!isTraceImageSafeMimeType(mimeType)) return null;
346
+ const data = typeof image.data === "string" ? image.data.replace(/\s+/g, "") : "";
347
+ if (!data || !/^[A-Za-z0-9+/]*={0,2}$/.test(data)) return null;
348
+ const byteLength = parseFiniteNumber(image.byteLength);
349
+ return {
350
+ id: typeof image.id === "string" && image.id.trim() ? image.id.trim() : ("trace-image-" + fallbackIndex),
351
+ mimeType,
352
+ data,
353
+ byteLength: byteLength == null ? estimateTraceImageByteLength(data) : byteLength,
354
+ label: parseNonEmptyString(image.label),
355
+ };
356
+ }
357
+
358
+ function estimateTraceImageByteLength(data) {
359
+ const compact = String(data || "").replace(/\s+/g, "");
360
+ if (!compact) return null;
361
+ const padding = compact.endsWith("==") ? 2 : (compact.endsWith("=") ? 1 : 0);
362
+ return Math.max(0, Math.floor((compact.length * 3) / 4) - padding);
363
+ }
364
+
290
365
  function normalizeTraceEntry(entry, fallbackIndex) {
291
366
  if (!entry || typeof entry !== "object") return null;
292
367
  if (entry.type === "assistant") {
@@ -310,6 +385,9 @@
310
385
  label: parseNonEmptyString(entry.label),
311
386
  argsSummary: parseNonEmptyString(entry.argsSummary),
312
387
  output: typeof entry.output === "string" ? entry.output : "",
388
+ images: Array.isArray(entry.images)
389
+ ? entry.images.map((image, imageIndex) => normalizeTraceImage(image, imageIndex)).filter(Boolean)
390
+ : [],
313
391
  startedAt: parseFiniteNumber(entry.startedAt) || Date.now(),
314
392
  updatedAt: parseFiniteNumber(entry.updatedAt) || Date.now(),
315
393
  status: normalizeTraceEntryStatus(entry.status),
@@ -542,6 +620,22 @@
542
620
  });
543
621
  }
544
622
 
623
+ function formatTraceImageSize(byteLength) {
624
+ if (typeof byteLength !== "number" || !Number.isFinite(byteLength) || byteLength < 0) return "unknown size";
625
+ if (byteLength < 1024) return formatNumber(byteLength) + " B";
626
+ if (byteLength < 1024 * 1024) return (byteLength / 1024).toFixed(byteLength >= 100 * 1024 ? 0 : 1).replace(/\.0$/, "") + " KB";
627
+ return (byteLength / (1024 * 1024)).toFixed(byteLength >= 100 * 1024 * 1024 ? 0 : 1).replace(/\.0$/, "") + " MB";
628
+ }
629
+
630
+ function describeTraceImageForText(image) {
631
+ if (!image || typeof image !== "object") return "";
632
+ const parts = [];
633
+ if (image.label) parts.push(String(image.label));
634
+ parts.push(String(image.mimeType || "image"));
635
+ parts.push(formatTraceImageSize(image.byteLength));
636
+ return parts.filter(Boolean).join(" — ");
637
+ }
638
+
545
639
  function buildVisibleWorkingText(filterOverride) {
546
640
  const filter = normalizeTraceFilter(filterOverride || traceFilter);
547
641
  const entries = getTraceEntriesForFilter(filter);
@@ -576,6 +670,12 @@
576
670
  if (String(entry.output || "").trim()) {
577
671
  parts.push("Output:\n" + String(entry.output || "").trim());
578
672
  }
673
+ const imageSummaries = Array.isArray(entry.images)
674
+ ? entry.images.map(describeTraceImageForText).filter(Boolean)
675
+ : [];
676
+ if (imageSummaries.length) {
677
+ parts.push("Images:\n" + imageSummaries.map((summary) => "- " + summary).join("\n"));
678
+ }
579
679
  return parts.join("\n\n").trim();
580
680
  }).filter(Boolean).join("\n\n---\n\n");
581
681
  }
@@ -688,6 +788,641 @@
688
788
  setStatus("Loaded visible working into editor.", "success");
689
789
  }
690
790
 
791
+ function normalizeReplRuntime(value) {
792
+ const runtime = String(value || "").trim().toLowerCase();
793
+ return runtime === "shell" || runtime === "python" || runtime === "ipython" || runtime === "julia" || runtime === "r" || runtime === "ghci" || runtime === "clojure"
794
+ ? runtime
795
+ : "python";
796
+ }
797
+
798
+ function normalizeReplSession(session) {
799
+ if (!session || typeof session !== "object") return null;
800
+ const sessionName = typeof session.sessionName === "string" && session.sessionName.trim() ? session.sessionName.trim() : "";
801
+ if (!sessionName) return null;
802
+ return {
803
+ sessionName,
804
+ target: typeof session.target === "string" ? session.target : (sessionName + ":0.0"),
805
+ runtime: typeof session.runtime === "string" ? session.runtime : "unknown",
806
+ label: typeof session.label === "string" && session.label.trim() ? session.label.trim() : sessionName,
807
+ source: typeof session.source === "string" ? session.source : "tmux",
808
+ };
809
+ }
810
+
811
+ function setReplRuntime(runtime) {
812
+ replRuntime = normalizeReplRuntime(runtime);
813
+ try {
814
+ if (window.localStorage) window.localStorage.setItem("piStudio.replRuntime", replRuntime);
815
+ } catch {
816
+ // Ignore storage failures.
817
+ }
818
+ }
819
+
820
+ function normalizeReplSendMode(value) {
821
+ return String(value || "").trim().toLowerCase() === "literate" ? "literate" : "scratch";
822
+ }
823
+
824
+ function setReplSendMode(mode) {
825
+ replSendMode = normalizeReplSendMode(mode);
826
+ if (replSendModeSelect) replSendModeSelect.value = replSendMode;
827
+ try {
828
+ if (window.localStorage) window.localStorage.setItem("piStudio.replSendMode", replSendMode);
829
+ } catch {
830
+ // Ignore storage failures.
831
+ }
832
+ }
833
+
834
+ function setReplJournalCollapsed(collapsed) {
835
+ replJournalCollapsed = Boolean(collapsed);
836
+ try {
837
+ if (window.localStorage) window.localStorage.setItem("piStudio.replJournalCollapsed", replJournalCollapsed ? "true" : "false");
838
+ } catch {
839
+ // Ignore storage failures.
840
+ }
841
+ renderReplViewIfActive({ force: true });
842
+ }
843
+
844
+ function setReplSessions(sessions) {
845
+ replSessions = Array.isArray(sessions)
846
+ ? sessions.map(normalizeReplSession).filter(Boolean)
847
+ : [];
848
+ if (replActiveSessionName && !replSessions.some((session) => session.sessionName === replActiveSessionName)) {
849
+ replActiveSessionName = replSessions[0] ? replSessions[0].sessionName : "";
850
+ }
851
+ }
852
+
853
+ function getActiveReplSession() {
854
+ return replSessions.find((session) => session.sessionName === replActiveSessionName) || null;
855
+ }
856
+
857
+ function buildActiveReplPromptContext() {
858
+ if (rightView !== "repl") return "";
859
+ const session = getActiveReplSession();
860
+ if (!session) return "";
861
+ const runtime = session.runtime && session.runtime !== "unknown" ? session.runtime : "unknown";
862
+ return [
863
+ "[Studio active REPL]",
864
+ "The right pane is mirroring an active tmux-backed REPL session.",
865
+ "If the user refers to the active REPL, send code to this session rather than inventing a separate one.",
866
+ "Session name: " + session.sessionName,
867
+ "tmux target: " + (session.target || (session.sessionName + ":0.0")),
868
+ "runtime: " + runtime,
869
+ "Suggested shell command for direct interaction: tmux paste-buffer/send-keys targeting " + (session.target || (session.sessionName + ":0.0")),
870
+ "Prefer existing REPL tools when they target this same session; otherwise use tmux directly.",
871
+ "[/Studio active REPL]",
872
+ ].join("\n");
873
+ }
874
+
875
+ function prepareEditorTextForRunRequest(text) {
876
+ const prepared = prepareEditorTextForSend(text);
877
+ const replContext = buildActiveReplPromptContext();
878
+ return replContext ? (replContext + "\n\n" + prepared) : prepared;
879
+ }
880
+
881
+ function setActiveReplSession(sessionName) {
882
+ const name = String(sessionName || "").trim();
883
+ if (!name) {
884
+ replActiveSessionName = "";
885
+ return;
886
+ }
887
+ replActiveSessionName = name;
888
+ }
889
+
890
+ function trimReplTranscript(text) {
891
+ const value = String(text || "");
892
+ if (value.length <= REPL_TRANSCRIPT_MAX_CHARS) return value;
893
+ return "… " + formatCompactNumber(value.length - REPL_TRANSCRIPT_MAX_CHARS) + " earlier chars omitted …\n" + value.slice(value.length - REPL_TRANSCRIPT_MAX_CHARS);
894
+ }
895
+
896
+ function requestReplList() {
897
+ if (wsState === "Disconnected") return false;
898
+ return sendMessage({ type: "repl_list_request" });
899
+ }
900
+
901
+ function requestReplCapture() {
902
+ if (wsState === "Disconnected") return false;
903
+ if (replActiveSessionName) {
904
+ return sendMessage({ type: "repl_capture_request", sessionName: replActiveSessionName });
905
+ }
906
+ return sendMessage({ type: "repl_list_request" });
907
+ }
908
+
909
+ function isReplControlFocused() {
910
+ const activeEl = document.activeElement;
911
+ return activeEl instanceof Element && Boolean(activeEl.closest(".repl-controls"));
912
+ }
913
+
914
+ function renderReplViewIfActive(options) {
915
+ if (rightView !== "repl") return;
916
+ if ((!options || options.force !== true) && isReplControlFocused()) return;
917
+ if (traceRenderRaf !== null) return;
918
+ traceRenderRaf = window.requestAnimationFrame(() => {
919
+ traceRenderRaf = null;
920
+ refreshResponseUi();
921
+ });
922
+ }
923
+
924
+ function startReplPolling() {
925
+ if (rightView !== "repl") return;
926
+ if (replPollTimer !== null) return;
927
+ requestReplCapture();
928
+ replPollTimer = window.setInterval(() => {
929
+ if (rightView !== "repl") {
930
+ stopReplPolling();
931
+ return;
932
+ }
933
+ requestReplCapture();
934
+ }, REPL_POLL_INTERVAL_MS);
935
+ }
936
+
937
+ function stopReplPolling() {
938
+ if (replPollTimer !== null) {
939
+ window.clearInterval(replPollTimer);
940
+ replPollTimer = null;
941
+ }
942
+ }
943
+
944
+ function getActiveReplRuntime() {
945
+ const session = getActiveReplSession();
946
+ if (session && session.runtime && session.runtime !== "unknown") return normalizeReplRuntime(session.runtime);
947
+ return normalizeReplRuntime(replRuntime);
948
+ }
949
+
950
+ function getEditorSelectionRange() {
951
+ const raw = String(sourceTextEl.value || "");
952
+ const start = typeof sourceTextEl.selectionStart === "number" ? sourceTextEl.selectionStart : 0;
953
+ const end = typeof sourceTextEl.selectionEnd === "number" ? sourceTextEl.selectionEnd : start;
954
+ const safeStart = Math.max(0, Math.min(start, raw.length));
955
+ const safeEnd = Math.max(safeStart, Math.min(end, raw.length));
956
+ return { raw, start: safeStart, end: safeEnd, selected: safeEnd > safeStart ? raw.slice(safeStart, safeEnd) : "" };
957
+ }
958
+
959
+ function normalizeMarkdownFenceLanguage(info) {
960
+ let value = String(info || "").trim();
961
+ if (!value) return "";
962
+ let first = "";
963
+ if (value.startsWith("{")) {
964
+ const closeIndex = value.indexOf("}");
965
+ const inner = closeIndex >= 0 ? value.slice(1, closeIndex) : value.slice(1);
966
+ first = inner.split(/[\s,]+/)[0] || "";
967
+ } else {
968
+ first = value.split(/\s+/)[0] || "";
969
+ }
970
+ first = first.replace(/^\./, "").trim().toLowerCase();
971
+ if (first === "py") return "python";
972
+ if (first === "jl") return "julia";
973
+ if (first === "sh" || first === "zsh" || first === "fish") return "shell";
974
+ if (first === "bash") return "shell";
975
+ if (first === "hs" || first === "haskell") return "ghci";
976
+ if (first === "clj" || first === "cljc") return "clojure";
977
+ return first;
978
+ }
979
+
980
+ function parseMarkdownCodeFences(markdown) {
981
+ const text = String(markdown || "");
982
+ const blocks = [];
983
+ let offset = 0;
984
+ let open = null;
985
+ while (offset <= text.length) {
986
+ const newlineIndex = text.indexOf("\n", offset);
987
+ const lineEnd = newlineIndex >= 0 ? newlineIndex : text.length;
988
+ const lineWithNewlineEnd = newlineIndex >= 0 ? newlineIndex + 1 : lineEnd;
989
+ const line = text.slice(offset, lineEnd);
990
+ if (!open) {
991
+ const openMatch = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
992
+ if (openMatch) {
993
+ const fence = openMatch[1] || "";
994
+ open = {
995
+ start: offset,
996
+ fence,
997
+ fenceChar: fence.charAt(0),
998
+ fenceLength: fence.length,
999
+ info: openMatch[2] || "",
1000
+ contentStart: lineWithNewlineEnd,
1001
+ };
1002
+ }
1003
+ } else {
1004
+ const closePattern = new RegExp("^ {0,3}" + open.fenceChar + "{" + open.fenceLength + ",}[ \\t]*$");
1005
+ if (closePattern.test(line)) {
1006
+ blocks.push({
1007
+ start: open.start,
1008
+ end: lineWithNewlineEnd,
1009
+ contentStart: open.contentStart,
1010
+ contentEnd: offset,
1011
+ info: open.info,
1012
+ language: normalizeMarkdownFenceLanguage(open.info),
1013
+ code: text.slice(open.contentStart, offset),
1014
+ });
1015
+ open = null;
1016
+ }
1017
+ }
1018
+ if (newlineIndex < 0) break;
1019
+ offset = lineWithNewlineEnd;
1020
+ }
1021
+ return blocks;
1022
+ }
1023
+
1024
+ function isFenceLanguageCompatibleWithRuntime(language, runtime) {
1025
+ const lang = normalizeMarkdownFenceLanguage(language);
1026
+ if (!lang) return true;
1027
+ const activeRuntime = normalizeReplRuntime(runtime || getActiveReplRuntime());
1028
+ if (activeRuntime === "python" || activeRuntime === "ipython") return lang === "python" || lang === "ipython";
1029
+ if (activeRuntime === "r") return lang === "r";
1030
+ if (activeRuntime === "julia") return lang === "julia";
1031
+ if (activeRuntime === "shell") return lang === "shell" || lang === "bash" || lang === "sh";
1032
+ if (activeRuntime === "ghci") return lang === "ghci" || lang === "haskell";
1033
+ if (activeRuntime === "clojure") return lang === "clojure";
1034
+ return true;
1035
+ }
1036
+
1037
+ function stripFencedBlocksFromMarkdown(markdown, blocks) {
1038
+ const text = String(markdown || "");
1039
+ const ranges = Array.isArray(blocks) ? blocks : parseMarkdownCodeFences(text);
1040
+ if (!ranges.length) return text.trim();
1041
+ let cursor = 0;
1042
+ const pieces = [];
1043
+ ranges.forEach((block) => {
1044
+ pieces.push(text.slice(cursor, block.start));
1045
+ cursor = block.end;
1046
+ });
1047
+ pieces.push(text.slice(cursor));
1048
+ return pieces.join("\n").replace(/\n{3,}/g, "\n\n").trim();
1049
+ }
1050
+
1051
+ function getCurrentMarkdownCodeFence(markdown, caretOffset) {
1052
+ const text = String(markdown || "");
1053
+ const safeCaret = Math.max(0, Math.min(Math.floor(Number(caretOffset) || 0), text.length));
1054
+ return parseMarkdownCodeFences(text).find((block) => safeCaret >= block.contentStart && safeCaret <= block.contentEnd) || null;
1055
+ }
1056
+
1057
+ function unwrapSingleMarkdownCodeFenceForReplSend(text) {
1058
+ const source = String(text || "");
1059
+ const blocks = parseMarkdownCodeFences(source);
1060
+ if (blocks.length !== 1) return null;
1061
+ const block = blocks[0];
1062
+ if (source.slice(0, block.start).trim() || source.slice(block.end).trim()) return null;
1063
+ if (!isFenceLanguageCompatibleWithRuntime(block.language, getActiveReplRuntime())) return null;
1064
+ const code = String(block.code || "").trimEnd();
1065
+ if (!code.trim()) return null;
1066
+ return {
1067
+ code,
1068
+ label: "single " + (block.language || getActiveReplRuntime()) + " chunk",
1069
+ };
1070
+ }
1071
+
1072
+ function buildScratchReplSendPayload() {
1073
+ const range = getEditorSelectionRange();
1074
+ const selected = range.selected;
1075
+ const source = selected || range.raw;
1076
+ const unwrapped = unwrapSingleMarkdownCodeFenceForReplSend(source);
1077
+ return {
1078
+ text: prepareEditorTextForSend(unwrapped ? unwrapped.code : source),
1079
+ prose: "",
1080
+ label: unwrapped ? unwrapped.label : (selected ? "selection" : "full editor"),
1081
+ mode: "scratch",
1082
+ noteOnly: false,
1083
+ skippedChunks: 0,
1084
+ };
1085
+ }
1086
+
1087
+ function buildLiterateReplSendPayload() {
1088
+ const range = getEditorSelectionRange();
1089
+ const runtime = getActiveReplRuntime();
1090
+ if (range.selected) {
1091
+ const blocks = parseMarkdownCodeFences(range.selected);
1092
+ if (blocks.length) {
1093
+ const compatibleBlocks = blocks.filter((block) => isFenceLanguageCompatibleWithRuntime(block.language, runtime));
1094
+ const code = compatibleBlocks.map((block) => String(block.code || "").trimEnd()).filter((chunk) => chunk.trim()).join("\n\n");
1095
+ const prose = stripFencedBlocksFromMarkdown(range.selected, blocks);
1096
+ if (code.trim()) {
1097
+ return {
1098
+ text: prepareEditorTextForSend(code),
1099
+ prose,
1100
+ label: "selection · " + compatibleBlocks.length + " code chunk" + (compatibleBlocks.length === 1 ? "" : "s"),
1101
+ mode: "literate",
1102
+ noteOnly: false,
1103
+ skippedChunks: Math.max(0, blocks.length - compatibleBlocks.length),
1104
+ };
1105
+ }
1106
+ if (prose.trim()) {
1107
+ return {
1108
+ text: "",
1109
+ prose,
1110
+ label: "selected prose",
1111
+ mode: "literate",
1112
+ noteOnly: true,
1113
+ skippedChunks: blocks.length,
1114
+ };
1115
+ }
1116
+ return { error: "Selected code chunks do not match the active REPL runtime." };
1117
+ }
1118
+ return {
1119
+ text: prepareEditorTextForSend(range.selected),
1120
+ prose: "",
1121
+ label: "selection",
1122
+ mode: "literate",
1123
+ noteOnly: false,
1124
+ skippedChunks: 0,
1125
+ };
1126
+ }
1127
+
1128
+ const currentBlock = getCurrentMarkdownCodeFence(range.raw, range.start);
1129
+ if (currentBlock) {
1130
+ if (!isFenceLanguageCompatibleWithRuntime(currentBlock.language, runtime)) {
1131
+ return { error: "Current code chunk is marked " + (currentBlock.language || "unknown") + ", but the active REPL is " + runtime + "." };
1132
+ }
1133
+ return {
1134
+ text: prepareEditorTextForSend(String(currentBlock.code || "").trimEnd()),
1135
+ prose: "",
1136
+ label: "current " + (currentBlock.language || runtime) + " chunk",
1137
+ mode: "literate",
1138
+ noteOnly: false,
1139
+ skippedChunks: 0,
1140
+ };
1141
+ }
1142
+
1143
+ const allBlocks = parseMarkdownCodeFences(range.raw);
1144
+ if (allBlocks.length) {
1145
+ return { error: "Place the cursor inside a code chunk, select text, or use Run all chunks. Switch send mode to Scratch to send the full editor." };
1146
+ }
1147
+
1148
+ return {
1149
+ text: prepareEditorTextForSend(range.raw),
1150
+ prose: "",
1151
+ label: "full editor",
1152
+ mode: "literate",
1153
+ noteOnly: false,
1154
+ skippedChunks: 0,
1155
+ };
1156
+ }
1157
+
1158
+ function buildAllChunksReplSendPayload() {
1159
+ const range = getEditorSelectionRange();
1160
+ const runtime = getActiveReplRuntime();
1161
+ const blocks = parseMarkdownCodeFences(range.raw);
1162
+ if (!blocks.length) return { error: "No fenced code chunks found in the editor." };
1163
+ const compatibleBlocks = blocks.filter((block) => isFenceLanguageCompatibleWithRuntime(block.language, runtime));
1164
+ const code = compatibleBlocks.map((block) => String(block.code || "").trimEnd()).filter((chunk) => chunk.trim()).join("\n\n");
1165
+ if (!code.trim()) return { error: "No code chunks match the active REPL runtime." };
1166
+ return {
1167
+ text: prepareEditorTextForSend(code),
1168
+ prose: "",
1169
+ label: "all " + runtime + " chunks · " + compatibleBlocks.length + " of " + blocks.length,
1170
+ mode: "literate",
1171
+ noteOnly: false,
1172
+ skippedChunks: Math.max(0, blocks.length - compatibleBlocks.length),
1173
+ };
1174
+ }
1175
+
1176
+ function getSelectedOrCurrentParagraphForReplNote() {
1177
+ const range = getEditorSelectionRange();
1178
+ if (range.selected.trim()) return range.selected.trim();
1179
+ const before = range.raw.lastIndexOf("\n\n", Math.max(0, range.start - 1));
1180
+ const after = range.raw.indexOf("\n\n", range.start);
1181
+ const start = before >= 0 ? before + 2 : 0;
1182
+ const end = after >= 0 ? after : range.raw.length;
1183
+ return range.raw.slice(start, end).trim();
1184
+ }
1185
+
1186
+ function trimReplJournalOutput(text) {
1187
+ const value = String(text || "").trimEnd();
1188
+ if (value.length <= REPL_JOURNAL_OUTPUT_MAX_CHARS) return value;
1189
+ return "… " + formatCompactNumber(value.length - REPL_JOURNAL_OUTPUT_MAX_CHARS) + " earlier chars omitted …\n" + value.slice(value.length - REPL_JOURNAL_OUTPUT_MAX_CHARS);
1190
+ }
1191
+
1192
+ function createReplJournalEntry(details) {
1193
+ return {
1194
+ id: "repl-journal-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8),
1195
+ requestId: details.requestId || "",
1196
+ createdAt: Date.now(),
1197
+ updatedAt: Date.now(),
1198
+ sessionName: details.sessionName || "",
1199
+ runtime: details.runtime || getActiveReplRuntime(),
1200
+ label: details.label || "REPL send",
1201
+ mode: details.mode || replSendMode,
1202
+ prose: String(details.prose || ""),
1203
+ code: String(details.code || ""),
1204
+ output: String(details.output || ""),
1205
+ beforeTranscript: String(details.beforeTranscript || ""),
1206
+ status: details.status || "sent",
1207
+ skippedChunks: Math.max(0, Math.floor(Number(details.skippedChunks) || 0)),
1208
+ };
1209
+ }
1210
+
1211
+ function addReplJournalEntry(details) {
1212
+ const entry = createReplJournalEntry(details || {});
1213
+ replJournalEntries = [...replJournalEntries, entry].slice(-REPL_JOURNAL_MAX_ENTRIES);
1214
+ return entry;
1215
+ }
1216
+
1217
+ function extractReplTranscriptDelta(before, after) {
1218
+ const previous = String(before || "");
1219
+ const current = String(after || "");
1220
+ if (!current) return "";
1221
+ if (!previous) return current;
1222
+ const directIndex = current.indexOf(previous);
1223
+ if (directIndex >= 0) return current.slice(directIndex + previous.length).replace(/^\s+/, "");
1224
+ const previousLines = previous.split("\n");
1225
+ const maxSuffixLines = Math.min(24, previousLines.length);
1226
+ for (let count = maxSuffixLines; count >= 1; count -= 1) {
1227
+ const suffix = previousLines.slice(previousLines.length - count).join("\n");
1228
+ if (!suffix.trim()) continue;
1229
+ const suffixIndex = current.indexOf(suffix);
1230
+ if (suffixIndex >= 0) return current.slice(suffixIndex + suffix.length).replace(/^\s+/, "");
1231
+ }
1232
+ return current;
1233
+ }
1234
+
1235
+ function updateActiveReplJournalEntryFromTranscript(sessionName, transcript) {
1236
+ if (!activeReplJournalEntryId) return;
1237
+ const entryIndex = replJournalEntries.findIndex((entry) => entry.id === activeReplJournalEntryId);
1238
+ if (entryIndex < 0) return;
1239
+ const entry = replJournalEntries[entryIndex];
1240
+ if (entry.sessionName && sessionName && entry.sessionName !== sessionName) return;
1241
+ const delta = trimReplJournalOutput(extractReplTranscriptDelta(entry.beforeTranscript, transcript));
1242
+ if (!delta.trim()) return;
1243
+ replJournalEntries = replJournalEntries.map((candidate) => candidate.id === entry.id
1244
+ ? { ...candidate, output: delta, status: "captured", updatedAt: Date.now() }
1245
+ : candidate);
1246
+ }
1247
+
1248
+ function getMarkdownFenceForText(text, language) {
1249
+ const value = String(text || "");
1250
+ let fence = "```";
1251
+ while (value.includes(fence)) fence += "`";
1252
+ return fence + (language ? language : "") + "\n" + value.replace(/\s+$/, "") + "\n" + fence;
1253
+ }
1254
+
1255
+ function buildReplJournalMarkdown() {
1256
+ const lines = ["# Studio REPL journal", "", "Generated: " + new Date().toLocaleString(), ""];
1257
+ if (!replJournalEntries.length) {
1258
+ lines.push("_No journal entries yet._");
1259
+ return lines.join("\n");
1260
+ }
1261
+ replJournalEntries.forEach((entry, index) => {
1262
+ lines.push("## " + (index + 1) + ". " + (entry.label || "REPL entry"));
1263
+ lines.push("");
1264
+ lines.push("- Time: " + new Date(entry.createdAt || Date.now()).toLocaleString());
1265
+ if (entry.runtime) lines.push("- Runtime: " + entry.runtime);
1266
+ if (entry.sessionName) lines.push("- Session: `" + entry.sessionName + "`");
1267
+ if (entry.skippedChunks) lines.push("- Skipped chunks: " + entry.skippedChunks);
1268
+ lines.push("");
1269
+ if (String(entry.prose || "").trim()) {
1270
+ lines.push(String(entry.prose).trim());
1271
+ lines.push("");
1272
+ }
1273
+ if (String(entry.code || "").trim()) {
1274
+ lines.push(getMarkdownFenceForText(entry.code, entry.runtime === "ipython" ? "python" : entry.runtime));
1275
+ lines.push("");
1276
+ }
1277
+ if (String(entry.output || "").trim()) {
1278
+ lines.push("Output:");
1279
+ lines.push("");
1280
+ lines.push(getMarkdownFenceForText(entry.output, "text"));
1281
+ lines.push("");
1282
+ }
1283
+ });
1284
+ return lines.join("\n").replace(/\n{4,}/g, "\n\n\n").trimEnd() + "\n";
1285
+ }
1286
+
1287
+ async function copyReplJournalToClipboard() {
1288
+ if (!replJournalEntries.length) {
1289
+ setStatus("No REPL journal entries to copy yet.", "warning");
1290
+ return;
1291
+ }
1292
+ if (await writeTextToClipboard(buildReplJournalMarkdown())) {
1293
+ setStatus("Copied REPL journal as Markdown.", "success");
1294
+ } else {
1295
+ setStatus("Clipboard write failed.", "warning");
1296
+ }
1297
+ }
1298
+
1299
+ function exportReplJournalMarkdown() {
1300
+ if (!replJournalEntries.length) {
1301
+ setStatus("No REPL journal entries to export yet.", "warning");
1302
+ return;
1303
+ }
1304
+ const blob = new Blob([buildReplJournalMarkdown()], { type: "text/markdown;charset=utf-8" });
1305
+ const blobUrl = URL.createObjectURL(blob);
1306
+ const link = document.createElement("a");
1307
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
1308
+ link.href = blobUrl;
1309
+ link.download = "studio-repl-journal-" + stamp + ".md";
1310
+ document.body.appendChild(link);
1311
+ link.click();
1312
+ link.remove();
1313
+ window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
1314
+ setStatus("Exported REPL journal Markdown.", "success");
1315
+ }
1316
+
1317
+ function clearReplJournal() {
1318
+ replJournalEntries = [];
1319
+ activeReplJournalEntryId = "";
1320
+ setStatus("Cleared REPL journal.", "success");
1321
+ renderReplViewIfActive({ force: true });
1322
+ }
1323
+
1324
+ function loadReplJournalIntoEditor() {
1325
+ if (!replJournalEntries.length) {
1326
+ setStatus("No REPL journal entries to load yet.", "warning");
1327
+ return;
1328
+ }
1329
+ const markdown = buildReplJournalMarkdown();
1330
+ setEditorText(markdown, { preserveScroll: false, preserveSelection: false });
1331
+ setSourceState({ source: "blank", label: "REPL journal", path: null });
1332
+ setEditorLanguage("markdown");
1333
+ setStatus("Loaded REPL journal into editor.", "success");
1334
+ }
1335
+
1336
+ function addSelectedReplJournalNote() {
1337
+ const note = getSelectedOrCurrentParagraphForReplNote();
1338
+ if (!note.trim()) {
1339
+ setStatus("Select prose or place the cursor in a paragraph to journal a note.", "warning");
1340
+ return;
1341
+ }
1342
+ addReplJournalEntry({
1343
+ label: "note",
1344
+ prose: note,
1345
+ status: "note",
1346
+ mode: "literate",
1347
+ sessionName: replActiveSessionName,
1348
+ runtime: getActiveReplRuntime(),
1349
+ });
1350
+ setStatus("Added note to REPL journal.", "success");
1351
+ renderReplViewIfActive({ force: true });
1352
+ }
1353
+
1354
+ function sendReplPayload(payload) {
1355
+ const session = getActiveReplSession();
1356
+ if (!session) {
1357
+ setStatus("Start or select a REPL session first.", "warning");
1358
+ return;
1359
+ }
1360
+ if (!payload || payload.error) {
1361
+ setStatus(payload && payload.error ? payload.error : "Nothing to send to REPL.", "warning");
1362
+ return;
1363
+ }
1364
+ if (payload.noteOnly) {
1365
+ if (String(payload.prose || "").trim()) {
1366
+ addReplJournalEntry({
1367
+ label: payload.label || "note",
1368
+ prose: payload.prose,
1369
+ status: "note",
1370
+ mode: payload.mode || "literate",
1371
+ sessionName: session.sessionName,
1372
+ runtime: getActiveReplRuntime(),
1373
+ skippedChunks: payload.skippedChunks,
1374
+ });
1375
+ setStatus("Added prose to REPL journal.", "success");
1376
+ renderReplViewIfActive({ force: true });
1377
+ } else {
1378
+ setStatus("No code or prose found to send.", "warning");
1379
+ }
1380
+ return;
1381
+ }
1382
+ const text = String(payload.text || "");
1383
+ if (!text.trim()) {
1384
+ setStatus("Editor text is empty.", "warning");
1385
+ return;
1386
+ }
1387
+ const requestId = makeRequestId();
1388
+ const journalEntry = addReplJournalEntry({
1389
+ requestId,
1390
+ sessionName: session.sessionName,
1391
+ runtime: getActiveReplRuntime(),
1392
+ label: payload.label,
1393
+ mode: payload.mode,
1394
+ prose: payload.prose,
1395
+ code: text,
1396
+ beforeTranscript: replTranscript,
1397
+ status: "sending",
1398
+ skippedChunks: payload.skippedChunks,
1399
+ });
1400
+ activeReplJournalEntryId = journalEntry.id;
1401
+ replBusy = true;
1402
+ syncActionButtons();
1403
+ renderReplViewIfActive({ force: true });
1404
+ const skippedSuffix = payload.skippedChunks ? " (skipped " + payload.skippedChunks + " incompatible chunk" + (payload.skippedChunks === 1 ? "" : "s") + ")" : "";
1405
+ setStatus("Sending " + (payload.label || "editor text") + " to REPL…" + skippedSuffix, "info");
1406
+ if (!sendMessage({ type: "repl_send_request", requestId, sessionName: session.sessionName, text })) {
1407
+ replBusy = false;
1408
+ replJournalEntries = replJournalEntries.map((entry) => entry.id === journalEntry.id ? { ...entry, status: "error" } : entry);
1409
+ syncActionButtons();
1410
+ }
1411
+ }
1412
+
1413
+ function sendEditorTextToRepl(options) {
1414
+ const action = options && options.action ? options.action : "default";
1415
+ if (action === "all-chunks") {
1416
+ sendReplPayload(buildAllChunksReplSendPayload());
1417
+ return;
1418
+ }
1419
+ if (action === "note") {
1420
+ addSelectedReplJournalNote();
1421
+ return;
1422
+ }
1423
+ sendReplPayload(replSendMode === "literate" ? buildLiterateReplSendPayload() : buildScratchReplSendPayload());
1424
+ }
1425
+
691
1426
  function renderTraceViewIfActive() {
692
1427
  if (rightView !== "trace") return;
693
1428
  if (traceRenderRaf !== null) return;
@@ -1152,6 +1887,8 @@
1152
1887
  const actionLineOneEl = makeStudioUiRefreshElement("div", "studio-refresh-action-line");
1153
1888
  if (!isEditorOnlyMode && sendRunBtn) actionLineOneEl.appendChild(sendRunBtn);
1154
1889
  if (!isEditorOnlyMode && queueSteerBtn) actionLineOneEl.appendChild(queueSteerBtn);
1890
+ if (!isEditorOnlyMode && sendReplBtn) actionLineOneEl.appendChild(sendReplBtn);
1891
+ if (!isEditorOnlyMode && replSendModeSelect) actionLineOneEl.appendChild(replSendModeSelect);
1155
1892
  const actionLineTwoEl = makeStudioUiRefreshElement("div", "studio-refresh-action-line");
1156
1893
  actionLineTwoEl.appendChild(copyDraftBtn);
1157
1894
  if (openCompanionBtn) actionLineTwoEl.appendChild(openCompanionBtn);
@@ -2143,6 +2880,23 @@
2143
2880
  return;
2144
2881
  }
2145
2882
 
2883
+ if (
2884
+ key === "Enter"
2885
+ && (event.metaKey || event.ctrlKey)
2886
+ && !event.altKey
2887
+ && event.shiftKey
2888
+ && activePane === "left"
2889
+ && !isEditorOnlyMode
2890
+ ) {
2891
+ event.preventDefault();
2892
+ if (sendReplBtn && !sendReplBtn.hidden && !sendReplBtn.disabled) {
2893
+ sendReplBtn.click();
2894
+ } else {
2895
+ setStatus("Open REPL view and start/select a session before sending to REPL.", "warning");
2896
+ }
2897
+ return;
2898
+ }
2899
+
2146
2900
  if (
2147
2901
  key === "Enter"
2148
2902
  && (event.metaKey || event.ctrlKey)
@@ -2455,6 +3209,18 @@
2455
3209
  return;
2456
3210
  }
2457
3211
 
3212
+ if (rightView === "repl") {
3213
+ const session = getActiveReplSession();
3214
+ if (replTmuxAvailable === false) {
3215
+ referenceBadgeEl.textContent = "REPL: tmux unavailable";
3216
+ return;
3217
+ }
3218
+ referenceBadgeEl.textContent = session
3219
+ ? ("REPL: " + session.label + (replCapturedAt ? (" · updated " + formatReferenceTime(replCapturedAt)) : ""))
3220
+ : "REPL: no session selected";
3221
+ return;
3222
+ }
3223
+
2458
3224
  if (rightView === "editor-preview") {
2459
3225
  const hasResponse = Boolean(latestResponseMarkdown && latestResponseMarkdown.trim());
2460
3226
  if (hasResponse) {
@@ -3645,8 +4411,13 @@
3645
4411
  }
3646
4412
 
3647
4413
  function handleTracePaneScroll() {
3648
- if (rightView !== "trace") return;
3649
- traceAutoScroll = shouldStickTraceToBottom();
4414
+ if (rightView === "trace") {
4415
+ traceAutoScroll = shouldStickTraceToBottom();
4416
+ return;
4417
+ }
4418
+ if (rightView === "repl") {
4419
+ replFollow = shouldStickTraceToBottom();
4420
+ }
3650
4421
  }
3651
4422
 
3652
4423
  async function handleTracePaneClick(event) {
@@ -3687,10 +4458,129 @@
3687
4458
  }
3688
4459
  }
3689
4460
 
4461
+ function handleReplPaneClick(event) {
4462
+ if (rightView !== "repl") return;
4463
+ const target = event.target;
4464
+ const actionBtn = target instanceof Element ? target.closest("[data-repl-action]") : null;
4465
+ if (!actionBtn) return;
4466
+ event.preventDefault();
4467
+ const action = actionBtn.getAttribute("data-repl-action") || "";
4468
+ if (action === "start" || action === "new-session") {
4469
+ const requestId = makeRequestId();
4470
+ replBusy = true;
4471
+ replError = "";
4472
+ replMessage = (action === "new-session" ? "Starting new " : "Starting ") + replRuntime + " REPL…";
4473
+ syncActionButtons();
4474
+ renderReplViewIfActive({ force: true });
4475
+ if (!sendMessage({ type: "repl_start_request", requestId, runtime: replRuntime, newSession: action === "new-session" })) {
4476
+ replBusy = false;
4477
+ syncActionButtons();
4478
+ }
4479
+ return;
4480
+ }
4481
+ if (action === "stop-session") {
4482
+ const session = getActiveReplSession();
4483
+ if (!session) {
4484
+ setStatus("Start or select a REPL session first.", "warning");
4485
+ return;
4486
+ }
4487
+ const requestId = makeRequestId();
4488
+ replBusy = true;
4489
+ replError = "";
4490
+ replMessage = "Stopping " + session.sessionName + "…";
4491
+ syncActionButtons();
4492
+ renderReplViewIfActive({ force: true });
4493
+ if (!sendMessage({ type: "repl_stop_request", requestId, sessionName: session.sessionName })) {
4494
+ replBusy = false;
4495
+ syncActionButtons();
4496
+ }
4497
+ return;
4498
+ }
4499
+ if (action === "interrupt") {
4500
+ const session = getActiveReplSession();
4501
+ if (!session) {
4502
+ setStatus("Start or select a REPL session first.", "warning");
4503
+ return;
4504
+ }
4505
+ const requestId = makeRequestId();
4506
+ replBusy = true;
4507
+ replError = "";
4508
+ replMessage = "Interrupting REPL…";
4509
+ syncActionButtons();
4510
+ renderReplViewIfActive({ force: true });
4511
+ if (!sendMessage({ type: "repl_interrupt_request", requestId, sessionName: session.sessionName })) {
4512
+ replBusy = false;
4513
+ syncActionButtons();
4514
+ }
4515
+ return;
4516
+ }
4517
+ if (action === "run-all-chunks") {
4518
+ sendEditorTextToRepl({ action: "all-chunks" });
4519
+ return;
4520
+ }
4521
+ if (action === "journal-note") {
4522
+ sendEditorTextToRepl({ action: "note" });
4523
+ return;
4524
+ }
4525
+ if (action === "journal-toggle") {
4526
+ setReplJournalCollapsed(!replJournalCollapsed);
4527
+ return;
4528
+ }
4529
+ if (action === "load-journal") {
4530
+ loadReplJournalIntoEditor();
4531
+ return;
4532
+ }
4533
+ if (action === "copy-journal") {
4534
+ void copyReplJournalToClipboard();
4535
+ return;
4536
+ }
4537
+ if (action === "export-journal") {
4538
+ exportReplJournalMarkdown();
4539
+ return;
4540
+ }
4541
+ if (action === "clear-journal") {
4542
+ clearReplJournal();
4543
+ return;
4544
+ }
4545
+ if (action === "refresh") {
4546
+ replError = "";
4547
+ replMessage = "";
4548
+ requestReplCapture();
4549
+ return;
4550
+ }
4551
+ if (action === "follow") {
4552
+ replFollow = !replFollow;
4553
+ renderReplViewIfActive({ force: true });
4554
+ }
4555
+ }
4556
+
4557
+ function handleReplPaneChange(event) {
4558
+ if (rightView !== "repl") return;
4559
+ const target = event.target;
4560
+ if (!(target instanceof Element)) return;
4561
+ const runtimeSelect = target.closest("[data-repl-runtime]");
4562
+ if (runtimeSelect && "value" in runtimeSelect) {
4563
+ setReplRuntime(runtimeSelect.value);
4564
+ renderReplViewIfActive({ force: true });
4565
+ return;
4566
+ }
4567
+ const sessionSelect = target.closest("[data-repl-session]");
4568
+ if (sessionSelect && "value" in sessionSelect) {
4569
+ setActiveReplSession(sessionSelect.value);
4570
+ replError = "";
4571
+ replMessage = "";
4572
+ replFollow = true;
4573
+ requestReplCapture();
4574
+ renderReplViewIfActive({ force: true });
4575
+ }
4576
+ }
4577
+
3690
4578
  function attachResponsePaneInteractionHandlers() {
3691
4579
  if (!critiqueViewEl) return;
3692
4580
  critiqueViewEl.addEventListener("scroll", handleTracePaneScroll);
3693
4581
  critiqueViewEl.addEventListener("click", handleTracePaneClick);
4582
+ critiqueViewEl.addEventListener("click", handleReplPaneClick);
4583
+ critiqueViewEl.addEventListener("change", handleReplPaneChange);
3694
4584
  }
3695
4585
 
3696
4586
  function replaceResponsePaneWithClone() {
@@ -3919,35 +4809,42 @@
3919
4809
  return;
3920
4810
  }
3921
4811
 
4812
+ const exportingReplJournal = rightView === "repl";
3922
4813
  const rightPaneShowsPreview = rightView === "preview" || rightView === "editor-preview";
3923
- if (!rightPaneShowsPreview) {
3924
- setStatus("Switch right pane to Response (Preview) or Editor (Preview) to export PDF.", "warning");
4814
+ if (!rightPaneShowsPreview && !exportingReplJournal) {
4815
+ setStatus("Switch right pane to Response (Preview), Editor (Preview), or REPL journal to export PDF.", "warning");
4816
+ return;
4817
+ }
4818
+ if (exportingReplJournal && !replJournalEntries.length) {
4819
+ setStatus("No REPL journal entries to export yet.", "warning");
3925
4820
  return;
3926
4821
  }
3927
4822
 
3928
- const htmlArtifactSource = getRightPaneHtmlArtifactSource();
4823
+ const htmlArtifactSource = exportingReplJournal ? "" : getRightPaneHtmlArtifactSource();
3929
4824
  if (htmlArtifactSource) {
3930
4825
  setStatus("PDF export does not support interactive HTML previews yet. Export as HTML or use the browser print dialog inside the preview.", "warning");
3931
4826
  return;
3932
4827
  }
3933
4828
 
3934
- const markdown = rightView === "editor-preview"
3935
- ? prepareEditorTextForPdfExport(sourceTextEl.value)
3936
- : prepareEditorTextForPreview(latestResponseMarkdown);
4829
+ const markdown = exportingReplJournal
4830
+ ? buildReplJournalMarkdown()
4831
+ : (rightView === "editor-preview"
4832
+ ? prepareEditorTextForPdfExport(sourceTextEl.value)
4833
+ : prepareEditorTextForPreview(latestResponseMarkdown));
3937
4834
  if (!markdown || !markdown.trim()) {
3938
4835
  setStatus("Nothing to export yet.", "warning");
3939
4836
  return;
3940
4837
  }
3941
4838
 
3942
4839
  const effectivePath = getEffectiveSavePath();
3943
- const sourcePath = effectivePath || sourceState.path || "";
4840
+ const sourcePath = exportingReplJournal ? "" : (effectivePath || sourceState.path || "");
3944
4841
  const resourceDir = (!sourcePath && resourceDirInput) ? resourceDirInput.value.trim() : "";
3945
4842
  const isEditorPreview = rightView === "editor-preview";
3946
4843
  const editorPdfLanguage = isEditorPreview ? normalizeFenceLanguage(editorLanguage || "") : "";
3947
4844
  const isLatex = isEditorPreview
3948
4845
  ? editorPdfLanguage === "latex"
3949
4846
  : /\\documentclass\b|\\begin\{document\}/.test(markdown);
3950
- let filenameHint = isEditorPreview ? "studio-editor-preview.pdf" : "studio-response-preview.pdf";
4847
+ let filenameHint = exportingReplJournal ? "studio-repl-journal.pdf" : (isEditorPreview ? "studio-editor-preview.pdf" : "studio-response-preview.pdf");
3951
4848
  if (sourcePath) {
3952
4849
  const baseName = sourcePath.split(/[\\/]/).pop() || "studio";
3953
4850
  const stem = baseName.replace(/\.[^.]+$/, "") || "studio";
@@ -4084,31 +4981,36 @@
4084
4981
  return;
4085
4982
  }
4086
4983
 
4984
+ const exportingReplJournal = rightView === "repl";
4087
4985
  const rightPaneShowsPreview = rightView === "preview" || rightView === "editor-preview";
4088
- if (!rightPaneShowsPreview) {
4089
- setStatus("Switch right pane to Response (Preview) or Editor (Preview) to export HTML.", "warning");
4986
+ if (!rightPaneShowsPreview && !exportingReplJournal) {
4987
+ setStatus("Switch right pane to Response (Preview), Editor (Preview), or REPL journal to export HTML.", "warning");
4988
+ return;
4989
+ }
4990
+ if (exportingReplJournal && !replJournalEntries.length) {
4991
+ setStatus("No REPL journal entries to export yet.", "warning");
4090
4992
  return;
4091
4993
  }
4092
4994
 
4093
- const htmlArtifactSource = getRightPaneHtmlArtifactSource();
4094
- const markdown = htmlArtifactSource || (rightView === "editor-preview"
4995
+ const htmlArtifactSource = exportingReplJournal ? "" : getRightPaneHtmlArtifactSource();
4996
+ const markdown = exportingReplJournal ? buildReplJournalMarkdown() : (htmlArtifactSource || (rightView === "editor-preview"
4095
4997
  ? prepareEditorTextForHtmlExport(sourceTextEl.value)
4096
- : prepareEditorTextForPreview(latestResponseMarkdown));
4998
+ : prepareEditorTextForPreview(latestResponseMarkdown)));
4097
4999
  if (!markdown || !markdown.trim()) {
4098
5000
  setStatus("Nothing to export yet.", "warning");
4099
5001
  return;
4100
5002
  }
4101
5003
 
4102
5004
  const effectivePath = getEffectiveSavePath();
4103
- const sourcePath = effectivePath || sourceState.path || "";
5005
+ const sourcePath = exportingReplJournal ? "" : (effectivePath || sourceState.path || "");
4104
5006
  const resourceDir = (!sourcePath && resourceDirInput) ? resourceDirInput.value.trim() : "";
4105
5007
  const isEditorPreview = rightView === "editor-preview";
4106
5008
  const editorHtmlLanguage = htmlArtifactSource ? "html" : (isEditorPreview ? normalizeFenceLanguage(editorLanguage || "") : "");
4107
5009
  const isLatex = htmlArtifactSource ? false : (isEditorPreview
4108
5010
  ? editorHtmlLanguage === "latex"
4109
5011
  : /\\documentclass\b|\\begin\{document\}/.test(markdown));
4110
- let filenameHint = isEditorPreview ? "studio-editor-preview.html" : "studio-response-preview.html";
4111
- let titleHint = isEditorPreview ? "Studio editor preview" : "Studio response preview";
5012
+ let filenameHint = exportingReplJournal ? "studio-repl-journal.html" : (isEditorPreview ? "studio-editor-preview.html" : "studio-response-preview.html");
5013
+ let titleHint = exportingReplJournal ? "Studio REPL journal" : (isEditorPreview ? "Studio editor preview" : "Studio response preview");
4112
5014
  if (sourcePath) {
4113
5015
  const baseName = sourcePath.split(/[\\/]/).pop() || "studio";
4114
5016
  const stem = baseName.replace(/\.[^.]+$/, "") || "studio";
@@ -4267,6 +5169,78 @@
4267
5169
  return String(text || "").replace(/\r\n/g, "\n").replace(/\u200b/g, "");
4268
5170
  }
4269
5171
 
5172
+ function getCopyableBlockquoteText(blockEl) {
5173
+ const clone = blockEl && typeof blockEl.cloneNode === "function" ? blockEl.cloneNode(true) : null;
5174
+ const sourceEl = clone && typeof clone.querySelectorAll === "function" ? clone : blockEl;
5175
+ if (!sourceEl) return "";
5176
+ if (typeof sourceEl.querySelectorAll === "function") {
5177
+ Array.from(sourceEl.querySelectorAll(".studio-copy-block-btn")).forEach((buttonEl) => {
5178
+ if (buttonEl && buttonEl.parentNode) buttonEl.parentNode.removeChild(buttonEl);
5179
+ });
5180
+ }
5181
+
5182
+ const blockTags = new Set(["ADDRESS", "ARTICLE", "ASIDE", "BLOCKQUOTE", "DIV", "FIGCAPTION", "FIGURE", "FOOTER", "H1", "H2", "H3", "H4", "H5", "H6", "HEADER", "LI", "OL", "P", "PRE", "SECTION", "TABLE", "TBODY", "TD", "TH", "THEAD", "TR", "UL"]);
5183
+ const isElementBlock = (node) => node && node.nodeType === 1 && blockTags.has(String(node.tagName || "").toUpperCase());
5184
+
5185
+ const collectInlineText = (node) => {
5186
+ if (!node) return "";
5187
+ if (node.nodeType === Node.TEXT_NODE) return node.nodeValue || "";
5188
+ if (node.nodeType !== Node.ELEMENT_NODE) return "";
5189
+ const tag = String(node.tagName || "").toUpperCase();
5190
+ if (tag === "SCRIPT" || tag === "STYLE" || tag === "BUTTON") return "";
5191
+ if (tag === "BR") return "\n";
5192
+ const childText = Array.from(node.childNodes || []).map(collectInlineText).join("");
5193
+ return isElementBlock(node) ? childText.trim() : childText;
5194
+ };
5195
+
5196
+ const collectBlocks = (node) => {
5197
+ if (!node) return [];
5198
+ const parts = [];
5199
+ let inlineBuffer = "";
5200
+ const flushInline = () => {
5201
+ const text = inlineBuffer.replace(/[ \t]+\n/g, "\n").trim();
5202
+ if (text) parts.push(text);
5203
+ inlineBuffer = "";
5204
+ };
5205
+
5206
+ Array.from(node.childNodes || []).forEach((child) => {
5207
+ if (child.nodeType === Node.TEXT_NODE) {
5208
+ inlineBuffer += child.nodeValue || "";
5209
+ return;
5210
+ }
5211
+ if (child.nodeType !== Node.ELEMENT_NODE) return;
5212
+ const tag = String(child.tagName || "").toUpperCase();
5213
+ if (tag === "SCRIPT" || tag === "STYLE" || tag === "BUTTON") return;
5214
+ if (tag === "BR") {
5215
+ inlineBuffer += "\n";
5216
+ return;
5217
+ }
5218
+ if (isElementBlock(child)) {
5219
+ flushInline();
5220
+ if (tag === "UL" || tag === "OL") {
5221
+ Array.from(child.children || []).forEach((item, itemIndex) => {
5222
+ if (!item || String(item.tagName || "").toUpperCase() !== "LI") return;
5223
+ const prefix = tag === "OL" ? (String(itemIndex + 1) + ". ") : "- ";
5224
+ const itemText = collectInlineText(item).trim();
5225
+ if (itemText) parts.push(prefix + itemText);
5226
+ });
5227
+ return;
5228
+ }
5229
+ const blockText = tag === "BLOCKQUOTE"
5230
+ ? collectBlocks(child).join("\n\n").trim()
5231
+ : collectInlineText(child).trim();
5232
+ if (blockText) parts.push(blockText);
5233
+ return;
5234
+ }
5235
+ inlineBuffer += collectInlineText(child);
5236
+ });
5237
+ flushInline();
5238
+ return parts;
5239
+ };
5240
+
5241
+ return normalizeCopyableBlockText(collectBlocks(sourceEl).join("\n\n")).trim();
5242
+ }
5243
+
4270
5244
  function getCopyablePreviewBlockText(blockEl) {
4271
5245
  if (!blockEl || typeof blockEl.querySelectorAll !== "function") return "";
4272
5246
  if (blockEl.classList && blockEl.classList.contains("preview-code-lines")) {
@@ -4277,6 +5251,10 @@
4277
5251
  );
4278
5252
  }
4279
5253
 
5254
+ if (blockEl.matches && blockEl.matches("blockquote")) {
5255
+ return getCopyableBlockquoteText(blockEl);
5256
+ }
5257
+
4280
5258
  const codeEl = typeof blockEl.querySelector === "function"
4281
5259
  ? blockEl.querySelector("pre code, code")
4282
5260
  : null;
@@ -4334,7 +5312,7 @@
4334
5312
 
4335
5313
  function decorateCopyablePreviewBlocks(targetEl) {
4336
5314
  if (!targetEl || typeof targetEl.querySelectorAll !== "function") return;
4337
- const blocks = Array.from(targetEl.querySelectorAll("div.sourceCode, pre, .preview-code-lines"));
5315
+ const blocks = Array.from(targetEl.querySelectorAll("div.sourceCode, pre, .preview-code-lines, blockquote"));
4338
5316
  blocks.forEach((blockEl) => {
4339
5317
  if (!blockEl || !(blockEl instanceof Element)) return;
4340
5318
  if (blockEl.dataset && blockEl.dataset.studioCopyDecorated === "1") return;
@@ -4572,6 +5550,182 @@
4572
5550
  + "</div>";
4573
5551
  }
4574
5552
 
5553
+ function renderTraceImages(images) {
5554
+ const normalizedImages = Array.isArray(images)
5555
+ ? images.map((image, index) => normalizeTraceImage(image, index)).filter(Boolean)
5556
+ : [];
5557
+ if (!normalizedImages.length) return "";
5558
+ const cards = normalizedImages.map((image) => {
5559
+ const src = "data:" + image.mimeType + ";base64," + image.data;
5560
+ const caption = describeTraceImageForText(image);
5561
+ const alt = image.label || ("Working output image: " + image.mimeType);
5562
+ return "<figure class='trace-image-card'>"
5563
+ + "<img src='" + escapeHtml(src) + "' alt='" + escapeHtml(alt) + "' loading='lazy' decoding='async' />"
5564
+ + "<figcaption class='trace-image-caption'>" + escapeHtml(caption) + "</figcaption>"
5565
+ + "</figure>";
5566
+ }).join("");
5567
+ return "<div class='trace-image-gallery'>" + cards + "</div>";
5568
+ }
5569
+
5570
+ function getReplRuntimeHighlightLanguage(runtime) {
5571
+ const normalized = normalizeReplRuntime(runtime || getActiveReplRuntime());
5572
+ if (normalized === "shell") return "bash";
5573
+ if (normalized === "ipython") return "python";
5574
+ if (normalized === "python" || normalized === "julia" || normalized === "r") return normalized;
5575
+ return "text";
5576
+ }
5577
+
5578
+ function renderHighlightedReplCode(text, runtime) {
5579
+ const source = String(text || "");
5580
+ const language = getReplRuntimeHighlightLanguage(runtime);
5581
+ if (!source.trim() || language === "text") return escapeHtml(source);
5582
+ return highlightCode(source, language, "preview");
5583
+ }
5584
+
5585
+ function renderHighlightedReplTranscriptLine(line, runtime) {
5586
+ const source = String(line || "");
5587
+ const normalized = normalizeReplRuntime(runtime || getActiveReplRuntime());
5588
+ const language = getReplRuntimeHighlightLanguage(normalized);
5589
+ let match = null;
5590
+ if (normalized === "python" || normalized === "ipython") {
5591
+ match = source.match(/^(\s*(?:>>>|\.\.\.|In \[\d+\]:|\.\.\.?:)\s?)(.*)$/);
5592
+ } else if (normalized === "r") {
5593
+ match = source.match(/^(\s*(?:>|\+)\s?)(.*)$/);
5594
+ } else if (normalized === "julia") {
5595
+ match = source.match(/^(\s*julia>\s?)(.*)$/);
5596
+ } else if (normalized === "shell") {
5597
+ match = source.match(/^(.+(?:[$%#])\s+)(.+)$/);
5598
+ } else if (normalized === "ghci") {
5599
+ match = source.match(/^(\s*(?:ghci>|[A-Za-z0-9_.]+>)\s?)(.*)$/);
5600
+ } else if (normalized === "clojure") {
5601
+ match = source.match(/^(\s*(?:[A-Za-z0-9_.-]+=>)\s?)(.*)$/);
5602
+ }
5603
+ if (!match || !String(match[2] || "").trim() || language === "text") return escapeHtml(source);
5604
+ return "<span class='repl-prompt'>" + escapeHtml(match[1] || "") + "</span>" + highlightCodeLine(match[2] || "", language, "preview");
5605
+ }
5606
+
5607
+ function renderReplTranscriptHtml(transcript, runtime) {
5608
+ const source = String(transcript || "");
5609
+ const lines = source.replace(/\r\n/g, "\n").split("\n");
5610
+ const body = lines.map((line) => renderHighlightedReplTranscriptLine(line, runtime)).join("\n");
5611
+ return "<pre class='repl-transcript repl-transcript-highlight'>" + body + "</pre>";
5612
+ }
5613
+
5614
+ function buildReplJournalHtml() {
5615
+ const hasEntries = replJournalEntries.length > 0;
5616
+ const entryCount = replJournalEntries.length;
5617
+ const collapsedClass = replJournalCollapsed ? " is-collapsed" : "";
5618
+ const actions = "<div class='repl-journal-actions'>"
5619
+ + "<button type='button' data-repl-action='journal-toggle' aria-expanded='" + (replJournalCollapsed ? "false" : "true") + "'>" + (replJournalCollapsed ? "Show journal" : "Hide journal") + "</button>"
5620
+ + "<button type='button' data-repl-action='load-journal'" + (hasEntries ? "" : " disabled") + ">Load in editor</button>"
5621
+ + "<button type='button' data-repl-action='copy-journal'" + (hasEntries ? "" : " disabled") + ">Copy journal</button>"
5622
+ + "<button type='button' data-repl-action='export-journal'" + (hasEntries ? "" : " disabled") + ">Export .md</button>"
5623
+ + "<button type='button' data-repl-action='clear-journal'" + (hasEntries ? "" : " disabled") + ">Clear</button>"
5624
+ + "</div>";
5625
+ const summaryText = hasEntries
5626
+ ? (entryCount + " journal entr" + (entryCount === 1 ? "y" : "ies") + ". Export is Markdown.")
5627
+ : "Runs and notes you send from Studio will appear here.";
5628
+ if (replJournalCollapsed || !hasEntries) {
5629
+ return "<section class='repl-journal repl-journal-compact" + collapsedClass + "'>"
5630
+ + "<div class='repl-journal-compact-row'>"
5631
+ + "<div class='repl-journal-compact-title'><span class='repl-journal-chip'>Journal</span><span>" + escapeHtml(summaryText) + "</span></div>"
5632
+ + actions
5633
+ + "</div>"
5634
+ + "</section>";
5635
+ }
5636
+ const omitted = Math.max(0, replJournalEntries.length - 12);
5637
+ const cards = replJournalEntries.slice(-12).map((entry) => {
5638
+ const time = formatReferenceTime(entry.createdAt) || "journal";
5639
+ const code = String(entry.code || "").trim()
5640
+ ? "<div class='repl-journal-section'><div class='repl-journal-label'>Code</div><pre class='repl-journal-code response-markdown-highlight'>" + renderHighlightedReplCode(entry.code, entry.runtime) + "</pre></div>"
5641
+ : "";
5642
+ const prose = String(entry.prose || "").trim()
5643
+ ? "<div class='repl-journal-section'><div class='repl-journal-label'>Note</div><div class='repl-journal-prose'>" + escapeHtml(entry.prose) + "</div></div>"
5644
+ : "";
5645
+ const output = String(entry.output || "").trim()
5646
+ ? "<div class='repl-journal-section'><div class='repl-journal-label'>Output</div><pre class='repl-journal-output'>" + escapeHtml(trimReplJournalOutput(entry.output)) + "</pre></div>"
5647
+ : "";
5648
+ const skipped = entry.skippedChunks ? "<span class='trace-card-meta'>skipped " + escapeHtml(String(entry.skippedChunks)) + "</span>" : "";
5649
+ return "<article class='repl-journal-card'>"
5650
+ + "<div class='repl-journal-card-header'>"
5651
+ + "<span class='trace-kind-badge'>" + escapeHtml(entry.mode === "literate" ? "Literate" : (entry.status === "note" ? "Note" : "Scratch")) + "</span>"
5652
+ + "<span class='trace-card-title'>" + escapeHtml(entry.label || "REPL entry") + "</span>"
5653
+ + "<span class='trace-card-meta'>" + escapeHtml(time) + "</span>"
5654
+ + (entry.runtime ? "<span class='trace-card-meta'>" + escapeHtml(entry.runtime) + "</span>" : "")
5655
+ + skipped
5656
+ + "</div>"
5657
+ + prose
5658
+ + code
5659
+ + (output || "<div class='trace-empty-inline'>" + escapeHtml(entry.status === "note" ? "Journal note only." : "Waiting for captured output…") + "</div>")
5660
+ + "</article>";
5661
+ }).join("");
5662
+ return "<section class='repl-journal'>"
5663
+ + "<div class='repl-journal-header'><div><h3>Journal</h3><p>Side log of notes, code sends, and captured output. Separate from the live REPL transcript.</p></div>" + actions + "</div>"
5664
+ + (omitted ? "<div class='repl-journal-omitted'>Showing latest 12 entries; " + escapeHtml(String(omitted)) + " older entries remain in export.</div>" : "")
5665
+ + "<div class='repl-journal-list'>" + cards + "</div>"
5666
+ + "</section>";
5667
+ }
5668
+
5669
+ function buildReplPanelHtml() {
5670
+ const runtimeOptions = [
5671
+ ["shell", "Shell"],
5672
+ ["python", "Python"],
5673
+ ["ipython", "IPython"],
5674
+ ["julia", "Julia"],
5675
+ ["r", "R"],
5676
+ ["ghci", "GHCi"],
5677
+ ["clojure", "Clojure"],
5678
+ ].map(([value, label]) => "<option value='" + escapeHtml(value) + "'" + (replRuntime === value ? " selected" : "") + ">" + escapeHtml(label) + "</option>").join("");
5679
+ const sessionOptions = replSessions.length
5680
+ ? replSessions.map((session) => "<option value='" + escapeHtml(session.sessionName) + "'" + (session.sessionName === replActiveSessionName ? " selected" : "") + ">" + escapeHtml(session.label || session.sessionName) + "</option>").join("")
5681
+ : "<option value=''>No REPL sessions</option>";
5682
+ const activeSession = getActiveReplSession();
5683
+ const statusLabel = replTmuxAvailable === false
5684
+ ? "tmux missing"
5685
+ : (activeSession ? "Mirroring" : "Idle");
5686
+ const captured = replCapturedAt ? formatReferenceTime(replCapturedAt) : "";
5687
+ const transcript = trimReplTranscript(replTranscript);
5688
+ const emptyMessage = replTmuxAvailable === false
5689
+ ? "tmux is not available. Install tmux to use Studio REPL sessions."
5690
+ : (activeSession ? "No REPL output captured yet." : "Start a REPL session, or attach to a detected pi-repl session, to mirror it here.");
5691
+ const body = transcript
5692
+ ? renderReplTranscriptHtml(transcript, activeSession ? activeSession.runtime : replRuntime)
5693
+ : "<div class='repl-empty'>" + escapeHtml(emptyMessage) + "</div>";
5694
+ const canSendToActiveSession = Boolean(activeSession) && !replBusy && replTmuxAvailable !== false;
5695
+ const canStopActiveSession = Boolean(activeSession && activeSession.source === "studio" && !replBusy && replTmuxAvailable !== false);
5696
+ return "<div class='repl-panel'>"
5697
+ + "<div class='repl-toolbar'>"
5698
+ + "<div class='repl-summary'>"
5699
+ + "<span class='trace-summary-badge'>REPL</span>"
5700
+ + "<span class='trace-summary-status trace-status-" + (activeSession ? "running" : "idle") + "'>" + escapeHtml(statusLabel) + "</span>"
5701
+ + (activeSession ? "<span class='trace-summary-meta'>" + escapeHtml(activeSession.sessionName) + "</span>" : "")
5702
+ + (captured ? "<span class='trace-summary-meta'>Updated " + escapeHtml(captured) + "</span>" : "")
5703
+ + "</div>"
5704
+ + "<div class='repl-controls'>"
5705
+ + "<label class='repl-control-label'>Runtime <select data-repl-runtime aria-label='REPL runtime'>" + runtimeOptions + "</select></label>"
5706
+ + "<button type='button' data-repl-action='start'" + (replBusy || replTmuxAvailable === false ? " disabled" : "") + " title='Start or attach to the default session for this runtime.'>Start</button>"
5707
+ + "<label class='repl-control-label'>Session <select data-repl-session aria-label='REPL session'" + (replSessions.length ? "" : " disabled") + ">" + sessionOptions + "</select></label>"
5708
+ + "<details class='repl-more-controls'>"
5709
+ + "<summary title='More REPL actions'>More</summary>"
5710
+ + "<div class='repl-more-menu'>"
5711
+ + "<button type='button' data-repl-action='new-session'" + (replBusy || replTmuxAvailable === false ? " disabled" : "") + " title='Start a new additional session for this runtime.'>New session</button>"
5712
+ + "<button type='button' data-repl-action='stop-session'" + (canStopActiveSession ? "" : " disabled") + " title='Stop the selected Studio-owned REPL session.'>Stop session</button>"
5713
+ + "<button type='button' data-repl-action='interrupt'" + (activeSession && !replBusy ? "" : " disabled") + " title='Send Ctrl+C to the active REPL session.'>Interrupt</button>"
5714
+ + "<button type='button' data-repl-action='run-all-chunks'" + (canSendToActiveSession ? "" : " disabled") + " title='Literate mode: send all fenced code chunks matching the active REPL runtime.'>Run all chunks</button>"
5715
+ + "<button type='button' data-repl-action='journal-note' title='Add the selected prose/current paragraph to the literate journal without sending it to the runtime.'>Journal note</button>"
5716
+ + "<button type='button' data-repl-action='refresh'>Refresh</button>"
5717
+ + "<button type='button' data-repl-action='follow'>Follow: " + (replFollow ? "On" : "Off") + "</button>"
5718
+ + "</div>"
5719
+ + "</details>"
5720
+ + "</div>"
5721
+ + "</div>"
5722
+ + (replMessage ? "<div class='repl-notice repl-notice-info'>" + escapeHtml(replMessage) + "</div>" : "")
5723
+ + (replError ? "<div class='repl-notice repl-notice-error'>" + escapeHtml(replError) + "</div>" : "")
5724
+ + body
5725
+ + buildReplJournalHtml()
5726
+ + "</div>";
5727
+ }
5728
+
4575
5729
  function buildTracePanelHtml() {
4576
5730
  const state = traceState || createEmptyTraceState();
4577
5731
  const filter = normalizeTraceFilter(traceFilter);
@@ -4665,8 +5819,12 @@
4665
5819
  const argsSummary = entry.argsSummary
4666
5820
  ? "<div class='trace-section'><div class='trace-section-label'>Input</div>" + renderTraceOutput(entry.argsSummary, entry.id + ":input") + "</div>"
4667
5821
  : "";
4668
- const output = entry.output
4669
- ? "<div class='trace-section'><div class='trace-section-label'>Output</div>" + renderTraceOutput(entry.output, entry.id + ":output") + "</div>"
5822
+ const imageOutput = renderTraceImages(entry.images);
5823
+ const outputPieces = [];
5824
+ if (entry.output) outputPieces.push(renderTraceOutput(entry.output, entry.id + ":output"));
5825
+ if (imageOutput) outputPieces.push(imageOutput);
5826
+ const output = outputPieces.length
5827
+ ? "<div class='trace-section'><div class='trace-section-label'>Output</div>" + outputPieces.join("") + "</div>"
4670
5828
  : "<div class='trace-empty-inline'>No output yet.</div>";
4671
5829
  const toolStatusLabel = entry.isError
4672
5830
  ? "Error"
@@ -4702,12 +5860,32 @@
4702
5860
  scheduleResponsePaneRepaintNudge();
4703
5861
  }
4704
5862
 
5863
+ function renderReplView() {
5864
+ if (!critiqueViewEl) return;
5865
+ const shouldStick = replFollow || shouldStickTraceToBottom();
5866
+ const previousScrollTop = critiqueViewEl.scrollTop;
5867
+ finishPreviewRender(critiqueViewEl);
5868
+ critiqueViewEl.innerHTML = buildReplPanelHtml();
5869
+ critiqueViewEl.classList.remove("response-scroll-resetting");
5870
+ if (shouldStick) {
5871
+ critiqueViewEl.scrollTop = critiqueViewEl.scrollHeight;
5872
+ } else {
5873
+ critiqueViewEl.scrollTop = previousScrollTop;
5874
+ }
5875
+ scheduleResponsePaneRepaintNudge();
5876
+ }
5877
+
4705
5878
  function renderActiveResult() {
4706
5879
  if (rightView === "trace") {
4707
5880
  renderTraceView();
4708
5881
  return;
4709
5882
  }
4710
5883
 
5884
+ if (rightView === "repl") {
5885
+ renderReplView();
5886
+ return;
5887
+ }
5888
+
4711
5889
  if (rightView === "editor-preview") {
4712
5890
  const editorText = prepareEditorTextForPreview(sourceTextEl.value || "");
4713
5891
  if (!editorText.trim()) {
@@ -4782,10 +5960,10 @@
4782
5960
  : normalizeForCompare(sourceTextEl.value);
4783
5961
  const responseLoaded = hasResponse && normalizedEditor === latestResponseNormalized;
4784
5962
  const isCritiqueResponse = hasResponse && latestResponseIsStructuredCritique;
4785
- const showingTrace = rightView === "trace";
5963
+ const showingAuxiliaryRightPane = rightView === "trace" || rightView === "repl";
4786
5964
 
4787
5965
  if (responseWrapEl) {
4788
- responseWrapEl.hidden = showingTrace;
5966
+ responseWrapEl.hidden = showingAuxiliaryRightPane;
4789
5967
  }
4790
5968
 
4791
5969
  const critiqueNotes = isCritiqueResponse ? latestCritiqueNotes : "";
@@ -4808,21 +5986,30 @@
4808
5986
  copyResponseBtn.textContent = "Copy response text";
4809
5987
 
4810
5988
  const rightPaneShowsPreview = rightView === "preview" || rightView === "editor-preview";
4811
- const exportText = rightView === "editor-preview" ? prepareEditorTextForPreview(sourceTextEl.value) : latestResponseMarkdown;
4812
- const canExportPreview = rightPaneShowsPreview && Boolean(String(exportText || "").trim());
4813
- const htmlArtifactExportSource = canExportPreview ? getRightPaneHtmlArtifactSource() : "";
5989
+ const exportingReplJournal = rightView === "repl";
5990
+ const exportText = exportingReplJournal
5991
+ ? (replJournalEntries.length ? buildReplJournalMarkdown() : "")
5992
+ : (rightView === "editor-preview" ? prepareEditorTextForPreview(sourceTextEl.value) : latestResponseMarkdown);
5993
+ const canExportPreview = (rightPaneShowsPreview || exportingReplJournal) && Boolean(String(exportText || "").trim());
5994
+ const htmlArtifactExportSource = canExportPreview && !exportingReplJournal ? getRightPaneHtmlArtifactSource() : "";
4814
5995
  const isHtmlArtifactPreview = Boolean(htmlArtifactExportSource);
4815
5996
  if (exportPdfBtn) {
4816
5997
  exportPdfBtn.disabled = uiBusy || previewExportInProgress || !canExportPreview;
4817
- exportPdfBtn.textContent = previewExportInProgress ? "Exporting…" : "Export right preview";
5998
+ exportPdfBtn.textContent = previewExportInProgress
5999
+ ? "Exporting…"
6000
+ : (exportingReplJournal ? "Export REPL journal" : "Export right preview");
4818
6001
  if (rightView === "trace") {
4819
6002
  exportPdfBtn.title = "Working view does not support preview export.";
6003
+ } else if (exportingReplJournal && !replJournalEntries.length) {
6004
+ exportPdfBtn.title = "No REPL journal entries to export yet.";
4820
6005
  } else if (rightView === "markdown") {
4821
- exportPdfBtn.title = "Switch right pane to Response (Preview) or Editor (Preview) to export.";
6006
+ exportPdfBtn.title = "Switch right pane to Response (Preview), Editor (Preview), or REPL journal to export.";
4822
6007
  } else if (!canExportPreview) {
4823
6008
  exportPdfBtn.title = "Nothing to export yet.";
4824
6009
  } else if (isHtmlArtifactPreview) {
4825
6010
  exportPdfBtn.title = "This is an interactive HTML preview. Export as HTML; PDF export is not available yet.";
6011
+ } else if (exportingReplJournal) {
6012
+ exportPdfBtn.title = "Choose PDF or HTML and export the REPL journal.";
4826
6013
  } else {
4827
6014
  exportPdfBtn.title = "Choose PDF or HTML and export the current right-pane preview.";
4828
6015
  }
@@ -4831,18 +6018,20 @@
4831
6018
  exportPreviewPdfBtn.disabled = uiBusy || previewExportInProgress || !canExportPreview || isHtmlArtifactPreview;
4832
6019
  exportPreviewPdfBtn.title = isHtmlArtifactPreview
4833
6020
  ? "Interactive HTML preview PDF export is not available yet."
4834
- : "Export the current right-pane preview as PDF.";
6021
+ : (exportingReplJournal ? "Export the REPL journal as PDF." : "Export the current right-pane preview as PDF.");
4835
6022
  }
4836
6023
  if (exportPreviewHtmlBtn) {
4837
6024
  exportPreviewHtmlBtn.disabled = uiBusy || previewExportInProgress || !canExportPreview;
4838
6025
  exportPreviewHtmlBtn.title = isHtmlArtifactPreview
4839
6026
  ? "Export the authored HTML preview."
4840
- : "Export the current right-pane preview as standalone HTML.";
6027
+ : (exportingReplJournal ? "Export the REPL journal as standalone HTML." : "Export the current right-pane preview as standalone HTML.");
4841
6028
  }
4842
6029
  if (exportPreviewControlsEl) {
4843
6030
  exportPreviewControlsEl.title = canExportPreview
4844
- ? (isHtmlArtifactPreview ? "Export this HTML preview." : "Choose a format and export the current right-pane preview.")
4845
- : "Switch right pane to a non-empty preview before exporting.";
6031
+ ? (exportingReplJournal
6032
+ ? "Choose a format and export the REPL journal."
6033
+ : (isHtmlArtifactPreview ? "Export this HTML preview." : "Choose a format and export the current right-pane preview."))
6034
+ : (exportingReplJournal ? "No REPL journal entries to export yet." : "Switch right pane to a non-empty preview before exporting.");
4846
6035
  }
4847
6036
  if (!canExportPreview || previewExportInProgress) {
4848
6037
  closeExportPreviewMenu();
@@ -5032,6 +6221,108 @@
5032
6221
  updateOutlineUi();
5033
6222
  }
5034
6223
 
6224
+ function applySourceTextEdit(nextText, selectionStart, selectionEnd) {
6225
+ const value = String(nextText || "");
6226
+ sourceTextEl.value = value;
6227
+ const maxIndex = value.length;
6228
+ const safeStart = Math.max(0, Math.min(Math.floor(Number(selectionStart) || 0), maxIndex));
6229
+ const safeEnd = Math.max(safeStart, Math.min(Math.floor(Number(selectionEnd) || safeStart), maxIndex));
6230
+ sourceTextEl.setSelectionRange(safeStart, safeEnd);
6231
+ sourceTextEl.dispatchEvent(new Event("input", { bubbles: true }));
6232
+ syncEditorHighlightScroll();
6233
+ if (editorView === "markdown") {
6234
+ scheduleEditorLineNumberRender();
6235
+ }
6236
+ }
6237
+
6238
+ function getSourceTextLineEditBounds(text, selectionStart, selectionEnd) {
6239
+ const source = String(text || "");
6240
+ const safeStart = Math.max(0, Math.min(Math.floor(Number(selectionStart) || 0), source.length));
6241
+ const safeEnd = Math.max(safeStart, Math.min(Math.floor(Number(selectionEnd) || safeStart), source.length));
6242
+ const rangeEnd = safeEnd > safeStart && source.charAt(safeEnd - 1) === "\n" ? safeEnd - 1 : safeEnd;
6243
+ const lineStart = source.lastIndexOf("\n", Math.max(0, safeStart - 1)) + 1;
6244
+ const nextNewline = source.indexOf("\n", Math.max(lineStart, rangeEnd));
6245
+ const lineEnd = nextNewline >= 0 ? nextNewline : source.length;
6246
+ return { lineStart, lineEnd, selectionStart: safeStart, selectionEnd: safeEnd };
6247
+ }
6248
+
6249
+ function countSourceTextLines(text) {
6250
+ if (!text) return 1;
6251
+ return String(text).split("\n").length;
6252
+ }
6253
+
6254
+ function getSourceLineUnindentLength(line) {
6255
+ const source = String(line || "");
6256
+ if (source.startsWith(EDITOR_TAB_TEXT)) return EDITOR_TAB_TEXT.length;
6257
+ if (source.startsWith("\t")) return 1;
6258
+ const spaces = source.match(/^ +/);
6259
+ return spaces ? Math.min(EDITOR_TAB_TEXT.length, spaces[0].length) : 0;
6260
+ }
6261
+
6262
+ function getRemovedIndentBeforePosition(lineStart, removeLength, position) {
6263
+ if (removeLength <= 0 || position <= lineStart) return 0;
6264
+ if (position <= lineStart + removeLength) return position - lineStart;
6265
+ return removeLength;
6266
+ }
6267
+
6268
+ function indentSourceTextSelection() {
6269
+ const source = String(sourceTextEl.value || "");
6270
+ const start = typeof sourceTextEl.selectionStart === "number" ? sourceTextEl.selectionStart : source.length;
6271
+ const end = typeof sourceTextEl.selectionEnd === "number" ? sourceTextEl.selectionEnd : start;
6272
+ const selected = source.slice(Math.min(start, end), Math.max(start, end));
6273
+ if (start === end || !selected.includes("\n")) {
6274
+ const before = source.slice(0, Math.min(start, end));
6275
+ const after = source.slice(Math.max(start, end));
6276
+ const nextCaret = before.length + EDITOR_TAB_TEXT.length;
6277
+ applySourceTextEdit(before + EDITOR_TAB_TEXT + after, nextCaret, nextCaret);
6278
+ return;
6279
+ }
6280
+
6281
+ const bounds = getSourceTextLineEditBounds(source, start, end);
6282
+ const segment = source.slice(bounds.lineStart, bounds.lineEnd);
6283
+ const lineCount = countSourceTextLines(segment);
6284
+ const replacement = segment.replace(/^/gm, EDITOR_TAB_TEXT);
6285
+ const next = source.slice(0, bounds.lineStart) + replacement + source.slice(bounds.lineEnd);
6286
+ const nextStart = bounds.selectionStart + (bounds.lineStart < bounds.selectionStart ? EDITOR_TAB_TEXT.length : 0);
6287
+ const nextEnd = bounds.selectionEnd + (lineCount * EDITOR_TAB_TEXT.length);
6288
+ applySourceTextEdit(next, nextStart, nextEnd);
6289
+ }
6290
+
6291
+ function unindentSourceTextSelection() {
6292
+ const source = String(sourceTextEl.value || "");
6293
+ const start = typeof sourceTextEl.selectionStart === "number" ? sourceTextEl.selectionStart : source.length;
6294
+ const end = typeof sourceTextEl.selectionEnd === "number" ? sourceTextEl.selectionEnd : start;
6295
+ const bounds = getSourceTextLineEditBounds(source, start, end);
6296
+ const segment = source.slice(bounds.lineStart, bounds.lineEnd);
6297
+ const lines = segment.split("\n");
6298
+ let absoluteLineStart = bounds.lineStart;
6299
+ let removedBeforeStart = 0;
6300
+ let removedBeforeEnd = 0;
6301
+ const nextLines = lines.map((line, index) => {
6302
+ const removeLength = getSourceLineUnindentLength(line);
6303
+ removedBeforeStart += getRemovedIndentBeforePosition(absoluteLineStart, removeLength, bounds.selectionStart);
6304
+ removedBeforeEnd += getRemovedIndentBeforePosition(absoluteLineStart, removeLength, bounds.selectionEnd);
6305
+ absoluteLineStart += line.length + (index < lines.length - 1 ? 1 : 0);
6306
+ return removeLength > 0 ? line.slice(removeLength) : line;
6307
+ });
6308
+ const replacement = nextLines.join("\n");
6309
+ if (replacement === segment) return;
6310
+ const next = source.slice(0, bounds.lineStart) + replacement + source.slice(bounds.lineEnd);
6311
+ const nextStart = Math.max(bounds.lineStart, bounds.selectionStart - removedBeforeStart);
6312
+ const nextEnd = Math.max(nextStart, bounds.selectionEnd - removedBeforeEnd);
6313
+ applySourceTextEdit(next, nextStart, nextEnd);
6314
+ }
6315
+
6316
+ function handleSourceTextTabKey(event) {
6317
+ if (!event || event.key !== "Tab" || event.metaKey || event.ctrlKey || event.altKey) return;
6318
+ event.preventDefault();
6319
+ if (event.shiftKey) {
6320
+ unindentSourceTextSelection();
6321
+ } else {
6322
+ indentSourceTextSelection();
6323
+ }
6324
+ }
6325
+
5035
6326
  function setEditorView(nextView) {
5036
6327
  editorView = nextView === "preview" ? "preview" : "markdown";
5037
6328
  editorViewSelect.value = editorView;
@@ -5073,11 +6364,19 @@
5073
6364
  ? "preview"
5074
6365
  : (nextView === "editor-preview"
5075
6366
  ? "editor-preview"
5076
- : ((nextView === "trace" || nextView === "thinking") ? "trace" : "markdown"));
6367
+ : (nextView === "repl"
6368
+ ? "repl"
6369
+ : ((nextView === "trace" || nextView === "thinking") ? "trace" : "markdown")));
5077
6370
  rightViewSelect.value = rightView;
5078
6371
  if (rightView === "trace" && previousView !== "trace") {
5079
6372
  traceAutoScroll = true;
5080
6373
  }
6374
+ if (rightView === "repl" && previousView !== "repl") {
6375
+ replFollow = true;
6376
+ startReplPolling();
6377
+ } else if (rightView !== "repl" && previousView === "repl") {
6378
+ stopReplPolling();
6379
+ }
5081
6380
 
5082
6381
  if (rightView !== "editor-preview" && responseEditorPreviewTimer) {
5083
6382
  window.clearTimeout(responseEditorPreviewTimer);
@@ -10525,6 +11824,14 @@
10525
11824
  queueSteerBtn.classList.remove("request-stop-active");
10526
11825
  queueSteerBtn.title = "Queue steering is unavailable in editor-only mode.";
10527
11826
  }
11827
+ if (sendReplBtn) {
11828
+ sendReplBtn.hidden = true;
11829
+ sendReplBtn.disabled = true;
11830
+ }
11831
+ if (replSendModeSelect) {
11832
+ replSendModeSelect.hidden = true;
11833
+ replSendModeSelect.disabled = true;
11834
+ }
10528
11835
  if (critiqueBtn) {
10529
11836
  critiqueBtn.textContent = "Critique text";
10530
11837
  critiqueBtn.classList.remove("request-stop-active");
@@ -10539,11 +11846,14 @@
10539
11846
  sendRunBtn.textContent = directIsStop ? "Stop" : "Run editor text";
10540
11847
  sendRunBtn.classList.toggle("request-stop-active", directIsStop);
10541
11848
  sendRunBtn.disabled = wsState === "Disconnected" || (!directIsStop && (uiBusy || critiqueIsStop));
11849
+ const replHint = rightView === "repl" && getActiveReplSession()
11850
+ ? " Includes active REPL identity for prompts that refer to it."
11851
+ : "";
10542
11852
  sendRunBtn.title = directIsStop
10543
11853
  ? "Stop the active run. Shortcut: Esc."
10544
11854
  : (annotationsEnabled
10545
- ? "Run editor text as-is (includes [an: ...] markers). Shortcut: Cmd/Ctrl+Enter. Stop the active request with Esc."
10546
- : "Run editor text with [an: ...] markers stripped. Shortcut: Cmd/Ctrl+Enter. Stop the active request with Esc.");
11855
+ ? "Run editor text as-is (includes [an: ...] markers). Shortcut: Cmd/Ctrl+Enter. Stop the active request with Esc." + replHint
11856
+ : "Run editor text with [an: ...] markers stripped. Shortcut: Cmd/Ctrl+Enter. Stop the active request with Esc." + replHint);
10547
11857
  }
10548
11858
 
10549
11859
  if (queueSteerBtn) {
@@ -10557,6 +11867,25 @@
10557
11867
  : "Queue steering is available while Run editor text is active.";
10558
11868
  }
10559
11869
 
11870
+ if (sendReplBtn) {
11871
+ const hasSession = Boolean(getActiveReplSession());
11872
+ sendReplBtn.hidden = rightView !== "repl";
11873
+ sendReplBtn.disabled = wsState === "Disconnected" || uiBusy || replBusy || !hasSession;
11874
+ sendReplBtn.title = hasSession
11875
+ ? (replSendMode === "literate"
11876
+ ? "Literate send: selected code/prose, or the current fenced code chunk. Shortcut: Cmd/Ctrl+Shift+Enter."
11877
+ : "Scratch send: selection, or full editor if no selection. Shortcut: Cmd/Ctrl+Shift+Enter.")
11878
+ : "Start or select a REPL session in the right pane first.";
11879
+ }
11880
+ if (replSendModeSelect) {
11881
+ replSendModeSelect.hidden = rightView !== "repl";
11882
+ replSendModeSelect.disabled = wsState === "Disconnected" || uiBusy || replBusy;
11883
+ replSendModeSelect.value = replSendMode;
11884
+ replSendModeSelect.title = replSendMode === "literate"
11885
+ ? "Literate send: Send to REPL uses the selection/current fenced code chunk."
11886
+ : "Scratch send: Send to REPL uses the selection, or full editor if no selection.";
11887
+ }
11888
+
10560
11889
  if (critiqueBtn) {
10561
11890
  critiqueBtn.textContent = critiqueIsStop ? "Stop" : "Critique text";
10562
11891
  critiqueBtn.classList.toggle("request-stop-active", critiqueIsStop);
@@ -10871,6 +12200,63 @@
10871
12200
  return;
10872
12201
  }
10873
12202
 
12203
+ if (message.type === "repl_state") {
12204
+ replTmuxAvailable = typeof message.tmuxAvailable === "boolean" ? message.tmuxAvailable : replTmuxAvailable;
12205
+ setReplSessions(message.sessions);
12206
+ if (typeof message.activeSessionName === "string" && message.activeSessionName.trim()) {
12207
+ setActiveReplSession(message.activeSessionName);
12208
+ }
12209
+ if (typeof message.transcript === "string") replTranscript = trimReplTranscript(message.transcript);
12210
+ if (typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
12211
+ replError = typeof message.replError === "string" ? message.replError : (typeof message.captureError === "string" ? message.captureError : "");
12212
+ replMessage = typeof message.replMessage === "string" ? message.replMessage : "";
12213
+ replBusy = false;
12214
+ syncActionButtons();
12215
+ renderReplViewIfActive();
12216
+ updateReferenceBadge();
12217
+ return;
12218
+ }
12219
+
12220
+ if (message.type === "repl_capture") {
12221
+ if (message.session) {
12222
+ const session = normalizeReplSession(message.session);
12223
+ if (session && !replSessions.some((candidate) => candidate.sessionName === session.sessionName)) {
12224
+ replSessions = [...replSessions, session];
12225
+ }
12226
+ }
12227
+ if (typeof message.activeSessionName === "string" && message.activeSessionName.trim()) {
12228
+ setActiveReplSession(message.activeSessionName);
12229
+ }
12230
+ if (typeof message.transcript === "string") {
12231
+ replTranscript = trimReplTranscript(message.transcript);
12232
+ updateActiveReplJournalEntryFromTranscript(
12233
+ typeof message.activeSessionName === "string" && message.activeSessionName.trim() ? message.activeSessionName : replActiveSessionName,
12234
+ replTranscript
12235
+ );
12236
+ }
12237
+ if (typeof message.capturedAt === "number") replCapturedAt = message.capturedAt;
12238
+ replError = typeof message.replError === "string" ? message.replError : "";
12239
+ if (typeof message.replMessage === "string") replMessage = message.replMessage;
12240
+ replBusy = false;
12241
+ syncActionButtons();
12242
+ renderReplViewIfActive();
12243
+ updateReferenceBadge();
12244
+ return;
12245
+ }
12246
+
12247
+ if (message.type === "repl_send_ack") {
12248
+ replBusy = false;
12249
+ replMessage = typeof message.message === "string" ? message.message : "Sent editor text to REPL.";
12250
+ replError = "";
12251
+ if (typeof message.requestId === "string") {
12252
+ replJournalEntries = replJournalEntries.map((entry) => entry.requestId === message.requestId ? { ...entry, status: "sent", updatedAt: Date.now() } : entry);
12253
+ }
12254
+ setStatus(replMessage, "success");
12255
+ syncActionButtons();
12256
+ renderReplViewIfActive({ force: true });
12257
+ return;
12258
+ }
12259
+
10874
12260
  if (message.type === "request_started") {
10875
12261
  pendingRequestId = typeof message.requestId === "string" ? message.requestId : pendingRequestId;
10876
12262
  pendingKind = typeof message.kind === "string" ? message.kind : "unknown";
@@ -11271,6 +12657,14 @@
11271
12657
  if (typeof message.requestId === "string") {
11272
12658
  clearArmedTitleAttention(message.requestId);
11273
12659
  }
12660
+ if (replBusy) {
12661
+ replBusy = false;
12662
+ replError = typeof message.message === "string" ? message.message : "REPL request failed.";
12663
+ if (typeof message.requestId === "string") {
12664
+ replJournalEntries = replJournalEntries.map((entry) => entry.requestId === message.requestId ? { ...entry, status: "error", output: replError, updatedAt: Date.now() } : entry);
12665
+ }
12666
+ renderReplViewIfActive({ force: true });
12667
+ }
11274
12668
  stickyStudioKind = null;
11275
12669
  setBusy(false);
11276
12670
  setWsState("Ready");
@@ -11823,6 +13217,8 @@
11823
13217
  requestLatestResponse();
11824
13218
  });
11825
13219
 
13220
+ sourceTextEl.addEventListener("keydown", handleSourceTextTabKey);
13221
+
11826
13222
  sourceTextEl.addEventListener("input", () => {
11827
13223
  if (activePreviewCommentSelection) {
11828
13224
  clearPreviewCommentSelection();
@@ -12194,7 +13590,7 @@
12194
13590
  return;
12195
13591
  }
12196
13592
 
12197
- const prepared = prepareEditorTextForSend(sourceTextEl.value);
13593
+ const prepared = prepareEditorTextForRunRequest(sourceTextEl.value);
12198
13594
  if (!prepared.trim()) {
12199
13595
  setStatus("Editor is empty. Nothing to run.", "warning");
12200
13596
  return;
@@ -12218,7 +13614,7 @@
12218
13614
 
12219
13615
  if (queueSteerBtn) {
12220
13616
  queueSteerBtn.addEventListener("click", () => {
12221
- const prepared = prepareEditorTextForSend(sourceTextEl.value);
13617
+ const prepared = prepareEditorTextForRunRequest(sourceTextEl.value);
12222
13618
  if (!prepared.trim()) {
12223
13619
  setStatus("Editor is empty. Nothing to queue.", "warning");
12224
13620
  return;
@@ -12240,6 +13636,20 @@
12240
13636
  });
12241
13637
  }
12242
13638
 
13639
+ if (sendReplBtn) {
13640
+ sendReplBtn.addEventListener("click", () => {
13641
+ sendEditorTextToRepl();
13642
+ });
13643
+ }
13644
+
13645
+ if (replSendModeSelect) {
13646
+ replSendModeSelect.addEventListener("change", () => {
13647
+ setReplSendMode(replSendModeSelect.value);
13648
+ syncActionButtons();
13649
+ renderReplViewIfActive({ force: true });
13650
+ });
13651
+ }
13652
+
12243
13653
  copyDraftBtn.addEventListener("click", async () => {
12244
13654
  const content = sourceTextEl.value;
12245
13655
  if (!content.trim()) {
@@ -12658,6 +14068,7 @@
12658
14068
  const storedAnnotationsEnabled = readStoredAnnotationsEnabled();
12659
14069
  const initialAnnotationsEnabled = storedAnnotationsEnabled ?? Boolean(annotationModeSelect ? annotationModeSelect.value !== "off" : true);
12660
14070
  setAnnotationsEnabled(initialAnnotationsEnabled, { silent: true });
14071
+ setReplSendMode(replSendMode);
12661
14072
 
12662
14073
  setEditorView(editorView);
12663
14074
  setRightView(rightView);