pi-studio 0.9.0 → 0.9.2
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/CHANGELOG.md +26 -0
- package/README.md +4 -2
- package/client/studio-client.js +801 -136
- package/client/studio.css +419 -35
- package/index.ts +766 -345
- package/package.json +4 -3
package/index.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext, SessionEntry, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "@sinclair/typebox";
|
|
3
4
|
import { spawn, spawnSync } from "node:child_process";
|
|
4
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
-
import { readFileSync, statSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
7
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
7
8
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
8
9
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -411,6 +412,14 @@ const STUDIO_TRACE_SNAPSHOT_MAX_IMAGE_BASE64_CHARS = 6_000_000;
|
|
|
411
412
|
const STUDIO_TRACE_IMAGE_SAFE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
412
413
|
const STUDIO_REPL_CAPTURE_LINES = 800;
|
|
413
414
|
const STUDIO_REPL_SEND_MAX_CHARS = 200_000;
|
|
415
|
+
const STUDIO_REPL_SEND_DEFAULT_TIMEOUT_MS = 20_000;
|
|
416
|
+
const STUDIO_REPL_SEND_MAX_TIMEOUT_MS = 120_000;
|
|
417
|
+
const STUDIO_REPL_CONTROL_ROOT = join(tmpdir(), "pi-studio-repl");
|
|
418
|
+
const STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES = 2_000_000;
|
|
419
|
+
const STUDIO_PANDOC_TIMEOUT_MS = readStudioPositiveEnvMs("PI_STUDIO_PANDOC_TIMEOUT_MS", 120_000, 5_000, 15 * 60_000);
|
|
420
|
+
const STUDIO_LATEX_TIMEOUT_MS = readStudioPositiveEnvMs("PI_STUDIO_LATEX_TIMEOUT_MS", 120_000, 5_000, 15 * 60_000);
|
|
421
|
+
const STUDIO_MERMAID_TIMEOUT_MS = readStudioPositiveEnvMs("PI_STUDIO_MERMAID_TIMEOUT_MS", 60_000, 5_000, 10 * 60_000);
|
|
422
|
+
const STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES = readStudioPositiveEnvMs("PI_STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES", 50_000_000, 1_000_000, 500_000_000);
|
|
414
423
|
const STUDIO_REPL_RUNTIME_LABELS: Record<StudioReplRuntime, string> = {
|
|
415
424
|
shell: "Shell",
|
|
416
425
|
python: "Python",
|
|
@@ -420,6 +429,16 @@ const STUDIO_REPL_RUNTIME_LABELS: Record<StudioReplRuntime, string> = {
|
|
|
420
429
|
ghci: "GHCi",
|
|
421
430
|
clojure: "Clojure",
|
|
422
431
|
};
|
|
432
|
+
const STUDIO_REPL_SEND_TOOL_PARAMS = Type.Object({
|
|
433
|
+
code: Type.String({ description: "Code to execute in the active or selected Studio REPL session." }),
|
|
434
|
+
sessionName: Type.Optional(Type.String({ description: "Exact Studio/pi-repl tmux session name. If omitted, Studio uses the active REPL session, or the first session matching target." })),
|
|
435
|
+
target: Type.Optional(Type.String({ description: "Optional runtime target: shell, python, ipython, julia, r, ghci, or clojure. Used when sessionName is omitted." })),
|
|
436
|
+
timeoutMs: Type.Optional(Type.Number({ description: "Maximum time to wait for completion when Studio can detect it (default 20000, max 120000).", minimum: 1000, maximum: STUDIO_REPL_SEND_MAX_TIMEOUT_MS })),
|
|
437
|
+
});
|
|
438
|
+
const STUDIO_REPL_STATUS_TOOL_PARAMS = Type.Object({
|
|
439
|
+
sessionName: Type.Optional(Type.String({ description: "Exact Studio/pi-repl tmux session name to inspect." })),
|
|
440
|
+
target: Type.Optional(Type.String({ description: "Optional runtime target: shell, python, ipython, julia, r, ghci, or clojure. If omitted, report all Studio-visible REPL sessions." })),
|
|
441
|
+
});
|
|
423
442
|
const MAX_STUDIO_TRACE_SNAPSHOTS = RESPONSE_HISTORY_LIMIT;
|
|
424
443
|
const TRANSIENT_STUDIO_DOCUMENT_TTL_MS = 30 * 60 * 1000;
|
|
425
444
|
const MAX_TRANSIENT_STUDIO_DOCUMENTS = 16;
|
|
@@ -432,9 +451,155 @@ const STUDIO_DEFAULT_SCRATCHPAD_DOCUMENT_KEY = "doc:blank:blank";
|
|
|
432
451
|
const STUDIO_PERSISTENT_STATE_DIR = join(getAgentDir(), "pi-studio");
|
|
433
452
|
const STUDIO_PERSISTENT_STATE_PATH = join(STUDIO_PERSISTENT_STATE_DIR, "local-state.json");
|
|
434
453
|
|
|
454
|
+
type StudioSubprocessResult = {
|
|
455
|
+
code: number | null;
|
|
456
|
+
signal: NodeJS.Signals | null;
|
|
457
|
+
stdout: string;
|
|
458
|
+
stderr: string;
|
|
459
|
+
stdoutTruncated: boolean;
|
|
460
|
+
stderrTruncated: boolean;
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
type StudioSubprocessOptions = {
|
|
464
|
+
cwd?: string;
|
|
465
|
+
input?: string;
|
|
466
|
+
timeoutMs?: number;
|
|
467
|
+
stdoutMaxBytes?: number;
|
|
468
|
+
stderrMaxBytes?: number;
|
|
469
|
+
notFoundMessage?: string;
|
|
470
|
+
notFoundError?: () => Error;
|
|
471
|
+
label?: string;
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
function readStudioPositiveEnvMs(name: string, fallback: number, min: number, max: number): number {
|
|
475
|
+
const parsed = Number.parseInt(String(process.env[name] ?? ""), 10);
|
|
476
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
|
477
|
+
return Math.max(min, Math.min(max, parsed));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function appendStudioSubprocessChunk(chunks: Buffer[], chunk: Buffer | string, state: { bytes: number; truncated: boolean }, maxBytes = STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES): void {
|
|
481
|
+
if (state.bytes >= maxBytes) {
|
|
482
|
+
state.truncated = true;
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
486
|
+
const remaining = maxBytes - state.bytes;
|
|
487
|
+
if (buffer.length <= remaining) {
|
|
488
|
+
chunks.push(buffer);
|
|
489
|
+
state.bytes += buffer.length;
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
chunks.push(buffer.subarray(0, remaining));
|
|
493
|
+
state.bytes += remaining;
|
|
494
|
+
state.truncated = true;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function finalizeStudioSubprocessOutput(chunks: Buffer[], truncated: boolean): string {
|
|
498
|
+
const value = Buffer.concat(chunks).toString("utf-8").trim();
|
|
499
|
+
return truncated ? `${value}\n[output truncated by Studio]`.trim() : value;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function runStudioSubprocess(command: string, args: string[], options: StudioSubprocessOptions = {}): Promise<StudioSubprocessResult> {
|
|
503
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
504
|
+
const timeoutMs = Math.max(1_000, Math.floor(options.timeoutMs ?? STUDIO_PANDOC_TIMEOUT_MS));
|
|
505
|
+
const child = spawn(command, args, {
|
|
506
|
+
cwd: options.cwd,
|
|
507
|
+
stdio: [typeof options.input === "string" ? "pipe" : "ignore", "pipe", "pipe"],
|
|
508
|
+
});
|
|
509
|
+
const stdoutChunks: Buffer[] = [];
|
|
510
|
+
const stderrChunks: Buffer[] = [];
|
|
511
|
+
const stdoutMaxBytes = Math.max(1, Math.floor(options.stdoutMaxBytes ?? STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES));
|
|
512
|
+
const stderrMaxBytes = Math.max(1, Math.floor(options.stderrMaxBytes ?? STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES));
|
|
513
|
+
const stdoutState = { bytes: 0, truncated: false };
|
|
514
|
+
const stderrState = { bytes: 0, truncated: false };
|
|
515
|
+
let settled = false;
|
|
516
|
+
let timedOut = false;
|
|
517
|
+
let killTimer: NodeJS.Timeout | null = null;
|
|
518
|
+
|
|
519
|
+
const cleanup = () => {
|
|
520
|
+
clearTimeout(timeoutTimer);
|
|
521
|
+
if (killTimer) clearTimeout(killTimer);
|
|
522
|
+
};
|
|
523
|
+
const fail = (error: Error) => {
|
|
524
|
+
if (settled) return;
|
|
525
|
+
settled = true;
|
|
526
|
+
cleanup();
|
|
527
|
+
rejectPromise(error);
|
|
528
|
+
};
|
|
529
|
+
const succeed = (result: StudioSubprocessResult) => {
|
|
530
|
+
if (settled) return;
|
|
531
|
+
settled = true;
|
|
532
|
+
cleanup();
|
|
533
|
+
resolvePromise(result);
|
|
534
|
+
};
|
|
535
|
+
const label = options.label || basename(command) || command;
|
|
536
|
+
const timeoutTimer = setTimeout(() => {
|
|
537
|
+
timedOut = true;
|
|
538
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
539
|
+
killTimer = setTimeout(() => {
|
|
540
|
+
try { child.kill("SIGKILL"); } catch {}
|
|
541
|
+
}, 2_000);
|
|
542
|
+
}, timeoutMs);
|
|
543
|
+
|
|
544
|
+
child.stdout?.on("data", (chunk: Buffer | string) => appendStudioSubprocessChunk(stdoutChunks, chunk, stdoutState, stdoutMaxBytes));
|
|
545
|
+
child.stderr?.on("data", (chunk: Buffer | string) => appendStudioSubprocessChunk(stderrChunks, chunk, stderrState, stderrMaxBytes));
|
|
546
|
+
|
|
547
|
+
child.once("error", (error) => {
|
|
548
|
+
const errno = error as NodeJS.ErrnoException;
|
|
549
|
+
if (errno.code === "ENOENT") {
|
|
550
|
+
fail(options.notFoundError ? options.notFoundError() : new Error(options.notFoundMessage || `${command} was not found.`));
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
fail(error);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
child.once("close", (code, signal) => {
|
|
557
|
+
if (timedOut) {
|
|
558
|
+
fail(new Error(`${label} timed out after ${Math.round(timeoutMs / 1000)}s.`));
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
succeed({
|
|
562
|
+
code,
|
|
563
|
+
signal,
|
|
564
|
+
stdout: finalizeStudioSubprocessOutput(stdoutChunks, stdoutState.truncated),
|
|
565
|
+
stderr: finalizeStudioSubprocessOutput(stderrChunks, stderrState.truncated),
|
|
566
|
+
stdoutTruncated: stdoutState.truncated,
|
|
567
|
+
stderrTruncated: stderrState.truncated,
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
if (typeof options.input === "string") {
|
|
572
|
+
child.stdin?.end(options.input);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function buildStudioPandocPdfEngineOptArgs(pdfEngine: string): string[] {
|
|
578
|
+
const engineName = basename(String(pdfEngine || "")).toLowerCase();
|
|
579
|
+
if (!/^(?:pdf|xe|lua)?latex$/.test(engineName)) return [];
|
|
580
|
+
return [
|
|
581
|
+
"--pdf-engine-opt=-interaction=nonstopmode",
|
|
582
|
+
"--pdf-engine-opt=-halt-on-error",
|
|
583
|
+
"--pdf-engine-opt=-file-line-error",
|
|
584
|
+
];
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const STUDIO_PANDOC_HTML_FRAGMENT_TEMPLATE = `<!doctype html>
|
|
588
|
+
<html>
|
|
589
|
+
<head>
|
|
590
|
+
<meta charset="utf-8" />
|
|
591
|
+
<title>pi Studio preview</title>
|
|
592
|
+
</head>
|
|
593
|
+
<body>
|
|
594
|
+
$body$
|
|
595
|
+
</body>
|
|
596
|
+
</html>
|
|
597
|
+
`;
|
|
598
|
+
|
|
435
599
|
let studioPersistentStateCache: StudioPersistentState | null = null;
|
|
436
600
|
let studioPersistentStateQueue: Promise<void> = Promise.resolve();
|
|
437
601
|
let transientStudioDocuments: Map<string, { document: InitialStudioDocument; createdAt: number }> = new Map();
|
|
602
|
+
const studioReplControlSubmissionLabels = new Map<string, string>();
|
|
438
603
|
|
|
439
604
|
function createEmptyStudioPersistentState(): StudioPersistentState {
|
|
440
605
|
return {
|
|
@@ -4469,46 +4634,17 @@ async function renderStudioMermaidDiagramForPdf(source: string, workDir: string,
|
|
|
4469
4634
|
const outputPath = join(workDir, `mermaid-diagram-${blockNumber}.pdf`);
|
|
4470
4635
|
|
|
4471
4636
|
await writeFile(inputPath, source, "utf-8");
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
if (settled) return;
|
|
4480
|
-
settled = true;
|
|
4481
|
-
reject(error);
|
|
4482
|
-
};
|
|
4483
|
-
|
|
4484
|
-
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
4485
|
-
stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
4486
|
-
});
|
|
4487
|
-
|
|
4488
|
-
child.once("error", (error) => {
|
|
4489
|
-
const errno = error as NodeJS.ErrnoException;
|
|
4490
|
-
if (errno.code === "ENOENT") {
|
|
4491
|
-
fail(
|
|
4492
|
-
new MermaidCliMissingError(
|
|
4493
|
-
"Mermaid CLI (mmdc) not found. Install with `npm install -g @mermaid-js/mermaid-cli` or set MERMAID_CLI_PATH.",
|
|
4494
|
-
),
|
|
4495
|
-
);
|
|
4496
|
-
return;
|
|
4497
|
-
}
|
|
4498
|
-
fail(error);
|
|
4499
|
-
});
|
|
4500
|
-
|
|
4501
|
-
child.once("close", (code) => {
|
|
4502
|
-
if (settled) return;
|
|
4503
|
-
settled = true;
|
|
4504
|
-
if (code === 0) {
|
|
4505
|
-
resolve();
|
|
4506
|
-
return;
|
|
4507
|
-
}
|
|
4508
|
-
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
|
|
4509
|
-
reject(new Error(`Mermaid CLI failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
|
|
4510
|
-
});
|
|
4637
|
+
const args = ["-i", inputPath, "-o", outputPath, "-t", mermaidTheme, "-f"];
|
|
4638
|
+
const result = await runStudioSubprocess(mermaidCommand, args, {
|
|
4639
|
+
timeoutMs: STUDIO_MERMAID_TIMEOUT_MS,
|
|
4640
|
+
label: "Mermaid CLI",
|
|
4641
|
+
notFoundError: () => new MermaidCliMissingError(
|
|
4642
|
+
"Mermaid CLI (mmdc) not found. Install with `npm install -g @mermaid-js/mermaid-cli` or set MERMAID_CLI_PATH.",
|
|
4643
|
+
),
|
|
4511
4644
|
});
|
|
4645
|
+
if (result.code !== 0) {
|
|
4646
|
+
throw new Error(`Mermaid CLI failed with exit code ${result.code}${result.stderr ? `: ${result.stderr}` : ""}`);
|
|
4647
|
+
}
|
|
4512
4648
|
|
|
4513
4649
|
return outputPath;
|
|
4514
4650
|
}
|
|
@@ -4693,82 +4829,67 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
|
|
|
4693
4829
|
const inputFormat = isLatex ? "latex" : "markdown+lists_without_preceding_blankline-blank_before_blockquote-blank_before_header+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris-raw_html";
|
|
4694
4830
|
const bibliographyArgs = buildStudioPandocBibliographyArgs(markdown, isLatex, resourcePath);
|
|
4695
4831
|
const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none", ...bibliographyArgs];
|
|
4832
|
+
let htmlTemplateDir: string | null = null;
|
|
4696
4833
|
if (resourcePath) {
|
|
4697
4834
|
args.push(`--resource-path=${resourcePath}`);
|
|
4698
|
-
// Embed images as data URIs so
|
|
4699
|
-
|
|
4835
|
+
// Embed images as data URIs so browser previews and exported HTML keep local figures.
|
|
4836
|
+
// A minimal template prevents Pandoc's standalone default CSS/title block from leaking
|
|
4837
|
+
// into Studio's own standalone export wrapper.
|
|
4838
|
+
htmlTemplateDir = join(tmpdir(), `pi-studio-pandoc-html-${randomUUID()}`);
|
|
4839
|
+
await mkdir(htmlTemplateDir, { recursive: true });
|
|
4840
|
+
const htmlTemplatePath = join(htmlTemplateDir, "template.html");
|
|
4841
|
+
await writeFile(htmlTemplatePath, STUDIO_PANDOC_HTML_FRAGMENT_TEMPLATE, "utf-8");
|
|
4842
|
+
args.push("--embed-resources", "--standalone", `--template=${htmlTemplatePath}`);
|
|
4700
4843
|
}
|
|
4701
4844
|
const normalizedMarkdown = isLatex
|
|
4702
4845
|
? sourceWithResolvedRefs
|
|
4703
4846
|
: normalizeStudioMarkdownFencedBlocks(prepareStudioMarkdownForPandoc(sourceWithResolvedRefs));
|
|
4704
4847
|
const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath);
|
|
4705
4848
|
|
|
4706
|
-
let
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
reject(error);
|
|
4716
|
-
};
|
|
4717
|
-
|
|
4718
|
-
const succeed = (html: string) => {
|
|
4719
|
-
if (settled) return;
|
|
4720
|
-
settled = true;
|
|
4721
|
-
resolve(html);
|
|
4722
|
-
};
|
|
4723
|
-
|
|
4724
|
-
child.stdout.on("data", (chunk: Buffer | string) => {
|
|
4725
|
-
stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
4726
|
-
});
|
|
4727
|
-
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
4728
|
-
stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
4729
|
-
});
|
|
4730
|
-
|
|
4731
|
-
child.once("error", (error) => {
|
|
4732
|
-
const errno = error as NodeJS.ErrnoException;
|
|
4733
|
-
if (errno.code === "ENOENT") {
|
|
4734
|
-
fail(new Error("pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."));
|
|
4735
|
-
return;
|
|
4736
|
-
}
|
|
4737
|
-
fail(error);
|
|
4738
|
-
});
|
|
4739
|
-
|
|
4740
|
-
child.once("close", (code) => {
|
|
4741
|
-
if (settled) return;
|
|
4742
|
-
if (code === 0) {
|
|
4743
|
-
let html = Buffer.concat(stdoutChunks).toString("utf-8");
|
|
4744
|
-
// When --standalone was used, extract only the <body> content
|
|
4745
|
-
if (resourcePath) {
|
|
4746
|
-
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
|
4747
|
-
if (bodyMatch) html = bodyMatch[1];
|
|
4748
|
-
}
|
|
4749
|
-
if (isLatex) {
|
|
4750
|
-
html = decorateStudioLatexRenderedHtml(
|
|
4751
|
-
html,
|
|
4752
|
-
sourcePath,
|
|
4753
|
-
resourcePath,
|
|
4754
|
-
latexSubfigurePreviewTransform.subfigureGroups,
|
|
4755
|
-
latexAlgorithmPreviewTransform.algorithmBlocks,
|
|
4756
|
-
);
|
|
4757
|
-
} else {
|
|
4758
|
-
html = decorateStudioPreviewPageBreakHtml(html);
|
|
4759
|
-
}
|
|
4760
|
-
html = decorateStudioPandocSyntaxHtml(html);
|
|
4761
|
-
succeed(stripMathMlAnnotationTags(html));
|
|
4762
|
-
return;
|
|
4763
|
-
}
|
|
4764
|
-
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
|
|
4765
|
-
fail(new Error(`pandoc failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
|
|
4849
|
+
let pandocResult: StudioSubprocessResult;
|
|
4850
|
+
try {
|
|
4851
|
+
pandocResult = await runStudioSubprocess(pandocCommand, args, {
|
|
4852
|
+
cwd: pandocWorkingDir,
|
|
4853
|
+
input: normalizedMarkdown,
|
|
4854
|
+
timeoutMs: STUDIO_PANDOC_TIMEOUT_MS,
|
|
4855
|
+
stdoutMaxBytes: STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES,
|
|
4856
|
+
label: "pandoc HTML render",
|
|
4857
|
+
notFoundMessage: "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary.",
|
|
4766
4858
|
});
|
|
4859
|
+
} finally {
|
|
4860
|
+
if (htmlTemplateDir) {
|
|
4861
|
+
await rm(htmlTemplateDir, { recursive: true, force: true }).catch(() => undefined);
|
|
4862
|
+
}
|
|
4863
|
+
}
|
|
4864
|
+
if (pandocResult.code !== 0) {
|
|
4865
|
+
throw new Error(`pandoc failed with exit code ${pandocResult.code}${pandocResult.stderr ? `: ${pandocResult.stderr}` : ""}`);
|
|
4866
|
+
}
|
|
4867
|
+
if (pandocResult.stdoutTruncated) {
|
|
4868
|
+
throw new Error(`pandoc HTML output exceeded ${Math.round(STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES / 1_000_000)} MB. Reduce embedded assets or set PI_STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES higher.`);
|
|
4869
|
+
}
|
|
4767
4870
|
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4871
|
+
let renderedHtml = pandocResult.stdout;
|
|
4872
|
+
// When --standalone was used for --embed-resources, extract only the <body> content.
|
|
4873
|
+
if (resourcePath) {
|
|
4874
|
+
const bodyMatch = renderedHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
|
4875
|
+
if (!bodyMatch) {
|
|
4876
|
+
throw new Error("pandoc HTML render did not include a complete body element.");
|
|
4877
|
+
}
|
|
4878
|
+
renderedHtml = bodyMatch[1];
|
|
4879
|
+
}
|
|
4880
|
+
if (isLatex) {
|
|
4881
|
+
renderedHtml = decorateStudioLatexRenderedHtml(
|
|
4882
|
+
renderedHtml,
|
|
4883
|
+
sourcePath,
|
|
4884
|
+
resourcePath,
|
|
4885
|
+
latexSubfigurePreviewTransform.subfigureGroups,
|
|
4886
|
+
latexAlgorithmPreviewTransform.algorithmBlocks,
|
|
4887
|
+
);
|
|
4888
|
+
} else {
|
|
4889
|
+
renderedHtml = decorateStudioPreviewPageBreakHtml(renderedHtml);
|
|
4890
|
+
}
|
|
4891
|
+
renderedHtml = decorateStudioPandocSyntaxHtml(renderedHtml);
|
|
4892
|
+
return stripMathMlAnnotationTags(renderedHtml);
|
|
4772
4893
|
}
|
|
4773
4894
|
|
|
4774
4895
|
function escapeStudioRegExpLiteral(text: string): string {
|
|
@@ -5160,54 +5281,22 @@ ${literalPdfConfig.fontSizeCommand}\\section*{${title.replace(/[{}\\]/g, "").tri
|
|
|
5160
5281
|
await writeFile(texPath, texDocument, "utf-8");
|
|
5161
5282
|
|
|
5162
5283
|
try {
|
|
5163
|
-
await
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
const fail = (error: Error) => {
|
|
5174
|
-
if (settled) return;
|
|
5175
|
-
settled = true;
|
|
5176
|
-
reject(error);
|
|
5177
|
-
};
|
|
5178
|
-
|
|
5179
|
-
child.stdout.on("data", (chunk: Buffer | string) => {
|
|
5180
|
-
stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
5181
|
-
});
|
|
5182
|
-
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
5183
|
-
stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
5184
|
-
});
|
|
5185
|
-
|
|
5186
|
-
child.once("error", (error) => {
|
|
5187
|
-
const errno = error as NodeJS.ErrnoException;
|
|
5188
|
-
if (errno.code === "ENOENT") {
|
|
5189
|
-
fail(new Error(
|
|
5190
|
-
`${pdfEngine} was not found. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE.`,
|
|
5191
|
-
));
|
|
5192
|
-
return;
|
|
5193
|
-
}
|
|
5194
|
-
fail(error);
|
|
5195
|
-
});
|
|
5196
|
-
|
|
5197
|
-
child.once("close", (code) => {
|
|
5198
|
-
if (settled) return;
|
|
5199
|
-
if (code === 0) {
|
|
5200
|
-
settled = true;
|
|
5201
|
-
resolve();
|
|
5202
|
-
return;
|
|
5203
|
-
}
|
|
5204
|
-
const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
|
|
5205
|
-
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
|
|
5206
|
-
const errorMatch = stdout.match(/^! .+$/m);
|
|
5207
|
-
const hint = errorMatch ? `: ${errorMatch[0]}` : (stderr ? `: ${stderr}` : "");
|
|
5208
|
-
fail(new Error(`${pdfEngine} literal-text PDF export failed with exit code ${code}${hint}`));
|
|
5209
|
-
});
|
|
5284
|
+
const latexResult = await runStudioSubprocess(pdfEngine, [
|
|
5285
|
+
"-interaction=nonstopmode",
|
|
5286
|
+
"-halt-on-error",
|
|
5287
|
+
"-file-line-error",
|
|
5288
|
+
"input.tex",
|
|
5289
|
+
], {
|
|
5290
|
+
cwd: tempDir,
|
|
5291
|
+
timeoutMs: STUDIO_LATEX_TIMEOUT_MS,
|
|
5292
|
+
label: `${pdfEngine} literal-text PDF export`,
|
|
5293
|
+
notFoundMessage: `${pdfEngine} was not found. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE.`,
|
|
5210
5294
|
});
|
|
5295
|
+
if (latexResult.code !== 0) {
|
|
5296
|
+
const errorMatch = latexResult.stdout.match(/^! .+$/m);
|
|
5297
|
+
const hint = errorMatch ? `: ${errorMatch[0]}` : (latexResult.stderr ? `: ${latexResult.stderr}` : "");
|
|
5298
|
+
throw new Error(`${pdfEngine} literal-text PDF export failed with exit code ${latexResult.code}${hint}`);
|
|
5299
|
+
}
|
|
5211
5300
|
|
|
5212
5301
|
return await readFile(outputPath);
|
|
5213
5302
|
} finally {
|
|
@@ -5469,46 +5558,18 @@ async function renderStudioPdfFromGeneratedLatex(
|
|
|
5469
5558
|
const pandocSource = inputFormat === "latex" ? markdown : normalizeStudioMarkdownFencedBlocks(markdown);
|
|
5470
5559
|
|
|
5471
5560
|
try {
|
|
5472
|
-
await
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
reject(error);
|
|
5481
|
-
};
|
|
5482
|
-
|
|
5483
|
-
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
5484
|
-
stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
5485
|
-
});
|
|
5486
|
-
|
|
5487
|
-
child.once("error", (error) => {
|
|
5488
|
-
const errno = error as NodeJS.ErrnoException;
|
|
5489
|
-
if (errno.code === "ENOENT") {
|
|
5490
|
-
const commandHint = pandocCommand === "pandoc"
|
|
5491
|
-
? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
|
|
5492
|
-
: `${pandocCommand} was not found. Check PANDOC_PATH.`;
|
|
5493
|
-
fail(new Error(commandHint));
|
|
5494
|
-
return;
|
|
5495
|
-
}
|
|
5496
|
-
fail(error);
|
|
5497
|
-
});
|
|
5498
|
-
|
|
5499
|
-
child.once("close", (code) => {
|
|
5500
|
-
if (settled) return;
|
|
5501
|
-
if (code === 0) {
|
|
5502
|
-
settled = true;
|
|
5503
|
-
resolve();
|
|
5504
|
-
return;
|
|
5505
|
-
}
|
|
5506
|
-
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
|
|
5507
|
-
fail(new Error(`pandoc LaTeX generation failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
|
|
5508
|
-
});
|
|
5509
|
-
|
|
5510
|
-
child.stdin.end(pandocSource);
|
|
5561
|
+
const pandocResult = await runStudioSubprocess(pandocCommand, pandocArgs, {
|
|
5562
|
+
cwd: pandocWorkingDir,
|
|
5563
|
+
input: pandocSource,
|
|
5564
|
+
timeoutMs: STUDIO_PANDOC_TIMEOUT_MS,
|
|
5565
|
+
label: "pandoc LaTeX generation",
|
|
5566
|
+
notFoundMessage: pandocCommand === "pandoc"
|
|
5567
|
+
? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
|
|
5568
|
+
: `${pandocCommand} was not found. Check PANDOC_PATH.`,
|
|
5511
5569
|
});
|
|
5570
|
+
if (pandocResult.code !== 0) {
|
|
5571
|
+
throw new Error(`pandoc LaTeX generation failed with exit code ${pandocResult.code}${pandocResult.stderr ? `: ${pandocResult.stderr}` : ""}`);
|
|
5572
|
+
}
|
|
5512
5573
|
|
|
5513
5574
|
const generatedLatex = await readFile(latexPath, "utf-8");
|
|
5514
5575
|
const injectedLatex = injectStudioLatexPdfSubfigureBlocks(generatedLatex, subfigureGroups, sourcePath, resourcePath);
|
|
@@ -5519,55 +5580,23 @@ async function renderStudioPdfFromGeneratedLatex(
|
|
|
5519
5580
|
const normalizedLatex = normalizeStudioGeneratedFigureCaptions(alignedReadyLatex);
|
|
5520
5581
|
await writeFile(latexPath, normalizedLatex, "utf-8");
|
|
5521
5582
|
|
|
5522
|
-
await
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
5527
|
-
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
const fail = (error: Error) => {
|
|
5534
|
-
if (settled) return;
|
|
5535
|
-
settled = true;
|
|
5536
|
-
reject(error);
|
|
5537
|
-
};
|
|
5538
|
-
|
|
5539
|
-
child.stdout.on("data", (chunk: Buffer | string) => {
|
|
5540
|
-
stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
5541
|
-
});
|
|
5542
|
-
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
5543
|
-
stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
5544
|
-
});
|
|
5545
|
-
|
|
5546
|
-
child.once("error", (error) => {
|
|
5547
|
-
const errno = error as NodeJS.ErrnoException;
|
|
5548
|
-
if (errno.code === "ENOENT") {
|
|
5549
|
-
fail(new Error(
|
|
5550
|
-
`${pdfEngine} was not found. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE.`,
|
|
5551
|
-
));
|
|
5552
|
-
return;
|
|
5553
|
-
}
|
|
5554
|
-
fail(error);
|
|
5555
|
-
});
|
|
5556
|
-
|
|
5557
|
-
child.once("close", (code) => {
|
|
5558
|
-
if (settled) return;
|
|
5559
|
-
if (code === 0) {
|
|
5560
|
-
settled = true;
|
|
5561
|
-
resolve();
|
|
5562
|
-
return;
|
|
5563
|
-
}
|
|
5564
|
-
const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
|
|
5565
|
-
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
|
|
5566
|
-
const errorMatch = stdout.match(/^! .+$/m);
|
|
5567
|
-
const hint = errorMatch ? `: ${errorMatch[0]}` : (stderr ? `: ${stderr}` : "");
|
|
5568
|
-
fail(new Error(`${pdfEngine} PDF export failed with exit code ${code}${hint}`));
|
|
5569
|
-
});
|
|
5583
|
+
const latexResult = await runStudioSubprocess(pdfEngine, [
|
|
5584
|
+
"-interaction=nonstopmode",
|
|
5585
|
+
"-halt-on-error",
|
|
5586
|
+
"-file-line-error",
|
|
5587
|
+
`-output-directory=${tempDir}`,
|
|
5588
|
+
latexPath,
|
|
5589
|
+
], {
|
|
5590
|
+
cwd: pandocWorkingDir,
|
|
5591
|
+
timeoutMs: STUDIO_LATEX_TIMEOUT_MS,
|
|
5592
|
+
label: `${pdfEngine} PDF export`,
|
|
5593
|
+
notFoundMessage: `${pdfEngine} was not found. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE.`,
|
|
5570
5594
|
});
|
|
5595
|
+
if (latexResult.code !== 0) {
|
|
5596
|
+
const errorMatch = latexResult.stdout.match(/^! .+$/m);
|
|
5597
|
+
const hint = errorMatch ? `: ${errorMatch[0]}` : (latexResult.stderr ? `: ${latexResult.stderr}` : "");
|
|
5598
|
+
throw new Error(`${pdfEngine} PDF export failed with exit code ${latexResult.code}${hint}`);
|
|
5599
|
+
}
|
|
5571
5600
|
|
|
5572
5601
|
return { pdf: await readFile(outputPath) };
|
|
5573
5602
|
} finally {
|
|
@@ -5627,6 +5656,7 @@ async function renderStudioPdfWithPandoc(
|
|
|
5627
5656
|
"-f", inputFormat,
|
|
5628
5657
|
"-o", outputPath,
|
|
5629
5658
|
`--pdf-engine=${pdfEngine}`,
|
|
5659
|
+
...buildStudioPandocPdfEngineOptArgs(pdfEngine),
|
|
5630
5660
|
...buildStudioPdfPandocVariableArgs(pdfOptions, inputFormat !== "latex"),
|
|
5631
5661
|
"-V", "urlcolor=blue",
|
|
5632
5662
|
"-V", "linkcolor=blue",
|
|
@@ -5636,49 +5666,22 @@ async function renderStudioPdfWithPandoc(
|
|
|
5636
5666
|
if (resourcePath) args.push(`--resource-path=${resourcePath}`);
|
|
5637
5667
|
|
|
5638
5668
|
try {
|
|
5639
|
-
await
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
reject(error);
|
|
5648
|
-
};
|
|
5649
|
-
|
|
5650
|
-
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
5651
|
-
stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
5652
|
-
});
|
|
5653
|
-
|
|
5654
|
-
child.once("error", (error) => {
|
|
5655
|
-
const errno = error as NodeJS.ErrnoException;
|
|
5656
|
-
if (errno.code === "ENOENT") {
|
|
5657
|
-
const commandHint = pandocCommand === "pandoc"
|
|
5658
|
-
? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
|
|
5659
|
-
: `${pandocCommand} was not found. Check PANDOC_PATH.`;
|
|
5660
|
-
fail(new Error(commandHint));
|
|
5661
|
-
return;
|
|
5662
|
-
}
|
|
5663
|
-
fail(error);
|
|
5664
|
-
});
|
|
5665
|
-
|
|
5666
|
-
child.once("close", (code) => {
|
|
5667
|
-
if (settled) return;
|
|
5668
|
-
if (code === 0) {
|
|
5669
|
-
settled = true;
|
|
5670
|
-
resolve();
|
|
5671
|
-
return;
|
|
5672
|
-
}
|
|
5673
|
-
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
|
|
5674
|
-
const hint = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
|
|
5675
|
-
? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
|
|
5676
|
-
: "";
|
|
5677
|
-
fail(new Error(`pandoc PDF export failed with exit code ${code}${stderr ? `: ${stderr}` : ""}${hint}`));
|
|
5678
|
-
});
|
|
5679
|
-
|
|
5680
|
-
child.stdin.end(pandocSource);
|
|
5669
|
+
const pandocResult = await runStudioSubprocess(pandocCommand, args, {
|
|
5670
|
+
cwd: pandocWorkingDir,
|
|
5671
|
+
input: pandocSource,
|
|
5672
|
+
timeoutMs: STUDIO_PANDOC_TIMEOUT_MS,
|
|
5673
|
+
label: "pandoc PDF export",
|
|
5674
|
+
notFoundMessage: pandocCommand === "pandoc"
|
|
5675
|
+
? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
|
|
5676
|
+
: `${pandocCommand} was not found. Check PANDOC_PATH.`,
|
|
5681
5677
|
});
|
|
5678
|
+
if (pandocResult.code !== 0) {
|
|
5679
|
+
const stderr = pandocResult.stderr;
|
|
5680
|
+
const hint = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
|
|
5681
|
+
? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
|
|
5682
|
+
: "";
|
|
5683
|
+
throw new Error(`pandoc PDF export failed with exit code ${pandocResult.code}${stderr ? `: ${stderr}` : ""}${hint}`);
|
|
5684
|
+
}
|
|
5682
5685
|
|
|
5683
5686
|
return { pdf: await readFile(outputPath), warning };
|
|
5684
5687
|
} finally {
|
|
@@ -5780,6 +5783,7 @@ async function renderStudioPdfWithPandoc(
|
|
|
5780
5783
|
"-f", inputFormat,
|
|
5781
5784
|
"-o", outputPath,
|
|
5782
5785
|
`--pdf-engine=${pdfEngine}`,
|
|
5786
|
+
...buildStudioPandocPdfEngineOptArgs(pdfEngine),
|
|
5783
5787
|
...buildStudioPdfPandocVariableArgs(pdfOptions, !isLatex),
|
|
5784
5788
|
"-V", "urlcolor=blue",
|
|
5785
5789
|
"-V", "linkcolor=blue",
|
|
@@ -5790,49 +5794,22 @@ async function renderStudioPdfWithPandoc(
|
|
|
5790
5794
|
const pandocSource = isLatex ? markdownForPdf : normalizeStudioMarkdownFencedBlocks(markdownForPdf);
|
|
5791
5795
|
|
|
5792
5796
|
try {
|
|
5793
|
-
await
|
|
5794
|
-
|
|
5795
|
-
|
|
5796
|
-
|
|
5797
|
-
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
|
|
5801
|
-
reject(error);
|
|
5802
|
-
};
|
|
5803
|
-
|
|
5804
|
-
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
5805
|
-
stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
5806
|
-
});
|
|
5807
|
-
|
|
5808
|
-
child.once("error", (error) => {
|
|
5809
|
-
const errno = error as NodeJS.ErrnoException;
|
|
5810
|
-
if (errno.code === "ENOENT") {
|
|
5811
|
-
const commandHint = pandocCommand === "pandoc"
|
|
5812
|
-
? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
|
|
5813
|
-
: `${pandocCommand} was not found. Check PANDOC_PATH.`;
|
|
5814
|
-
fail(new Error(commandHint));
|
|
5815
|
-
return;
|
|
5816
|
-
}
|
|
5817
|
-
fail(error);
|
|
5818
|
-
});
|
|
5819
|
-
|
|
5820
|
-
child.once("close", (code) => {
|
|
5821
|
-
if (settled) return;
|
|
5822
|
-
if (code === 0) {
|
|
5823
|
-
settled = true;
|
|
5824
|
-
resolve();
|
|
5825
|
-
return;
|
|
5826
|
-
}
|
|
5827
|
-
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
|
|
5828
|
-
const hint = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
|
|
5829
|
-
? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
|
|
5830
|
-
: "";
|
|
5831
|
-
fail(new Error(`pandoc PDF export failed with exit code ${code}${stderr ? `: ${stderr}` : ""}${hint}`));
|
|
5832
|
-
});
|
|
5833
|
-
|
|
5834
|
-
child.stdin.end(pandocSource);
|
|
5797
|
+
const pandocResult = await runStudioSubprocess(pandocCommand, args, {
|
|
5798
|
+
cwd: pandocWorkingDir,
|
|
5799
|
+
input: pandocSource,
|
|
5800
|
+
timeoutMs: STUDIO_PANDOC_TIMEOUT_MS,
|
|
5801
|
+
label: "pandoc PDF export",
|
|
5802
|
+
notFoundMessage: pandocCommand === "pandoc"
|
|
5803
|
+
? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
|
|
5804
|
+
: `${pandocCommand} was not found. Check PANDOC_PATH.`,
|
|
5835
5805
|
});
|
|
5806
|
+
if (pandocResult.code !== 0) {
|
|
5807
|
+
const stderr = pandocResult.stderr;
|
|
5808
|
+
const hint = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
|
|
5809
|
+
? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
|
|
5810
|
+
: "";
|
|
5811
|
+
throw new Error(`pandoc PDF export failed with exit code ${pandocResult.code}${stderr ? `: ${stderr}` : ""}${hint}`);
|
|
5812
|
+
}
|
|
5836
5813
|
|
|
5837
5814
|
return { pdf: await readFile(outputPath), warning: mermaidPrepared.warning };
|
|
5838
5815
|
} finally {
|
|
@@ -7031,7 +7008,7 @@ function summarizeStudioTraceToolArgs(toolName: string, args: unknown): string |
|
|
|
7031
7008
|
if (normalizedTool === "read" || normalizedTool === "write" || normalizedTool === "edit") {
|
|
7032
7009
|
return trimSummary(typeof payload.path === "string" ? payload.path : "");
|
|
7033
7010
|
}
|
|
7034
|
-
if (normalizedTool === "repl_send") {
|
|
7011
|
+
if (normalizedTool === "repl_send" || normalizedTool === "studio_repl_send") {
|
|
7035
7012
|
return trimSummary(typeof payload.code === "string" ? payload.code : "");
|
|
7036
7013
|
}
|
|
7037
7014
|
try {
|
|
@@ -7158,6 +7135,23 @@ function listStudioReplSessions(): { tmuxAvailable: boolean; sessions: StudioRep
|
|
|
7158
7135
|
return { tmuxAvailable: true, sessions };
|
|
7159
7136
|
}
|
|
7160
7137
|
|
|
7138
|
+
function getStudioReplPromptPrefix(line: string): string {
|
|
7139
|
+
const source = String(line || "");
|
|
7140
|
+
const match = source.match(/^(\s*(?:(?:In \[\d+\]:)|(?:\.\.\.)|(?:>>>)|(?:julia>)|(?:ghci>)|(?:Prelude>)|(?:\*?[A-Za-z0-9_.:]+>)|(?:[^\s>]+=>)|(?:>)|(?:\+))\s*)/);
|
|
7141
|
+
return match ? (match[1] || "") : "";
|
|
7142
|
+
}
|
|
7143
|
+
|
|
7144
|
+
function sanitizeStudioReplTranscript(transcript: string): string {
|
|
7145
|
+
let value = String(transcript || "");
|
|
7146
|
+
for (const [sourceFile, label] of studioReplControlSubmissionLabels) {
|
|
7147
|
+
if (!value.includes(sourceFile)) continue;
|
|
7148
|
+
const escaped = sourceFile.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7149
|
+
const linePattern = new RegExp(`^.*${escaped}.*$`, "gm");
|
|
7150
|
+
value = value.replace(linePattern, (line) => `${getStudioReplPromptPrefix(line)}${label}`.trimEnd());
|
|
7151
|
+
}
|
|
7152
|
+
return value.replace(/[\t ]+$/gm, "").trimEnd();
|
|
7153
|
+
}
|
|
7154
|
+
|
|
7161
7155
|
function captureStudioReplSession(sessionName: string): { ok: true; transcript: string; session: StudioReplSessionInfo } | { ok: false; message: string } {
|
|
7162
7156
|
if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
|
|
7163
7157
|
const inferred = inferStudioReplSessionRuntime(sessionName);
|
|
@@ -7168,9 +7162,9 @@ function captureStudioReplSession(sessionName: string): { ok: true; transcript:
|
|
|
7168
7162
|
label: formatStudioReplSessionLabel(sessionName, inferred.runtime, inferred.source),
|
|
7169
7163
|
source: inferred.source,
|
|
7170
7164
|
};
|
|
7171
|
-
const result = runStudioTmux(["capture-pane", "-p", "-t", session.target, "-S", `-${STUDIO_REPL_CAPTURE_LINES}`], { timeout: 3_000 });
|
|
7165
|
+
const result = runStudioTmux(["capture-pane", "-J", "-p", "-t", session.target, "-S", `-${STUDIO_REPL_CAPTURE_LINES}`], { timeout: 3_000 });
|
|
7172
7166
|
if (!result.ok) return { ok: false, message: result.message };
|
|
7173
|
-
return { ok: true, transcript: result.stdout
|
|
7167
|
+
return { ok: true, transcript: sanitizeStudioReplTranscript(result.stdout), session };
|
|
7174
7168
|
}
|
|
7175
7169
|
|
|
7176
7170
|
function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options?: { newSession?: boolean }): { ok: true; session: StudioReplSessionInfo; message: string } | { ok: false; message: string } {
|
|
@@ -7218,32 +7212,298 @@ function stopStudioReplSession(sessionName: string): { ok: true; message: string
|
|
|
7218
7212
|
return { ok: true, message: `Stopped ${sessionName}.` };
|
|
7219
7213
|
}
|
|
7220
7214
|
|
|
7221
|
-
|
|
7222
|
-
|
|
7215
|
+
type StudioReplControlFiles = {
|
|
7216
|
+
dir: string;
|
|
7217
|
+
sourceFile: string;
|
|
7218
|
+
doneFile: string;
|
|
7219
|
+
};
|
|
7220
|
+
|
|
7221
|
+
type StudioReplPreparedSubmission = {
|
|
7222
|
+
runtime: StudioReplRuntime | "unknown";
|
|
7223
|
+
usedControlFile: boolean;
|
|
7224
|
+
submissionText: string;
|
|
7225
|
+
controlFiles?: StudioReplControlFiles;
|
|
7226
|
+
};
|
|
7227
|
+
|
|
7228
|
+
type StudioReplSendSuccess = {
|
|
7229
|
+
ok: true;
|
|
7230
|
+
message: string;
|
|
7231
|
+
runtime: StudioReplRuntime | "unknown";
|
|
7232
|
+
usedControlFile: boolean;
|
|
7233
|
+
submissionText: string;
|
|
7234
|
+
controlFiles?: StudioReplControlFiles;
|
|
7235
|
+
};
|
|
7236
|
+
|
|
7237
|
+
type StudioReplSendFailure = { ok: false; message: string };
|
|
7238
|
+
|
|
7239
|
+
function sleep(ms: number): Promise<void> {
|
|
7240
|
+
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
7241
|
+
}
|
|
7242
|
+
|
|
7243
|
+
function clampStudioReplSendTimeout(timeoutMs: number | undefined): number {
|
|
7244
|
+
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) return STUDIO_REPL_SEND_DEFAULT_TIMEOUT_MS;
|
|
7245
|
+
return Math.max(1_000, Math.min(STUDIO_REPL_SEND_MAX_TIMEOUT_MS, Math.round(timeoutMs)));
|
|
7246
|
+
}
|
|
7247
|
+
|
|
7248
|
+
function shellQuote(value: string): string {
|
|
7249
|
+
return `'${String(value || "").replace(/'/g, `'"'"'`)}'`;
|
|
7250
|
+
}
|
|
7251
|
+
|
|
7252
|
+
function getStudioReplControlFiles(sessionName: string, runtime: StudioReplRuntime | "unknown"): StudioReplControlFiles {
|
|
7253
|
+
const safeSession = sessionName.replace(/[^-_.A-Za-z0-9]+/g, "_");
|
|
7254
|
+
const safeRuntime = String(runtime || "repl").replace(/[^-_.A-Za-z0-9]+/g, "_");
|
|
7255
|
+
const dir = join(STUDIO_REPL_CONTROL_ROOT, safeSession, randomUUID().replace(/-/g, ""));
|
|
7256
|
+
const extension = runtime === "julia"
|
|
7257
|
+
? "jl"
|
|
7258
|
+
: runtime === "r"
|
|
7259
|
+
? "R"
|
|
7260
|
+
: runtime === "ghci"
|
|
7261
|
+
? "ghci"
|
|
7262
|
+
: runtime === "clojure"
|
|
7263
|
+
? "clj"
|
|
7264
|
+
: "py";
|
|
7265
|
+
return {
|
|
7266
|
+
dir,
|
|
7267
|
+
sourceFile: join(dir, `studio-repl-${safeRuntime}.${extension}`),
|
|
7268
|
+
doneFile: join(dir, "done.flag"),
|
|
7269
|
+
};
|
|
7270
|
+
}
|
|
7271
|
+
|
|
7272
|
+
function buildStudioPythonControlSource(runtime: "python" | "ipython", code: string, doneFile: string): string {
|
|
7273
|
+
if (runtime === "ipython") {
|
|
7274
|
+
return [
|
|
7275
|
+
"from pathlib import Path as __pi_studio_path",
|
|
7276
|
+
"import traceback as __pi_studio_traceback",
|
|
7277
|
+
"try:",
|
|
7278
|
+
" __pi_studio_ip = get_ipython()",
|
|
7279
|
+
" if __pi_studio_ip is None:",
|
|
7280
|
+
" raise RuntimeError('Expected IPython session, but get_ipython() returned None.')",
|
|
7281
|
+
` __pi_studio_result = __pi_studio_ip.run_cell(${JSON.stringify(code)}, store_history=False)`,
|
|
7282
|
+
" if getattr(__pi_studio_result, 'error_in_exec', None) is None and getattr(__pi_studio_result, 'result', None) is not None:",
|
|
7283
|
+
" print(repr(__pi_studio_result.result))",
|
|
7284
|
+
"except Exception:",
|
|
7285
|
+
" __pi_studio_traceback.print_exc()",
|
|
7286
|
+
"finally:",
|
|
7287
|
+
` __pi_studio_path(${JSON.stringify(doneFile)}).write_text('done\\n', encoding='utf-8')`,
|
|
7288
|
+
].join("\n");
|
|
7289
|
+
}
|
|
7290
|
+
|
|
7291
|
+
return [
|
|
7292
|
+
"from pathlib import Path as __pi_studio_path",
|
|
7293
|
+
"import traceback as __pi_studio_traceback",
|
|
7294
|
+
`__pi_studio_code = ${JSON.stringify(code)}`,
|
|
7295
|
+
"try:",
|
|
7296
|
+
" try:",
|
|
7297
|
+
" __pi_studio_expr = compile(__pi_studio_code, '<pi-studio-repl>', 'eval')",
|
|
7298
|
+
" except SyntaxError:",
|
|
7299
|
+
" exec(compile(__pi_studio_code, '<pi-studio-repl>', 'exec'), globals())",
|
|
7300
|
+
" else:",
|
|
7301
|
+
" __pi_studio_value = eval(__pi_studio_expr, globals())",
|
|
7302
|
+
" if __pi_studio_value is not None:",
|
|
7303
|
+
" print(repr(__pi_studio_value))",
|
|
7304
|
+
"except Exception:",
|
|
7305
|
+
" __pi_studio_traceback.print_exc()",
|
|
7306
|
+
"finally:",
|
|
7307
|
+
` __pi_studio_path(${JSON.stringify(doneFile)}).write_text('done\\n', encoding='utf-8')`,
|
|
7308
|
+
].join("\n");
|
|
7309
|
+
}
|
|
7310
|
+
|
|
7311
|
+
function buildStudioJuliaControlSource(code: string, doneFile: string): string {
|
|
7312
|
+
return [
|
|
7313
|
+
"try",
|
|
7314
|
+
` local __pi_studio_result = Base.include_string(Main, ${JSON.stringify(code)}, "pi-studio-repl")`,
|
|
7315
|
+
" if !isnothing(__pi_studio_result)",
|
|
7316
|
+
" println(repr(__pi_studio_result))",
|
|
7317
|
+
" end",
|
|
7318
|
+
"catch e",
|
|
7319
|
+
" Base.display_error(stderr, e, catch_backtrace())",
|
|
7320
|
+
"finally",
|
|
7321
|
+
` write(${JSON.stringify(doneFile)}, "done\\n")`,
|
|
7322
|
+
"end",
|
|
7323
|
+
].join("\n");
|
|
7324
|
+
}
|
|
7325
|
+
|
|
7326
|
+
function buildStudioRControlSource(code: string, doneFile: string): string {
|
|
7327
|
+
return [
|
|
7328
|
+
"local({",
|
|
7329
|
+
` .__pi_studio_done_file <- ${JSON.stringify(doneFile)}`,
|
|
7330
|
+
` .__pi_studio_code <- ${JSON.stringify(code)}`,
|
|
7331
|
+
" tryCatch({",
|
|
7332
|
+
" .__pi_studio_exprs <- parse(text = .__pi_studio_code, keep.source = FALSE)",
|
|
7333
|
+
" .__pi_studio_value <- NULL",
|
|
7334
|
+
" .__pi_studio_visible <- FALSE",
|
|
7335
|
+
" for (.__pi_studio_expr in .__pi_studio_exprs) {",
|
|
7336
|
+
" .__pi_studio_result <- withVisible(eval(.__pi_studio_expr, envir = .GlobalEnv))",
|
|
7337
|
+
" .__pi_studio_value <- .__pi_studio_result$value",
|
|
7338
|
+
" .__pi_studio_visible <- isTRUE(.__pi_studio_result$visible)",
|
|
7339
|
+
" }",
|
|
7340
|
+
" if (.__pi_studio_visible) print(.__pi_studio_value)",
|
|
7341
|
+
" }, error = function(e) {",
|
|
7342
|
+
" .__pi_studio_call <- conditionCall(e)",
|
|
7343
|
+
" if (is.null(.__pi_studio_call)) {",
|
|
7344
|
+
" message(\"Error: \", conditionMessage(e))",
|
|
7345
|
+
" } else {",
|
|
7346
|
+
" message(\"Error in \", paste(deparse(.__pi_studio_call), collapse = \" \"), \": \", conditionMessage(e))",
|
|
7347
|
+
" }",
|
|
7348
|
+
" }, finally = {",
|
|
7349
|
+
" writeLines(\"done\", .__pi_studio_done_file)",
|
|
7350
|
+
" })",
|
|
7351
|
+
"})",
|
|
7352
|
+
].join("\n");
|
|
7353
|
+
}
|
|
7354
|
+
|
|
7355
|
+
function buildStudioClojureControlSource(code: string, doneFile: string): string {
|
|
7356
|
+
return [
|
|
7357
|
+
"(let [code " + JSON.stringify(code) + "]",
|
|
7358
|
+
" (try",
|
|
7359
|
+
" (let [rdr (clojure.lang.LineNumberingPushbackReader. (java.io.StringReader. code))]",
|
|
7360
|
+
" (loop [last-val nil has-val false]",
|
|
7361
|
+
" (let [form (read rdr false :pi-studio/eof)]",
|
|
7362
|
+
" (if (= form :pi-studio/eof)",
|
|
7363
|
+
" (when (and has-val (some? last-val)) (prn last-val))",
|
|
7364
|
+
" (recur (eval form) true)))))",
|
|
7365
|
+
" (catch Throwable t",
|
|
7366
|
+
" (#'clojure.main/repl-caught t))",
|
|
7367
|
+
" (finally",
|
|
7368
|
+
` (spit ${JSON.stringify(doneFile)} "done\\n"))))`,
|
|
7369
|
+
].join("\n");
|
|
7370
|
+
}
|
|
7371
|
+
|
|
7372
|
+
function buildStudioReplControlSource(runtime: StudioReplRuntime, code: string, doneFile: string): string | null {
|
|
7373
|
+
if (runtime === "python" || runtime === "ipython") return buildStudioPythonControlSource(runtime, code, doneFile);
|
|
7374
|
+
if (runtime === "julia") return buildStudioJuliaControlSource(code, doneFile);
|
|
7375
|
+
if (runtime === "r") return buildStudioRControlSource(code, doneFile);
|
|
7376
|
+
if (runtime === "ghci") return `${code.replace(/\r/g, "").trimEnd()}\n:! touch ${shellQuote(doneFile)}\n`;
|
|
7377
|
+
if (runtime === "clojure") return buildStudioClojureControlSource(code, doneFile);
|
|
7378
|
+
return null;
|
|
7379
|
+
}
|
|
7380
|
+
|
|
7381
|
+
function buildStudioReplSubmissionLine(runtime: StudioReplRuntime, sourceFile: string): string {
|
|
7382
|
+
const quotedPath = JSON.stringify(sourceFile);
|
|
7383
|
+
if (runtime === "julia") return `include(${quotedPath})`;
|
|
7384
|
+
if (runtime === "r") return `source(${quotedPath}, local=.GlobalEnv)`;
|
|
7385
|
+
if (runtime === "ghci") return `:script ${quotedPath}`;
|
|
7386
|
+
if (runtime === "clojure") return `(do (load-file ${quotedPath}) :pi-studio/silent)`;
|
|
7387
|
+
return `exec(open(${quotedPath}, encoding="utf-8").read(), globals())`;
|
|
7388
|
+
}
|
|
7389
|
+
|
|
7390
|
+
function buildStudioReplPreviewComment(runtime: StudioReplRuntime, code: string): string | undefined {
|
|
7391
|
+
const normalized = code.replace(/\r/g, "").trimEnd();
|
|
7392
|
+
const lineCount = normalized ? normalized.split("\n").length : 0;
|
|
7393
|
+
if (lineCount <= 1) return undefined;
|
|
7394
|
+
const prefix = runtime === "ghci" ? "--" : runtime === "clojure" ? ";;" : "#";
|
|
7395
|
+
return `${prefix} Studio sent ${lineCount}-line snippet`;
|
|
7396
|
+
}
|
|
7397
|
+
|
|
7398
|
+
function buildStudioReplDisplayLabel(runtime: StudioReplRuntime, code: string): string {
|
|
7399
|
+
const normalized = code.replace(/\r/g, "").trim();
|
|
7400
|
+
const singleLine = normalized && !normalized.includes("\n") ? normalized.replace(/\s+/g, " ") : "";
|
|
7401
|
+
if (singleLine && singleLine.length <= 140) return singleLine;
|
|
7402
|
+
return buildStudioReplPreviewComment(runtime, code) || "# Studio sent code";
|
|
7403
|
+
}
|
|
7404
|
+
|
|
7405
|
+
function rememberStudioReplControlSubmission(sourceFile: string, label: string): void {
|
|
7406
|
+
studioReplControlSubmissionLabels.set(sourceFile, label);
|
|
7407
|
+
while (studioReplControlSubmissionLabels.size > 300) {
|
|
7408
|
+
const oldest = studioReplControlSubmissionLabels.keys().next().value;
|
|
7409
|
+
if (!oldest) break;
|
|
7410
|
+
studioReplControlSubmissionLabels.delete(oldest);
|
|
7411
|
+
}
|
|
7412
|
+
}
|
|
7413
|
+
|
|
7414
|
+
function prepareStudioReplSubmission(sessionName: string, source: string): StudioReplPreparedSubmission {
|
|
7415
|
+
const normalizedSource = String(source || "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
7223
7416
|
const runtime = inferStudioReplSessionRuntime(sessionName).runtime;
|
|
7224
|
-
if (
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7417
|
+
if (runtime !== "unknown" && runtime !== "shell") {
|
|
7418
|
+
const controlFiles = getStudioReplControlFiles(sessionName, runtime);
|
|
7419
|
+
const controlSource = buildStudioReplControlSource(runtime, normalizedSource, controlFiles.doneFile);
|
|
7420
|
+
if (controlSource) {
|
|
7421
|
+
mkdirSync(controlFiles.dir, { recursive: true });
|
|
7422
|
+
try {
|
|
7423
|
+
unlinkSync(controlFiles.doneFile);
|
|
7424
|
+
} catch {
|
|
7425
|
+
// Ignore stale done file cleanup failures.
|
|
7426
|
+
}
|
|
7427
|
+
writeFileSync(controlFiles.sourceFile, controlSource, "utf-8");
|
|
7428
|
+
const submissionLine = buildStudioReplSubmissionLine(runtime, controlFiles.sourceFile);
|
|
7429
|
+
rememberStudioReplControlSubmission(controlFiles.sourceFile, buildStudioReplDisplayLabel(runtime, normalizedSource));
|
|
7430
|
+
return {
|
|
7431
|
+
runtime,
|
|
7432
|
+
usedControlFile: true,
|
|
7433
|
+
controlFiles,
|
|
7434
|
+
submissionText: submissionLine,
|
|
7435
|
+
};
|
|
7436
|
+
}
|
|
7229
7437
|
}
|
|
7230
|
-
|
|
7438
|
+
|
|
7439
|
+
return {
|
|
7440
|
+
runtime,
|
|
7441
|
+
usedControlFile: false,
|
|
7442
|
+
submissionText: normalizedSource.replace(/\n+$/, ""),
|
|
7443
|
+
};
|
|
7231
7444
|
}
|
|
7232
7445
|
|
|
7233
|
-
function
|
|
7446
|
+
function pasteTextToStudioReplPane(sessionName: string, text: string): { ok: true } | { ok: false; message: string } {
|
|
7447
|
+
const bufferName = `pi-studio-repl-${randomUUID().replace(/-/g, "")}`;
|
|
7448
|
+
const target = getStudioReplPaneTarget(sessionName);
|
|
7449
|
+
const loadResult = runStudioTmux(["load-buffer", "-b", bufferName, "-"], { input: text, timeout: 5_000 });
|
|
7450
|
+
if (!loadResult.ok) return { ok: false, message: loadResult.message || "Failed to load text into tmux buffer." };
|
|
7451
|
+
try {
|
|
7452
|
+
const pasteResult = runStudioTmux(["paste-buffer", "-d", "-b", bufferName, "-t", target], { timeout: 5_000 });
|
|
7453
|
+
if (!pasteResult.ok) return { ok: false, message: pasteResult.message || "Failed to paste text into REPL session." };
|
|
7454
|
+
const enterResult = runStudioTmux(["send-keys", "-t", target, "C-m"], { timeout: 5_000 });
|
|
7455
|
+
if (!enterResult.ok) return { ok: false, message: enterResult.message || "Failed to send Enter to REPL session." };
|
|
7456
|
+
return { ok: true };
|
|
7457
|
+
} finally {
|
|
7458
|
+
runStudioTmux(["delete-buffer", "-b", bufferName], { timeout: 2_000 });
|
|
7459
|
+
}
|
|
7460
|
+
}
|
|
7461
|
+
|
|
7462
|
+
function sendTextToStudioReplSession(sessionName: string, text: string): StudioReplSendSuccess | StudioReplSendFailure {
|
|
7234
7463
|
if (!/^[-_.A-Za-z0-9]+$/.test(sessionName)) return { ok: false, message: "Invalid REPL session name." };
|
|
7235
7464
|
const source = String(text || "");
|
|
7236
7465
|
if (!source.trim()) return { ok: false, message: "Editor text is empty." };
|
|
7237
7466
|
if (source.length > STUDIO_REPL_SEND_MAX_CHARS) {
|
|
7238
7467
|
return { ok: false, message: `REPL input is too large (${source.length} chars; max ${STUDIO_REPL_SEND_MAX_CHARS}).` };
|
|
7239
7468
|
}
|
|
7240
|
-
const
|
|
7241
|
-
const
|
|
7242
|
-
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
|
|
7246
|
-
|
|
7469
|
+
const prepared = prepareStudioReplSubmission(sessionName, source);
|
|
7470
|
+
const pasted = pasteTextToStudioReplPane(sessionName, prepared.submissionText);
|
|
7471
|
+
if (!pasted.ok) return { ok: false, message: pasted.message };
|
|
7472
|
+
return {
|
|
7473
|
+
ok: true,
|
|
7474
|
+
message: "Sent to REPL.",
|
|
7475
|
+
runtime: prepared.runtime,
|
|
7476
|
+
usedControlFile: prepared.usedControlFile,
|
|
7477
|
+
submissionText: prepared.submissionText,
|
|
7478
|
+
controlFiles: prepared.controlFiles,
|
|
7479
|
+
};
|
|
7480
|
+
}
|
|
7481
|
+
|
|
7482
|
+
function extractStudioReplTranscriptDelta(before: string, after: string): string {
|
|
7483
|
+
const previous = String(before || "");
|
|
7484
|
+
const current = String(after || "");
|
|
7485
|
+
if (!current) return "";
|
|
7486
|
+
if (!previous) return current.trim();
|
|
7487
|
+
const directIndex = current.indexOf(previous);
|
|
7488
|
+
if (directIndex >= 0) return current.slice(directIndex + previous.length).trim();
|
|
7489
|
+
const previousLines = previous.split("\n");
|
|
7490
|
+
for (let count = Math.min(previousLines.length, 40); count >= 1; count -= 1) {
|
|
7491
|
+
const suffix = previousLines.slice(previousLines.length - count).join("\n");
|
|
7492
|
+
if (!suffix.trim()) continue;
|
|
7493
|
+
const suffixIndex = current.indexOf(suffix);
|
|
7494
|
+
if (suffixIndex >= 0) return current.slice(suffixIndex + suffix.length).trim();
|
|
7495
|
+
}
|
|
7496
|
+
return current.trim();
|
|
7497
|
+
}
|
|
7498
|
+
|
|
7499
|
+
async function waitForStudioReplDoneFile(doneFile: string | undefined, timeoutMs: number): Promise<boolean> {
|
|
7500
|
+
if (!doneFile) return false;
|
|
7501
|
+
const deadline = Date.now() + clampStudioReplSendTimeout(timeoutMs);
|
|
7502
|
+
while (Date.now() < deadline) {
|
|
7503
|
+
if (existsSync(doneFile)) return true;
|
|
7504
|
+
await sleep(100);
|
|
7505
|
+
}
|
|
7506
|
+
return existsSync(doneFile);
|
|
7247
7507
|
}
|
|
7248
7508
|
|
|
7249
7509
|
function interruptStudioReplSession(sessionName: string): { ok: true; message: string } | { ok: false; message: string } {
|
|
@@ -7708,6 +7968,7 @@ ${cssVarsBlock}
|
|
|
7708
7968
|
<label class="file-label" title="Load a local file into editor text.">Load file content<input id="fileInput" type="file" accept=".md,.markdown,.mdx,.qmd,.js,.mjs,.cjs,.jsx,.ts,.mts,.cts,.tsx,.py,.pyw,.sh,.bash,.zsh,.json,.jsonc,.json5,.rs,.c,.h,.cpp,.cxx,.cc,.hpp,.hxx,.jl,.f90,.f95,.f03,.f,.for,.r,.R,.m,.tex,.latex,.diff,.patch,.java,.go,.rb,.swift,.html,.htm,.css,.xml,.yaml,.yml,.toml,.lua,.txt,.rst,.adoc" /></label>
|
|
7709
7969
|
<button id="loadGitDiffBtn" type="button" title="Load the current git diff from the Studio context into the editor.">Load git diff</button>
|
|
7710
7970
|
<button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
|
|
7971
|
+
<button id="zenModeBtn" class="zen-mode-btn" type="button" title="Hide secondary Studio controls.">⊙ Zen</button>
|
|
7711
7972
|
</div>
|
|
7712
7973
|
</header>
|
|
7713
7974
|
|
|
@@ -7745,8 +8006,8 @@ ${cssVarsBlock}
|
|
|
7745
8006
|
<button id="queueSteerBtn" type="button" title="Queue steering is available while Run editor text is active." disabled>Queue steering</button>
|
|
7746
8007
|
<button id="sendReplBtn" type="button" hidden title="Send the current selection, or the full editor text, to the active REPL session shown in the right pane.">Send to REPL</button>
|
|
7747
8008
|
<select id="replSendModeSelect" hidden aria-label="REPL send mode" title="Choose how Send to REPL interprets the editor text.">
|
|
7748
|
-
<option value="
|
|
7749
|
-
<option value="literate">Literate
|
|
8009
|
+
<option value="raw" selected>Send mode: Raw</option>
|
|
8010
|
+
<option value="literate">Send mode: Literate</option>
|
|
7750
8011
|
</select>
|
|
7751
8012
|
<button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy text</button>
|
|
7752
8013
|
<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>
|
|
@@ -8032,6 +8293,164 @@ export default function (pi: ExtensionAPI) {
|
|
|
8032
8293
|
let compactInProgress = false;
|
|
8033
8294
|
let compactRequestId: string | null = null;
|
|
8034
8295
|
|
|
8296
|
+
const selectStudioReplSessionForTool = (params: { sessionName?: string; target?: string }): { session: StudioReplSessionInfo | null; error?: string; sessions: StudioReplSessionInfo[] } => {
|
|
8297
|
+
const state = listStudioReplSessions();
|
|
8298
|
+
const sessions = state.sessions;
|
|
8299
|
+
if (!state.tmuxAvailable) return { session: null, error: "tmux is not available.", sessions };
|
|
8300
|
+
if (typeof params.sessionName === "string" && params.sessionName.trim()) {
|
|
8301
|
+
const requested = params.sessionName.trim();
|
|
8302
|
+
const session = sessions.find((candidate) => candidate.sessionName === requested) ?? null;
|
|
8303
|
+
return session
|
|
8304
|
+
? { session, sessions }
|
|
8305
|
+
: { session: null, error: `No Studio-visible REPL session named ${requested}.`, sessions };
|
|
8306
|
+
}
|
|
8307
|
+
const target = normalizeStudioReplRuntime(params.target);
|
|
8308
|
+
if (target) {
|
|
8309
|
+
const active = studioReplActiveSessionName
|
|
8310
|
+
? sessions.find((candidate) => candidate.sessionName === studioReplActiveSessionName && candidate.runtime === target)
|
|
8311
|
+
: null;
|
|
8312
|
+
const session = active ?? sessions.find((candidate) => candidate.runtime === target) ?? null;
|
|
8313
|
+
return session
|
|
8314
|
+
? { session, sessions }
|
|
8315
|
+
: { session: null, error: `No running Studio-visible ${STUDIO_REPL_RUNTIME_LABELS[target]} REPL session.`, sessions };
|
|
8316
|
+
}
|
|
8317
|
+
if (studioReplActiveSessionName) {
|
|
8318
|
+
const active = sessions.find((candidate) => candidate.sessionName === studioReplActiveSessionName);
|
|
8319
|
+
if (active) return { session: active, sessions };
|
|
8320
|
+
}
|
|
8321
|
+
return sessions[0]
|
|
8322
|
+
? { session: sessions[0], sessions }
|
|
8323
|
+
: { session: null, error: "No Studio-visible REPL sessions are running. Open Studio REPL view or start a session first.", sessions };
|
|
8324
|
+
};
|
|
8325
|
+
|
|
8326
|
+
const broadcastStudioReplToolSend = (payload: Record<string, unknown>) => {
|
|
8327
|
+
if (!serverState) return;
|
|
8328
|
+
const serialized = JSON.stringify({ type: "repl_tool_send", ...payload });
|
|
8329
|
+
for (const client of serverState.clients) {
|
|
8330
|
+
if (client.readyState !== WebSocket.OPEN) continue;
|
|
8331
|
+
try {
|
|
8332
|
+
client.send(serialized);
|
|
8333
|
+
} catch {
|
|
8334
|
+
// Ignore transport errors; close handler will clean up.
|
|
8335
|
+
}
|
|
8336
|
+
}
|
|
8337
|
+
};
|
|
8338
|
+
|
|
8339
|
+
pi.registerTool({
|
|
8340
|
+
name: "studio_repl_status",
|
|
8341
|
+
label: "Studio REPL status",
|
|
8342
|
+
description: "Inspect Studio-visible tmux REPL sessions and the active Studio REPL session.",
|
|
8343
|
+
promptSnippet: "Inspect the active Studio REPL session and other Studio-visible REPL sessions.",
|
|
8344
|
+
promptGuidelines: [
|
|
8345
|
+
"Use studio_repl_status before claiming whether a Studio REPL session is active if you are unsure.",
|
|
8346
|
+
"Use studio_repl_send, not raw tmux shell commands, when the user asks you to run code in the active Studio REPL.",
|
|
8347
|
+
],
|
|
8348
|
+
parameters: STUDIO_REPL_STATUS_TOOL_PARAMS,
|
|
8349
|
+
async execute(_toolCallId, params) {
|
|
8350
|
+
const selected = selectStudioReplSessionForTool({ sessionName: params.sessionName, target: params.target });
|
|
8351
|
+
const lines = [
|
|
8352
|
+
`Active Studio REPL: ${studioReplActiveSessionName || "none"}`,
|
|
8353
|
+
`tmux sessions visible to Studio: ${selected.sessions.length}`,
|
|
8354
|
+
];
|
|
8355
|
+
if (selected.error) lines.push(`Selection: ${selected.error}`);
|
|
8356
|
+
if (selected.session) {
|
|
8357
|
+
lines.push(`Selected: ${selected.session.sessionName} (${selected.session.runtime}, ${selected.session.source})`);
|
|
8358
|
+
}
|
|
8359
|
+
for (const session of selected.sessions) {
|
|
8360
|
+
lines.push(`- ${session.sessionName} | runtime=${session.runtime} | source=${session.source} | target=${session.target}`);
|
|
8361
|
+
}
|
|
8362
|
+
return {
|
|
8363
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
8364
|
+
details: {
|
|
8365
|
+
activeSessionName: studioReplActiveSessionName,
|
|
8366
|
+
selectedSession: selected.session,
|
|
8367
|
+
sessions: selected.sessions,
|
|
8368
|
+
} as Record<string, unknown>,
|
|
8369
|
+
};
|
|
8370
|
+
},
|
|
8371
|
+
});
|
|
8372
|
+
|
|
8373
|
+
pi.registerTool({
|
|
8374
|
+
name: "studio_repl_send",
|
|
8375
|
+
label: "Send to Studio REPL",
|
|
8376
|
+
description: "Execute code in the active or selected Studio tmux-backed REPL session using Studio's safe runtime-specific submission protocol.",
|
|
8377
|
+
promptSnippet: "Execute code in the active Studio REPL session safely, including multiline Python/R/Julia/GHCi/Clojure snippets.",
|
|
8378
|
+
promptGuidelines: [
|
|
8379
|
+
"Use studio_repl_send when the user asks to run code in the active Studio REPL.",
|
|
8380
|
+
"Do not improvise tmux paste-buffer commands for Studio REPL code; studio_repl_send handles multiline quoting and runtime-specific submission.",
|
|
8381
|
+
"If several REPL sessions of the same runtime are running, use studio_repl_status first or pass the exact sessionName when known.",
|
|
8382
|
+
],
|
|
8383
|
+
parameters: STUDIO_REPL_SEND_TOOL_PARAMS,
|
|
8384
|
+
executionMode: "sequential",
|
|
8385
|
+
async execute(toolCallId, params) {
|
|
8386
|
+
const selected = selectStudioReplSessionForTool({ sessionName: params.sessionName, target: params.target });
|
|
8387
|
+
if (!selected.session) {
|
|
8388
|
+
return {
|
|
8389
|
+
content: [{ type: "text", text: selected.error || "No Studio REPL session selected." }],
|
|
8390
|
+
details: { ok: false, error: selected.error || "No Studio REPL session selected.", sessions: selected.sessions } as Record<string, unknown>,
|
|
8391
|
+
};
|
|
8392
|
+
}
|
|
8393
|
+
|
|
8394
|
+
const before = captureStudioReplSession(selected.session.sessionName);
|
|
8395
|
+
const beforeTranscript = before.ok ? before.transcript : "";
|
|
8396
|
+
const sent = sendTextToStudioReplSession(selected.session.sessionName, params.code);
|
|
8397
|
+
if (!sent.ok) {
|
|
8398
|
+
return {
|
|
8399
|
+
content: [{ type: "text", text: sent.message }],
|
|
8400
|
+
details: { ok: false, error: sent.message, session: selected.session, sessions: selected.sessions } as Record<string, unknown>,
|
|
8401
|
+
};
|
|
8402
|
+
}
|
|
8403
|
+
studioReplActiveSessionName = selected.session.sessionName;
|
|
8404
|
+
|
|
8405
|
+
const timeoutMs = clampStudioReplSendTimeout(params.timeoutMs);
|
|
8406
|
+
let completed = false;
|
|
8407
|
+
if (sent.controlFiles?.doneFile) {
|
|
8408
|
+
completed = await waitForStudioReplDoneFile(sent.controlFiles.doneFile, timeoutMs);
|
|
8409
|
+
} else {
|
|
8410
|
+
await sleep(Math.min(750, timeoutMs));
|
|
8411
|
+
}
|
|
8412
|
+
const after = captureStudioReplSession(selected.session.sessionName);
|
|
8413
|
+
const afterTranscript = after.ok ? after.transcript : "";
|
|
8414
|
+
const output = extractStudioReplTranscriptDelta(beforeTranscript, afterTranscript);
|
|
8415
|
+
const statusLine = sent.controlFiles?.doneFile
|
|
8416
|
+
? (completed ? "Completed." : `Timed out after ${timeoutMs} ms waiting for completion marker.`)
|
|
8417
|
+
: "Submitted.";
|
|
8418
|
+
const text = [
|
|
8419
|
+
`${statusLine} ${sent.message}`,
|
|
8420
|
+
output ? "" : undefined,
|
|
8421
|
+
output || undefined,
|
|
8422
|
+
].filter(Boolean).join("\n");
|
|
8423
|
+
broadcastStudioReplToolSend({
|
|
8424
|
+
toolCallId,
|
|
8425
|
+
sessionName: selected.session.sessionName,
|
|
8426
|
+
runtime: sent.runtime === "unknown" ? selected.session.runtime : sent.runtime,
|
|
8427
|
+
code: params.code,
|
|
8428
|
+
label: "Pi",
|
|
8429
|
+
output,
|
|
8430
|
+
completed,
|
|
8431
|
+
timedOut: Boolean(sent.controlFiles?.doneFile && !completed),
|
|
8432
|
+
transcript: afterTranscript,
|
|
8433
|
+
capturedAt: Date.now(),
|
|
8434
|
+
});
|
|
8435
|
+
return {
|
|
8436
|
+
content: [{ type: "text", text }],
|
|
8437
|
+
details: {
|
|
8438
|
+
ok: true,
|
|
8439
|
+
completed,
|
|
8440
|
+
timedOut: Boolean(sent.controlFiles?.doneFile && !completed),
|
|
8441
|
+
timeoutMs,
|
|
8442
|
+
session: selected.session,
|
|
8443
|
+
sessions: selected.sessions,
|
|
8444
|
+
runtime: sent.runtime,
|
|
8445
|
+
usedControlFile: sent.usedControlFile,
|
|
8446
|
+
submissionText: sent.submissionText,
|
|
8447
|
+
controlFiles: sent.controlFiles,
|
|
8448
|
+
output,
|
|
8449
|
+
} as Record<string, unknown>,
|
|
8450
|
+
};
|
|
8451
|
+
},
|
|
8452
|
+
});
|
|
8453
|
+
|
|
8035
8454
|
const isStudioDirectRunChainActive = () => Boolean(studioDirectRunChain);
|
|
8036
8455
|
const getQueuedStudioSteeringCount = () => queuedStudioDirectRequests.length;
|
|
8037
8456
|
const getStudioClientCounts = (): { full: number; editorOnly: number } => {
|
|
@@ -11408,6 +11827,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
11408
11827
|
const outputPath = buildStudioResponseExportOutputPath(ctx.cwd, "pdf");
|
|
11409
11828
|
|
|
11410
11829
|
try {
|
|
11830
|
+
ctx.ui.notify("Exporting last response Studio PDF…", "info");
|
|
11411
11831
|
const { pdf, warning } = await renderStudioPdfWithPandoc(
|
|
11412
11832
|
response.markdown,
|
|
11413
11833
|
isLatex,
|
|
@@ -11465,6 +11885,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
11465
11885
|
const outputPath = buildStudioPdfOutputPath(file.resolvedPath);
|
|
11466
11886
|
|
|
11467
11887
|
try {
|
|
11888
|
+
ctx.ui.notify(`Exporting Studio PDF: ${outputPath}`, "info");
|
|
11468
11889
|
const { pdf, warning } = await renderStudioPdfWithPandoc(
|
|
11469
11890
|
file.text,
|
|
11470
11891
|
isLatex,
|