pi-studio 0.9.5 → 0.9.7

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
@@ -125,6 +125,22 @@ interface StudioReplSessionInfo {
125
125
  source: "studio" | "pi-repl" | "tmux";
126
126
  }
127
127
 
128
+ interface StudioReplJournalEntry {
129
+ id: string;
130
+ requestId: string;
131
+ createdAt: number;
132
+ updatedAt: number;
133
+ sessionName: string;
134
+ runtime: StudioReplRuntime | "unknown";
135
+ label: string;
136
+ mode: "raw" | "literate" | "agent";
137
+ prose: string;
138
+ code: string;
139
+ output: string;
140
+ status: "sent" | "captured" | "timeout" | "error" | "note";
141
+ skippedChunks: number;
142
+ }
143
+
128
144
  interface PreparedStudioPdfExport {
129
145
  pdf: Buffer;
130
146
  filename: string;
@@ -466,6 +482,7 @@ const STUDIO_REPL_CAPTURE_LINES = 800;
466
482
  const STUDIO_REPL_SEND_MAX_CHARS = 200_000;
467
483
  const STUDIO_REPL_SEND_DEFAULT_TIMEOUT_MS = 20_000;
468
484
  const STUDIO_REPL_SEND_MAX_TIMEOUT_MS = 120_000;
485
+ const STUDIO_REPL_JOURNAL_MAX_ENTRIES = 300;
469
486
  const STUDIO_REPL_CONTROL_ROOT = join(tmpdir(), "pi-studio-repl");
470
487
  const STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES = 2_000_000;
471
488
  const STUDIO_PANDOC_TIMEOUT_MS = readStudioPositiveEnvMs("PI_STUDIO_PANDOC_TIMEOUT_MS", 120_000, 5_000, 15 * 60_000);
@@ -651,7 +668,7 @@ $body$
651
668
  let studioPersistentStateCache: StudioPersistentState | null = null;
652
669
  let studioPersistentStateQueue: Promise<void> = Promise.resolve();
653
670
  let transientStudioDocuments: Map<string, { document: InitialStudioDocument; createdAt: number }> = new Map();
654
- const studioReplControlSubmissionLabels = new Map<string, string>();
671
+ let studioReplJournalEntries: StudioReplJournalEntry[] = [];
655
672
 
656
673
  function createEmptyStudioPersistentState(): StudioPersistentState {
657
674
  return {
@@ -7750,23 +7767,6 @@ function listStudioReplSessions(): { tmuxAvailable: boolean; sessions: StudioRep
7750
7767
  return { tmuxAvailable: true, sessions };
7751
7768
  }
7752
7769
 
7753
- function getStudioReplPromptPrefix(line: string): string {
7754
- const source = String(line || "");
7755
- const match = source.match(/^(\s*(?:(?:In \[\d+\]:)|(?:\.\.\.)|(?:>>>)|(?:julia>)|(?:ghci>)|(?:Prelude>)|(?:\*?[A-Za-z0-9_.:]+>)|(?:[^\s>]+=>)|(?:>)|(?:\+))\s*)/);
7756
- return match ? (match[1] || "") : "";
7757
- }
7758
-
7759
- function sanitizeStudioReplTranscript(transcript: string): string {
7760
- let value = String(transcript || "");
7761
- for (const [sourceFile, label] of studioReplControlSubmissionLabels) {
7762
- if (!value.includes(sourceFile)) continue;
7763
- const escaped = sourceFile.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7764
- const linePattern = new RegExp(`^.*${escaped}.*$`, "gm");
7765
- value = value.replace(linePattern, (line) => `${getStudioReplPromptPrefix(line)}${label}`.trimEnd());
7766
- }
7767
- return value.replace(/[\t ]+$/gm, "").trimEnd();
7768
- }
7769
-
7770
7770
  function captureStudioReplSession(sessionName: string): { ok: true; transcript: string; session: StudioReplSessionInfo } | { ok: false; message: string } {
7771
7771
  if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
7772
7772
  const inferred = inferStudioReplSessionRuntime(sessionName);
@@ -7779,7 +7779,7 @@ function captureStudioReplSession(sessionName: string): { ok: true; transcript:
7779
7779
  };
7780
7780
  const result = runStudioTmux(["capture-pane", "-J", "-p", "-t", session.target, "-S", `-${STUDIO_REPL_CAPTURE_LINES}`], { timeout: 3_000 });
7781
7781
  if (!result.ok) return { ok: false, message: result.message };
7782
- return { ok: true, transcript: sanitizeStudioReplTranscript(result.stdout), session };
7782
+ return { ok: true, transcript: String(result.stdout || "").replace(/[\t ]+$/gm, "").trimEnd(), session };
7783
7783
  }
7784
7784
 
7785
7785
  function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options?: { newSession?: boolean }): { ok: true; session: StudioReplSessionInfo; message: string } | { ok: false; message: string } {
@@ -7955,10 +7955,11 @@ function buildStudioRControlSource(code: string, doneFile: string): string {
7955
7955
  " if (.__pi_studio_visible) print(.__pi_studio_value)",
7956
7956
  " }, error = function(e) {",
7957
7957
  " .__pi_studio_call <- conditionCall(e)",
7958
- " if (is.null(.__pi_studio_call)) {",
7958
+ " .__pi_studio_call_text <- if (is.null(.__pi_studio_call)) \"\" else paste(deparse(.__pi_studio_call), collapse = \" \")",
7959
+ " if (is.null(.__pi_studio_call) || grepl(\"__pi_studio_code\", .__pi_studio_call_text, fixed = TRUE)) {",
7959
7960
  " message(\"Error: \", conditionMessage(e))",
7960
7961
  " } else {",
7961
- " message(\"Error in \", paste(deparse(.__pi_studio_call), collapse = \" \"), \": \", conditionMessage(e))",
7962
+ " message(\"Error in \", .__pi_studio_call_text, \": \", conditionMessage(e))",
7962
7963
  " }",
7963
7964
  " }, finally = {",
7964
7965
  " writeLines(\"done\", .__pi_studio_done_file)",
@@ -8002,30 +8003,6 @@ function buildStudioReplSubmissionLine(runtime: StudioReplRuntime, sourceFile: s
8002
8003
  return `exec(open(${quotedPath}, encoding="utf-8").read(), globals())`;
8003
8004
  }
8004
8005
 
8005
- function buildStudioReplPreviewComment(runtime: StudioReplRuntime, code: string): string | undefined {
8006
- const normalized = code.replace(/\r/g, "").trimEnd();
8007
- const lineCount = normalized ? normalized.split("\n").length : 0;
8008
- if (lineCount <= 1) return undefined;
8009
- const prefix = runtime === "ghci" ? "--" : runtime === "clojure" ? ";;" : "#";
8010
- return `${prefix} Studio sent ${lineCount}-line snippet`;
8011
- }
8012
-
8013
- function buildStudioReplDisplayLabel(runtime: StudioReplRuntime, code: string): string {
8014
- const normalized = code.replace(/\r/g, "").trim();
8015
- const singleLine = normalized && !normalized.includes("\n") ? normalized.replace(/\s+/g, " ") : "";
8016
- if (singleLine && singleLine.length <= 140) return singleLine;
8017
- return buildStudioReplPreviewComment(runtime, code) || "# Studio sent code";
8018
- }
8019
-
8020
- function rememberStudioReplControlSubmission(sourceFile: string, label: string): void {
8021
- studioReplControlSubmissionLabels.set(sourceFile, label);
8022
- while (studioReplControlSubmissionLabels.size > 300) {
8023
- const oldest = studioReplControlSubmissionLabels.keys().next().value;
8024
- if (!oldest) break;
8025
- studioReplControlSubmissionLabels.delete(oldest);
8026
- }
8027
- }
8028
-
8029
8006
  function prepareStudioReplSubmission(sessionName: string, source: string): StudioReplPreparedSubmission {
8030
8007
  const normalizedSource = String(source || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
8031
8008
  const runtime = inferStudioReplSessionRuntime(sessionName).runtime;
@@ -8041,7 +8018,6 @@ function prepareStudioReplSubmission(sessionName: string, source: string): Studi
8041
8018
  }
8042
8019
  writeFileSync(controlFiles.sourceFile, controlSource, "utf-8");
8043
8020
  const submissionLine = buildStudioReplSubmissionLine(runtime, controlFiles.sourceFile);
8044
- rememberStudioReplControlSubmission(controlFiles.sourceFile, buildStudioReplDisplayLabel(runtime, normalizedSource));
8045
8021
  return {
8046
8022
  runtime,
8047
8023
  usedControlFile: true,
@@ -8111,6 +8087,112 @@ function extractStudioReplTranscriptDelta(before: string, after: string): string
8111
8087
  return current.trim();
8112
8088
  }
8113
8089
 
8090
+ function stripStudioReplSubmissionEcho(output: string): string {
8091
+ let value = String(output || "").replace(/^\s+/, "");
8092
+ // The raw tmux mirror should stay raw, but Studio/tool result output should not
8093
+ // expose the temp-file wrapper used to submit multiline snippets safely. The
8094
+ // `pi-studio-re` fragment intentionally catches IPython's wrapped display of
8095
+ // `pi-studio-repl/...` paths across continuation prompt lines.
8096
+ const submissionEchoPatterns = [
8097
+ /^.*exec\(open\([\s\S]*?pi-studio-re[\s\S]*?globals\(\)\)\s*$/gm,
8098
+ /^.*include\([\s\S]*?pi-studio-re[\s\S]*?\.jl"\)\s*$/gm,
8099
+ /^.*source\([\s\S]*?pi-studio-re[\s\S]*?local\s*=\s*\.GlobalEnv\)\s*$/gm,
8100
+ /^.*:script\s+[\s\S]*?pi-studio-re[\s\S]*?\.ghci"?\s*$/gm,
8101
+ /^.*\(do\s+\(load-file\s+[\s\S]*?pi-studio-re[\s\S]*?:pi-studio\/silent\)\s*$/gm,
8102
+ ];
8103
+ for (const pattern of submissionEchoPatterns) value = value.replace(pattern, "");
8104
+ return value.replace(/^(?:\s*\n)+/, "").replace(/[\t ]+$/gm, "").trimEnd();
8105
+ }
8106
+
8107
+ function stripTrailingStudioReplPrompts(output: string): string {
8108
+ const lines = String(output || "").replace(/\r\n/g, "\n").split("\n");
8109
+ while (lines.length > 0 && /^\s*(?:>>>|\.\.\.|In \[\d+\]:|julia>|>|\+|ghci>|Prelude>|\*?[A-Za-z0-9_.:]+>|[^\s>]+=>)\s*$/.test(lines[lines.length - 1] || "")) {
8110
+ lines.pop();
8111
+ }
8112
+ return lines.join("\n").trimEnd();
8113
+ }
8114
+
8115
+ function cleanStudioReplCapturedOutput(output: string): string {
8116
+ return stripTrailingStudioReplPrompts(stripStudioReplSubmissionEcho(output));
8117
+ }
8118
+
8119
+ function normalizeStudioReplJournalMode(mode: unknown): StudioReplJournalEntry["mode"] {
8120
+ return mode === "literate" || mode === "agent" ? mode : "raw";
8121
+ }
8122
+
8123
+ function normalizeStudioReplJournalStatus(status: unknown): StudioReplJournalEntry["status"] {
8124
+ return status === "captured" || status === "timeout" || status === "error" || status === "note" ? status : "sent";
8125
+ }
8126
+
8127
+ function makeStudioReplJournalEntry(details: Partial<StudioReplJournalEntry> & { sessionName: string; code: string }): StudioReplJournalEntry {
8128
+ const now = Date.now();
8129
+ return {
8130
+ id: typeof details.id === "string" && details.id.trim() ? details.id.trim() : `repl-journal-${now.toString(36)}-${randomUUID().slice(0, 8)}`,
8131
+ requestId: typeof details.requestId === "string" ? details.requestId : "",
8132
+ createdAt: typeof details.createdAt === "number" && Number.isFinite(details.createdAt) ? details.createdAt : now,
8133
+ updatedAt: typeof details.updatedAt === "number" && Number.isFinite(details.updatedAt) ? details.updatedAt : now,
8134
+ sessionName: String(details.sessionName || ""),
8135
+ runtime: details.runtime || "unknown",
8136
+ label: typeof details.label === "string" && details.label.trim() ? details.label.trim() : "REPL send",
8137
+ mode: normalizeStudioReplJournalMode(details.mode),
8138
+ prose: typeof details.prose === "string" ? details.prose : "",
8139
+ code: String(details.code || ""),
8140
+ output: typeof details.output === "string" ? details.output : "",
8141
+ status: normalizeStudioReplJournalStatus(details.status),
8142
+ skippedChunks: Math.max(0, Math.floor(Number(details.skippedChunks) || 0)),
8143
+ };
8144
+ }
8145
+
8146
+ function upsertStudioReplJournalEntry(entry: StudioReplJournalEntry): StudioReplJournalEntry {
8147
+ const existingIndex = studioReplJournalEntries.findIndex((candidate) => (
8148
+ (entry.requestId && candidate.requestId === entry.requestId)
8149
+ || candidate.id === entry.id
8150
+ ));
8151
+ if (existingIndex >= 0) {
8152
+ const existing = studioReplJournalEntries[existingIndex];
8153
+ studioReplJournalEntries[existingIndex] = {
8154
+ ...existing,
8155
+ ...entry,
8156
+ createdAt: existing.createdAt || entry.createdAt,
8157
+ updatedAt: Math.max(existing.updatedAt || 0, entry.updatedAt || 0, Date.now()),
8158
+ };
8159
+ } else {
8160
+ studioReplJournalEntries.push(entry);
8161
+ }
8162
+ studioReplJournalEntries = studioReplJournalEntries
8163
+ .sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0))
8164
+ .slice(-STUDIO_REPL_JOURNAL_MAX_ENTRIES);
8165
+ return studioReplJournalEntries.find((candidate) => candidate.id === entry.id || (entry.requestId && candidate.requestId === entry.requestId)) || entry;
8166
+ }
8167
+
8168
+ function recordStudioReplJournalEntry(details: Partial<StudioReplJournalEntry> & { sessionName: string; code: string }): StudioReplJournalEntry {
8169
+ return upsertStudioReplJournalEntry(makeStudioReplJournalEntry(details));
8170
+ }
8171
+
8172
+ function updateStudioReplJournalEntryOutput(requestId: string, sessionName: string, output: string, status: StudioReplJournalEntry["status"]): void {
8173
+ const normalizedRequestId = String(requestId || "");
8174
+ const normalizedSessionName = String(sessionName || "");
8175
+ const existing = studioReplJournalEntries.find((entry) => (
8176
+ (normalizedRequestId && entry.requestId === normalizedRequestId)
8177
+ || (!normalizedRequestId && normalizedSessionName && entry.sessionName === normalizedSessionName && entry.status === "sent")
8178
+ ));
8179
+ if (!existing) return;
8180
+ upsertStudioReplJournalEntry({
8181
+ ...existing,
8182
+ output: String(output || ""),
8183
+ status,
8184
+ updatedAt: Date.now(),
8185
+ });
8186
+ }
8187
+
8188
+ function getStudioReplJournalEntries(sessionName: string | null | undefined): StudioReplJournalEntry[] {
8189
+ const normalizedSessionName = String(sessionName || "").trim();
8190
+ const entries = normalizedSessionName
8191
+ ? studioReplJournalEntries.filter((entry) => entry.sessionName === normalizedSessionName)
8192
+ : studioReplJournalEntries;
8193
+ return entries.slice(-STUDIO_REPL_JOURNAL_MAX_ENTRIES).map((entry) => ({ ...entry }));
8194
+ }
8195
+
8114
8196
  async function waitForStudioReplDoneFile(doneFile: string | undefined, timeoutMs: number): Promise<boolean> {
8115
8197
  if (!doneFile) return false;
8116
8198
  const deadline = Date.now() + clampStudioReplSendTimeout(timeoutMs);
@@ -8194,9 +8276,33 @@ function isSshSession(): boolean {
8194
8276
  );
8195
8277
  }
8196
8278
 
8197
- function buildStudioSshTunnelHint(port: number, studioUrl: string): string | null {
8279
+ function parseStudioLaunchOpenFlags(rawArgs: string): { args: string; openRemoteBrowser: boolean; error?: string } {
8280
+ const parsed = tokenizeStudioCommandArgs(rawArgs);
8281
+ if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, error: parsed.error };
8282
+ const remaining: string[] = [];
8283
+ let openRemoteBrowser = false;
8284
+ for (const token of parsed.tokens) {
8285
+ if (token === "--open-remote" || token === "--open-remote-browser") {
8286
+ openRemoteBrowser = true;
8287
+ continue;
8288
+ }
8289
+ remaining.push(token);
8290
+ }
8291
+ return { args: remaining.join(" "), openRemoteBrowser };
8292
+ }
8293
+
8294
+ function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean }): boolean {
8295
+ return !isSshSession() || Boolean(options?.openRemoteBrowser);
8296
+ }
8297
+
8298
+ function buildStudioSshTunnelHint(port: number): string | null {
8198
8299
  if (!isSshSession()) return null;
8199
- return `SSH detected. Full Studio URL: ${studioUrl}. Forward the remote Studio port with: ssh -L ${port}:127.0.0.1:${port} <remote-host>. Open the full URL locally through the tunnel, preserving its ?token=... parameter. If you choose a different local port, change only the port in the URL; keep the token.`;
8300
+ return [
8301
+ "SSH detected. Studio was not opened in the remote browser.",
8302
+ "To open it locally, run this on your local machine:",
8303
+ ` ssh -L ${port}:127.0.0.1:${port} <remote-host>`,
8304
+ "Then open the Studio URL above in your local browser.",
8305
+ ].join("\n");
8200
8306
  }
8201
8307
 
8202
8308
  function resolveRequestedStudioDocumentFromUrl(
@@ -8619,11 +8725,15 @@ ${cssVarsBlock}
8619
8725
  <div class="source-actions-row">
8620
8726
  <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>
8621
8727
  <button id="queueSteerBtn" type="button" title="Queue steering is available while Run editor text is active." disabled>Queue steering</button>
8728
+ </div>
8729
+ <div class="source-actions-row repl-action-line" hidden>
8622
8730
  <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>
8623
8731
  <select id="replSendModeSelect" hidden aria-label="REPL send mode" title="Choose how Send to REPL interprets the editor text.">
8624
8732
  <option value="raw" selected>Send mode: Raw</option>
8625
8733
  <option value="literate">Send mode: Literate</option>
8626
8734
  </select>
8735
+ </div>
8736
+ <div class="source-actions-row">
8627
8737
  <button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy text</button>
8628
8738
  <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>
8629
8739
  <button id="sendEditorBtn" type="button">Send to pi editor</button>
@@ -9028,7 +9138,8 @@ export default function (pi: ExtensionAPI) {
9028
9138
  }
9029
9139
  const after = captureStudioReplSession(selected.session.sessionName);
9030
9140
  const afterTranscript = after.ok ? after.transcript : "";
9031
- const output = extractStudioReplTranscriptDelta(beforeTranscript, afterTranscript);
9141
+ const rawOutput = extractStudioReplTranscriptDelta(beforeTranscript, afterTranscript);
9142
+ const output = cleanStudioReplCapturedOutput(rawOutput);
9032
9143
  const statusLine = sent.controlFiles?.doneFile
9033
9144
  ? (completed ? "Completed." : `Timed out after ${timeoutMs} ms waiting for completion marker.`)
9034
9145
  : "Submitted.";
@@ -9037,6 +9148,16 @@ export default function (pi: ExtensionAPI) {
9037
9148
  output ? "" : undefined,
9038
9149
  output || undefined,
9039
9150
  ].filter(Boolean).join("\n");
9151
+ recordStudioReplJournalEntry({
9152
+ requestId: `tool:${toolCallId}`,
9153
+ sessionName: selected.session.sessionName,
9154
+ runtime: sent.runtime === "unknown" ? selected.session.runtime : sent.runtime,
9155
+ label: "Pi",
9156
+ mode: "agent",
9157
+ code: params.code,
9158
+ output,
9159
+ status: sent.controlFiles?.doneFile && !completed ? "timeout" : (output.trim() ? "captured" : "sent"),
9160
+ });
9040
9161
  broadcastStudioReplToolSend({
9041
9162
  toolCallId,
9042
9163
  sessionName: selected.session.sessionName,
@@ -9048,6 +9169,7 @@ export default function (pi: ExtensionAPI) {
9048
9169
  timedOut: Boolean(sent.controlFiles?.doneFile && !completed),
9049
9170
  transcript: afterTranscript,
9050
9171
  capturedAt: Date.now(),
9172
+ journalEntries: getStudioReplJournalEntries(selected.session.sessionName),
9051
9173
  });
9052
9174
  return {
9053
9175
  content: [{ type: "text", text }],
@@ -9512,6 +9634,7 @@ export default function (pi: ExtensionAPI) {
9512
9634
  tmuxAvailable: state.tmuxAvailable,
9513
9635
  sessions: state.sessions,
9514
9636
  activeSessionName: studioReplActiveSessionName,
9637
+ journalEntries: getStudioReplJournalEntries(studioReplActiveSessionName),
9515
9638
  error: state.error ?? null,
9516
9639
  ...extra,
9517
9640
  });
@@ -9525,6 +9648,7 @@ export default function (pi: ExtensionAPI) {
9525
9648
  sendReplStateToClient(client, {
9526
9649
  transcript: "",
9527
9650
  capturedAt: Date.now(),
9651
+ journalEntries: [],
9528
9652
  ...extra,
9529
9653
  });
9530
9654
  return;
@@ -9536,6 +9660,7 @@ export default function (pi: ExtensionAPI) {
9536
9660
  transcript: "",
9537
9661
  captureError: captured.message,
9538
9662
  capturedAt: Date.now(),
9663
+ journalEntries: getStudioReplJournalEntries(targetSession),
9539
9664
  ...extra,
9540
9665
  });
9541
9666
  return;
@@ -9547,6 +9672,7 @@ export default function (pi: ExtensionAPI) {
9547
9672
  activeSessionName: captured.session.sessionName,
9548
9673
  transcript: captured.transcript,
9549
9674
  capturedAt: Date.now(),
9675
+ journalEntries: getStudioReplJournalEntries(captured.session.sessionName),
9550
9676
  ...extra,
9551
9677
  });
9552
9678
  };
@@ -10557,6 +10683,8 @@ export default function (pi: ExtensionAPI) {
10557
10683
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
10558
10684
  return;
10559
10685
  }
10686
+ const before = captureStudioReplSession(msg.sessionName);
10687
+ const beforeTranscript = before.ok ? before.transcript : "";
10560
10688
  const sent = sendTextToStudioReplSession(msg.sessionName, msg.text);
10561
10689
  if (!sent.ok) {
10562
10690
  sendToClient(client, { type: "error", requestId: msg.requestId, message: sent.message });
@@ -10564,8 +10692,47 @@ export default function (pi: ExtensionAPI) {
10564
10692
  return;
10565
10693
  }
10566
10694
  studioReplActiveSessionName = msg.sessionName;
10567
- sendToClient(client, { type: "repl_send_ack", requestId: msg.requestId, sessionName: msg.sessionName, message: sent.message });
10568
- setTimeout(() => sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId }), 150);
10695
+ recordStudioReplJournalEntry({
10696
+ requestId: msg.requestId,
10697
+ sessionName: msg.sessionName,
10698
+ runtime: sent.runtime,
10699
+ label: "Studio",
10700
+ mode: "raw",
10701
+ code: msg.text,
10702
+ status: "sent",
10703
+ });
10704
+ sendToClient(client, {
10705
+ type: "repl_send_ack",
10706
+ requestId: msg.requestId,
10707
+ sessionName: msg.sessionName,
10708
+ message: sent.message,
10709
+ journalEntries: getStudioReplJournalEntries(msg.sessionName),
10710
+ });
10711
+ void (async () => {
10712
+ try {
10713
+ const timeoutMs = STUDIO_REPL_SEND_DEFAULT_TIMEOUT_MS;
10714
+ let completed = false;
10715
+ if (sent.controlFiles?.doneFile) {
10716
+ completed = await waitForStudioReplDoneFile(sent.controlFiles.doneFile, timeoutMs);
10717
+ } else {
10718
+ await sleep(Math.min(750, timeoutMs));
10719
+ }
10720
+ const after = captureStudioReplSession(msg.sessionName);
10721
+ const afterTranscript = after.ok ? after.transcript : "";
10722
+ const rawOutput = extractStudioReplTranscriptDelta(beforeTranscript, afterTranscript);
10723
+ const output = cleanStudioReplCapturedOutput(rawOutput);
10724
+ updateStudioReplJournalEntryOutput(
10725
+ msg.requestId,
10726
+ msg.sessionName,
10727
+ output,
10728
+ sent.controlFiles?.doneFile && !completed ? "timeout" : (output.trim() ? "captured" : "sent"),
10729
+ );
10730
+ sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId });
10731
+ } catch (error) {
10732
+ updateStudioReplJournalEntryOutput(msg.requestId, msg.sessionName, error instanceof Error ? error.message : String(error), "error");
10733
+ sendReplCaptureToClient(client, msg.sessionName, { requestId: msg.requestId, replError: error instanceof Error ? error.message : String(error) });
10734
+ }
10735
+ })();
10569
10736
  return;
10570
10737
  }
10571
10738
 
@@ -12369,6 +12536,12 @@ export default function (pi: ExtensionAPI) {
12369
12536
  mode: StudioUiMode,
12370
12537
  options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; replaceExistingFull?: boolean },
12371
12538
  ) => {
12539
+ const launchOpenFlags = parseStudioLaunchOpenFlags(trimmed);
12540
+ if (launchOpenFlags.error) {
12541
+ ctx.ui.notify(`${launchOpenFlags.error} Use ${options?.commandLabel ?? "/studio"} --help`, "error");
12542
+ return;
12543
+ }
12544
+ const launchArgs = launchOpenFlags.args;
12372
12545
  if (mode === "full" && hasConnectedFullStudioView()) {
12373
12546
  if (options?.replaceExistingFull) {
12374
12547
  closeStudioClientsByMode("full", 4001, "Full Studio replaced");
@@ -12377,7 +12550,7 @@ export default function (pi: ExtensionAPI) {
12377
12550
  if (serverState) {
12378
12551
  const url = buildStudioUrl(serverState.port, serverState.token, "full");
12379
12552
  ctx.ui.notify(`Studio URL: ${url}`, "info");
12380
- const sshTunnelHint = buildStudioSshTunnelHint(serverState.port, url);
12553
+ const sshTunnelHint = buildStudioSshTunnelHint(serverState.port);
12381
12554
  if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
12382
12555
  }
12383
12556
  return;
@@ -12399,23 +12572,30 @@ export default function (pi: ExtensionAPI) {
12399
12572
  // ignore theme read errors
12400
12573
  }
12401
12574
 
12402
- const selected = resolveStudioLaunchDocument(trimmed, ctx, options);
12575
+ const selected = resolveStudioLaunchDocument(launchArgs, ctx, options);
12403
12576
  if (!selected) return;
12404
12577
  initialStudioDocument = selected;
12405
12578
 
12406
12579
  const state = await ensureServer();
12407
12580
  const url = buildStudioUrl(state.port, state.token, mode, selected);
12408
- const sshTunnelHint = buildStudioSshTunnelHint(state.port, url);
12581
+ const sshTunnelHint = buildStudioSshTunnelHint(state.port);
12409
12582
  const openedLabel = mode === "editor-only" ? "pi Studio editor-only view" : "pi Studio";
12410
12583
 
12584
+ const shouldOpenBrowser = shouldAutoOpenStudioBrowser({
12585
+ openRemoteBrowser: launchOpenFlags.openRemoteBrowser,
12586
+ });
12411
12587
  try {
12412
- await openUrlInDefaultBrowser(url);
12413
- if (selected.source === "file") {
12414
- ctx.ui.notify(`Opened ${openedLabel} with file loaded: ${selected.label}`, "info");
12415
- } else if (selected.source === "last-response") {
12416
- ctx.ui.notify(`Opened ${openedLabel} with last model response (${selected.text.length} chars).`, "info");
12588
+ if (!shouldOpenBrowser) {
12589
+ ctx.ui.notify(`${openedLabel} is ready. Browser auto-open was skipped because SSH was detected.`, "info");
12417
12590
  } else {
12418
- ctx.ui.notify(`Opened ${openedLabel} with blank editor.`, "info");
12591
+ await openUrlInDefaultBrowser(url);
12592
+ if (selected.source === "file") {
12593
+ ctx.ui.notify(`Opened ${openedLabel} with file loaded: ${selected.label}`, "info");
12594
+ } else if (selected.source === "last-response") {
12595
+ ctx.ui.notify(`Opened ${openedLabel} with last model response (${selected.text.length} chars).`, "info");
12596
+ } else {
12597
+ ctx.ui.notify(`Opened ${openedLabel} with blank editor.`, "info");
12598
+ }
12419
12599
  }
12420
12600
  } catch (error) {
12421
12601
  const message = error instanceof Error ? error.message : String(error);
@@ -12452,7 +12632,7 @@ export default function (pi: ExtensionAPI) {
12452
12632
  `Studio running at ${url} (busy: ${isStudioBusy() ? "yes" : "no"}; full views: ${counts.full}; editor-only views: ${counts.editorOnly})`,
12453
12633
  "info",
12454
12634
  );
12455
- const sshTunnelHint = buildStudioSshTunnelHint(serverState.port, url);
12635
+ const sshTunnelHint = buildStudioSshTunnelHint(serverState.port);
12456
12636
  if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
12457
12637
  return;
12458
12638
  }
@@ -12464,6 +12644,7 @@ export default function (pi: ExtensionAPI) {
12464
12644
  + " /studio <path> Open studio with file preloaded\n"
12465
12645
  + " /studio --blank Open with blank editor\n"
12466
12646
  + " /studio --last Open with last model response\n"
12647
+ + " /studio --open-remote Over SSH, open the remote browser anyway\n"
12467
12648
  + " /studio --status Show studio status\n"
12468
12649
  + " /studio --stop Stop studio server\n"
12469
12650
  + " Note: only one full /studio view is allowed per Pi session.\n"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",