pi-studio 0.9.10 → 0.9.12
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 +22 -0
- package/README.md +1 -1
- package/client/studio-client.js +981 -70
- package/client/studio.css +224 -32
- package/index.ts +505 -32
- package/package.json +1 -1
package/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { completeSimple, type ThinkingLevel } from "@earendil-works/pi-ai";
|
|
|
4
4
|
import { Type } from "@sinclair/typebox";
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
9
9
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
10
10
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -64,6 +64,19 @@ interface StudioPromptDescriptor {
|
|
|
64
64
|
promptTriggerText: string | null;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
interface StudioHtmlPreviewMathRenderItem {
|
|
68
|
+
mathId: string;
|
|
69
|
+
tex: string;
|
|
70
|
+
display: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface StudioHtmlPreviewMathRenderResult {
|
|
74
|
+
mathId: string;
|
|
75
|
+
ok: boolean;
|
|
76
|
+
html?: string;
|
|
77
|
+
error?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
67
80
|
interface ActiveStudioRequest extends StudioPromptDescriptor {
|
|
68
81
|
id: string;
|
|
69
82
|
kind: StudioRequestKind;
|
|
@@ -352,6 +365,7 @@ interface ReplStartRequestMessage {
|
|
|
352
365
|
requestId: string;
|
|
353
366
|
runtime: StudioReplRuntime;
|
|
354
367
|
newSession?: boolean;
|
|
368
|
+
command?: string;
|
|
355
369
|
}
|
|
356
370
|
|
|
357
371
|
interface ReplStopRequestMessage {
|
|
@@ -460,6 +474,9 @@ const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
|
460
474
|
const PREVIEW_RENDER_MAX_CHARS = 400_000;
|
|
461
475
|
const PDF_EXPORT_MAX_CHARS = 400_000;
|
|
462
476
|
const HTML_EXPORT_MAX_CHARS = 400_000;
|
|
477
|
+
const HTML_PREVIEW_MATH_RENDER_MAX_ITEMS = 250;
|
|
478
|
+
const HTML_PREVIEW_MATH_RENDER_ITEM_MAX_CHARS = 8_000;
|
|
479
|
+
const HTML_PREVIEW_MATH_RENDER_TOTAL_MAX_CHARS = 120_000;
|
|
463
480
|
const STUDIO_QUIZ_SOURCE_MAX_CHARS = 80_000;
|
|
464
481
|
const STUDIO_QUIZ_CONTEXT_FILE_MAX_CHARS = 14_000;
|
|
465
482
|
const STUDIO_QUIZ_CONTEXT_MAX_FILES = 18;
|
|
@@ -2348,11 +2365,69 @@ function buildStudioCompanionLabel(_label: string | undefined): string {
|
|
|
2348
2365
|
return "copy of editor text";
|
|
2349
2366
|
}
|
|
2350
2367
|
|
|
2368
|
+
const STUDIO_HTML_PREVIEW_RESOURCE_MAX_BYTES = 25 * 1024 * 1024;
|
|
2369
|
+
const STUDIO_HTML_PREVIEW_IMAGE_MIME_BY_EXT = new Map<string, string>([
|
|
2370
|
+
[".png", "image/png"],
|
|
2371
|
+
[".jpg", "image/jpeg"],
|
|
2372
|
+
[".jpeg", "image/jpeg"],
|
|
2373
|
+
[".gif", "image/gif"],
|
|
2374
|
+
[".webp", "image/webp"],
|
|
2375
|
+
]);
|
|
2376
|
+
|
|
2351
2377
|
function resolveStudioPdfResourcePath(pdfPath: string | undefined, sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
|
|
2352
2378
|
const baseDir = resolveStudioBaseDir(sourcePath, resourceDir, fallbackCwd);
|
|
2353
2379
|
return resolveStudioPdfResourceFile(pdfPath, baseDir);
|
|
2354
2380
|
}
|
|
2355
2381
|
|
|
2382
|
+
function stripStudioHtmlPreviewResourceUrlSuffix(resourcePath: string): string {
|
|
2383
|
+
const withoutHash = resourcePath.split("#")[0] ?? resourcePath;
|
|
2384
|
+
return withoutHash.split("?")[0] ?? withoutHash;
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
function decodeStudioHtmlPreviewResourcePath(resourcePath: string): string {
|
|
2388
|
+
try {
|
|
2389
|
+
return decodeURIComponent(resourcePath);
|
|
2390
|
+
} catch {
|
|
2391
|
+
return resourcePath;
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
function resolveStudioHtmlPreviewResourcePath(
|
|
2396
|
+
resourcePath: string | undefined,
|
|
2397
|
+
sourcePath: string | undefined,
|
|
2398
|
+
resourceDir: string | undefined,
|
|
2399
|
+
fallbackCwd: string,
|
|
2400
|
+
): { filePath: string; mimeType: string } {
|
|
2401
|
+
const rawPath = typeof resourcePath === "string" ? resourcePath.trim() : "";
|
|
2402
|
+
if (!rawPath) throw new Error("Missing HTML preview resource path.");
|
|
2403
|
+
if (/\0/.test(rawPath)) throw new Error("Invalid HTML preview resource path.");
|
|
2404
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
|
|
2405
|
+
throw new Error("Only local HTML preview resources are supported.");
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
const baseDir = resolveStudioBaseDir(sourcePath, resourceDir, fallbackCwd);
|
|
2409
|
+
const cleanedPath = decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(rawPath));
|
|
2410
|
+
const expandedPath = expandHome(cleanedPath);
|
|
2411
|
+
const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(baseDir, expandedPath);
|
|
2412
|
+
const ext = extname(candidate).toLowerCase();
|
|
2413
|
+
const mimeType = STUDIO_HTML_PREVIEW_IMAGE_MIME_BY_EXT.get(ext);
|
|
2414
|
+
if (!mimeType) throw new Error("Only local PNG, JPEG, GIF, and WebP images can be embedded in HTML previews.");
|
|
2415
|
+
|
|
2416
|
+
const baseReal = realpathSync(baseDir);
|
|
2417
|
+
const candidateReal = realpathSync(candidate);
|
|
2418
|
+
const rel = relative(baseReal, candidateReal);
|
|
2419
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
2420
|
+
throw new Error("HTML preview resource path must stay within the current Studio resource directory.");
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
const stat = statSync(candidateReal);
|
|
2424
|
+
if (!stat.isFile()) throw new Error("HTML preview resource path does not refer to a file.");
|
|
2425
|
+
if (stat.size > STUDIO_HTML_PREVIEW_RESOURCE_MAX_BYTES) {
|
|
2426
|
+
throw new Error("HTML preview resource is too large to embed.");
|
|
2427
|
+
}
|
|
2428
|
+
return { filePath: candidateReal, mimeType };
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2356
2431
|
function resolveStudioPandocWorkingDir(baseDir: string | undefined): string | undefined {
|
|
2357
2432
|
const normalized = typeof baseDir === "string" ? baseDir.trim() : "";
|
|
2358
2433
|
if (!normalized) return undefined;
|
|
@@ -4900,6 +4975,72 @@ function stripMathMlAnnotationTags(html: string): string {
|
|
|
4900
4975
|
});
|
|
4901
4976
|
}
|
|
4902
4977
|
|
|
4978
|
+
function normalizeStudioHtmlPreviewMathForPandoc(tex: string): string {
|
|
4979
|
+
return String(tex ?? "")
|
|
4980
|
+
.replace(/\r\n/g, "\n")
|
|
4981
|
+
.replace(/\\rm\s*\{([^{}]+)\}/g, "\\mathrm{$1}")
|
|
4982
|
+
.replace(/\\rm\s+([A-Za-z]+)(?=[^A-Za-z]|$)/g, "\\mathrm{$1}");
|
|
4983
|
+
}
|
|
4984
|
+
|
|
4985
|
+
function getStudioHtmlPreviewMathWrapperId(index: number): string {
|
|
4986
|
+
return `studio-html-preview-math-${Math.max(0, Math.floor(index))}`;
|
|
4987
|
+
}
|
|
4988
|
+
|
|
4989
|
+
function buildStudioHtmlPreviewMathPandocSource(items: StudioHtmlPreviewMathRenderItem[]): string {
|
|
4990
|
+
return items.map((item, index) => {
|
|
4991
|
+
const wrapperId = getStudioHtmlPreviewMathWrapperId(index);
|
|
4992
|
+
const tex = normalizeStudioHtmlPreviewMathForPandoc(item.tex);
|
|
4993
|
+
const mathSource = item.display ? `\\[\n${tex}\n\\]` : `\\(${tex}\\)`;
|
|
4994
|
+
return `:::: {#${wrapperId} .studio-html-preview-math-render-item}\n${mathSource}\n::::`;
|
|
4995
|
+
}).join("\n\n");
|
|
4996
|
+
}
|
|
4997
|
+
|
|
4998
|
+
function extractStudioHtmlPreviewMathHtml(renderedHtml: string, wrapperId: string): string {
|
|
4999
|
+
const idPattern = escapeStudioRegExpLiteral(wrapperId);
|
|
5000
|
+
const wrapperPattern = new RegExp(`<div\\b(?=[^>]*\\bid="${idPattern}")[^>]*>([\\s\\S]*?)<\\/div>`, "i");
|
|
5001
|
+
const wrapperMatch = String(renderedHtml ?? "").match(wrapperPattern);
|
|
5002
|
+
const wrapperHtml = wrapperMatch ? String(wrapperMatch[1] ?? "") : "";
|
|
5003
|
+
const mathMatch = wrapperHtml.match(/<math\b[\s\S]*?<\/math>/i);
|
|
5004
|
+
return mathMatch ? stripMathMlAnnotationTags(mathMatch[0]) : "";
|
|
5005
|
+
}
|
|
5006
|
+
|
|
5007
|
+
async function renderStudioHtmlPreviewMathWithPandoc(items: StudioHtmlPreviewMathRenderItem[]): Promise<StudioHtmlPreviewMathRenderResult[]> {
|
|
5008
|
+
if (items.length === 0) return [];
|
|
5009
|
+
const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
|
|
5010
|
+
const inputFormat = "markdown+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash";
|
|
5011
|
+
const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none"];
|
|
5012
|
+
const source = buildStudioHtmlPreviewMathPandocSource(items);
|
|
5013
|
+
const pandocResult = await runStudioSubprocess(pandocCommand, args, {
|
|
5014
|
+
input: source,
|
|
5015
|
+
timeoutMs: STUDIO_PANDOC_TIMEOUT_MS,
|
|
5016
|
+
stdoutMaxBytes: Math.min(STUDIO_HTML_RENDER_OUTPUT_MAX_BYTES, 10_000_000),
|
|
5017
|
+
label: "pandoc HTML preview math render",
|
|
5018
|
+
notFoundMessage: "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary.",
|
|
5019
|
+
});
|
|
5020
|
+
if (pandocResult.code !== 0) {
|
|
5021
|
+
throw new Error(`pandoc math render failed with exit code ${pandocResult.code}${pandocResult.stderr ? `: ${pandocResult.stderr}` : ""}`);
|
|
5022
|
+
}
|
|
5023
|
+
if (pandocResult.stdoutTruncated) {
|
|
5024
|
+
throw new Error("pandoc math render output exceeded Studio's size limit.");
|
|
5025
|
+
}
|
|
5026
|
+
|
|
5027
|
+
return items.map((item, index) => {
|
|
5028
|
+
const html = extractStudioHtmlPreviewMathHtml(pandocResult.stdout, getStudioHtmlPreviewMathWrapperId(index));
|
|
5029
|
+
if (!html) {
|
|
5030
|
+
return {
|
|
5031
|
+
mathId: item.mathId,
|
|
5032
|
+
ok: false,
|
|
5033
|
+
error: "Pandoc did not render this expression as MathML.",
|
|
5034
|
+
};
|
|
5035
|
+
}
|
|
5036
|
+
return {
|
|
5037
|
+
mathId: item.mathId,
|
|
5038
|
+
ok: true,
|
|
5039
|
+
html,
|
|
5040
|
+
};
|
|
5041
|
+
});
|
|
5042
|
+
}
|
|
5043
|
+
|
|
4903
5044
|
function normalizeObsidianImages(markdown: string): string {
|
|
4904
5045
|
// Use angle-bracket destinations so paths with spaces/special chars are safe for Pandoc
|
|
4905
5046
|
return markdown
|
|
@@ -6197,6 +6338,23 @@ function respondPdfFile(req: IncomingMessage, res: ServerResponse, filePath: str
|
|
|
6197
6338
|
res.end(method === "HEAD" ? undefined : pdf);
|
|
6198
6339
|
}
|
|
6199
6340
|
|
|
6341
|
+
function respondHtmlPreviewResourceJson(req: IncomingMessage, res: ServerResponse, filePath: string, mimeType: string): void {
|
|
6342
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
6343
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
6344
|
+
res.setHeader("Allow", "GET, HEAD");
|
|
6345
|
+
respondJson(res, 405, { ok: false, error: "Method not allowed. Use GET." });
|
|
6346
|
+
return;
|
|
6347
|
+
}
|
|
6348
|
+
|
|
6349
|
+
const data = method === "HEAD" ? "" : readFileSync(filePath).toString("base64");
|
|
6350
|
+
respondJson(res, 200, {
|
|
6351
|
+
ok: true,
|
|
6352
|
+
mimeType,
|
|
6353
|
+
filename: basename(filePath),
|
|
6354
|
+
dataUrl: method === "HEAD" ? "" : `data:${mimeType};base64,${data}`,
|
|
6355
|
+
});
|
|
6356
|
+
}
|
|
6357
|
+
|
|
6200
6358
|
function openUrlInDefaultBrowser(url: string): Promise<void> {
|
|
6201
6359
|
const openCommand =
|
|
6202
6360
|
process.platform === "darwin"
|
|
@@ -7101,6 +7259,7 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
|
|
|
7101
7259
|
requestId: msg.requestId,
|
|
7102
7260
|
runtime,
|
|
7103
7261
|
newSession: Boolean(msg.newSession),
|
|
7262
|
+
command: typeof msg.command === "string" ? msg.command : undefined,
|
|
7104
7263
|
};
|
|
7105
7264
|
}
|
|
7106
7265
|
}
|
|
@@ -7244,37 +7403,184 @@ function isGenericToolActivityLabel(label: string | null | undefined): boolean {
|
|
|
7244
7403
|
|| normalized === "editing file";
|
|
7245
7404
|
}
|
|
7246
7405
|
|
|
7406
|
+
function splitStudioShellWords(segment: string): string[] {
|
|
7407
|
+
const words: string[] = [];
|
|
7408
|
+
let current = "";
|
|
7409
|
+
let quote: "'" | "\"" | null = null;
|
|
7410
|
+
let escaped = false;
|
|
7411
|
+
|
|
7412
|
+
for (const char of String(segment || "")) {
|
|
7413
|
+
if (escaped) {
|
|
7414
|
+
current += char;
|
|
7415
|
+
escaped = false;
|
|
7416
|
+
continue;
|
|
7417
|
+
}
|
|
7418
|
+
if (char === "\\" && quote !== "'") {
|
|
7419
|
+
escaped = true;
|
|
7420
|
+
continue;
|
|
7421
|
+
}
|
|
7422
|
+
if (quote) {
|
|
7423
|
+
if (char === quote) quote = null;
|
|
7424
|
+
else current += char;
|
|
7425
|
+
continue;
|
|
7426
|
+
}
|
|
7427
|
+
if (char === "'" || char === "\"") {
|
|
7428
|
+
quote = char;
|
|
7429
|
+
continue;
|
|
7430
|
+
}
|
|
7431
|
+
if (/\s/.test(char)) {
|
|
7432
|
+
if (current) {
|
|
7433
|
+
words.push(current);
|
|
7434
|
+
current = "";
|
|
7435
|
+
}
|
|
7436
|
+
continue;
|
|
7437
|
+
}
|
|
7438
|
+
current += char;
|
|
7439
|
+
}
|
|
7440
|
+
if (escaped) current += "\\";
|
|
7441
|
+
if (current) words.push(current);
|
|
7442
|
+
return words;
|
|
7443
|
+
}
|
|
7444
|
+
|
|
7445
|
+
function normalizeStudioShellCommandToken(token: string): string {
|
|
7446
|
+
let value = String(token || "").trim();
|
|
7447
|
+
if (!value) return "";
|
|
7448
|
+
const slashIndex = value.lastIndexOf("/");
|
|
7449
|
+
if (slashIndex >= 0) value = value.slice(slashIndex + 1);
|
|
7450
|
+
return value.toLowerCase();
|
|
7451
|
+
}
|
|
7452
|
+
|
|
7453
|
+
function isStudioShellAssignmentToken(token: string): boolean {
|
|
7454
|
+
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(String(token || ""));
|
|
7455
|
+
}
|
|
7456
|
+
|
|
7457
|
+
function getStudioShellSegmentCommand(segment: string): { name: string; words: string[]; commandIndex: number } | null {
|
|
7458
|
+
const words = splitStudioShellWords(segment);
|
|
7459
|
+
let index = 0;
|
|
7460
|
+
const skipAssignments = () => {
|
|
7461
|
+
while (index < words.length && isStudioShellAssignmentToken(words[index] || "")) index += 1;
|
|
7462
|
+
};
|
|
7463
|
+
|
|
7464
|
+
skipAssignments();
|
|
7465
|
+
let guard = 0;
|
|
7466
|
+
while (index < words.length && guard < 12) {
|
|
7467
|
+
guard += 1;
|
|
7468
|
+
const name = normalizeStudioShellCommandToken(words[index] || "");
|
|
7469
|
+
if (!name) {
|
|
7470
|
+
index += 1;
|
|
7471
|
+
continue;
|
|
7472
|
+
}
|
|
7473
|
+
if (name === "command" || name === "builtin" || name === "exec") {
|
|
7474
|
+
index += 1;
|
|
7475
|
+
skipAssignments();
|
|
7476
|
+
continue;
|
|
7477
|
+
}
|
|
7478
|
+
if (name === "time") {
|
|
7479
|
+
index += 1;
|
|
7480
|
+
while (index < words.length && String(words[index] || "").startsWith("-")) index += 1;
|
|
7481
|
+
skipAssignments();
|
|
7482
|
+
continue;
|
|
7483
|
+
}
|
|
7484
|
+
if (name === "env") {
|
|
7485
|
+
index += 1;
|
|
7486
|
+
while (index < words.length) {
|
|
7487
|
+
const token = String(words[index] || "");
|
|
7488
|
+
const lowerToken = token.toLowerCase();
|
|
7489
|
+
if (isStudioShellAssignmentToken(token)) {
|
|
7490
|
+
index += 1;
|
|
7491
|
+
continue;
|
|
7492
|
+
}
|
|
7493
|
+
if (lowerToken === "-u" || lowerToken === "--unset" || lowerToken === "-s" || lowerToken === "-S") {
|
|
7494
|
+
index += 2;
|
|
7495
|
+
continue;
|
|
7496
|
+
}
|
|
7497
|
+
if (lowerToken.startsWith("-")) {
|
|
7498
|
+
index += 1;
|
|
7499
|
+
continue;
|
|
7500
|
+
}
|
|
7501
|
+
break;
|
|
7502
|
+
}
|
|
7503
|
+
skipAssignments();
|
|
7504
|
+
continue;
|
|
7505
|
+
}
|
|
7506
|
+
if (name === "sudo") {
|
|
7507
|
+
index += 1;
|
|
7508
|
+
while (index < words.length) {
|
|
7509
|
+
const option = String(words[index] || "");
|
|
7510
|
+
if (!option.startsWith("-")) break;
|
|
7511
|
+
const lowerOption = option.toLowerCase();
|
|
7512
|
+
index += 1;
|
|
7513
|
+
if (["-c", "-g", "-h", "-p", "-t", "-u"].includes(lowerOption) && index < words.length) {
|
|
7514
|
+
index += 1;
|
|
7515
|
+
}
|
|
7516
|
+
}
|
|
7517
|
+
skipAssignments();
|
|
7518
|
+
continue;
|
|
7519
|
+
}
|
|
7520
|
+
return { name, words, commandIndex: index };
|
|
7521
|
+
}
|
|
7522
|
+
return null;
|
|
7523
|
+
}
|
|
7524
|
+
|
|
7525
|
+
function getStudioGitSubcommand(args: string[]): string {
|
|
7526
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
7527
|
+
const token = String(args[index] || "").toLowerCase();
|
|
7528
|
+
if (!token) continue;
|
|
7529
|
+
if (["-c", "-C", "--git-dir", "--work-tree", "--namespace", "--exec-path"].map((value) => value.toLowerCase()).includes(token)) {
|
|
7530
|
+
index += 1;
|
|
7531
|
+
continue;
|
|
7532
|
+
}
|
|
7533
|
+
if (/^--(?:git-dir|work-tree|namespace|exec-path)=/.test(token)) continue;
|
|
7534
|
+
if (token.startsWith("-")) continue;
|
|
7535
|
+
return normalizeStudioShellCommandToken(token);
|
|
7536
|
+
}
|
|
7537
|
+
return "";
|
|
7538
|
+
}
|
|
7539
|
+
|
|
7247
7540
|
function deriveBashActivityLabel(command: string): string | null {
|
|
7248
7541
|
const normalized = String(command || "").trim();
|
|
7249
7542
|
if (!normalized) return null;
|
|
7250
|
-
const lower = normalized.toLowerCase();
|
|
7251
7543
|
|
|
7252
|
-
const segments =
|
|
7544
|
+
const segments = normalized
|
|
7253
7545
|
.split(/(?:&&|\|\||;|\n)+/g)
|
|
7254
7546
|
.map((segment) => segment.trim())
|
|
7255
7547
|
.filter((segment) => segment.length > 0);
|
|
7256
7548
|
|
|
7257
7549
|
let hasPwd = false;
|
|
7550
|
+
let hasLs = false;
|
|
7258
7551
|
let hasLsCurrent = false;
|
|
7259
7552
|
let hasLsParent = false;
|
|
7260
7553
|
let hasFind = false;
|
|
7261
7554
|
let hasFindCurrentListing = false;
|
|
7262
7555
|
let hasFindParentListing = false;
|
|
7556
|
+
let hasTextSearch = false;
|
|
7557
|
+
let hasFileRead = false;
|
|
7558
|
+
let hasGit = false;
|
|
7559
|
+
let hasGitStatus = false;
|
|
7560
|
+
let hasGitDiff = false;
|
|
7561
|
+
let hasNpm = false;
|
|
7562
|
+
let hasPython = false;
|
|
7563
|
+
let hasNode = false;
|
|
7263
7564
|
|
|
7264
7565
|
for (const segment of segments) {
|
|
7265
|
-
|
|
7566
|
+
const commandInfo = getStudioShellSegmentCommand(segment);
|
|
7567
|
+
if (!commandInfo) continue;
|
|
7568
|
+
const commandName = commandInfo.name;
|
|
7569
|
+
const args = commandInfo.words.slice(commandInfo.commandIndex + 1);
|
|
7570
|
+
|
|
7571
|
+
if (commandName === "pwd") hasPwd = true;
|
|
7266
7572
|
|
|
7267
|
-
if (
|
|
7268
|
-
|
|
7573
|
+
if (commandName === "ls") {
|
|
7574
|
+
hasLs = true;
|
|
7575
|
+
if (args.some((arg) => arg === ".." || arg === "../" || arg.startsWith("../"))) hasLsParent = true;
|
|
7269
7576
|
else hasLsCurrent = true;
|
|
7270
7577
|
}
|
|
7271
7578
|
|
|
7272
|
-
if (
|
|
7579
|
+
if (commandName === "find") {
|
|
7273
7580
|
hasFind = true;
|
|
7274
|
-
const
|
|
7275
|
-
const
|
|
7276
|
-
const
|
|
7277
|
-
const listingLike = /-maxdepth\s+\d+\b/.test(segment) && !hasSelector;
|
|
7581
|
+
const pathToken = args.find((arg) => arg && !String(arg).startsWith("-")) || "";
|
|
7582
|
+
const hasSelector = /-(?:name|iname|regex|path|ipath|newer|mtime|mmin|size|user|group)\b/i.test(segment);
|
|
7583
|
+
const listingLike = /-maxdepth\s+\d+\b/i.test(segment) && !hasSelector;
|
|
7278
7584
|
|
|
7279
7585
|
if (listingLike) {
|
|
7280
7586
|
if (pathToken === ".." || pathToken === "../") {
|
|
@@ -7284,6 +7590,18 @@ function deriveBashActivityLabel(command: string): string | null {
|
|
|
7284
7590
|
}
|
|
7285
7591
|
}
|
|
7286
7592
|
}
|
|
7593
|
+
|
|
7594
|
+
if (commandName === "rg" || commandName === "grep") hasTextSearch = true;
|
|
7595
|
+
if (commandName === "cat" || commandName === "sed" || commandName === "awk") hasFileRead = true;
|
|
7596
|
+
if (commandName === "git") {
|
|
7597
|
+
hasGit = true;
|
|
7598
|
+
const subcommand = getStudioGitSubcommand(args);
|
|
7599
|
+
if (subcommand === "status") hasGitStatus = true;
|
|
7600
|
+
if (subcommand === "diff") hasGitDiff = true;
|
|
7601
|
+
}
|
|
7602
|
+
if (commandName === "npm") hasNpm = true;
|
|
7603
|
+
if (/^python(?:3(?:\.\d+)?)?$/.test(commandName)) hasPython = true;
|
|
7604
|
+
if (commandName === "node") hasNode = true;
|
|
7287
7605
|
}
|
|
7288
7606
|
|
|
7289
7607
|
const hasCurrentListing = hasLsCurrent || hasFindCurrentListing;
|
|
@@ -7298,34 +7616,34 @@ function deriveBashActivityLabel(command: string): string | null {
|
|
|
7298
7616
|
if (hasParentListing) {
|
|
7299
7617
|
return "Listing parent directory files";
|
|
7300
7618
|
}
|
|
7301
|
-
if (hasCurrentListing ||
|
|
7619
|
+
if (hasCurrentListing || hasLs) {
|
|
7302
7620
|
return "Listing directory files";
|
|
7303
7621
|
}
|
|
7304
|
-
if (hasFind
|
|
7622
|
+
if (hasFind) {
|
|
7305
7623
|
return "Searching files";
|
|
7306
7624
|
}
|
|
7307
|
-
if (
|
|
7625
|
+
if (hasTextSearch) {
|
|
7308
7626
|
return "Searching text in files";
|
|
7309
7627
|
}
|
|
7310
|
-
if (
|
|
7628
|
+
if (hasFileRead) {
|
|
7311
7629
|
return "Reading file content";
|
|
7312
7630
|
}
|
|
7313
|
-
if (
|
|
7631
|
+
if (hasGitStatus) {
|
|
7314
7632
|
return "Checking git status";
|
|
7315
7633
|
}
|
|
7316
|
-
if (
|
|
7634
|
+
if (hasGitDiff) {
|
|
7317
7635
|
return "Reviewing git changes";
|
|
7318
7636
|
}
|
|
7319
|
-
if (
|
|
7637
|
+
if (hasGit) {
|
|
7320
7638
|
return "Running git command";
|
|
7321
7639
|
}
|
|
7322
|
-
if (
|
|
7640
|
+
if (hasNpm) {
|
|
7323
7641
|
return "Running npm command";
|
|
7324
7642
|
}
|
|
7325
|
-
if (
|
|
7643
|
+
if (hasPython) {
|
|
7326
7644
|
return "Running Python command";
|
|
7327
7645
|
}
|
|
7328
|
-
if (
|
|
7646
|
+
if (hasNode) {
|
|
7329
7647
|
return "Running Node.js command";
|
|
7330
7648
|
}
|
|
7331
7649
|
return "Running shell command";
|
|
@@ -7667,7 +7985,7 @@ function normalizeStudioReplRuntime(value: unknown): StudioReplRuntime | null {
|
|
|
7667
7985
|
return isStudioReplRuntime(normalized) ? normalized : null;
|
|
7668
7986
|
}
|
|
7669
7987
|
|
|
7670
|
-
function
|
|
7988
|
+
function getDefaultStudioReplRuntimeCommand(runtime: StudioReplRuntime): string {
|
|
7671
7989
|
if (runtime === "shell") return String(process.env.SHELL || "bash").trim() || "bash";
|
|
7672
7990
|
if (runtime === "python") return "python3";
|
|
7673
7991
|
if (runtime === "ipython") return "ipython";
|
|
@@ -7677,13 +7995,31 @@ function getStudioReplRuntimeCommand(runtime: StudioReplRuntime): string {
|
|
|
7677
7995
|
return "clojure";
|
|
7678
7996
|
}
|
|
7679
7997
|
|
|
7680
|
-
function
|
|
7681
|
-
|
|
7998
|
+
function normalizeStudioReplCommandOverride(runtime: StudioReplRuntime, command: string | undefined): string | undefined {
|
|
7999
|
+
const normalized = String(command ?? "").replace(/\r?\n/g, " ").replace(/\s+/g, " ").trim();
|
|
8000
|
+
if (!normalized) return undefined;
|
|
8001
|
+
if (normalized.length > 240) return undefined;
|
|
8002
|
+
if (normalized === getDefaultStudioReplRuntimeCommand(runtime)) return undefined;
|
|
8003
|
+
return normalized;
|
|
8004
|
+
}
|
|
8005
|
+
|
|
8006
|
+
function getStudioReplRuntimeCommand(runtime: StudioReplRuntime, command?: string): string {
|
|
8007
|
+
return normalizeStudioReplCommandOverride(runtime, command) || getDefaultStudioReplRuntimeCommand(runtime);
|
|
8008
|
+
}
|
|
8009
|
+
|
|
8010
|
+
function getStudioReplCommandSessionSuffix(runtime: StudioReplRuntime, command?: string): string {
|
|
8011
|
+
const normalized = normalizeStudioReplCommandOverride(runtime, command);
|
|
8012
|
+
if (!normalized) return "";
|
|
8013
|
+
return `-${createHash("sha1").update(`${runtime}\n${normalized}`).digest("hex").slice(0, 8)}`;
|
|
8014
|
+
}
|
|
8015
|
+
|
|
8016
|
+
function getStudioReplSessionName(runtime: StudioReplRuntime, command?: string): string {
|
|
8017
|
+
return `pi-studio-repl-${runtime}${getStudioReplCommandSessionSuffix(runtime, command)}`;
|
|
7682
8018
|
}
|
|
7683
8019
|
|
|
7684
|
-
function getNewStudioReplSessionName(runtime: StudioReplRuntime): string {
|
|
8020
|
+
function getNewStudioReplSessionName(runtime: StudioReplRuntime, command?: string): string {
|
|
7685
8021
|
const suffix = `${Date.now().toString(36)}-${randomUUID().slice(0, 6)}`;
|
|
7686
|
-
return `pi-studio-repl-${runtime}-${suffix}`;
|
|
8022
|
+
return `pi-studio-repl-${runtime}${getStudioReplCommandSessionSuffix(runtime, command)}-${suffix}`;
|
|
7687
8023
|
}
|
|
7688
8024
|
|
|
7689
8025
|
function getStudioReplPaneTarget(sessionName: string): string {
|
|
@@ -7783,9 +8119,10 @@ function captureStudioReplSession(sessionName: string): { ok: true; transcript:
|
|
|
7783
8119
|
return { ok: true, transcript: String(result.stdout || "").replace(/[\t ]+$/gm, "").trimEnd(), session };
|
|
7784
8120
|
}
|
|
7785
8121
|
|
|
7786
|
-
function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options?: { newSession?: boolean }): { ok: true; session: StudioReplSessionInfo; message: string } | { ok: false; message: string } {
|
|
8122
|
+
function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options?: { newSession?: boolean; command?: string }): { ok: true; session: StudioReplSessionInfo; message: string } | { ok: false; message: string } {
|
|
7787
8123
|
if (!isTmuxAvailable()) return { ok: false, message: "tmux is not available. Install tmux to use Studio REPL sessions." };
|
|
7788
|
-
const
|
|
8124
|
+
const commandOverride = normalizeStudioReplCommandOverride(runtime, options?.command);
|
|
8125
|
+
const sessionName = options?.newSession ? getNewStudioReplSessionName(runtime, commandOverride) : getStudioReplSessionName(runtime, commandOverride);
|
|
7789
8126
|
const existing = runStudioTmux(["has-session", "-t", sessionName], { timeout: 3_000 });
|
|
7790
8127
|
if (existing.ok) {
|
|
7791
8128
|
const inferred = inferStudioReplSessionRuntime(sessionName);
|
|
@@ -7801,7 +8138,7 @@ function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options
|
|
|
7801
8138
|
message: `${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL is already running.`,
|
|
7802
8139
|
};
|
|
7803
8140
|
}
|
|
7804
|
-
const command = getStudioReplRuntimeCommand(runtime);
|
|
8141
|
+
const command = getStudioReplRuntimeCommand(runtime, commandOverride);
|
|
7805
8142
|
const result = runStudioTmux(["new-session", "-d", "-s", sessionName, "-c", cwd || process.cwd(), command], { timeout: 5_000 });
|
|
7806
8143
|
if (!result.ok) return { ok: false, message: result.message || `Failed to start ${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL.` };
|
|
7807
8144
|
return {
|
|
@@ -7813,7 +8150,7 @@ function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options
|
|
|
7813
8150
|
label: formatStudioReplSessionLabel(sessionName, runtime, "studio"),
|
|
7814
8151
|
source: "studio",
|
|
7815
8152
|
},
|
|
7816
|
-
message: `Started ${options?.newSession ? "new " : ""}${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL.`,
|
|
8153
|
+
message: `Started ${options?.newSession ? "new " : ""}${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL${commandOverride ? ` with custom command: ${commandOverride}` : ""}.`,
|
|
7817
8154
|
};
|
|
7818
8155
|
}
|
|
7819
8156
|
|
|
@@ -8855,6 +9192,8 @@ ${cssVarsBlock}
|
|
|
8855
9192
|
</div>
|
|
8856
9193
|
</section>
|
|
8857
9194
|
|
|
9195
|
+
<div id="paneResizeHandle" class="pane-resize-handle" role="separator" aria-label="Resize editor and response panes" aria-orientation="vertical" tabindex="0" title="Drag to resize panes. Double-click or press Enter/Space to reset; drag very close to the middle to snap to 50/50."></div>
|
|
9196
|
+
|
|
8858
9197
|
<section id="rightPane">
|
|
8859
9198
|
<div id="rightSectionHeader" class="section-header">
|
|
8860
9199
|
<div class="section-header-main">
|
|
@@ -8963,6 +9302,14 @@ ${cssVarsBlock}
|
|
|
8963
9302
|
<div><dt>Tab / Shift+Tab</dt><dd>Indent or unindent selected editor text</dd></div>
|
|
8964
9303
|
</dl>
|
|
8965
9304
|
</section>
|
|
9305
|
+
<section class="shortcuts-group">
|
|
9306
|
+
<h3>Response</h3>
|
|
9307
|
+
<dl>
|
|
9308
|
+
<div><dt>Alt/Option+←</dt><dd>Previous response when not editing text</dd></div>
|
|
9309
|
+
<div><dt>Alt/Option+→</dt><dd>Next response when not editing text</dd></div>
|
|
9310
|
+
<div><dt>Alt/Option+l</dt><dd>Latest response when not editing text</dd></div>
|
|
9311
|
+
</dl>
|
|
9312
|
+
</section>
|
|
8966
9313
|
<section class="shortcuts-group">
|
|
8967
9314
|
<h3>REPL</h3>
|
|
8968
9315
|
<dl>
|
|
@@ -9916,7 +10263,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
9916
10263
|
}
|
|
9917
10264
|
lastSpecificToolActivityLabel = baseLabel;
|
|
9918
10265
|
} else {
|
|
9919
|
-
|
|
10266
|
+
// Generic shell/tool labels such as "Running git command" are often
|
|
10267
|
+
// stale or too broad once the model has moved on. Keep the precise
|
|
10268
|
+
// Working trace entry, but do not promote generic labels into the
|
|
10269
|
+
// live Studio/terminal status line.
|
|
10270
|
+
nextLabel = null;
|
|
9920
10271
|
}
|
|
9921
10272
|
} else {
|
|
9922
10273
|
nextLabel = null;
|
|
@@ -10677,7 +11028,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
10677
11028
|
sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
|
|
10678
11029
|
return;
|
|
10679
11030
|
}
|
|
10680
|
-
const started = startStudioReplSession(msg.runtime, studioCwd, { newSession: msg.newSession });
|
|
11031
|
+
const started = startStudioReplSession(msg.runtime, studioCwd, { newSession: msg.newSession, command: msg.command });
|
|
10681
11032
|
if (!started.ok) {
|
|
10682
11033
|
sendReplStateToClient(client, { requestId: msg.requestId, replError: started.message });
|
|
10683
11034
|
sendToClient(client, { type: "error", requestId: msg.requestId, message: started.message });
|
|
@@ -11479,6 +11830,84 @@ export default function (pi: ExtensionAPI) {
|
|
|
11479
11830
|
}
|
|
11480
11831
|
};
|
|
11481
11832
|
|
|
11833
|
+
const handleRenderMathRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
|
11834
|
+
let rawBody = "";
|
|
11835
|
+
try {
|
|
11836
|
+
rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
|
|
11837
|
+
} catch (error) {
|
|
11838
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
11839
|
+
const status = message.includes("exceeds") ? 413 : 400;
|
|
11840
|
+
respondJson(res, status, { ok: false, error: message });
|
|
11841
|
+
return;
|
|
11842
|
+
}
|
|
11843
|
+
|
|
11844
|
+
let parsedBody: unknown;
|
|
11845
|
+
try {
|
|
11846
|
+
parsedBody = rawBody ? JSON.parse(rawBody) : {};
|
|
11847
|
+
} catch {
|
|
11848
|
+
respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
|
|
11849
|
+
return;
|
|
11850
|
+
}
|
|
11851
|
+
|
|
11852
|
+
const rawItems =
|
|
11853
|
+
parsedBody && typeof parsedBody === "object" && Array.isArray((parsedBody as { items?: unknown }).items)
|
|
11854
|
+
? (parsedBody as { items: unknown[] }).items
|
|
11855
|
+
: null;
|
|
11856
|
+
if (!rawItems) {
|
|
11857
|
+
respondJson(res, 400, { ok: false, error: "Missing math items array in request body." });
|
|
11858
|
+
return;
|
|
11859
|
+
}
|
|
11860
|
+
if (rawItems.length > HTML_PREVIEW_MATH_RENDER_MAX_ITEMS) {
|
|
11861
|
+
respondJson(res, 413, {
|
|
11862
|
+
ok: false,
|
|
11863
|
+
error: `HTML preview math render accepts at most ${HTML_PREVIEW_MATH_RENDER_MAX_ITEMS} items per request.`,
|
|
11864
|
+
});
|
|
11865
|
+
return;
|
|
11866
|
+
}
|
|
11867
|
+
|
|
11868
|
+
const items: StudioHtmlPreviewMathRenderItem[] = [];
|
|
11869
|
+
let totalChars = 0;
|
|
11870
|
+
for (const rawItem of rawItems) {
|
|
11871
|
+
const item = rawItem && typeof rawItem === "object" ? rawItem as { mathId?: unknown; tex?: unknown; display?: unknown } : null;
|
|
11872
|
+
const mathId = typeof item?.mathId === "string" ? item.mathId.trim() : "";
|
|
11873
|
+
const tex = typeof item?.tex === "string" ? item.tex : "";
|
|
11874
|
+
if (!mathId || !tex.trim()) continue;
|
|
11875
|
+
if (mathId.length > 160) {
|
|
11876
|
+
respondJson(res, 400, { ok: false, error: "Math item id is too long." });
|
|
11877
|
+
return;
|
|
11878
|
+
}
|
|
11879
|
+
if (tex.length > HTML_PREVIEW_MATH_RENDER_ITEM_MAX_CHARS) {
|
|
11880
|
+
respondJson(res, 413, {
|
|
11881
|
+
ok: false,
|
|
11882
|
+
error: `A math expression exceeds ${HTML_PREVIEW_MATH_RENDER_ITEM_MAX_CHARS} characters.`,
|
|
11883
|
+
});
|
|
11884
|
+
return;
|
|
11885
|
+
}
|
|
11886
|
+
totalChars += tex.length;
|
|
11887
|
+
if (totalChars > HTML_PREVIEW_MATH_RENDER_TOTAL_MAX_CHARS) {
|
|
11888
|
+
respondJson(res, 413, {
|
|
11889
|
+
ok: false,
|
|
11890
|
+
error: `Math render text exceeds ${HTML_PREVIEW_MATH_RENDER_TOTAL_MAX_CHARS} characters.`,
|
|
11891
|
+
});
|
|
11892
|
+
return;
|
|
11893
|
+
}
|
|
11894
|
+
items.push({ mathId, tex, display: Boolean(item?.display) });
|
|
11895
|
+
}
|
|
11896
|
+
|
|
11897
|
+
if (items.length === 0) {
|
|
11898
|
+
respondJson(res, 400, { ok: false, error: "No valid math items to render." });
|
|
11899
|
+
return;
|
|
11900
|
+
}
|
|
11901
|
+
|
|
11902
|
+
try {
|
|
11903
|
+
const results = await renderStudioHtmlPreviewMathWithPandoc(items);
|
|
11904
|
+
respondJson(res, 200, { ok: true, renderer: "pandoc", results });
|
|
11905
|
+
} catch (error) {
|
|
11906
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
11907
|
+
respondJson(res, 500, { ok: false, error: `Math render failed: ${message}` });
|
|
11908
|
+
}
|
|
11909
|
+
};
|
|
11910
|
+
|
|
11482
11911
|
const handleExportPdfRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
|
11483
11912
|
let rawBody = "";
|
|
11484
11913
|
try {
|
|
@@ -11844,6 +12273,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
11844
12273
|
return;
|
|
11845
12274
|
}
|
|
11846
12275
|
|
|
12276
|
+
if (requestUrl.pathname === "/render-math") {
|
|
12277
|
+
const token = requestUrl.searchParams.get("token") ?? "";
|
|
12278
|
+
if (token !== serverState.token) {
|
|
12279
|
+
respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
|
|
12280
|
+
return;
|
|
12281
|
+
}
|
|
12282
|
+
|
|
12283
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
12284
|
+
if (method !== "POST") {
|
|
12285
|
+
res.setHeader("Allow", "POST");
|
|
12286
|
+
respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
|
|
12287
|
+
return;
|
|
12288
|
+
}
|
|
12289
|
+
|
|
12290
|
+
void handleRenderMathRequest(req, res).catch((error) => {
|
|
12291
|
+
respondJson(res, 500, {
|
|
12292
|
+
ok: false,
|
|
12293
|
+
error: `Math render failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
12294
|
+
});
|
|
12295
|
+
});
|
|
12296
|
+
return;
|
|
12297
|
+
}
|
|
12298
|
+
|
|
11847
12299
|
if (requestUrl.pathname === "/export-pdf") {
|
|
11848
12300
|
const token = requestUrl.searchParams.get("token") ?? "";
|
|
11849
12301
|
if (token !== serverState.token) {
|
|
@@ -11929,6 +12381,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
11929
12381
|
return;
|
|
11930
12382
|
}
|
|
11931
12383
|
|
|
12384
|
+
if (requestUrl.pathname === "/html-preview-resource") {
|
|
12385
|
+
const token = requestUrl.searchParams.get("token") ?? "";
|
|
12386
|
+
if (token !== serverState.token) {
|
|
12387
|
+
respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
|
|
12388
|
+
return;
|
|
12389
|
+
}
|
|
12390
|
+
|
|
12391
|
+
try {
|
|
12392
|
+
const resource = resolveStudioHtmlPreviewResourcePath(
|
|
12393
|
+
requestUrl.searchParams.get("path") ?? "",
|
|
12394
|
+
requestUrl.searchParams.get("sourcePath") ?? undefined,
|
|
12395
|
+
requestUrl.searchParams.get("resourceDir") ?? undefined,
|
|
12396
|
+
studioCwd,
|
|
12397
|
+
);
|
|
12398
|
+
respondHtmlPreviewResourceJson(req, res, resource.filePath, resource.mimeType);
|
|
12399
|
+
} catch (error) {
|
|
12400
|
+
respondJson(res, 404, { ok: false, error: `HTML preview resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
|
|
12401
|
+
}
|
|
12402
|
+
return;
|
|
12403
|
+
}
|
|
12404
|
+
|
|
11932
12405
|
if (requestUrl.pathname !== "/") {
|
|
11933
12406
|
respondText(res, 404, "Not found");
|
|
11934
12407
|
return;
|