pi-studio 0.9.11 → 0.9.13

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";
@@ -365,6 +365,7 @@ interface ReplStartRequestMessage {
365
365
  requestId: string;
366
366
  runtime: StudioReplRuntime;
367
367
  newSession?: boolean;
368
+ command?: string;
368
369
  }
369
370
 
370
371
  interface ReplStopRequestMessage {
@@ -1861,6 +1862,36 @@ function expandHome(pathInput: string): string {
1861
1862
  return join(home, pathInput.slice(2));
1862
1863
  }
1863
1864
 
1865
+ function normalizeStudioResourceDirectoryInput(resourceDir: string): string {
1866
+ let value = stripMatchingPathQuotes(String(resourceDir || "").trim());
1867
+ if (!value) return "";
1868
+ if (/^file:\/\//i.test(value)) {
1869
+ try {
1870
+ value = decodeURIComponent(new URL(value).pathname || value).trim();
1871
+ } catch {
1872
+ // Keep the original value if URL parsing fails.
1873
+ }
1874
+ }
1875
+ const windowsMatch = value.match(/.*([A-Za-z]:[\\/].*)$/);
1876
+ if (windowsMatch?.[1]) return windowsMatch[1].trim();
1877
+ const markers = ["/Users/", "/home/", "/Volumes/", "/private/", "/tmp/", "/var/", "/opt/", "/Applications/"];
1878
+ let embeddedAbsoluteIndex = -1;
1879
+ for (const marker of markers) {
1880
+ const index = value.lastIndexOf(marker);
1881
+ if (index > 0) embeddedAbsoluteIndex = Math.max(embeddedAbsoluteIndex, index);
1882
+ }
1883
+ if (embeddedAbsoluteIndex > 0) value = value.slice(embeddedAbsoluteIndex).trim();
1884
+ return value;
1885
+ }
1886
+
1887
+ function recoverLikelyDroppedLeadingSlashPath(pathInput: string): string {
1888
+ const value = String(pathInput || "").trim();
1889
+ if (!value || isAbsolute(value)) return value;
1890
+ if (!/^(?:Users|home|Volumes|private|tmp|var|opt|Applications)\//.test(value)) return value;
1891
+ const candidate = `/${value}`;
1892
+ return existsSync(candidate) ? candidate : value;
1893
+ }
1894
+
1864
1895
  function resolveStudioPath(pathArg: string, cwd: string): { ok: true; resolved: string; label: string } | { ok: false; message: string } {
1865
1896
  const normalized = normalizePathInput(pathArg);
1866
1897
  if (!normalized) {
@@ -2331,23 +2362,53 @@ function resolveStudioBaseDir(sourcePath: string | undefined, resourceDir: strin
2331
2362
  return dirname(isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded));
2332
2363
  }
2333
2364
 
2334
- const resource = typeof resourceDir === "string" ? resourceDir.trim() : "";
2365
+ const resource = normalizeStudioResourceDirectoryInput(typeof resourceDir === "string" ? resourceDir : "");
2335
2366
  if (resource) {
2336
- const expanded = expandHome(resource);
2367
+ const expanded = recoverLikelyDroppedLeadingSlashPath(expandHome(resource));
2337
2368
  return isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded);
2338
2369
  }
2339
2370
 
2340
2371
  return fallbackCwd;
2341
2372
  }
2342
2373
 
2374
+ function isPathInsideOrEqualDirectory(childPath: string, parentDir: string): boolean {
2375
+ const rel = relative(parentDir, childPath);
2376
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
2377
+ }
2378
+
2379
+ function resolveStudioPreviewResourceContext(sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): { baseDir: string; boundaryDir: string } {
2380
+ const source = typeof sourcePath === "string" ? sourcePath.trim() : "";
2381
+ const sourceBaseDir = source
2382
+ ? dirname(isAbsolute(recoverLikelyDroppedLeadingSlashPath(expandHome(source)))
2383
+ ? recoverLikelyDroppedLeadingSlashPath(expandHome(source))
2384
+ : resolve(fallbackCwd, recoverLikelyDroppedLeadingSlashPath(expandHome(source))))
2385
+ : "";
2386
+
2387
+ const resource = normalizeStudioResourceDirectoryInput(typeof resourceDir === "string" ? resourceDir : "");
2388
+ const resourceBaseDir = resource
2389
+ ? (isAbsolute(recoverLikelyDroppedLeadingSlashPath(expandHome(resource)))
2390
+ ? recoverLikelyDroppedLeadingSlashPath(expandHome(resource))
2391
+ : resolve(fallbackCwd, recoverLikelyDroppedLeadingSlashPath(expandHome(resource))))
2392
+ : "";
2393
+
2394
+ const baseDir = sourceBaseDir || resourceBaseDir || fallbackCwd;
2395
+ let boundaryDir = baseDir;
2396
+ if (resourceBaseDir) {
2397
+ boundaryDir = !sourceBaseDir || isPathInsideOrEqualDirectory(resolve(sourceBaseDir), resolve(resourceBaseDir))
2398
+ ? resourceBaseDir
2399
+ : baseDir;
2400
+ }
2401
+ return { baseDir, boundaryDir };
2402
+ }
2403
+
2343
2404
  function resolveStudioGitDiffBaseDir(sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
2344
2405
  return resolveStudioBaseDir(sourcePath, resourceDir, fallbackCwd);
2345
2406
  }
2346
2407
 
2347
2408
  function resolveStudioCompanionResourceDir(sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string | undefined {
2348
- const explicitResource = typeof resourceDir === "string" ? resourceDir.trim() : "";
2409
+ const explicitResource = normalizeStudioResourceDirectoryInput(typeof resourceDir === "string" ? resourceDir : "");
2349
2410
  if (explicitResource) {
2350
- const expanded = expandHome(explicitResource);
2411
+ const expanded = recoverLikelyDroppedLeadingSlashPath(expandHome(explicitResource));
2351
2412
  return isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded);
2352
2413
  }
2353
2414
 
@@ -2364,9 +2425,184 @@ function buildStudioCompanionLabel(_label: string | undefined): string {
2364
2425
  return "copy of editor text";
2365
2426
  }
2366
2427
 
2428
+ const STUDIO_HTML_PREVIEW_RESOURCE_MAX_BYTES = 25 * 1024 * 1024;
2429
+ const STUDIO_HTML_PREVIEW_IMAGE_MIME_BY_EXT = new Map<string, string>([
2430
+ [".png", "image/png"],
2431
+ [".jpg", "image/jpeg"],
2432
+ [".jpeg", "image/jpeg"],
2433
+ [".gif", "image/gif"],
2434
+ [".webp", "image/webp"],
2435
+ ]);
2436
+ const STUDIO_LOCAL_LINK_TEXT_EXTENSIONS = new Set([
2437
+ ".md", ".markdown", ".mdx", ".qmd", ".txt", ".tex", ".latex", ".rst", ".adoc",
2438
+ ".html", ".htm", ".css", ".xml", ".yaml", ".yml", ".toml", ".json", ".jsonc", ".json5", ".csv", ".tsv", ".log",
2439
+ ".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx",
2440
+ ".py", ".pyw", ".sh", ".bash", ".zsh", ".rs", ".c", ".h", ".cpp", ".cxx", ".cc", ".hpp", ".hxx",
2441
+ ".jl", ".f90", ".f95", ".f03", ".f", ".for", ".r", ".m", ".java", ".go", ".rb", ".swift", ".lua",
2442
+ ".diff", ".patch",
2443
+ ]);
2444
+ const STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
2445
+
2446
+ type StudioLocalPreviewResourceKind = "pdf" | "text" | "image" | "other";
2447
+
2448
+ interface StudioLocalPreviewResource {
2449
+ filePath: string;
2450
+ label: string;
2451
+ extension: string;
2452
+ kind: StudioLocalPreviewResourceKind;
2453
+ page: number | null;
2454
+ resourceDir: string;
2455
+ }
2456
+
2367
2457
  function resolveStudioPdfResourcePath(pdfPath: string | undefined, sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
2368
- const baseDir = resolveStudioBaseDir(sourcePath, resourceDir, fallbackCwd);
2369
- return resolveStudioPdfResourceFile(pdfPath, baseDir);
2458
+ const rawPath = typeof pdfPath === "string" ? pdfPath.trim() : "";
2459
+ if (!rawPath) throw new Error("Missing PDF path.");
2460
+ if (/\0/.test(rawPath)) throw new Error("Invalid PDF path.");
2461
+ if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
2462
+ throw new Error("Only local PDF paths are supported.");
2463
+ }
2464
+
2465
+ const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
2466
+ const cleanedPath = decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(rawPath));
2467
+ const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
2468
+ const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
2469
+ if (extname(candidate).toLowerCase() !== ".pdf") throw new Error("Only .pdf files can be embedded.");
2470
+
2471
+ const boundaryReal = realpathSync(context.boundaryDir);
2472
+ const candidateReal = realpathSync(candidate);
2473
+ const rel = relative(boundaryReal, candidateReal);
2474
+ if (rel.startsWith("..") || isAbsolute(rel)) {
2475
+ throw new Error("PDF path must stay within the current Studio resource directory.");
2476
+ }
2477
+
2478
+ const stat = statSync(candidateReal);
2479
+ if (!stat.isFile()) throw new Error("PDF path does not refer to a file.");
2480
+ return candidateReal;
2481
+ }
2482
+
2483
+ function stripStudioHtmlPreviewResourceUrlSuffix(resourcePath: string): string {
2484
+ const withoutHash = resourcePath.split("#")[0] ?? resourcePath;
2485
+ return withoutHash.split("?")[0] ?? withoutHash;
2486
+ }
2487
+
2488
+ function decodeStudioHtmlPreviewResourcePath(resourcePath: string): string {
2489
+ try {
2490
+ return decodeURIComponent(resourcePath);
2491
+ } catch {
2492
+ return resourcePath;
2493
+ }
2494
+ }
2495
+
2496
+ function parseStudioLocalPreviewResourcePage(resourcePath: string): number | null {
2497
+ const raw = String(resourcePath || "");
2498
+ const parts: string[] = [];
2499
+ const queryIndex = raw.indexOf("?");
2500
+ if (queryIndex >= 0) {
2501
+ const queryEnd = raw.indexOf("#", queryIndex);
2502
+ parts.push(raw.slice(queryIndex + 1, queryEnd >= 0 ? queryEnd : raw.length));
2503
+ }
2504
+ const hashIndex = raw.indexOf("#");
2505
+ if (hashIndex >= 0) parts.push(raw.slice(hashIndex + 1));
2506
+ for (const part of parts) {
2507
+ try {
2508
+ const params = new URLSearchParams(part);
2509
+ const rawPage = params.get("page") || params.get("p");
2510
+ if (rawPage) {
2511
+ const page = Number.parseInt(rawPage, 10);
2512
+ if (Number.isFinite(page) && page > 0) return page;
2513
+ }
2514
+ } catch {
2515
+ const match = part.match(/(?:^|[&;])page=(\d+)/i) || part.match(/^page=(\d+)$/i);
2516
+ if (match && match[1]) {
2517
+ const page = Number.parseInt(match[1], 10);
2518
+ if (Number.isFinite(page) && page > 0) return page;
2519
+ }
2520
+ }
2521
+ }
2522
+ return null;
2523
+ }
2524
+
2525
+ function getStudioLocalPreviewResourceKind(extension: string): StudioLocalPreviewResourceKind {
2526
+ const ext = extension.toLowerCase();
2527
+ if (ext === ".pdf") return "pdf";
2528
+ if (STUDIO_LOCAL_LINK_TEXT_EXTENSIONS.has(ext)) return "text";
2529
+ if (STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS.has(ext)) return "image";
2530
+ return "other";
2531
+ }
2532
+
2533
+ function resolveStudioLocalPreviewResourcePath(
2534
+ resourcePath: string | undefined,
2535
+ sourcePath: string | undefined,
2536
+ resourceDir: string | undefined,
2537
+ fallbackCwd: string,
2538
+ ): StudioLocalPreviewResource {
2539
+ const rawPath = typeof resourcePath === "string" ? resourcePath.trim() : "";
2540
+ if (!rawPath) throw new Error("Missing local resource path.");
2541
+ if (/\0/.test(rawPath)) throw new Error("Invalid local resource path.");
2542
+ if (/^\/\//.test(rawPath)) throw new Error("Network resources are not local Studio resources.");
2543
+ if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
2544
+ throw new Error("Only local relative resources are supported.");
2545
+ }
2546
+
2547
+ const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
2548
+ const cleanedPath = decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(rawPath));
2549
+ if (!cleanedPath || cleanedPath.startsWith("#")) throw new Error("Missing local resource path.");
2550
+ const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
2551
+ const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
2552
+ const extension = extname(candidate).toLowerCase();
2553
+ const boundaryReal = realpathSync(context.boundaryDir);
2554
+ const candidateReal = realpathSync(candidate);
2555
+ const rel = relative(boundaryReal, candidateReal);
2556
+ if (rel.startsWith("..") || isAbsolute(rel)) {
2557
+ throw new Error("Local resource path must stay within the current Studio resource directory.");
2558
+ }
2559
+
2560
+ const stat = statSync(candidateReal);
2561
+ if (!stat.isFile()) throw new Error("Local resource path does not refer to a file.");
2562
+ return {
2563
+ filePath: candidateReal,
2564
+ label: rel && rel !== "" ? rel : basename(candidateReal),
2565
+ extension,
2566
+ kind: getStudioLocalPreviewResourceKind(extension),
2567
+ page: parseStudioLocalPreviewResourcePage(rawPath),
2568
+ resourceDir: boundaryReal,
2569
+ };
2570
+ }
2571
+
2572
+ function resolveStudioHtmlPreviewResourcePath(
2573
+ resourcePath: string | undefined,
2574
+ sourcePath: string | undefined,
2575
+ resourceDir: string | undefined,
2576
+ fallbackCwd: string,
2577
+ ): { filePath: string; mimeType: string } {
2578
+ const rawPath = typeof resourcePath === "string" ? resourcePath.trim() : "";
2579
+ if (!rawPath) throw new Error("Missing HTML preview resource path.");
2580
+ if (/\0/.test(rawPath)) throw new Error("Invalid HTML preview resource path.");
2581
+ if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
2582
+ throw new Error("Only local HTML preview resources are supported.");
2583
+ }
2584
+
2585
+ const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
2586
+ const cleanedPath = decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(rawPath));
2587
+ const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
2588
+ const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
2589
+ const ext = extname(candidate).toLowerCase();
2590
+ const mimeType = STUDIO_HTML_PREVIEW_IMAGE_MIME_BY_EXT.get(ext);
2591
+ if (!mimeType) throw new Error("Only local PNG, JPEG, GIF, and WebP images can be embedded in HTML previews.");
2592
+
2593
+ const boundaryReal = realpathSync(context.boundaryDir);
2594
+ const candidateReal = realpathSync(candidate);
2595
+ const rel = relative(boundaryReal, candidateReal);
2596
+ if (rel.startsWith("..") || isAbsolute(rel)) {
2597
+ throw new Error("HTML preview resource path must stay within the current Studio resource directory.");
2598
+ }
2599
+
2600
+ const stat = statSync(candidateReal);
2601
+ if (!stat.isFile()) throw new Error("HTML preview resource path does not refer to a file.");
2602
+ if (stat.size > STUDIO_HTML_PREVIEW_RESOURCE_MAX_BYTES) {
2603
+ throw new Error("HTML preview resource is too large to embed.");
2604
+ }
2605
+ return { filePath: candidateReal, mimeType };
2370
2606
  }
2371
2607
 
2372
2608
  function resolveStudioPandocWorkingDir(baseDir: string | undefined): string | undefined {
@@ -6279,6 +6515,151 @@ function respondPdfFile(req: IncomingMessage, res: ServerResponse, filePath: str
6279
6515
  res.end(method === "HEAD" ? undefined : pdf);
6280
6516
  }
6281
6517
 
6518
+ function respondHtmlPreviewResourceJson(req: IncomingMessage, res: ServerResponse, filePath: string, mimeType: string): void {
6519
+ const method = (req.method ?? "GET").toUpperCase();
6520
+ if (method !== "GET" && method !== "HEAD") {
6521
+ res.setHeader("Allow", "GET, HEAD");
6522
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use GET." });
6523
+ return;
6524
+ }
6525
+
6526
+ const data = method === "HEAD" ? "" : readFileSync(filePath).toString("base64");
6527
+ respondJson(res, 200, {
6528
+ ok: true,
6529
+ mimeType,
6530
+ filename: basename(filePath),
6531
+ dataUrl: method === "HEAD" ? "" : `data:${mimeType};base64,${data}`,
6532
+ });
6533
+ }
6534
+
6535
+ function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResponse, requestUrl: URL, resource: StudioLocalPreviewResource, serverState: StudioServerState): void {
6536
+ const method = (req.method ?? "GET").toUpperCase();
6537
+ if (method !== "GET" && method !== "HEAD") {
6538
+ res.setHeader("Allow", "GET, HEAD");
6539
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use GET." });
6540
+ return;
6541
+ }
6542
+
6543
+ const action = (requestUrl.searchParams.get("action") ?? "resolve").trim().toLowerCase();
6544
+ const basePayload = {
6545
+ ok: true,
6546
+ kind: resource.kind,
6547
+ path: resource.filePath,
6548
+ label: resource.label,
6549
+ extension: resource.extension,
6550
+ page: resource.page,
6551
+ resourceDir: resource.resourceDir,
6552
+ };
6553
+
6554
+ if (method === "HEAD" || action === "resolve") {
6555
+ respondJson(res, 200, basePayload);
6556
+ return;
6557
+ }
6558
+
6559
+ if (action !== "document" && action !== "editor-url") {
6560
+ respondJson(res, 400, { ok: false, error: "Unsupported local link action." });
6561
+ return;
6562
+ }
6563
+ if (resource.kind !== "text") {
6564
+ respondJson(res, 400, { ok: false, error: "This local resource is not a text document Studio can load into the editor." });
6565
+ return;
6566
+ }
6567
+
6568
+ const file = readStudioFile(resource.filePath, dirname(resource.filePath));
6569
+ if (file.ok === false) {
6570
+ respondJson(res, 400, { ok: false, error: file.message });
6571
+ return;
6572
+ }
6573
+
6574
+ const document: InitialStudioDocument = {
6575
+ text: file.text,
6576
+ label: resource.label || file.label,
6577
+ source: "file",
6578
+ path: file.resolvedPath,
6579
+ resourceDir: resource.resourceDir,
6580
+ };
6581
+ if (action === "document") {
6582
+ respondJson(res, 200, {
6583
+ ...basePayload,
6584
+ text: file.text,
6585
+ resourceDir: resource.resourceDir,
6586
+ });
6587
+ return;
6588
+ }
6589
+
6590
+ const docId = storeTransientStudioDocument(document);
6591
+ const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId);
6592
+ const parsedUrl = new URL(url);
6593
+ respondJson(res, 200, {
6594
+ ...basePayload,
6595
+ url,
6596
+ relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
6597
+ });
6598
+ }
6599
+
6600
+ function revealStudioLocalFile(filePath: string): { ok: true; message: string } | { ok: false; message: string } {
6601
+ if (isSshSession()) {
6602
+ return { ok: false, message: "Cannot reveal files from an SSH/headless Studio session. Copy the path instead." };
6603
+ }
6604
+
6605
+ let command = "";
6606
+ let args: string[] = [];
6607
+ if (process.platform === "darwin") {
6608
+ command = "open";
6609
+ args = ["-R", filePath];
6610
+ } else if (process.platform === "win32") {
6611
+ command = "explorer.exe";
6612
+ args = [`/select,${filePath}`];
6613
+ } else {
6614
+ command = "xdg-open";
6615
+ args = [dirname(filePath)];
6616
+ }
6617
+
6618
+ const result = spawnSync(command, args, { stdio: "ignore" });
6619
+ if (result.error) {
6620
+ return { ok: false, message: `Could not open file manager: ${result.error.message}` };
6621
+ }
6622
+ if (result.status !== 0) {
6623
+ return { ok: false, message: "Could not open file manager for this resource." };
6624
+ }
6625
+ return { ok: true, message: process.platform === "linux" ? "Opened containing folder." : "Revealed resource in file manager." };
6626
+ }
6627
+
6628
+ async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res: ServerResponse, studioCwd: string): Promise<void> {
6629
+ const method = (req.method ?? "GET").toUpperCase();
6630
+ if (method !== "POST") {
6631
+ res.setHeader("Allow", "POST");
6632
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
6633
+ return;
6634
+ }
6635
+
6636
+ const rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
6637
+ let payload: Record<string, unknown> = {};
6638
+ try {
6639
+ payload = rawBody ? JSON.parse(rawBody) : {};
6640
+ } catch {
6641
+ respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
6642
+ return;
6643
+ }
6644
+
6645
+ try {
6646
+ const resource = resolveStudioLocalPreviewResourcePath(
6647
+ typeof payload.path === "string" ? payload.path : "",
6648
+ typeof payload.sourcePath === "string" ? payload.sourcePath : undefined,
6649
+ typeof payload.resourceDir === "string" ? payload.resourceDir : undefined,
6650
+ studioCwd,
6651
+ );
6652
+ const result = revealStudioLocalFile(resource.filePath);
6653
+ if (!result.ok) {
6654
+ respondJson(res, 409, { ok: false, error: result.message, path: resource.filePath });
6655
+ return;
6656
+ }
6657
+ respondJson(res, 200, { ok: true, message: result.message, path: resource.filePath, label: resource.label });
6658
+ } catch (error) {
6659
+ respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
6660
+ }
6661
+ }
6662
+
6282
6663
  function openUrlInDefaultBrowser(url: string): Promise<void> {
6283
6664
  const openCommand =
6284
6665
  process.platform === "darwin"
@@ -7183,6 +7564,7 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
7183
7564
  requestId: msg.requestId,
7184
7565
  runtime,
7185
7566
  newSession: Boolean(msg.newSession),
7567
+ command: typeof msg.command === "string" ? msg.command : undefined,
7186
7568
  };
7187
7569
  }
7188
7570
  }
@@ -7326,37 +7708,184 @@ function isGenericToolActivityLabel(label: string | null | undefined): boolean {
7326
7708
  || normalized === "editing file";
7327
7709
  }
7328
7710
 
7711
+ function splitStudioShellWords(segment: string): string[] {
7712
+ const words: string[] = [];
7713
+ let current = "";
7714
+ let quote: "'" | "\"" | null = null;
7715
+ let escaped = false;
7716
+
7717
+ for (const char of String(segment || "")) {
7718
+ if (escaped) {
7719
+ current += char;
7720
+ escaped = false;
7721
+ continue;
7722
+ }
7723
+ if (char === "\\" && quote !== "'") {
7724
+ escaped = true;
7725
+ continue;
7726
+ }
7727
+ if (quote) {
7728
+ if (char === quote) quote = null;
7729
+ else current += char;
7730
+ continue;
7731
+ }
7732
+ if (char === "'" || char === "\"") {
7733
+ quote = char;
7734
+ continue;
7735
+ }
7736
+ if (/\s/.test(char)) {
7737
+ if (current) {
7738
+ words.push(current);
7739
+ current = "";
7740
+ }
7741
+ continue;
7742
+ }
7743
+ current += char;
7744
+ }
7745
+ if (escaped) current += "\\";
7746
+ if (current) words.push(current);
7747
+ return words;
7748
+ }
7749
+
7750
+ function normalizeStudioShellCommandToken(token: string): string {
7751
+ let value = String(token || "").trim();
7752
+ if (!value) return "";
7753
+ const slashIndex = value.lastIndexOf("/");
7754
+ if (slashIndex >= 0) value = value.slice(slashIndex + 1);
7755
+ return value.toLowerCase();
7756
+ }
7757
+
7758
+ function isStudioShellAssignmentToken(token: string): boolean {
7759
+ return /^[A-Za-z_][A-Za-z0-9_]*=/.test(String(token || ""));
7760
+ }
7761
+
7762
+ function getStudioShellSegmentCommand(segment: string): { name: string; words: string[]; commandIndex: number } | null {
7763
+ const words = splitStudioShellWords(segment);
7764
+ let index = 0;
7765
+ const skipAssignments = () => {
7766
+ while (index < words.length && isStudioShellAssignmentToken(words[index] || "")) index += 1;
7767
+ };
7768
+
7769
+ skipAssignments();
7770
+ let guard = 0;
7771
+ while (index < words.length && guard < 12) {
7772
+ guard += 1;
7773
+ const name = normalizeStudioShellCommandToken(words[index] || "");
7774
+ if (!name) {
7775
+ index += 1;
7776
+ continue;
7777
+ }
7778
+ if (name === "command" || name === "builtin" || name === "exec") {
7779
+ index += 1;
7780
+ skipAssignments();
7781
+ continue;
7782
+ }
7783
+ if (name === "time") {
7784
+ index += 1;
7785
+ while (index < words.length && String(words[index] || "").startsWith("-")) index += 1;
7786
+ skipAssignments();
7787
+ continue;
7788
+ }
7789
+ if (name === "env") {
7790
+ index += 1;
7791
+ while (index < words.length) {
7792
+ const token = String(words[index] || "");
7793
+ const lowerToken = token.toLowerCase();
7794
+ if (isStudioShellAssignmentToken(token)) {
7795
+ index += 1;
7796
+ continue;
7797
+ }
7798
+ if (lowerToken === "-u" || lowerToken === "--unset" || lowerToken === "-s" || lowerToken === "-S") {
7799
+ index += 2;
7800
+ continue;
7801
+ }
7802
+ if (lowerToken.startsWith("-")) {
7803
+ index += 1;
7804
+ continue;
7805
+ }
7806
+ break;
7807
+ }
7808
+ skipAssignments();
7809
+ continue;
7810
+ }
7811
+ if (name === "sudo") {
7812
+ index += 1;
7813
+ while (index < words.length) {
7814
+ const option = String(words[index] || "");
7815
+ if (!option.startsWith("-")) break;
7816
+ const lowerOption = option.toLowerCase();
7817
+ index += 1;
7818
+ if (["-c", "-g", "-h", "-p", "-t", "-u"].includes(lowerOption) && index < words.length) {
7819
+ index += 1;
7820
+ }
7821
+ }
7822
+ skipAssignments();
7823
+ continue;
7824
+ }
7825
+ return { name, words, commandIndex: index };
7826
+ }
7827
+ return null;
7828
+ }
7829
+
7830
+ function getStudioGitSubcommand(args: string[]): string {
7831
+ for (let index = 0; index < args.length; index += 1) {
7832
+ const token = String(args[index] || "").toLowerCase();
7833
+ if (!token) continue;
7834
+ if (["-c", "-C", "--git-dir", "--work-tree", "--namespace", "--exec-path"].map((value) => value.toLowerCase()).includes(token)) {
7835
+ index += 1;
7836
+ continue;
7837
+ }
7838
+ if (/^--(?:git-dir|work-tree|namespace|exec-path)=/.test(token)) continue;
7839
+ if (token.startsWith("-")) continue;
7840
+ return normalizeStudioShellCommandToken(token);
7841
+ }
7842
+ return "";
7843
+ }
7844
+
7329
7845
  function deriveBashActivityLabel(command: string): string | null {
7330
7846
  const normalized = String(command || "").trim();
7331
7847
  if (!normalized) return null;
7332
- const lower = normalized.toLowerCase();
7333
7848
 
7334
- const segments = lower
7849
+ const segments = normalized
7335
7850
  .split(/(?:&&|\|\||;|\n)+/g)
7336
7851
  .map((segment) => segment.trim())
7337
7852
  .filter((segment) => segment.length > 0);
7338
7853
 
7339
7854
  let hasPwd = false;
7855
+ let hasLs = false;
7340
7856
  let hasLsCurrent = false;
7341
7857
  let hasLsParent = false;
7342
7858
  let hasFind = false;
7343
7859
  let hasFindCurrentListing = false;
7344
7860
  let hasFindParentListing = false;
7861
+ let hasTextSearch = false;
7862
+ let hasFileRead = false;
7863
+ let hasGit = false;
7864
+ let hasGitStatus = false;
7865
+ let hasGitDiff = false;
7866
+ let hasNpm = false;
7867
+ let hasPython = false;
7868
+ let hasNode = false;
7345
7869
 
7346
7870
  for (const segment of segments) {
7347
- if (/\bpwd\b/.test(segment)) hasPwd = true;
7871
+ const commandInfo = getStudioShellSegmentCommand(segment);
7872
+ if (!commandInfo) continue;
7873
+ const commandName = commandInfo.name;
7874
+ const args = commandInfo.words.slice(commandInfo.commandIndex + 1);
7875
+
7876
+ if (commandName === "pwd") hasPwd = true;
7348
7877
 
7349
- if (/\bls\b/.test(segment)) {
7350
- if (/\.\./.test(segment)) hasLsParent = true;
7878
+ if (commandName === "ls") {
7879
+ hasLs = true;
7880
+ if (args.some((arg) => arg === ".." || arg === "../" || arg.startsWith("../"))) hasLsParent = true;
7351
7881
  else hasLsCurrent = true;
7352
7882
  }
7353
7883
 
7354
- if (/\bfind\b/.test(segment)) {
7884
+ if (commandName === "find") {
7355
7885
  hasFind = true;
7356
- const pathMatch = segment.match(/\bfind\s+([^\s]+)/);
7357
- const pathToken = pathMatch ? pathMatch[1] : "";
7358
- const hasSelector = /-(?:name|iname|regex|path|ipath|newer|mtime|mmin|size|user|group)\b/.test(segment);
7359
- const listingLike = /-maxdepth\s+\d+\b/.test(segment) && !hasSelector;
7886
+ const pathToken = args.find((arg) => arg && !String(arg).startsWith("-")) || "";
7887
+ const hasSelector = /-(?:name|iname|regex|path|ipath|newer|mtime|mmin|size|user|group)\b/i.test(segment);
7888
+ const listingLike = /-maxdepth\s+\d+\b/i.test(segment) && !hasSelector;
7360
7889
 
7361
7890
  if (listingLike) {
7362
7891
  if (pathToken === ".." || pathToken === "../") {
@@ -7366,6 +7895,18 @@ function deriveBashActivityLabel(command: string): string | null {
7366
7895
  }
7367
7896
  }
7368
7897
  }
7898
+
7899
+ if (commandName === "rg" || commandName === "grep") hasTextSearch = true;
7900
+ if (commandName === "cat" || commandName === "sed" || commandName === "awk") hasFileRead = true;
7901
+ if (commandName === "git") {
7902
+ hasGit = true;
7903
+ const subcommand = getStudioGitSubcommand(args);
7904
+ if (subcommand === "status") hasGitStatus = true;
7905
+ if (subcommand === "diff") hasGitDiff = true;
7906
+ }
7907
+ if (commandName === "npm") hasNpm = true;
7908
+ if (/^python(?:3(?:\.\d+)?)?$/.test(commandName)) hasPython = true;
7909
+ if (commandName === "node") hasNode = true;
7369
7910
  }
7370
7911
 
7371
7912
  const hasCurrentListing = hasLsCurrent || hasFindCurrentListing;
@@ -7380,34 +7921,34 @@ function deriveBashActivityLabel(command: string): string | null {
7380
7921
  if (hasParentListing) {
7381
7922
  return "Listing parent directory files";
7382
7923
  }
7383
- if (hasCurrentListing || /\bls\b/.test(lower)) {
7924
+ if (hasCurrentListing || hasLs) {
7384
7925
  return "Listing directory files";
7385
7926
  }
7386
- if (hasFind || /\bfind\b/.test(lower)) {
7927
+ if (hasFind) {
7387
7928
  return "Searching files";
7388
7929
  }
7389
- if (/\brg\b/.test(lower) || /\bgrep\b/.test(lower)) {
7930
+ if (hasTextSearch) {
7390
7931
  return "Searching text in files";
7391
7932
  }
7392
- if (/\bcat\b/.test(lower) || /\bsed\b/.test(lower) || /\bawk\b/.test(lower)) {
7933
+ if (hasFileRead) {
7393
7934
  return "Reading file content";
7394
7935
  }
7395
- if (/\bgit\s+status\b/.test(lower)) {
7936
+ if (hasGitStatus) {
7396
7937
  return "Checking git status";
7397
7938
  }
7398
- if (/\bgit\s+diff\b/.test(lower)) {
7939
+ if (hasGitDiff) {
7399
7940
  return "Reviewing git changes";
7400
7941
  }
7401
- if (/\bgit\b/.test(lower)) {
7942
+ if (hasGit) {
7402
7943
  return "Running git command";
7403
7944
  }
7404
- if (/\bnpm\b/.test(lower)) {
7945
+ if (hasNpm) {
7405
7946
  return "Running npm command";
7406
7947
  }
7407
- if (/\bpython3?\b/.test(lower)) {
7948
+ if (hasPython) {
7408
7949
  return "Running Python command";
7409
7950
  }
7410
- if (/\bnode\b/.test(lower)) {
7951
+ if (hasNode) {
7411
7952
  return "Running Node.js command";
7412
7953
  }
7413
7954
  return "Running shell command";
@@ -7749,7 +8290,7 @@ function normalizeStudioReplRuntime(value: unknown): StudioReplRuntime | null {
7749
8290
  return isStudioReplRuntime(normalized) ? normalized : null;
7750
8291
  }
7751
8292
 
7752
- function getStudioReplRuntimeCommand(runtime: StudioReplRuntime): string {
8293
+ function getDefaultStudioReplRuntimeCommand(runtime: StudioReplRuntime): string {
7753
8294
  if (runtime === "shell") return String(process.env.SHELL || "bash").trim() || "bash";
7754
8295
  if (runtime === "python") return "python3";
7755
8296
  if (runtime === "ipython") return "ipython";
@@ -7759,13 +8300,31 @@ function getStudioReplRuntimeCommand(runtime: StudioReplRuntime): string {
7759
8300
  return "clojure";
7760
8301
  }
7761
8302
 
7762
- function getStudioReplSessionName(runtime: StudioReplRuntime): string {
7763
- return `pi-studio-repl-${runtime}`;
8303
+ function normalizeStudioReplCommandOverride(runtime: StudioReplRuntime, command: string | undefined): string | undefined {
8304
+ const normalized = String(command ?? "").replace(/\r?\n/g, " ").replace(/\s+/g, " ").trim();
8305
+ if (!normalized) return undefined;
8306
+ if (normalized.length > 240) return undefined;
8307
+ if (normalized === getDefaultStudioReplRuntimeCommand(runtime)) return undefined;
8308
+ return normalized;
8309
+ }
8310
+
8311
+ function getStudioReplRuntimeCommand(runtime: StudioReplRuntime, command?: string): string {
8312
+ return normalizeStudioReplCommandOverride(runtime, command) || getDefaultStudioReplRuntimeCommand(runtime);
8313
+ }
8314
+
8315
+ function getStudioReplCommandSessionSuffix(runtime: StudioReplRuntime, command?: string): string {
8316
+ const normalized = normalizeStudioReplCommandOverride(runtime, command);
8317
+ if (!normalized) return "";
8318
+ return `-${createHash("sha1").update(`${runtime}\n${normalized}`).digest("hex").slice(0, 8)}`;
8319
+ }
8320
+
8321
+ function getStudioReplSessionName(runtime: StudioReplRuntime, command?: string): string {
8322
+ return `pi-studio-repl-${runtime}${getStudioReplCommandSessionSuffix(runtime, command)}`;
7764
8323
  }
7765
8324
 
7766
- function getNewStudioReplSessionName(runtime: StudioReplRuntime): string {
8325
+ function getNewStudioReplSessionName(runtime: StudioReplRuntime, command?: string): string {
7767
8326
  const suffix = `${Date.now().toString(36)}-${randomUUID().slice(0, 6)}`;
7768
- return `pi-studio-repl-${runtime}-${suffix}`;
8327
+ return `pi-studio-repl-${runtime}${getStudioReplCommandSessionSuffix(runtime, command)}-${suffix}`;
7769
8328
  }
7770
8329
 
7771
8330
  function getStudioReplPaneTarget(sessionName: string): string {
@@ -7865,9 +8424,10 @@ function captureStudioReplSession(sessionName: string): { ok: true; transcript:
7865
8424
  return { ok: true, transcript: String(result.stdout || "").replace(/[\t ]+$/gm, "").trimEnd(), session };
7866
8425
  }
7867
8426
 
7868
- function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options?: { newSession?: boolean }): { ok: true; session: StudioReplSessionInfo; message: string } | { ok: false; message: string } {
8427
+ function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options?: { newSession?: boolean; command?: string }): { ok: true; session: StudioReplSessionInfo; message: string } | { ok: false; message: string } {
7869
8428
  if (!isTmuxAvailable()) return { ok: false, message: "tmux is not available. Install tmux to use Studio REPL sessions." };
7870
- const sessionName = options?.newSession ? getNewStudioReplSessionName(runtime) : getStudioReplSessionName(runtime);
8429
+ const commandOverride = normalizeStudioReplCommandOverride(runtime, options?.command);
8430
+ const sessionName = options?.newSession ? getNewStudioReplSessionName(runtime, commandOverride) : getStudioReplSessionName(runtime, commandOverride);
7871
8431
  const existing = runStudioTmux(["has-session", "-t", sessionName], { timeout: 3_000 });
7872
8432
  if (existing.ok) {
7873
8433
  const inferred = inferStudioReplSessionRuntime(sessionName);
@@ -7883,7 +8443,7 @@ function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options
7883
8443
  message: `${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL is already running.`,
7884
8444
  };
7885
8445
  }
7886
- const command = getStudioReplRuntimeCommand(runtime);
8446
+ const command = getStudioReplRuntimeCommand(runtime, commandOverride);
7887
8447
  const result = runStudioTmux(["new-session", "-d", "-s", sessionName, "-c", cwd || process.cwd(), command], { timeout: 5_000 });
7888
8448
  if (!result.ok) return { ok: false, message: result.message || `Failed to start ${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL.` };
7889
8449
  return {
@@ -7895,7 +8455,7 @@ function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options
7895
8455
  label: formatStudioReplSessionLabel(sessionName, runtime, "studio"),
7896
8456
  source: "studio",
7897
8457
  },
7898
- message: `Started ${options?.newSession ? "new " : ""}${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL.`,
8458
+ message: `Started ${options?.newSession ? "new " : ""}${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL${commandOverride ? ` with custom command: ${commandOverride}` : ""}.`,
7899
8459
  };
7900
8460
  }
7901
8461
 
@@ -8398,7 +8958,7 @@ function resolveRequestedStudioDocumentFromUrl(
8398
8958
  label: requestedLabel || file.label,
8399
8959
  source: "file",
8400
8960
  path: file.resolvedPath,
8401
- resourceDir: requestedResourceDir || undefined,
8961
+ resourceDir: requestedResourceDir || dirname(file.resolvedPath),
8402
8962
  };
8403
8963
  }
8404
8964
  }
@@ -8754,6 +9314,7 @@ ${cssVarsBlock}
8754
9314
  <button id="saveAsBtn" type="button" title="Save editor content to a new file path. Cmd/Ctrl+S falls back here when no direct save path is available.">Save editor as…</button>
8755
9315
  <button id="saveOverBtn" type="button" title="Overwrite current file with editor content. Shortcut: Cmd/Ctrl+S.">Save editor</button>
8756
9316
  <button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
9317
+ <button id="clearWorkspaceBtn" type="button" title="Clear the current editor draft in this browser tab. Saved files and responses are not changed.">Clear editor</button>
8757
9318
  <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>
8758
9319
  <button id="loadGitDiffBtn" type="button" title="Load the current git diff from the Studio context into the editor.">Load git diff</button>
8759
9320
  <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
@@ -8937,6 +9498,8 @@ ${cssVarsBlock}
8937
9498
  </div>
8938
9499
  </section>
8939
9500
 
9501
+ <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>
9502
+
8940
9503
  <section id="rightPane">
8941
9504
  <div id="rightSectionHeader" class="section-header">
8942
9505
  <div class="section-header-main">
@@ -9045,6 +9608,14 @@ ${cssVarsBlock}
9045
9608
  <div><dt>Tab / Shift+Tab</dt><dd>Indent or unindent selected editor text</dd></div>
9046
9609
  </dl>
9047
9610
  </section>
9611
+ <section class="shortcuts-group">
9612
+ <h3>Response</h3>
9613
+ <dl>
9614
+ <div><dt>Alt/Option+←</dt><dd>Previous response when not editing text</dd></div>
9615
+ <div><dt>Alt/Option+→</dt><dd>Next response when not editing text</dd></div>
9616
+ <div><dt>Alt/Option+l</dt><dd>Latest response when not editing text</dd></div>
9617
+ </dl>
9618
+ </section>
9048
9619
  <section class="shortcuts-group">
9049
9620
  <h3>REPL</h3>
9050
9621
  <dl>
@@ -9998,7 +10569,11 @@ export default function (pi: ExtensionAPI) {
9998
10569
  }
9999
10570
  lastSpecificToolActivityLabel = baseLabel;
10000
10571
  } else {
10001
- nextLabel = baseLabel;
10572
+ // Generic shell/tool labels such as "Running git command" are often
10573
+ // stale or too broad once the model has moved on. Keep the precise
10574
+ // Working trace entry, but do not promote generic labels into the
10575
+ // live Studio/terminal status line.
10576
+ nextLabel = null;
10002
10577
  }
10003
10578
  } else {
10004
10579
  nextLabel = null;
@@ -10759,7 +11334,7 @@ export default function (pi: ExtensionAPI) {
10759
11334
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
10760
11335
  return;
10761
11336
  }
10762
- const started = startStudioReplSession(msg.runtime, studioCwd, { newSession: msg.newSession });
11337
+ const started = startStudioReplSession(msg.runtime, studioCwd, { newSession: msg.newSession, command: msg.command });
10763
11338
  if (!started.ok) {
10764
11339
  sendReplStateToClient(client, { requestId: msg.requestId, replError: started.message });
10765
11340
  sendToClient(client, { type: "error", requestId: msg.requestId, message: started.message });
@@ -10972,6 +11547,7 @@ export default function (pi: ExtensionAPI) {
10972
11547
  label: result.label,
10973
11548
  source: "file",
10974
11549
  path: result.resolvedPath,
11550
+ resourceDir: dirname(result.resolvedPath),
10975
11551
  };
10976
11552
 
10977
11553
  sendToClient(client, {
@@ -10979,6 +11555,7 @@ export default function (pi: ExtensionAPI) {
10979
11555
  requestId: msg.requestId,
10980
11556
  path: result.resolvedPath,
10981
11557
  label: result.label,
11558
+ resourceDir: dirname(result.resolvedPath),
10982
11559
  message: `Saved editor text to ${result.label}`,
10983
11560
  });
10984
11561
  return;
@@ -11058,6 +11635,7 @@ export default function (pi: ExtensionAPI) {
11058
11635
  label: refreshed.label,
11059
11636
  source: "file",
11060
11637
  path: refreshed.resolvedPath,
11638
+ resourceDir: dirname(refreshed.resolvedPath),
11061
11639
  };
11062
11640
 
11063
11641
  broadcast({
@@ -12091,6 +12669,40 @@ export default function (pi: ExtensionAPI) {
12091
12669
  return;
12092
12670
  }
12093
12671
 
12672
+ if (requestUrl.pathname === "/local-preview-link") {
12673
+ const token = requestUrl.searchParams.get("token") ?? "";
12674
+ if (token !== serverState.token) {
12675
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
12676
+ return;
12677
+ }
12678
+
12679
+ try {
12680
+ const resource = resolveStudioLocalPreviewResourcePath(
12681
+ requestUrl.searchParams.get("path") ?? "",
12682
+ requestUrl.searchParams.get("sourcePath") ?? undefined,
12683
+ requestUrl.searchParams.get("resourceDir") ?? undefined,
12684
+ studioCwd,
12685
+ );
12686
+ respondLocalPreviewLinkJson(req, res, requestUrl, resource, serverState);
12687
+ } catch (error) {
12688
+ respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
12689
+ }
12690
+ return;
12691
+ }
12692
+
12693
+ if (requestUrl.pathname === "/reveal-local-resource") {
12694
+ const token = requestUrl.searchParams.get("token") ?? "";
12695
+ if (token !== serverState.token) {
12696
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
12697
+ return;
12698
+ }
12699
+
12700
+ void handleRevealLocalPreviewResourceRequest(req, res, studioCwd).catch((error) => {
12701
+ respondJson(res, 500, { ok: false, error: `Reveal failed: ${error instanceof Error ? error.message : String(error)}` });
12702
+ });
12703
+ return;
12704
+ }
12705
+
12094
12706
  if (requestUrl.pathname === "/pdf-resource") {
12095
12707
  const token = requestUrl.searchParams.get("token") ?? "";
12096
12708
  if (token !== serverState.token) {
@@ -12112,6 +12724,27 @@ export default function (pi: ExtensionAPI) {
12112
12724
  return;
12113
12725
  }
12114
12726
 
12727
+ if (requestUrl.pathname === "/html-preview-resource") {
12728
+ const token = requestUrl.searchParams.get("token") ?? "";
12729
+ if (token !== serverState.token) {
12730
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
12731
+ return;
12732
+ }
12733
+
12734
+ try {
12735
+ const resource = resolveStudioHtmlPreviewResourcePath(
12736
+ requestUrl.searchParams.get("path") ?? "",
12737
+ requestUrl.searchParams.get("sourcePath") ?? undefined,
12738
+ requestUrl.searchParams.get("resourceDir") ?? undefined,
12739
+ studioCwd,
12740
+ );
12741
+ respondHtmlPreviewResourceJson(req, res, resource.filePath, resource.mimeType);
12742
+ } catch (error) {
12743
+ respondJson(res, 404, { ok: false, error: `HTML preview resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
12744
+ }
12745
+ return;
12746
+ }
12747
+
12115
12748
  if (requestUrl.pathname !== "/") {
12116
12749
  respondText(res, 404, "Not found");
12117
12750
  return;
@@ -12731,6 +13364,7 @@ export default function (pi: ExtensionAPI) {
12731
13364
  label: file.label,
12732
13365
  source: "file",
12733
13366
  path: file.resolvedPath,
13367
+ resourceDir: dirname(file.resolvedPath),
12734
13368
  };
12735
13369
  };
12736
13370