pi-studio 0.9.12 → 0.9.14

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/client/studio.css CHANGED
@@ -396,7 +396,6 @@
396
396
  body[data-studio-mode="editor-only"] #sendRunBtn,
397
397
  body[data-studio-mode="editor-only"] #queueSteerBtn,
398
398
  body[data-studio-mode="editor-only"] #sendEditorBtn,
399
- body[data-studio-mode="editor-only"] #insertHeaderBtn,
400
399
  body[data-studio-mode="editor-only"] #lensSelect,
401
400
  body[data-studio-mode="editor-only"] #critiqueBtn,
402
401
  body[data-studio-mode="editor-only"] #followSelect,
@@ -642,6 +641,7 @@
642
641
  overflow: hidden;
643
642
  text-overflow: ellipsis;
644
643
  white-space: nowrap;
644
+ font-weight: 400;
645
645
  }
646
646
  .resource-dir-label:hover {
647
647
  color: var(--fg);
@@ -3584,17 +3584,29 @@
3584
3584
 
3585
3585
  .shortcuts-body {
3586
3586
  display: grid;
3587
- gap: 12px;
3588
- padding: 14px 18px 16px;
3587
+ gap: 16px;
3588
+ padding: 16px 18px 18px;
3589
3589
  overflow-y: auto;
3590
3590
  overflow-x: hidden;
3591
3591
  min-height: 0;
3592
3592
  background: var(--panel);
3593
3593
  }
3594
3594
 
3595
+ .shortcuts-group {
3596
+ display: block;
3597
+ min-height: auto;
3598
+ border: 0;
3599
+ border-radius: 0;
3600
+ background: transparent;
3601
+ box-shadow: none;
3602
+ overflow: visible;
3603
+ }
3604
+
3595
3605
  .shortcuts-group h3 {
3596
- margin: 0 0 6px;
3606
+ margin: 0 0 8px;
3607
+ padding-top: 3px;
3597
3608
  font-size: 12px;
3609
+ line-height: 1.5;
3598
3610
  font-weight: 700;
3599
3611
  color: var(--studio-info-text, var(--muted));
3600
3612
  text-transform: uppercase;
@@ -4260,7 +4272,6 @@
4260
4272
  }
4261
4273
 
4262
4274
  body.studio-ui-refresh #resourceDirBtn,
4263
- body.studio-ui-refresh #resourceDirLabel,
4264
4275
  body.studio-ui-refresh #reviewNotesBtn,
4265
4276
  body.studio-ui-refresh #outlineBtn,
4266
4277
  body.studio-ui-refresh #scratchpadBtn,
@@ -4268,6 +4279,11 @@
4268
4279
  color: var(--text);
4269
4280
  }
4270
4281
 
4282
+ body.studio-ui-refresh #resourceDirLabel {
4283
+ color: var(--studio-info-text, var(--muted));
4284
+ font-weight: 400;
4285
+ }
4286
+
4271
4287
  body.studio-ui-refresh #resourceDirInputWrap.visible {
4272
4288
  display: inline-flex;
4273
4289
  }
@@ -4471,19 +4487,24 @@
4471
4487
  justify-self: stretch;
4472
4488
  min-width: 0;
4473
4489
  max-width: 100%;
4474
- overflow: hidden;
4490
+ overflow: visible;
4475
4491
  position: relative;
4476
4492
  z-index: 1;
4477
4493
  }
4478
4494
 
4479
4495
  body.studio-ui-refresh .studio-refresh-toolbar-state .studio-refresh-action-line {
4480
4496
  max-width: 100%;
4481
- overflow: hidden;
4497
+ overflow: visible;
4482
4498
  flex-wrap: nowrap;
4483
4499
  justify-content: flex-end;
4484
4500
  white-space: nowrap;
4485
4501
  }
4486
4502
 
4503
+ body.studio-ui-refresh .studio-refresh-toolbar-state .studio-refresh-menu-anchor {
4504
+ min-width: 0;
4505
+ max-width: 100%;
4506
+ }
4507
+
4487
4508
  body.studio-ui-refresh .studio-refresh-toolbar-state button,
4488
4509
  body.studio-ui-refresh .studio-refresh-toolbar-state select,
4489
4510
  body.studio-ui-refresh .studio-refresh-toolbar-state .studio-refresh-chip {
@@ -4491,6 +4512,11 @@
4491
4512
  max-width: 100%;
4492
4513
  }
4493
4514
 
4515
+ body.studio-ui-refresh .studio-refresh-toolbar-state .studio-refresh-chip {
4516
+ overflow: hidden;
4517
+ text-overflow: ellipsis;
4518
+ }
4519
+
4494
4520
  body.studio-ui-refresh .studio-refresh-toolbar button,
4495
4521
  body.studio-ui-refresh .studio-refresh-toolbar select {
4496
4522
  font-size: 12px;
@@ -4674,6 +4700,41 @@
4674
4700
  font-weight: 600;
4675
4701
  }
4676
4702
 
4703
+ .studio-preview-link-menu {
4704
+ position: fixed;
4705
+ z-index: 12000;
4706
+ min-width: 180px;
4707
+ padding: 6px;
4708
+ border: 1px solid var(--border);
4709
+ border-radius: 10px;
4710
+ background: var(--panel);
4711
+ color: var(--text);
4712
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
4713
+ }
4714
+
4715
+ .studio-preview-link-menu[hidden] {
4716
+ display: none;
4717
+ }
4718
+
4719
+ .studio-preview-link-menu button {
4720
+ display: block;
4721
+ width: 100%;
4722
+ border: 0;
4723
+ border-radius: 7px;
4724
+ background: transparent;
4725
+ color: inherit;
4726
+ text-align: left;
4727
+ padding: 7px 9px;
4728
+ font: inherit;
4729
+ cursor: pointer;
4730
+ }
4731
+
4732
+ .studio-preview-link-menu button:hover,
4733
+ .studio-preview-link-menu button:focus-visible {
4734
+ background: var(--panel-2);
4735
+ outline: none;
4736
+ }
4737
+
4677
4738
  @media (max-width: 1280px) {
4678
4739
  body.studio-ui-refresh #rightSectionHeader,
4679
4740
  body.studio-ui-refresh .studio-refresh-header-top,
package/index.ts CHANGED
@@ -1862,6 +1862,36 @@ function expandHome(pathInput: string): string {
1862
1862
  return join(home, pathInput.slice(2));
1863
1863
  }
1864
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
+
1865
1895
  function resolveStudioPath(pathArg: string, cwd: string): { ok: true; resolved: string; label: string } | { ok: false; message: string } {
1866
1896
  const normalized = normalizePathInput(pathArg);
1867
1897
  if (!normalized) {
@@ -2332,23 +2362,53 @@ function resolveStudioBaseDir(sourcePath: string | undefined, resourceDir: strin
2332
2362
  return dirname(isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded));
2333
2363
  }
2334
2364
 
2335
- const resource = typeof resourceDir === "string" ? resourceDir.trim() : "";
2365
+ const resource = normalizeStudioResourceDirectoryInput(typeof resourceDir === "string" ? resourceDir : "");
2336
2366
  if (resource) {
2337
- const expanded = expandHome(resource);
2367
+ const expanded = recoverLikelyDroppedLeadingSlashPath(expandHome(resource));
2338
2368
  return isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded);
2339
2369
  }
2340
2370
 
2341
2371
  return fallbackCwd;
2342
2372
  }
2343
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
+
2344
2404
  function resolveStudioGitDiffBaseDir(sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
2345
2405
  return resolveStudioBaseDir(sourcePath, resourceDir, fallbackCwd);
2346
2406
  }
2347
2407
 
2348
2408
  function resolveStudioCompanionResourceDir(sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string | undefined {
2349
- const explicitResource = typeof resourceDir === "string" ? resourceDir.trim() : "";
2409
+ const explicitResource = normalizeStudioResourceDirectoryInput(typeof resourceDir === "string" ? resourceDir : "");
2350
2410
  if (explicitResource) {
2351
- const expanded = expandHome(explicitResource);
2411
+ const expanded = recoverLikelyDroppedLeadingSlashPath(expandHome(explicitResource));
2352
2412
  return isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded);
2353
2413
  }
2354
2414
 
@@ -2373,10 +2433,51 @@ const STUDIO_HTML_PREVIEW_IMAGE_MIME_BY_EXT = new Map<string, string>([
2373
2433
  [".gif", "image/gif"],
2374
2434
  [".webp", "image/webp"],
2375
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
+ }
2376
2456
 
2377
2457
  function resolveStudioPdfResourcePath(pdfPath: string | undefined, sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
2378
- const baseDir = resolveStudioBaseDir(sourcePath, resourceDir, fallbackCwd);
2379
- 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;
2380
2481
  }
2381
2482
 
2382
2483
  function stripStudioHtmlPreviewResourceUrlSuffix(resourcePath: string): string {
@@ -2392,6 +2493,82 @@ function decodeStudioHtmlPreviewResourcePath(resourcePath: string): string {
2392
2493
  }
2393
2494
  }
2394
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
+
2395
2572
  function resolveStudioHtmlPreviewResourcePath(
2396
2573
  resourcePath: string | undefined,
2397
2574
  sourcePath: string | undefined,
@@ -2405,17 +2582,17 @@ function resolveStudioHtmlPreviewResourcePath(
2405
2582
  throw new Error("Only local HTML preview resources are supported.");
2406
2583
  }
2407
2584
 
2408
- const baseDir = resolveStudioBaseDir(sourcePath, resourceDir, fallbackCwd);
2585
+ const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
2409
2586
  const cleanedPath = decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(rawPath));
2410
- const expandedPath = expandHome(cleanedPath);
2411
- const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(baseDir, expandedPath);
2587
+ const expandedPath = recoverLikelyDroppedLeadingSlashPath(expandHome(cleanedPath));
2588
+ const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(context.baseDir, expandedPath);
2412
2589
  const ext = extname(candidate).toLowerCase();
2413
2590
  const mimeType = STUDIO_HTML_PREVIEW_IMAGE_MIME_BY_EXT.get(ext);
2414
2591
  if (!mimeType) throw new Error("Only local PNG, JPEG, GIF, and WebP images can be embedded in HTML previews.");
2415
2592
 
2416
- const baseReal = realpathSync(baseDir);
2593
+ const boundaryReal = realpathSync(context.boundaryDir);
2417
2594
  const candidateReal = realpathSync(candidate);
2418
- const rel = relative(baseReal, candidateReal);
2595
+ const rel = relative(boundaryReal, candidateReal);
2419
2596
  if (rel.startsWith("..") || isAbsolute(rel)) {
2420
2597
  throw new Error("HTML preview resource path must stay within the current Studio resource directory.");
2421
2598
  }
@@ -6355,6 +6532,134 @@ function respondHtmlPreviewResourceJson(req: IncomingMessage, res: ServerRespons
6355
6532
  });
6356
6533
  }
6357
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
+
6358
6663
  function openUrlInDefaultBrowser(url: string): Promise<void> {
6359
6664
  const openCommand =
6360
6665
  process.platform === "darwin"
@@ -8653,7 +8958,7 @@ function resolveRequestedStudioDocumentFromUrl(
8653
8958
  label: requestedLabel || file.label,
8654
8959
  source: "file",
8655
8960
  path: file.resolvedPath,
8656
- resourceDir: requestedResourceDir || undefined,
8961
+ resourceDir: requestedResourceDir || dirname(file.resolvedPath),
8657
8962
  };
8658
8963
  }
8659
8964
  }
@@ -9009,6 +9314,7 @@ ${cssVarsBlock}
9009
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>
9010
9315
  <button id="saveOverBtn" type="button" title="Overwrite current file with editor content. Shortcut: Cmd/Ctrl+S.">Save editor</button>
9011
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>
9012
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>
9013
9319
  <button id="loadGitDiffBtn" type="button" title="Load the current git diff from the Studio context into the editor.">Load git diff</button>
9014
9320
  <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
@@ -9294,6 +9600,14 @@ ${cssVarsBlock}
9294
9600
  <div><dt>?</dt><dd>Show keyboard shortcuts when not editing text</dd></div>
9295
9601
  </dl>
9296
9602
  </section>
9603
+ <section class="shortcuts-group">
9604
+ <h3>View</h3>
9605
+ <dl>
9606
+ <div><dt>Alt/Option+=</dt><dd>Increase the active pane's text size when not editing text</dd></div>
9607
+ <div><dt>Alt/Option+-</dt><dd>Decrease the active pane's text size when not editing text</dd></div>
9608
+ <div><dt>Alt/Option+0</dt><dd>Reset the active pane's text size when not editing text</dd></div>
9609
+ </dl>
9610
+ </section>
9297
9611
  <section class="shortcuts-group">
9298
9612
  <h3>Editor</h3>
9299
9613
  <dl>
@@ -11241,6 +11555,7 @@ export default function (pi: ExtensionAPI) {
11241
11555
  label: result.label,
11242
11556
  source: "file",
11243
11557
  path: result.resolvedPath,
11558
+ resourceDir: dirname(result.resolvedPath),
11244
11559
  };
11245
11560
 
11246
11561
  sendToClient(client, {
@@ -11248,6 +11563,7 @@ export default function (pi: ExtensionAPI) {
11248
11563
  requestId: msg.requestId,
11249
11564
  path: result.resolvedPath,
11250
11565
  label: result.label,
11566
+ resourceDir: dirname(result.resolvedPath),
11251
11567
  message: `Saved editor text to ${result.label}`,
11252
11568
  });
11253
11569
  return;
@@ -11327,6 +11643,7 @@ export default function (pi: ExtensionAPI) {
11327
11643
  label: refreshed.label,
11328
11644
  source: "file",
11329
11645
  path: refreshed.resolvedPath,
11646
+ resourceDir: dirname(refreshed.resolvedPath),
11330
11647
  };
11331
11648
 
11332
11649
  broadcast({
@@ -12360,6 +12677,40 @@ export default function (pi: ExtensionAPI) {
12360
12677
  return;
12361
12678
  }
12362
12679
 
12680
+ if (requestUrl.pathname === "/local-preview-link") {
12681
+ const token = requestUrl.searchParams.get("token") ?? "";
12682
+ if (token !== serverState.token) {
12683
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
12684
+ return;
12685
+ }
12686
+
12687
+ try {
12688
+ const resource = resolveStudioLocalPreviewResourcePath(
12689
+ requestUrl.searchParams.get("path") ?? "",
12690
+ requestUrl.searchParams.get("sourcePath") ?? undefined,
12691
+ requestUrl.searchParams.get("resourceDir") ?? undefined,
12692
+ studioCwd,
12693
+ );
12694
+ respondLocalPreviewLinkJson(req, res, requestUrl, resource, serverState);
12695
+ } catch (error) {
12696
+ respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
12697
+ }
12698
+ return;
12699
+ }
12700
+
12701
+ if (requestUrl.pathname === "/reveal-local-resource") {
12702
+ const token = requestUrl.searchParams.get("token") ?? "";
12703
+ if (token !== serverState.token) {
12704
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
12705
+ return;
12706
+ }
12707
+
12708
+ void handleRevealLocalPreviewResourceRequest(req, res, studioCwd).catch((error) => {
12709
+ respondJson(res, 500, { ok: false, error: `Reveal failed: ${error instanceof Error ? error.message : String(error)}` });
12710
+ });
12711
+ return;
12712
+ }
12713
+
12363
12714
  if (requestUrl.pathname === "/pdf-resource") {
12364
12715
  const token = requestUrl.searchParams.get("token") ?? "";
12365
12716
  if (token !== serverState.token) {
@@ -12955,6 +13306,7 @@ export default function (pi: ExtensionAPI) {
12955
13306
  label: "last model response",
12956
13307
  source: "last-response",
12957
13308
  draftId: createStudioDraftId(),
13309
+ resourceDir: ctx.cwd,
12958
13310
  };
12959
13311
  }
12960
13312
  return {
@@ -12962,6 +13314,7 @@ export default function (pi: ExtensionAPI) {
12962
13314
  label: "blank",
12963
13315
  source: "blank",
12964
13316
  draftId: createStudioDraftId(),
13317
+ resourceDir: ctx.cwd,
12965
13318
  };
12966
13319
  }
12967
13320
 
@@ -12971,6 +13324,7 @@ export default function (pi: ExtensionAPI) {
12971
13324
  label: "blank",
12972
13325
  source: "blank",
12973
13326
  draftId: createStudioDraftId(),
13327
+ resourceDir: ctx.cwd,
12974
13328
  };
12975
13329
  }
12976
13330
 
@@ -12982,6 +13336,7 @@ export default function (pi: ExtensionAPI) {
12982
13336
  label: "blank",
12983
13337
  source: "blank",
12984
13338
  draftId: createStudioDraftId(),
13339
+ resourceDir: ctx.cwd,
12985
13340
  };
12986
13341
  }
12987
13342
  return {
@@ -12989,6 +13344,7 @@ export default function (pi: ExtensionAPI) {
12989
13344
  label: "last model response",
12990
13345
  source: "last-response",
12991
13346
  draftId: createStudioDraftId(),
13347
+ resourceDir: ctx.cwd,
12992
13348
  };
12993
13349
  }
12994
13350
 
@@ -13021,6 +13377,7 @@ export default function (pi: ExtensionAPI) {
13021
13377
  label: file.label,
13022
13378
  source: "file",
13023
13379
  path: file.resolvedPath,
13380
+ resourceDir: dirname(file.resolvedPath),
13024
13381
  };
13025
13382
  };
13026
13383
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.12",
3
+ "version": "0.9.14",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",