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/CHANGELOG.md +25 -0
- package/README.md +3 -1
- package/client/studio-client.js +1337 -114
- package/client/studio.css +236 -33
- package/index.ts +673 -39
- package/package.json +1 -1
package/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { completeSimple, type ThinkingLevel } from "@earendil-works/pi-ai";
|
|
|
4
4
|
import { Type } from "@sinclair/typebox";
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
9
9
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
10
10
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -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
|
|
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
|
|
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
|
|
2369
|
-
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
7350
|
-
|
|
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 (
|
|
7884
|
+
if (commandName === "find") {
|
|
7355
7885
|
hasFind = true;
|
|
7356
|
-
const
|
|
7357
|
-
const
|
|
7358
|
-
const
|
|
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 ||
|
|
7924
|
+
if (hasCurrentListing || hasLs) {
|
|
7384
7925
|
return "Listing directory files";
|
|
7385
7926
|
}
|
|
7386
|
-
if (hasFind
|
|
7927
|
+
if (hasFind) {
|
|
7387
7928
|
return "Searching files";
|
|
7388
7929
|
}
|
|
7389
|
-
if (
|
|
7930
|
+
if (hasTextSearch) {
|
|
7390
7931
|
return "Searching text in files";
|
|
7391
7932
|
}
|
|
7392
|
-
if (
|
|
7933
|
+
if (hasFileRead) {
|
|
7393
7934
|
return "Reading file content";
|
|
7394
7935
|
}
|
|
7395
|
-
if (
|
|
7936
|
+
if (hasGitStatus) {
|
|
7396
7937
|
return "Checking git status";
|
|
7397
7938
|
}
|
|
7398
|
-
if (
|
|
7939
|
+
if (hasGitDiff) {
|
|
7399
7940
|
return "Reviewing git changes";
|
|
7400
7941
|
}
|
|
7401
|
-
if (
|
|
7942
|
+
if (hasGit) {
|
|
7402
7943
|
return "Running git command";
|
|
7403
7944
|
}
|
|
7404
|
-
if (
|
|
7945
|
+
if (hasNpm) {
|
|
7405
7946
|
return "Running npm command";
|
|
7406
7947
|
}
|
|
7407
|
-
if (
|
|
7948
|
+
if (hasPython) {
|
|
7408
7949
|
return "Running Python command";
|
|
7409
7950
|
}
|
|
7410
|
-
if (
|
|
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
|
|
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
|
|
7763
|
-
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
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
|
|