pi-studio 0.6.9 → 0.7.0
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 +15 -0
- package/README.md +25 -2
- package/client/studio-client.js +286 -3
- package/client/studio.css +70 -2
- package/index.ts +194 -8
- package/package.json +1 -1
- package/shared/studio-pdf-resource.js +39 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@ All notable changes to `pi-studio` are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.7.0] — 2026-05-06
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Added explicit `studio-pdf` fenced blocks that render token-protected local PDFs in Studio preview cards.
|
|
11
|
+
- Added an **Open new editor** action that opens a detached copy of the current editor text in a new editor-only Studio view.
|
|
12
|
+
- Documented Studio Markdown asset paths and `studio-pdf` syntax in the README.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Hid response-sync badges in editor-only Studio views, simplified editor action labels, and slightly strengthened refreshed-layout focus icons.
|
|
16
|
+
|
|
17
|
+
## [0.6.10] — 2026-05-04
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- SSH tunnel hints now include the full tokenized Studio URL directly so the token remains visible even when the terminal only shows the latest notification.
|
|
21
|
+
|
|
7
22
|
## [0.6.9] — 2026-05-01
|
|
8
23
|
|
|
9
24
|
### Changed
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
|
|
|
19
19
|
## What it does
|
|
20
20
|
|
|
21
21
|
- Opens a two-pane browser workspace: **Editor** (left) + **Response/Working/Editor Preview** (right)
|
|
22
|
-
- Supports one canonical full Studio view per Pi session, plus additional editor-only companion views when you just want extra editing/preview surfaces
|
|
22
|
+
- Supports one canonical full Studio view per Pi session, plus additional editor-only companion views when you just want extra editing/preview surfaces; the editor toolbar can open a detached copy of the current editor text as a companion view
|
|
23
23
|
- Runs editor text directly, or asks for structured critique (auto/writing/code focus)
|
|
24
24
|
- Includes a live **Working** view for following current model/tool activity, with `All` / `Thinking` / `Tools` filters plus **Load visible into editor** and **Copy visible** actions
|
|
25
25
|
- Includes a local persistent scratchpad for quick notes you want to keep out of the main editor until you're ready to copy or insert them
|
|
@@ -35,6 +35,7 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
|
|
|
35
35
|
- strips markers before send (optional)
|
|
36
36
|
- saves `.annotated.md`
|
|
37
37
|
- Renders Markdown/LaTeX/code previews (math + Mermaid), theme-synced with pi
|
|
38
|
+
- Embeds local PDFs in Studio Markdown previews via explicit `studio-pdf` fenced blocks
|
|
38
39
|
- Ships optional `pi-studio-dark` and `pi-studio-light` themes tuned for Studio's browser workspace
|
|
39
40
|
- Exports right-pane preview as PDF (pandoc + LaTeX)
|
|
40
41
|
- Exports local files headlessly via `/studio-pdf <path>` to `<name>.studio.pdf`
|
|
@@ -72,10 +73,32 @@ Run once without installing:
|
|
|
72
73
|
pi -e https://github.com/omaclaren/pi-studio
|
|
73
74
|
```
|
|
74
75
|
|
|
76
|
+
## Studio Markdown extras
|
|
77
|
+
|
|
78
|
+
Studio previews standard Markdown, code fences, display math, Mermaid, and local images. When adding companion files such as generated plots or PDFs, prefer the project's existing folder convention. If there is no convention, `attachments/` is a reasonable default for newly generated assets. Use relative paths from the opened Markdown file or Studio working/resource directory, and wrap paths in angle brackets when spaces are possible:
|
|
79
|
+
|
|
80
|
+
```md
|
|
81
|
+

|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Local PDFs can be embedded with an explicit Studio-only fenced block:
|
|
85
|
+
|
|
86
|
+
````md
|
|
87
|
+
```studio-pdf
|
|
88
|
+
path: attachments/paper.pdf
|
|
89
|
+
title: Optional title
|
|
90
|
+
page: 3
|
|
91
|
+
height: 760
|
|
92
|
+
caption: Optional caption
|
|
93
|
+
```
|
|
94
|
+
````
|
|
95
|
+
|
|
96
|
+
`path` must point to a local `.pdf` within the current Studio resource directory. Relative paths resolve from the opened document's directory, or from Studio's working dir for non-file-backed content. `page` is an initial page hint for the browser PDF viewer, and `height` controls the embedded frame height in pixels. Use normal Markdown links for PDFs when embedding is not useful.
|
|
97
|
+
|
|
75
98
|
## Notes
|
|
76
99
|
|
|
77
100
|
- Local-only server (`127.0.0.1`) with tokenized Studio URLs.
|
|
78
|
-
- For remote SSH sessions, keep Studio bound to localhost and use SSH local port forwarding; `/studio` and `/studio --status` print the full tokenized localhost URL. Open that URL through the tunnel, preserving the `?token=...` parameter.
|
|
101
|
+
- For remote SSH sessions, keep Studio bound to localhost and use SSH local port forwarding; `/studio` and `/studio --status` print the full tokenized localhost URL. The SSH hint repeats the full URL so it is visible even if your terminal only shows the latest notification. Open that URL through the tunnel, preserving the `?token=...` parameter.
|
|
79
102
|
- Full Studio is a singleton per Pi session: use `/studio` to open it, `/studio-replace` to explicitly replace it, and `/studio-editor-only` for extra editing/preview tabs that do not take over the full Studio session view.
|
|
80
103
|
- Studio is designed as a complement to terminal pi, not a replacement.
|
|
81
104
|
- Installing pi-studio makes the optional `pi-studio-dark` and `pi-studio-light` themes available in pi's theme selector; it does not change your active theme.
|
package/client/studio-client.js
CHANGED
|
@@ -98,6 +98,7 @@
|
|
|
98
98
|
const saveOverBtn = document.getElementById("saveOverBtn");
|
|
99
99
|
const refreshFromDiskBtn = document.getElementById("refreshFromDiskBtn");
|
|
100
100
|
const sendEditorBtn = document.getElementById("sendEditorBtn");
|
|
101
|
+
const openCompanionBtn = document.getElementById("openCompanionBtn");
|
|
101
102
|
const getEditorBtn = document.getElementById("getEditorBtn");
|
|
102
103
|
const loadGitDiffBtn = document.getElementById("loadGitDiffBtn");
|
|
103
104
|
const sendRunBtn = document.getElementById("sendRunBtn");
|
|
@@ -149,7 +150,8 @@
|
|
|
149
150
|
const isEditorOnlyMode = studioMode === "editor-only";
|
|
150
151
|
|
|
151
152
|
const initialQueryParams = new URLSearchParams(window.location.search || "");
|
|
152
|
-
const explicitDocumentIdentityFromUrl = initialQueryParams.has("
|
|
153
|
+
const explicitDocumentIdentityFromUrl = initialQueryParams.has("docId")
|
|
154
|
+
|| initialQueryParams.has("docSource")
|
|
153
155
|
|| initialQueryParams.has("docLabel")
|
|
154
156
|
|| initialQueryParams.has("docPath")
|
|
155
157
|
|| initialQueryParams.has("draftId");
|
|
@@ -163,6 +165,8 @@
|
|
|
163
165
|
draftId: initialQueryParams.get("draftId")
|
|
164
166
|
|| ((document.body && document.body.dataset && document.body.dataset.initialDraftId) || null),
|
|
165
167
|
};
|
|
168
|
+
const initialResourceDir = initialQueryParams.get("resourceDir")
|
|
169
|
+
|| ((document.body && document.body.dataset && document.body.dataset.initialResourceDir) || "");
|
|
166
170
|
|
|
167
171
|
let ws = null;
|
|
168
172
|
let wsState = "Connecting";
|
|
@@ -173,6 +177,7 @@
|
|
|
173
177
|
let pendingRequestId = null;
|
|
174
178
|
let pendingKind = null;
|
|
175
179
|
let stickyStudioKind = null;
|
|
180
|
+
const pendingCompanionWindows = new Map();
|
|
176
181
|
let initialDocumentApplied = false;
|
|
177
182
|
function getInitialRightView(source) {
|
|
178
183
|
if (isEditorOnlyMode) return "editor-preview";
|
|
@@ -1041,6 +1046,7 @@
|
|
|
1041
1046
|
if (!isEditorOnlyMode && queueSteerBtn) actionLineOneEl.appendChild(queueSteerBtn);
|
|
1042
1047
|
const actionLineTwoEl = makeStudioUiRefreshElement("div", "studio-refresh-action-line");
|
|
1043
1048
|
actionLineTwoEl.appendChild(copyDraftBtn);
|
|
1049
|
+
if (openCompanionBtn) actionLineTwoEl.appendChild(openCompanionBtn);
|
|
1044
1050
|
if (!isEditorOnlyMode && sendEditorBtn) actionLineTwoEl.appendChild(sendEditorBtn);
|
|
1045
1051
|
if (actionLineOneEl.childNodes.length > 0) actionsEl.appendChild(actionLineOneEl);
|
|
1046
1052
|
actionsEl.appendChild(actionLineTwoEl);
|
|
@@ -1266,6 +1272,7 @@
|
|
|
1266
1272
|
if (kind === "send_to_editor") return "sending to pi editor";
|
|
1267
1273
|
if (kind === "get_from_editor") return "loading from pi editor";
|
|
1268
1274
|
if (kind === "load_git_diff") return "loading git diff";
|
|
1275
|
+
if (kind === "open_editor_only") return "opening companion editor";
|
|
1269
1276
|
if (kind === "refresh_from_disk") return "refreshing from disk";
|
|
1270
1277
|
if (kind === "save_as" || kind === "save_over") return "saving editor text";
|
|
1271
1278
|
return "submitting request";
|
|
@@ -2301,6 +2308,12 @@
|
|
|
2301
2308
|
function updateSyncBadge(normalizedEditorText) {
|
|
2302
2309
|
if (!syncBadgeEl) return;
|
|
2303
2310
|
|
|
2311
|
+
if (isEditorOnlyMode) {
|
|
2312
|
+
syncBadgeEl.hidden = true;
|
|
2313
|
+
syncBadgeEl.classList.remove("sync");
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2304
2317
|
if (rightView === "trace") {
|
|
2305
2318
|
syncBadgeEl.hidden = true;
|
|
2306
2319
|
syncBadgeEl.classList.remove("sync");
|
|
@@ -2339,6 +2352,148 @@
|
|
|
2339
2352
|
return "<div class='preview-error'>" + escapeHtml(String(message || "Preview rendering failed.")) + "</div>" + buildPlainMarkdownHtml(markdown, options);
|
|
2340
2353
|
}
|
|
2341
2354
|
|
|
2355
|
+
function stripMatchingQuotes(value) {
|
|
2356
|
+
const text = String(value || "").trim();
|
|
2357
|
+
if (text.length >= 2) {
|
|
2358
|
+
const first = text[0];
|
|
2359
|
+
const last = text[text.length - 1];
|
|
2360
|
+
if ((first === "\"" && last === "\"") || (first === "'" && last === "'")) {
|
|
2361
|
+
return text.slice(1, -1).trim();
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
return text;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
function parseStudioPdfBlockOptions(body) {
|
|
2368
|
+
const options = { path: "", title: "", caption: "", page: "", height: "" };
|
|
2369
|
+
String(body || "").split(/\r?\n/).forEach((line) => {
|
|
2370
|
+
const raw = String(line || "").trim();
|
|
2371
|
+
if (!raw || raw.startsWith("#")) return;
|
|
2372
|
+
const match = raw.match(/^([A-Za-z][A-Za-z0-9_-]*)\s*:\s*([\s\S]*)$/);
|
|
2373
|
+
if (match) {
|
|
2374
|
+
const key = String(match[1] || "").toLowerCase();
|
|
2375
|
+
const value = stripMatchingQuotes(match[2] || "");
|
|
2376
|
+
if (key === "path" || key === "src" || key === "file") options.path = value;
|
|
2377
|
+
else if (key === "title") options.title = value;
|
|
2378
|
+
else if (key === "caption") options.caption = value;
|
|
2379
|
+
else if (key === "page") options.page = value;
|
|
2380
|
+
else if (key === "height") options.height = value;
|
|
2381
|
+
return;
|
|
2382
|
+
}
|
|
2383
|
+
if (!options.path) options.path = stripMatchingQuotes(raw);
|
|
2384
|
+
});
|
|
2385
|
+
return options;
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
function prepareStudioPdfBlocksForPreview(markdown) {
|
|
2389
|
+
const blocks = [];
|
|
2390
|
+
const prefix = "STUDIO_PDF_BLOCK_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2) + "_";
|
|
2391
|
+
const source = String(markdown || "");
|
|
2392
|
+
const blockPattern = /(^|\n)([ \t]{0,3})(`{3,}|~{3,})[ \t]*studio-pdf[^\n]*\n([\s\S]*?)\n[ \t]*\3[ \t]*(?=\n|$)/g;
|
|
2393
|
+
const nextMarkdown = source.replace(blockPattern, (match, leadingNewline, _indent, _fence, body) => {
|
|
2394
|
+
const placeholder = prefix + blocks.length;
|
|
2395
|
+
blocks.push({ placeholder, options: parseStudioPdfBlockOptions(body) });
|
|
2396
|
+
return String(leadingNewline || "") + placeholder + "\n";
|
|
2397
|
+
});
|
|
2398
|
+
return { markdown: nextMarkdown, blocks };
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
function normalizeStudioPdfHeight(value) {
|
|
2402
|
+
const parsed = Number.parseInt(String(value || ""), 10);
|
|
2403
|
+
if (!Number.isFinite(parsed)) return 680;
|
|
2404
|
+
return Math.max(240, Math.min(1400, parsed));
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
function normalizeStudioPdfPage(value) {
|
|
2408
|
+
const parsed = Number.parseInt(String(value || ""), 10);
|
|
2409
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
function buildStudioPdfResourceUrl(options) {
|
|
2413
|
+
const token = getToken();
|
|
2414
|
+
if (!token) return "";
|
|
2415
|
+
const pdfPath = String(options && options.path ? options.path : "").trim();
|
|
2416
|
+
if (!pdfPath) return "";
|
|
2417
|
+
const effectivePath = getEffectiveSavePath();
|
|
2418
|
+
const sourcePath = effectivePath || sourceState.path || "";
|
|
2419
|
+
const params = new URLSearchParams({ token, path: pdfPath });
|
|
2420
|
+
if (sourcePath) {
|
|
2421
|
+
params.set("sourcePath", sourcePath);
|
|
2422
|
+
} else if (resourceDirInput && resourceDirInput.value.trim()) {
|
|
2423
|
+
params.set("resourceDir", resourceDirInput.value.trim());
|
|
2424
|
+
}
|
|
2425
|
+
return "/pdf-resource?" + params.toString();
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
function createStudioPdfCard(block) {
|
|
2429
|
+
const options = block && block.options ? block.options : {};
|
|
2430
|
+
const path = String(options.path || "").trim();
|
|
2431
|
+
const title = String(options.title || path || "Embedded PDF").trim();
|
|
2432
|
+
const caption = String(options.caption || "").trim();
|
|
2433
|
+
const height = normalizeStudioPdfHeight(options.height);
|
|
2434
|
+
const page = normalizeStudioPdfPage(options.page);
|
|
2435
|
+
const resourceUrl = buildStudioPdfResourceUrl(options);
|
|
2436
|
+
const viewerUrl = resourceUrl && page ? resourceUrl + "#page=" + encodeURIComponent(String(page)) : resourceUrl;
|
|
2437
|
+
|
|
2438
|
+
const card = document.createElement("figure");
|
|
2439
|
+
card.className = "studio-pdf-card";
|
|
2440
|
+
|
|
2441
|
+
const header = document.createElement("figcaption");
|
|
2442
|
+
header.className = "studio-pdf-card-header";
|
|
2443
|
+
const label = document.createElement("div");
|
|
2444
|
+
label.className = "studio-pdf-card-title";
|
|
2445
|
+
label.textContent = title;
|
|
2446
|
+
header.appendChild(label);
|
|
2447
|
+
|
|
2448
|
+
if (resourceUrl) {
|
|
2449
|
+
const openLink = document.createElement("a");
|
|
2450
|
+
openLink.className = "studio-pdf-card-link";
|
|
2451
|
+
openLink.href = viewerUrl;
|
|
2452
|
+
openLink.target = "_blank";
|
|
2453
|
+
openLink.rel = "noopener noreferrer";
|
|
2454
|
+
openLink.textContent = "Open PDF";
|
|
2455
|
+
header.appendChild(openLink);
|
|
2456
|
+
}
|
|
2457
|
+
card.appendChild(header);
|
|
2458
|
+
|
|
2459
|
+
if (caption) {
|
|
2460
|
+
const captionEl = document.createElement("div");
|
|
2461
|
+
captionEl.className = "studio-pdf-card-caption";
|
|
2462
|
+
captionEl.textContent = caption;
|
|
2463
|
+
card.appendChild(captionEl);
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
if (!resourceUrl) {
|
|
2467
|
+
const errorEl = document.createElement("div");
|
|
2468
|
+
errorEl.className = "studio-pdf-card-error";
|
|
2469
|
+
errorEl.textContent = "PDF block needs a local path.";
|
|
2470
|
+
card.appendChild(errorEl);
|
|
2471
|
+
return card;
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
const iframe = document.createElement("iframe");
|
|
2475
|
+
iframe.className = "studio-pdf-frame";
|
|
2476
|
+
iframe.src = viewerUrl;
|
|
2477
|
+
iframe.title = title;
|
|
2478
|
+
iframe.loading = "lazy";
|
|
2479
|
+
iframe.style.height = height + "px";
|
|
2480
|
+
card.appendChild(iframe);
|
|
2481
|
+
return card;
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
function renderStudioPdfBlocksInElement(targetEl, blocks) {
|
|
2485
|
+
if (!targetEl || !Array.isArray(blocks) || blocks.length === 0) return;
|
|
2486
|
+
const candidates = Array.from(targetEl.querySelectorAll("p, pre, div"));
|
|
2487
|
+
blocks.forEach((block) => {
|
|
2488
|
+
const placeholder = block && block.placeholder ? block.placeholder : "";
|
|
2489
|
+
if (!placeholder) return;
|
|
2490
|
+
const match = candidates.find((el) => String(el.textContent || "").trim() === placeholder);
|
|
2491
|
+
if (match && match.parentNode) {
|
|
2492
|
+
match.replaceWith(createStudioPdfCard(block));
|
|
2493
|
+
}
|
|
2494
|
+
});
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2342
2497
|
function sanitizeRenderedHtml(html, markdown, options) {
|
|
2343
2498
|
const rawHtml = typeof html === "string" ? html : "";
|
|
2344
2499
|
const mathAnnotationPreserved = rawHtml.replace(/<math\b([^>]*)>([\s\S]*?)<\/math>/gi, (match, attrs, inner) => {
|
|
@@ -3499,9 +3654,10 @@
|
|
|
3499
3654
|
const previewFallbackOptions = {
|
|
3500
3655
|
stripMarkdownHtmlComments: !previewingEditorText || editorLanguage !== "latex",
|
|
3501
3656
|
};
|
|
3657
|
+
const pdfPrepared = prepareStudioPdfBlocksForPreview(previewPrepared.markdown);
|
|
3502
3658
|
|
|
3503
3659
|
try {
|
|
3504
|
-
const renderedHtml = await renderMarkdownWithPandoc(
|
|
3660
|
+
const renderedHtml = await renderMarkdownWithPandoc(pdfPrepared.markdown, {
|
|
3505
3661
|
includeEditorLanguage: pane === "source" || rightView === "editor-preview",
|
|
3506
3662
|
});
|
|
3507
3663
|
|
|
@@ -3514,6 +3670,7 @@
|
|
|
3514
3670
|
clearPreviewJumpHighlight(targetEl);
|
|
3515
3671
|
finishPreviewRender(targetEl);
|
|
3516
3672
|
targetEl.innerHTML = sanitizeRenderedHtml(renderedHtml, markdown, previewFallbackOptions);
|
|
3673
|
+
renderStudioPdfBlocksInElement(targetEl, pdfPrepared.blocks);
|
|
3517
3674
|
applyPreviewAnnotationPlaceholdersToElement(targetEl, previewPrepared.placeholders);
|
|
3518
3675
|
await renderAnnotationMathInElement(targetEl);
|
|
3519
3676
|
decoratePdfEmbeds(targetEl);
|
|
@@ -4002,6 +4159,7 @@
|
|
|
4002
4159
|
if (loadGitDiffBtn) loadGitDiffBtn.disabled = uiBusy;
|
|
4003
4160
|
syncRunAndCritiqueButtons();
|
|
4004
4161
|
copyDraftBtn.disabled = uiBusy;
|
|
4162
|
+
if (openCompanionBtn) openCompanionBtn.disabled = uiBusy || wsState !== "Ready";
|
|
4005
4163
|
if (highlightSelect) highlightSelect.disabled = uiBusy;
|
|
4006
4164
|
if (lineNumbersSelect) lineNumbersSelect.disabled = uiBusy;
|
|
4007
4165
|
if (annotationModeSelect) annotationModeSelect.disabled = uiBusy;
|
|
@@ -10201,6 +10359,27 @@
|
|
|
10201
10359
|
return;
|
|
10202
10360
|
}
|
|
10203
10361
|
|
|
10362
|
+
if (message.type === "editor_only_ready") {
|
|
10363
|
+
const responseRequestId = typeof message.requestId === "string" ? message.requestId : "";
|
|
10364
|
+
if (responseRequestId && pendingRequestId === responseRequestId) {
|
|
10365
|
+
pendingRequestId = null;
|
|
10366
|
+
pendingKind = null;
|
|
10367
|
+
clearArmedTitleAttention(responseRequestId);
|
|
10368
|
+
stickyStudioKind = null;
|
|
10369
|
+
}
|
|
10370
|
+
setBusy(false);
|
|
10371
|
+
setWsState("Ready");
|
|
10372
|
+
const targetUrl = resolveCompanionEditorTargetUrl(message);
|
|
10373
|
+
const opened = navigatePendingCompanionWindow(responseRequestId, targetUrl);
|
|
10374
|
+
setStatus(
|
|
10375
|
+
opened
|
|
10376
|
+
? "Opened companion editor with a detached copy of the current editor text."
|
|
10377
|
+
: (targetUrl ? "Companion editor ready: " + targetUrl : "Companion editor is ready, but Studio did not receive a URL."),
|
|
10378
|
+
opened ? "success" : "warning",
|
|
10379
|
+
);
|
|
10380
|
+
return;
|
|
10381
|
+
}
|
|
10382
|
+
|
|
10204
10383
|
if (message.type === "studio_state") {
|
|
10205
10384
|
const busy = Boolean(message.busy);
|
|
10206
10385
|
agentBusyFromServer = Boolean(message.agentBusy);
|
|
@@ -10267,6 +10446,9 @@
|
|
|
10267
10446
|
}
|
|
10268
10447
|
|
|
10269
10448
|
if (message.type === "busy") {
|
|
10449
|
+
if (typeof message.requestId === "string") {
|
|
10450
|
+
closePendingCompanionWindow(message.requestId);
|
|
10451
|
+
}
|
|
10270
10452
|
if (message.requestId && pendingRequestId === message.requestId) {
|
|
10271
10453
|
if (pendingKind === "compact") {
|
|
10272
10454
|
compactInProgress = false;
|
|
@@ -10285,6 +10467,9 @@
|
|
|
10285
10467
|
}
|
|
10286
10468
|
|
|
10287
10469
|
if (message.type === "error") {
|
|
10470
|
+
if (typeof message.requestId === "string") {
|
|
10471
|
+
closePendingCompanionWindow(message.requestId);
|
|
10472
|
+
}
|
|
10288
10473
|
if (message.requestId && pendingRequestId === message.requestId) {
|
|
10289
10474
|
if (pendingKind === "compact") {
|
|
10290
10475
|
compactInProgress = false;
|
|
@@ -10494,6 +10679,69 @@
|
|
|
10494
10679
|
return requestId;
|
|
10495
10680
|
}
|
|
10496
10681
|
|
|
10682
|
+
function openPendingCompanionWindow(requestId) {
|
|
10683
|
+
if (!requestId) return null;
|
|
10684
|
+
let companionWindow = null;
|
|
10685
|
+
try {
|
|
10686
|
+
companionWindow = window.open("", "_blank");
|
|
10687
|
+
if (companionWindow && companionWindow.document && companionWindow.document.body) {
|
|
10688
|
+
companionWindow.document.title = "Opening companion editor…";
|
|
10689
|
+
companionWindow.document.body.innerHTML = "<p style=\"font: 13px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 16px;\">Opening companion editor…</p>";
|
|
10690
|
+
}
|
|
10691
|
+
} catch {
|
|
10692
|
+
companionWindow = null;
|
|
10693
|
+
}
|
|
10694
|
+
if (companionWindow) {
|
|
10695
|
+
pendingCompanionWindows.set(requestId, companionWindow);
|
|
10696
|
+
}
|
|
10697
|
+
return companionWindow;
|
|
10698
|
+
}
|
|
10699
|
+
|
|
10700
|
+
function takePendingCompanionWindow(requestId) {
|
|
10701
|
+
if (!requestId || !pendingCompanionWindows.has(requestId)) return null;
|
|
10702
|
+
const companionWindow = pendingCompanionWindows.get(requestId);
|
|
10703
|
+
pendingCompanionWindows.delete(requestId);
|
|
10704
|
+
return companionWindow || null;
|
|
10705
|
+
}
|
|
10706
|
+
|
|
10707
|
+
function closePendingCompanionWindow(requestId) {
|
|
10708
|
+
const companionWindow = takePendingCompanionWindow(requestId);
|
|
10709
|
+
if (!companionWindow || companionWindow.closed) return;
|
|
10710
|
+
try {
|
|
10711
|
+
companionWindow.close();
|
|
10712
|
+
} catch {}
|
|
10713
|
+
}
|
|
10714
|
+
|
|
10715
|
+
function resolveCompanionEditorTargetUrl(message) {
|
|
10716
|
+
const relativeUrl = message && typeof message.relativeUrl === "string" ? message.relativeUrl : "";
|
|
10717
|
+
if (relativeUrl) {
|
|
10718
|
+
try {
|
|
10719
|
+
return new URL(relativeUrl, window.location.href).href;
|
|
10720
|
+
} catch {}
|
|
10721
|
+
}
|
|
10722
|
+
return message && typeof message.url === "string" ? message.url : "";
|
|
10723
|
+
}
|
|
10724
|
+
|
|
10725
|
+
function navigatePendingCompanionWindow(requestId, targetUrl) {
|
|
10726
|
+
if (!targetUrl) {
|
|
10727
|
+
closePendingCompanionWindow(requestId);
|
|
10728
|
+
return false;
|
|
10729
|
+
}
|
|
10730
|
+
const companionWindow = takePendingCompanionWindow(requestId);
|
|
10731
|
+
if (companionWindow && !companionWindow.closed) {
|
|
10732
|
+
try {
|
|
10733
|
+
companionWindow.opener = null;
|
|
10734
|
+
companionWindow.location.href = targetUrl;
|
|
10735
|
+
return true;
|
|
10736
|
+
} catch {}
|
|
10737
|
+
}
|
|
10738
|
+
try {
|
|
10739
|
+
return Boolean(window.open(targetUrl, "_blank", "noopener"));
|
|
10740
|
+
} catch {
|
|
10741
|
+
return false;
|
|
10742
|
+
}
|
|
10743
|
+
}
|
|
10744
|
+
|
|
10497
10745
|
function describeSourceForAnnotation() {
|
|
10498
10746
|
if (sourceState.source === "file" && sourceState.label) {
|
|
10499
10747
|
return "file " + sourceState.label;
|
|
@@ -11056,6 +11304,38 @@
|
|
|
11056
11304
|
}
|
|
11057
11305
|
});
|
|
11058
11306
|
|
|
11307
|
+
if (openCompanionBtn) {
|
|
11308
|
+
openCompanionBtn.addEventListener("click", () => {
|
|
11309
|
+
const content = sourceTextEl.value;
|
|
11310
|
+
if (!content.trim()) {
|
|
11311
|
+
setStatus("Editor is empty. Nothing to copy into a companion view.", "warning");
|
|
11312
|
+
return;
|
|
11313
|
+
}
|
|
11314
|
+
|
|
11315
|
+
const requestId = beginUiAction("open_editor_only");
|
|
11316
|
+
if (!requestId) return;
|
|
11317
|
+
openPendingCompanionWindow(requestId);
|
|
11318
|
+
|
|
11319
|
+
const sent = sendMessage({
|
|
11320
|
+
type: "open_editor_only_request",
|
|
11321
|
+
requestId,
|
|
11322
|
+
content,
|
|
11323
|
+
label: sourceState && sourceState.label ? sourceState.label : "current editor",
|
|
11324
|
+
path: sourceState && sourceState.path ? sourceState.path : undefined,
|
|
11325
|
+
resourceDir: resourceDirInput && resourceDirInput.value.trim()
|
|
11326
|
+
? resourceDirInput.value.trim()
|
|
11327
|
+
: undefined,
|
|
11328
|
+
});
|
|
11329
|
+
|
|
11330
|
+
if (!sent) {
|
|
11331
|
+
closePendingCompanionWindow(requestId);
|
|
11332
|
+
pendingRequestId = null;
|
|
11333
|
+
pendingKind = null;
|
|
11334
|
+
setBusy(false);
|
|
11335
|
+
}
|
|
11336
|
+
});
|
|
11337
|
+
}
|
|
11338
|
+
|
|
11059
11339
|
if (getEditorBtn) {
|
|
11060
11340
|
getEditorBtn.addEventListener("click", () => {
|
|
11061
11341
|
const requestId = beginUiAction("get_from_editor");
|
|
@@ -11158,7 +11438,7 @@
|
|
|
11158
11438
|
|
|
11159
11439
|
try {
|
|
11160
11440
|
await writeTextToClipboard(content);
|
|
11161
|
-
setStatus("Copied
|
|
11441
|
+
setStatus("Copied text.", "success");
|
|
11162
11442
|
} catch (error) {
|
|
11163
11443
|
setStatus("Clipboard write failed.", "warning");
|
|
11164
11444
|
}
|
|
@@ -11540,6 +11820,9 @@
|
|
|
11540
11820
|
const initialResponseFontSize = readStoredFontSize(RESPONSE_FONT_SIZE_STORAGE_KEY, RESPONSE_FONT_SIZE_OPTIONS, DEFAULT_RESPONSE_FONT_SIZE);
|
|
11541
11821
|
setResponseFontSize(initialResponseFontSize, { persist: false });
|
|
11542
11822
|
|
|
11823
|
+
if (resourceDirInput && initialResourceDir) {
|
|
11824
|
+
resourceDirInput.value = initialResourceDir;
|
|
11825
|
+
}
|
|
11543
11826
|
setSourceState(initialSourceState);
|
|
11544
11827
|
refreshResponseUi();
|
|
11545
11828
|
updateAnnotatedReplyHeaderButton();
|
package/client/studio.css
CHANGED
|
@@ -1393,6 +1393,62 @@
|
|
|
1393
1393
|
max-width: 100%;
|
|
1394
1394
|
}
|
|
1395
1395
|
|
|
1396
|
+
.rendered-markdown .studio-pdf-card {
|
|
1397
|
+
margin: 1.15em 0;
|
|
1398
|
+
border: 1px solid var(--panel-border);
|
|
1399
|
+
border-radius: 12px;
|
|
1400
|
+
background: var(--panel);
|
|
1401
|
+
overflow: hidden;
|
|
1402
|
+
box-shadow: 0 1px 2px var(--shadow-color);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
.rendered-markdown .studio-pdf-card-header {
|
|
1406
|
+
display: flex;
|
|
1407
|
+
align-items: center;
|
|
1408
|
+
justify-content: space-between;
|
|
1409
|
+
gap: 10px;
|
|
1410
|
+
padding: 8px 10px;
|
|
1411
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
1412
|
+
background: var(--panel-2);
|
|
1413
|
+
color: var(--studio-info-text, var(--muted));
|
|
1414
|
+
font-size: 12px;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
.rendered-markdown .studio-pdf-card-title {
|
|
1418
|
+
min-width: 0;
|
|
1419
|
+
overflow: hidden;
|
|
1420
|
+
text-overflow: ellipsis;
|
|
1421
|
+
white-space: nowrap;
|
|
1422
|
+
font-weight: 600;
|
|
1423
|
+
color: var(--text);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
.rendered-markdown .studio-pdf-card-link {
|
|
1427
|
+
flex: 0 0 auto;
|
|
1428
|
+
font-size: 12px;
|
|
1429
|
+
font-weight: 600;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
.rendered-markdown .studio-pdf-card-caption {
|
|
1433
|
+
padding: 8px 10px 0;
|
|
1434
|
+
color: var(--studio-info-text, var(--muted));
|
|
1435
|
+
font-size: 12px;
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
.rendered-markdown .studio-pdf-card-error {
|
|
1439
|
+
padding: 12px;
|
|
1440
|
+
color: var(--warn);
|
|
1441
|
+
font-size: 12px;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
.rendered-markdown .studio-pdf-frame {
|
|
1445
|
+
display: block;
|
|
1446
|
+
width: 100%;
|
|
1447
|
+
min-height: 240px;
|
|
1448
|
+
border: 0;
|
|
1449
|
+
background: #fff;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1396
1452
|
.rendered-markdown .studio-subfigure-group {
|
|
1397
1453
|
margin: 1.25em auto;
|
|
1398
1454
|
}
|
|
@@ -2579,7 +2635,7 @@
|
|
|
2579
2635
|
width: 15px;
|
|
2580
2636
|
height: 15px;
|
|
2581
2637
|
stroke: currentColor;
|
|
2582
|
-
stroke-width:
|
|
2638
|
+
stroke-width: 2;
|
|
2583
2639
|
stroke-linecap: round;
|
|
2584
2640
|
stroke-linejoin: round;
|
|
2585
2641
|
fill: none;
|
|
@@ -2629,11 +2685,23 @@
|
|
|
2629
2685
|
min-width: 29px;
|
|
2630
2686
|
min-height: 29px;
|
|
2631
2687
|
padding: 0;
|
|
2632
|
-
color: var(--muted);
|
|
2688
|
+
color: var(--studio-info-text, var(--muted));
|
|
2633
2689
|
align-items: center;
|
|
2634
2690
|
justify-content: center;
|
|
2635
2691
|
}
|
|
2636
2692
|
|
|
2693
|
+
body.studio-ui-refresh #leftFocusBtn:not(:disabled):hover,
|
|
2694
|
+
body.studio-ui-refresh #rightFocusBtn:not(:disabled):hover {
|
|
2695
|
+
color: var(--text);
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
body.studio-ui-refresh #leftFocusBtn.is-active,
|
|
2699
|
+
body.studio-ui-refresh #rightFocusBtn.is-active {
|
|
2700
|
+
background: var(--accent);
|
|
2701
|
+
border-color: var(--accent);
|
|
2702
|
+
color: var(--accent-contrast);
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2637
2705
|
body.studio-ui-refresh #sourceBadge,
|
|
2638
2706
|
body.studio-ui-refresh #resourceDirBtn,
|
|
2639
2707
|
body.studio-ui-refresh #resourceDirLabel {
|
package/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
preserveLiteralLatexCommandsInMarkdown,
|
|
26
26
|
} from "./shared/studio-markdown-latex-literals.js";
|
|
27
27
|
import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js";
|
|
28
|
+
import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
|
|
28
29
|
|
|
29
30
|
type Lens = "writing" | "code";
|
|
30
31
|
type RequestedLens = Lens | "auto";
|
|
@@ -115,6 +116,7 @@ interface InitialStudioDocument {
|
|
|
115
116
|
source: StudioSourceKind;
|
|
116
117
|
path?: string;
|
|
117
118
|
draftId?: string;
|
|
119
|
+
resourceDir?: string;
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
interface PersistedStudioReviewNote {
|
|
@@ -249,6 +251,15 @@ interface LoadGitDiffRequestMessage {
|
|
|
249
251
|
resourceDir?: string;
|
|
250
252
|
}
|
|
251
253
|
|
|
254
|
+
interface OpenEditorOnlyRequestMessage {
|
|
255
|
+
type: "open_editor_only_request";
|
|
256
|
+
requestId: string;
|
|
257
|
+
content: string;
|
|
258
|
+
label?: string;
|
|
259
|
+
path?: string;
|
|
260
|
+
resourceDir?: string;
|
|
261
|
+
}
|
|
262
|
+
|
|
252
263
|
interface CancelRequestMessage {
|
|
253
264
|
type: "cancel_request";
|
|
254
265
|
requestId: string;
|
|
@@ -268,6 +279,7 @@ type IncomingStudioMessage =
|
|
|
268
279
|
| SendToEditorRequestMessage
|
|
269
280
|
| GetFromEditorRequestMessage
|
|
270
281
|
| LoadGitDiffRequestMessage
|
|
282
|
+
| OpenEditorOnlyRequestMessage
|
|
271
283
|
| CancelRequestMessage;
|
|
272
284
|
|
|
273
285
|
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -278,6 +290,8 @@ const RESPONSE_HISTORY_LIMIT = 30;
|
|
|
278
290
|
const CMUX_NOTIFY_TIMEOUT_MS = 1200;
|
|
279
291
|
const PREPARED_PDF_EXPORT_TTL_MS = 5 * 60 * 1000;
|
|
280
292
|
const MAX_PREPARED_PDF_EXPORTS = 8;
|
|
293
|
+
const TRANSIENT_STUDIO_DOCUMENT_TTL_MS = 30 * 60 * 1000;
|
|
294
|
+
const MAX_TRANSIENT_STUDIO_DOCUMENTS = 16;
|
|
281
295
|
const STUDIO_TERMINAL_NOTIFY_TITLE = "pi Studio";
|
|
282
296
|
const CMUX_STUDIO_STATUS_KEY = "pi_studio";
|
|
283
297
|
const CMUX_STUDIO_STATUS_COLOR_DARK = "#5ea1ff";
|
|
@@ -289,6 +303,7 @@ const STUDIO_PERSISTENT_STATE_PATH = join(STUDIO_PERSISTENT_STATE_DIR, "local-st
|
|
|
289
303
|
|
|
290
304
|
let studioPersistentStateCache: StudioPersistentState | null = null;
|
|
291
305
|
let studioPersistentStateQueue: Promise<void> = Promise.resolve();
|
|
306
|
+
let transientStudioDocuments: Map<string, { document: InitialStudioDocument; createdAt: number }> = new Map();
|
|
292
307
|
|
|
293
308
|
function createEmptyStudioPersistentState(): StudioPersistentState {
|
|
294
309
|
return {
|
|
@@ -1638,6 +1653,31 @@ function resolveStudioGitDiffBaseDir(sourcePath: string | undefined, resourceDir
|
|
|
1638
1653
|
return resolveStudioBaseDir(sourcePath, resourceDir, fallbackCwd);
|
|
1639
1654
|
}
|
|
1640
1655
|
|
|
1656
|
+
function resolveStudioCompanionResourceDir(sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string | undefined {
|
|
1657
|
+
const explicitResource = typeof resourceDir === "string" ? resourceDir.trim() : "";
|
|
1658
|
+
if (explicitResource) {
|
|
1659
|
+
const expanded = expandHome(explicitResource);
|
|
1660
|
+
return isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
const source = typeof sourcePath === "string" ? sourcePath.trim() : "";
|
|
1664
|
+
if (source) {
|
|
1665
|
+
const expanded = expandHome(source);
|
|
1666
|
+
return dirname(isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded));
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
return undefined;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
function buildStudioCompanionLabel(_label: string | undefined): string {
|
|
1673
|
+
return "copy of editor text";
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
function resolveStudioPdfResourcePath(pdfPath: string | undefined, sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
|
|
1677
|
+
const baseDir = resolveStudioBaseDir(sourcePath, resourceDir, fallbackCwd);
|
|
1678
|
+
return resolveStudioPdfResourceFile(pdfPath, baseDir);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1641
1681
|
function resolveStudioPandocWorkingDir(baseDir: string | undefined): string | undefined {
|
|
1642
1682
|
const normalized = typeof baseDir === "string" ? baseDir.trim() : "";
|
|
1643
1683
|
if (!normalized) return undefined;
|
|
@@ -5213,6 +5253,26 @@ function respondText(res: ServerResponse, status: number, text: string): void {
|
|
|
5213
5253
|
res.end(text);
|
|
5214
5254
|
}
|
|
5215
5255
|
|
|
5256
|
+
function respondPdfFile(req: IncomingMessage, res: ServerResponse, filePath: string): void {
|
|
5257
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
5258
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
5259
|
+
res.setHeader("Allow", "GET, HEAD");
|
|
5260
|
+
respondText(res, 405, "Method not allowed. Use GET.");
|
|
5261
|
+
return;
|
|
5262
|
+
}
|
|
5263
|
+
|
|
5264
|
+
const pdf = readFileSync(filePath);
|
|
5265
|
+
res.writeHead(200, {
|
|
5266
|
+
"Content-Type": "application/pdf",
|
|
5267
|
+
"Content-Length": String(pdf.length),
|
|
5268
|
+
"Content-Disposition": `inline; filename="${basename(filePath).replace(/["\\]/g, "") || "document.pdf"}"`,
|
|
5269
|
+
"Cache-Control": "no-store",
|
|
5270
|
+
"X-Content-Type-Options": "nosniff",
|
|
5271
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
5272
|
+
});
|
|
5273
|
+
res.end(method === "HEAD" ? undefined : pdf);
|
|
5274
|
+
}
|
|
5275
|
+
|
|
5216
5276
|
function openUrlInDefaultBrowser(url: string): Promise<void> {
|
|
5217
5277
|
const openCommand =
|
|
5218
5278
|
process.platform === "darwin"
|
|
@@ -5810,6 +5870,24 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
|
|
|
5810
5870
|
};
|
|
5811
5871
|
}
|
|
5812
5872
|
|
|
5873
|
+
if (
|
|
5874
|
+
msg.type === "open_editor_only_request"
|
|
5875
|
+
&& typeof msg.requestId === "string"
|
|
5876
|
+
&& typeof msg.content === "string"
|
|
5877
|
+
&& (msg.label === undefined || typeof msg.label === "string")
|
|
5878
|
+
&& (msg.path === undefined || typeof msg.path === "string")
|
|
5879
|
+
&& (msg.resourceDir === undefined || typeof msg.resourceDir === "string")
|
|
5880
|
+
) {
|
|
5881
|
+
return {
|
|
5882
|
+
type: "open_editor_only_request",
|
|
5883
|
+
requestId: msg.requestId,
|
|
5884
|
+
content: msg.content,
|
|
5885
|
+
label: typeof msg.label === "string" ? msg.label : undefined,
|
|
5886
|
+
path: typeof msg.path === "string" ? msg.path : undefined,
|
|
5887
|
+
resourceDir: typeof msg.resourceDir === "string" ? msg.resourceDir : undefined,
|
|
5888
|
+
};
|
|
5889
|
+
}
|
|
5890
|
+
|
|
5813
5891
|
if (msg.type === "cancel_request" && typeof msg.requestId === "string") {
|
|
5814
5892
|
return {
|
|
5815
5893
|
type: "cancel_request",
|
|
@@ -6067,18 +6145,52 @@ function normalizeStudioUiMode(raw: string | null | undefined): StudioUiMode {
|
|
|
6067
6145
|
return raw === "editor-only" ? "editor-only" : "full";
|
|
6068
6146
|
}
|
|
6069
6147
|
|
|
6148
|
+
function cleanupTransientStudioDocuments(now = Date.now()): void {
|
|
6149
|
+
for (const [id, entry] of transientStudioDocuments) {
|
|
6150
|
+
if (now - entry.createdAt > TRANSIENT_STUDIO_DOCUMENT_TTL_MS) {
|
|
6151
|
+
transientStudioDocuments.delete(id);
|
|
6152
|
+
}
|
|
6153
|
+
}
|
|
6154
|
+
|
|
6155
|
+
while (transientStudioDocuments.size > MAX_TRANSIENT_STUDIO_DOCUMENTS) {
|
|
6156
|
+
const oldest = transientStudioDocuments.keys().next().value;
|
|
6157
|
+
if (!oldest) break;
|
|
6158
|
+
transientStudioDocuments.delete(oldest);
|
|
6159
|
+
}
|
|
6160
|
+
}
|
|
6161
|
+
|
|
6162
|
+
function storeTransientStudioDocument(document: InitialStudioDocument): string {
|
|
6163
|
+
cleanupTransientStudioDocuments();
|
|
6164
|
+
const id = randomUUID();
|
|
6165
|
+
transientStudioDocuments.set(id, {
|
|
6166
|
+
document: { ...document },
|
|
6167
|
+
createdAt: Date.now(),
|
|
6168
|
+
});
|
|
6169
|
+
cleanupTransientStudioDocuments();
|
|
6170
|
+
return id;
|
|
6171
|
+
}
|
|
6172
|
+
|
|
6173
|
+
function readTransientStudioDocument(id: string): InitialStudioDocument | null {
|
|
6174
|
+
cleanupTransientStudioDocuments();
|
|
6175
|
+
const entry = transientStudioDocuments.get(id);
|
|
6176
|
+
return entry ? { ...entry.document } : null;
|
|
6177
|
+
}
|
|
6178
|
+
|
|
6070
6179
|
function buildStudioUrl(
|
|
6071
6180
|
port: number,
|
|
6072
6181
|
token: string,
|
|
6073
6182
|
mode: StudioUiMode = "full",
|
|
6074
6183
|
doc?: InitialStudioDocument | null,
|
|
6184
|
+
docId?: string,
|
|
6075
6185
|
): string {
|
|
6076
6186
|
const params = new URLSearchParams({ token });
|
|
6077
6187
|
if (mode !== "full") params.set("mode", mode);
|
|
6188
|
+
if (docId) params.set("docId", docId);
|
|
6078
6189
|
if (doc?.source) params.set("docSource", doc.source);
|
|
6079
6190
|
if (doc?.label) params.set("docLabel", doc.label);
|
|
6080
6191
|
if (doc?.path) params.set("docPath", doc.path);
|
|
6081
6192
|
if (doc?.draftId) params.set("draftId", doc.draftId);
|
|
6193
|
+
if (doc?.resourceDir) params.set("resourceDir", doc.resourceDir);
|
|
6082
6194
|
return `http://127.0.0.1:${port}/?${params.toString()}`;
|
|
6083
6195
|
}
|
|
6084
6196
|
|
|
@@ -6088,9 +6200,9 @@ function isSshSession(): boolean {
|
|
|
6088
6200
|
);
|
|
6089
6201
|
}
|
|
6090
6202
|
|
|
6091
|
-
function buildStudioSshTunnelHint(port: number): string | null {
|
|
6203
|
+
function buildStudioSshTunnelHint(port: number, studioUrl: string): string | null {
|
|
6092
6204
|
if (!isSshSession()) return null;
|
|
6093
|
-
return `SSH detected.
|
|
6205
|
+
return `SSH detected. Full Studio URL: ${studioUrl}. Forward the remote Studio port with: ssh -L ${port}:127.0.0.1:${port} <remote-host>. Open the full URL locally through the tunnel, preserving its ?token=... parameter. If you choose a different local port, change only the port in the URL; keep the token.`;
|
|
6094
6206
|
}
|
|
6095
6207
|
|
|
6096
6208
|
function resolveRequestedStudioDocumentFromUrl(
|
|
@@ -6099,10 +6211,17 @@ function resolveRequestedStudioDocumentFromUrl(
|
|
|
6099
6211
|
studioCwd: string,
|
|
6100
6212
|
latestResponse?: LastStudioResponse | null,
|
|
6101
6213
|
): InitialStudioDocument | null {
|
|
6214
|
+
const requestedDocId = (requestUrl.searchParams.get("docId") ?? "").trim();
|
|
6215
|
+
if (requestedDocId) {
|
|
6216
|
+
const transientDocument = readTransientStudioDocument(requestedDocId);
|
|
6217
|
+
if (transientDocument) return transientDocument;
|
|
6218
|
+
}
|
|
6219
|
+
|
|
6102
6220
|
const requestedPath = (requestUrl.searchParams.get("docPath") ?? "").trim();
|
|
6103
6221
|
const requestedSourceRaw = (requestUrl.searchParams.get("docSource") ?? "").trim();
|
|
6104
6222
|
const requestedLabel = (requestUrl.searchParams.get("docLabel") ?? "").trim();
|
|
6105
6223
|
const requestedDraftId = (requestUrl.searchParams.get("draftId") ?? "").trim();
|
|
6224
|
+
const requestedResourceDir = (requestUrl.searchParams.get("resourceDir") ?? "").trim();
|
|
6106
6225
|
|
|
6107
6226
|
if (requestedPath) {
|
|
6108
6227
|
const file = readStudioFile(requestedPath, studioCwd);
|
|
@@ -6112,6 +6231,7 @@ function resolveRequestedStudioDocumentFromUrl(
|
|
|
6112
6231
|
label: requestedLabel || file.label,
|
|
6113
6232
|
source: "file",
|
|
6114
6233
|
path: file.resolvedPath,
|
|
6234
|
+
resourceDir: requestedResourceDir || undefined,
|
|
6115
6235
|
};
|
|
6116
6236
|
}
|
|
6117
6237
|
}
|
|
@@ -6122,6 +6242,7 @@ function resolveRequestedStudioDocumentFromUrl(
|
|
|
6122
6242
|
label: requestedLabel || "last model response",
|
|
6123
6243
|
source: "last-response",
|
|
6124
6244
|
draftId: requestedDraftId || undefined,
|
|
6245
|
+
resourceDir: requestedResourceDir || undefined,
|
|
6125
6246
|
};
|
|
6126
6247
|
}
|
|
6127
6248
|
|
|
@@ -6131,6 +6252,7 @@ function resolveRequestedStudioDocumentFromUrl(
|
|
|
6131
6252
|
label: requestedLabel || requestedSourceRaw || "blank",
|
|
6132
6253
|
source: "blank",
|
|
6133
6254
|
draftId: requestedDraftId || undefined,
|
|
6255
|
+
resourceDir: requestedResourceDir || fallback?.resourceDir || undefined,
|
|
6134
6256
|
};
|
|
6135
6257
|
}
|
|
6136
6258
|
|
|
@@ -6370,6 +6492,7 @@ function buildStudioHtml(
|
|
|
6370
6492
|
const initialLabel = escapeHtmlForInline(initialDocument?.label ?? "blank");
|
|
6371
6493
|
const initialPath = escapeHtmlForInline(initialDocument?.path ?? "");
|
|
6372
6494
|
const initialDraftId = escapeHtmlForInline(initialDocument?.draftId ?? "");
|
|
6495
|
+
const initialResourceDir = escapeHtmlForInline(initialDocument?.resourceDir ?? "");
|
|
6373
6496
|
const initialModel = escapeHtmlForInline(initialModelLabel ?? "none");
|
|
6374
6497
|
const initialTerminal = escapeHtmlForInline(initialTerminalLabel ?? "unknown");
|
|
6375
6498
|
const initialTerminalDetailAttr = escapeHtmlForInline(initialTerminalDetail ?? initialTerminalLabel ?? "unknown");
|
|
@@ -6439,7 +6562,7 @@ ${cssVarsBlock}
|
|
|
6439
6562
|
</style>
|
|
6440
6563
|
<link rel="stylesheet" href="${stylesheetHref}" />
|
|
6441
6564
|
</head>
|
|
6442
|
-
<body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-initial-draft-id="${initialDraftId}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}" data-terminal-detail="${initialTerminalDetailAttr}" data-context-tokens="${initialContextTokens}" data-context-window="${initialContextWindow}" data-context-percent="${initialContextPercent}" data-studio-mode="${studioMode}">
|
|
6565
|
+
<body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-initial-draft-id="${initialDraftId}" data-initial-resource-dir="${initialResourceDir}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}" data-terminal-detail="${initialTerminalDetailAttr}" data-context-tokens="${initialContextTokens}" data-context-window="${initialContextWindow}" data-context-percent="${initialContextPercent}" data-studio-mode="${studioMode}">
|
|
6443
6566
|
<header>
|
|
6444
6567
|
<h1><span class="app-logo" aria-hidden="true">π</span> Studio <span class="app-subtitle">${appSubtitle}</span></h1>
|
|
6445
6568
|
<div class="controls">
|
|
@@ -6484,7 +6607,8 @@ ${cssVarsBlock}
|
|
|
6484
6607
|
<div class="source-actions-row">
|
|
6485
6608
|
<button id="sendRunBtn" type="button" title="Run editor text. While a direct run is active, this button becomes Stop. Cmd/Ctrl+Enter queues steering from the current editor text. Stop the active request with Esc.">Run editor text</button>
|
|
6486
6609
|
<button id="queueSteerBtn" type="button" title="Queue steering is available while Run editor text is active." disabled>Queue steering</button>
|
|
6487
|
-
<button id="copyDraftBtn" type="button"
|
|
6610
|
+
<button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy text</button>
|
|
6611
|
+
<button id="openCompanionBtn" type="button" title="Open a detached copy of the current editor text in a new editor-only Studio tab.">Open new editor</button>
|
|
6488
6612
|
<button id="sendEditorBtn" type="button">Send to pi editor</button>
|
|
6489
6613
|
</div>
|
|
6490
6614
|
<div class="source-actions-row">
|
|
@@ -7778,6 +7902,45 @@ export default function (pi: ExtensionAPI) {
|
|
|
7778
7902
|
return;
|
|
7779
7903
|
}
|
|
7780
7904
|
|
|
7905
|
+
if (msg.type === "open_editor_only_request") {
|
|
7906
|
+
if (!isValidRequestId(msg.requestId)) {
|
|
7907
|
+
sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
|
|
7908
|
+
return;
|
|
7909
|
+
}
|
|
7910
|
+
if (!serverState) {
|
|
7911
|
+
sendToClient(client, { type: "error", requestId: msg.requestId, message: "Studio server is not running." });
|
|
7912
|
+
return;
|
|
7913
|
+
}
|
|
7914
|
+
if (msg.content.length > PREVIEW_RENDER_MAX_CHARS) {
|
|
7915
|
+
sendToClient(client, {
|
|
7916
|
+
type: "error",
|
|
7917
|
+
requestId: msg.requestId,
|
|
7918
|
+
message: `Editor text is too large to copy into a companion view (${PREVIEW_RENDER_MAX_CHARS} character limit).`,
|
|
7919
|
+
});
|
|
7920
|
+
return;
|
|
7921
|
+
}
|
|
7922
|
+
|
|
7923
|
+
const resourceDir = resolveStudioCompanionResourceDir(msg.path, msg.resourceDir, studioCwd);
|
|
7924
|
+
const document: InitialStudioDocument = {
|
|
7925
|
+
text: msg.content,
|
|
7926
|
+
label: buildStudioCompanionLabel(msg.label),
|
|
7927
|
+
source: "blank",
|
|
7928
|
+
draftId: createStudioDraftId(),
|
|
7929
|
+
resourceDir,
|
|
7930
|
+
};
|
|
7931
|
+
const docId = storeTransientStudioDocument(document);
|
|
7932
|
+
const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId);
|
|
7933
|
+
const parsedUrl = new URL(url);
|
|
7934
|
+
sendToClient(client, {
|
|
7935
|
+
type: "editor_only_ready",
|
|
7936
|
+
requestId: msg.requestId,
|
|
7937
|
+
url,
|
|
7938
|
+
relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
|
|
7939
|
+
message: "Companion editor is ready with a detached copy of the current editor text.",
|
|
7940
|
+
});
|
|
7941
|
+
return;
|
|
7942
|
+
}
|
|
7943
|
+
|
|
7781
7944
|
if (msg.type === "cancel_request") {
|
|
7782
7945
|
if (!isValidRequestId(msg.requestId)) {
|
|
7783
7946
|
sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
|
|
@@ -8812,6 +8975,27 @@ export default function (pi: ExtensionAPI) {
|
|
|
8812
8975
|
return;
|
|
8813
8976
|
}
|
|
8814
8977
|
|
|
8978
|
+
if (requestUrl.pathname === "/pdf-resource") {
|
|
8979
|
+
const token = requestUrl.searchParams.get("token") ?? "";
|
|
8980
|
+
if (token !== serverState.token) {
|
|
8981
|
+
respondText(res, 403, "Invalid or expired studio token. Re-run /studio.");
|
|
8982
|
+
return;
|
|
8983
|
+
}
|
|
8984
|
+
|
|
8985
|
+
try {
|
|
8986
|
+
const filePath = resolveStudioPdfResourcePath(
|
|
8987
|
+
requestUrl.searchParams.get("path") ?? "",
|
|
8988
|
+
requestUrl.searchParams.get("sourcePath") ?? undefined,
|
|
8989
|
+
requestUrl.searchParams.get("resourceDir") ?? undefined,
|
|
8990
|
+
studioCwd,
|
|
8991
|
+
);
|
|
8992
|
+
respondPdfFile(req, res, filePath);
|
|
8993
|
+
} catch (error) {
|
|
8994
|
+
respondText(res, 404, `PDF resource unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
8995
|
+
}
|
|
8996
|
+
return;
|
|
8997
|
+
}
|
|
8998
|
+
|
|
8815
8999
|
if (requestUrl.pathname !== "/") {
|
|
8816
9000
|
respondText(res, 404, "Not found");
|
|
8817
9001
|
return;
|
|
@@ -9329,6 +9513,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
9329
9513
|
clearStudioDirectRunState();
|
|
9330
9514
|
clearPendingStudioCompletion();
|
|
9331
9515
|
clearPreparedPdfExports();
|
|
9516
|
+
transientStudioDocuments.clear();
|
|
9332
9517
|
clearCompactionState();
|
|
9333
9518
|
clearStudioTrace();
|
|
9334
9519
|
setTerminalActivity("idle");
|
|
@@ -9436,8 +9621,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
9436
9621
|
} else {
|
|
9437
9622
|
ctx.ui.notify("A full pi Studio view is already open for this session. Close it first, use /studio-replace for a fresh full Studio view, or use /studio-editor-only for a concurrent editor-only Studio view.", "warning");
|
|
9438
9623
|
if (serverState) {
|
|
9439
|
-
|
|
9440
|
-
|
|
9624
|
+
const url = buildStudioUrl(serverState.port, serverState.token, "full");
|
|
9625
|
+
ctx.ui.notify(`Studio URL: ${url}`, "info");
|
|
9626
|
+
const sshTunnelHint = buildStudioSshTunnelHint(serverState.port, url);
|
|
9441
9627
|
if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
|
|
9442
9628
|
}
|
|
9443
9629
|
return;
|
|
@@ -9464,7 +9650,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
9464
9650
|
|
|
9465
9651
|
const state = await ensureServer();
|
|
9466
9652
|
const url = buildStudioUrl(state.port, state.token, mode, selected);
|
|
9467
|
-
const sshTunnelHint = buildStudioSshTunnelHint(state.port);
|
|
9653
|
+
const sshTunnelHint = buildStudioSshTunnelHint(state.port, url);
|
|
9468
9654
|
const openedLabel = mode === "editor-only" ? "pi Studio editor-only view" : "pi Studio";
|
|
9469
9655
|
|
|
9470
9656
|
try {
|
|
@@ -9511,7 +9697,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
9511
9697
|
`Studio running at ${url} (busy: ${isStudioBusy() ? "yes" : "no"}; full views: ${counts.full}; editor-only views: ${counts.editorOnly})`,
|
|
9512
9698
|
"info",
|
|
9513
9699
|
);
|
|
9514
|
-
const sshTunnelHint = buildStudioSshTunnelHint(serverState.port);
|
|
9700
|
+
const sshTunnelHint = buildStudioSshTunnelHint(serverState.port, url);
|
|
9515
9701
|
if (sshTunnelHint) ctx.ui.notify(sshTunnelHint, "info");
|
|
9516
9702
|
return;
|
|
9517
9703
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, prompt/response history, and live Markdown/LaTeX/code preview",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { extname, isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function expandStudioPdfResourcePath(input) {
|
|
6
|
+
const value = String(input || "").trim();
|
|
7
|
+
if (value === "~") return homedir();
|
|
8
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
9
|
+
return resolve(homedir(), value.slice(2));
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function resolveStudioPdfResourceFile(pdfPath, baseDir) {
|
|
15
|
+
const rawPath = typeof pdfPath === "string" ? pdfPath.trim() : "";
|
|
16
|
+
if (!rawPath) throw new Error("Missing PDF path.");
|
|
17
|
+
if (/\0/.test(rawPath)) throw new Error("Invalid PDF path.");
|
|
18
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
|
|
19
|
+
throw new Error("Only local PDF paths are supported.");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const rawBaseDir = typeof baseDir === "string" ? baseDir.trim() : "";
|
|
23
|
+
if (!rawBaseDir) throw new Error("Missing Studio resource directory.");
|
|
24
|
+
|
|
25
|
+
const expandedPath = expandStudioPdfResourcePath(rawPath);
|
|
26
|
+
const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(rawBaseDir, expandedPath);
|
|
27
|
+
if (extname(candidate).toLowerCase() !== ".pdf") throw new Error("Only .pdf files can be embedded.");
|
|
28
|
+
|
|
29
|
+
const baseReal = realpathSync(rawBaseDir);
|
|
30
|
+
const candidateReal = realpathSync(candidate);
|
|
31
|
+
const rel = relative(baseReal, candidateReal);
|
|
32
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
33
|
+
throw new Error("PDF path must stay within the current Studio resource directory.");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const stat = statSync(candidateReal);
|
|
37
|
+
if (!stat.isFile()) throw new Error("PDF path does not refer to a file.");
|
|
38
|
+
return candidateReal;
|
|
39
|
+
}
|