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/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 = lower
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
- if (/\bpwd\b/.test(segment)) hasPwd = true;
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 (/\bls\b/.test(segment)) {
7268
- if (/\.\./.test(segment)) hasLsParent = true;
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 (/\bfind\b/.test(segment)) {
7579
+ if (commandName === "find") {
7273
7580
  hasFind = true;
7274
- const pathMatch = segment.match(/\bfind\s+([^\s]+)/);
7275
- const pathToken = pathMatch ? pathMatch[1] : "";
7276
- const hasSelector = /-(?:name|iname|regex|path|ipath|newer|mtime|mmin|size|user|group)\b/.test(segment);
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 || /\bls\b/.test(lower)) {
7619
+ if (hasCurrentListing || hasLs) {
7302
7620
  return "Listing directory files";
7303
7621
  }
7304
- if (hasFind || /\bfind\b/.test(lower)) {
7622
+ if (hasFind) {
7305
7623
  return "Searching files";
7306
7624
  }
7307
- if (/\brg\b/.test(lower) || /\bgrep\b/.test(lower)) {
7625
+ if (hasTextSearch) {
7308
7626
  return "Searching text in files";
7309
7627
  }
7310
- if (/\bcat\b/.test(lower) || /\bsed\b/.test(lower) || /\bawk\b/.test(lower)) {
7628
+ if (hasFileRead) {
7311
7629
  return "Reading file content";
7312
7630
  }
7313
- if (/\bgit\s+status\b/.test(lower)) {
7631
+ if (hasGitStatus) {
7314
7632
  return "Checking git status";
7315
7633
  }
7316
- if (/\bgit\s+diff\b/.test(lower)) {
7634
+ if (hasGitDiff) {
7317
7635
  return "Reviewing git changes";
7318
7636
  }
7319
- if (/\bgit\b/.test(lower)) {
7637
+ if (hasGit) {
7320
7638
  return "Running git command";
7321
7639
  }
7322
- if (/\bnpm\b/.test(lower)) {
7640
+ if (hasNpm) {
7323
7641
  return "Running npm command";
7324
7642
  }
7325
- if (/\bpython3?\b/.test(lower)) {
7643
+ if (hasPython) {
7326
7644
  return "Running Python command";
7327
7645
  }
7328
- if (/\bnode\b/.test(lower)) {
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 getStudioReplRuntimeCommand(runtime: StudioReplRuntime): string {
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 getStudioReplSessionName(runtime: StudioReplRuntime): string {
7681
- return `pi-studio-repl-${runtime}`;
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 sessionName = options?.newSession ? getNewStudioReplSessionName(runtime) : getStudioReplSessionName(runtime);
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
- nextLabel = baseLabel;
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;