pi-studio 0.9.47 → 0.9.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/studio.css CHANGED
@@ -148,7 +148,7 @@
148
148
  background: var(--panel-2);
149
149
  }
150
150
 
151
- button, select, .file-label {
151
+ button, select {
152
152
  border: 1px solid var(--control-border);
153
153
  background: var(--panel);
154
154
  color: var(--text);
@@ -163,14 +163,12 @@
163
163
  }
164
164
 
165
165
  button:not(:disabled):hover,
166
- select:hover,
167
- .file-label:hover {
166
+ select:hover {
168
167
  background: var(--panel-2);
169
168
  }
170
169
 
171
170
  button:focus-visible,
172
- select:focus-visible,
173
- .file-label:focus-within {
171
+ select:focus-visible {
174
172
  outline: 2px solid var(--accent-soft-strong);
175
173
  outline-offset: 1px;
176
174
  }
@@ -229,15 +227,8 @@
229
227
  filter: none;
230
228
  }
231
229
 
232
- .file-label {
233
- cursor: pointer;
234
- display: inline-flex;
235
- align-items: center;
236
- gap: 6px;
237
- }
238
-
239
- .file-label input {
240
- display: none;
230
+ .file-input-hidden {
231
+ display: none !important;
241
232
  }
242
233
 
243
234
  main {
@@ -1749,6 +1740,7 @@
1749
1740
 
1750
1741
  .rendered-markdown .studio-pdf-card-header {
1751
1742
  display: flex;
1743
+ flex-wrap: wrap;
1752
1744
  align-items: center;
1753
1745
  justify-content: space-between;
1754
1746
  gap: 10px;
@@ -1777,10 +1769,13 @@
1777
1769
  }
1778
1770
 
1779
1771
  .rendered-markdown .studio-pdf-card-actions {
1780
- flex: 0 0 auto;
1772
+ flex: 0 1 auto;
1781
1773
  display: inline-flex;
1774
+ flex-wrap: wrap;
1775
+ justify-content: flex-end;
1782
1776
  align-items: center;
1783
1777
  gap: 8px;
1778
+ margin-left: auto;
1784
1779
  }
1785
1780
 
1786
1781
  .rendered-markdown .studio-pdf-card-link,
@@ -1907,6 +1902,7 @@
1907
1902
  .studio-pdf-focus-header {
1908
1903
  flex: 0 0 auto;
1909
1904
  display: flex;
1905
+ flex-wrap: wrap;
1910
1906
  align-items: center;
1911
1907
  justify-content: space-between;
1912
1908
  gap: 12px;
@@ -1934,10 +1930,13 @@
1934
1930
  }
1935
1931
 
1936
1932
  .studio-pdf-focus-actions {
1937
- flex: 0 0 auto;
1933
+ flex: 0 1 auto;
1938
1934
  display: inline-flex;
1935
+ flex-wrap: wrap;
1936
+ justify-content: flex-end;
1939
1937
  align-items: center;
1940
1938
  gap: 8px;
1939
+ margin-left: auto;
1941
1940
  }
1942
1941
 
1943
1942
  .studio-pdf-focus-link,
@@ -2909,6 +2908,10 @@
2909
2908
  font-style: italic;
2910
2909
  }
2911
2910
 
2911
+ .studio-latex-compatibility-warning {
2912
+ margin: 0 0 0.85em;
2913
+ }
2914
+
2912
2915
  .marker {
2913
2916
  display: inline-block;
2914
2917
  padding: 0 4px;
@@ -5362,13 +5365,13 @@
5362
5365
  }
5363
5366
 
5364
5367
  body.studio-ui-refresh > header button,
5365
- body.studio-ui-refresh > header .file-label,
5366
5368
  body.studio-ui-refresh #responseActions button,
5367
5369
  body.studio-ui-refresh #responseActions select {
5368
5370
  padding: 5px 7px;
5369
5371
  font-size: 12px;
5370
5372
  }
5371
5373
 
5374
+
5372
5375
  body.studio-ui-refresh main {
5373
5376
  gap: 0;
5374
5377
  padding: 9px;
@@ -6101,6 +6104,102 @@
6101
6104
  font-weight: 700;
6102
6105
  }
6103
6106
 
6107
+ .studio-decision-overlay {
6108
+ position: fixed;
6109
+ inset: 0;
6110
+ z-index: 13000;
6111
+ display: grid;
6112
+ place-items: center;
6113
+ padding: 20px;
6114
+ background: rgba(0, 0, 0, 0.58);
6115
+ backdrop-filter: blur(2px);
6116
+ }
6117
+
6118
+ .studio-decision-overlay[hidden] {
6119
+ display: none !important;
6120
+ }
6121
+
6122
+ .studio-decision-dialog {
6123
+ width: min(560px, calc(100vw - 32px));
6124
+ max-height: min(80vh, 680px);
6125
+ overflow: auto;
6126
+ padding: 20px;
6127
+ border: 1px solid var(--panel-border);
6128
+ border-radius: 14px;
6129
+ background: var(--panel);
6130
+ color: var(--text);
6131
+ box-shadow: 0 24px 70px rgba(0, 0, 0, 0.48);
6132
+ }
6133
+
6134
+ .studio-decision-title {
6135
+ margin: 0;
6136
+ color: var(--text);
6137
+ font-size: 17px;
6138
+ line-height: 1.3;
6139
+ }
6140
+
6141
+ .studio-decision-message {
6142
+ margin-top: 10px;
6143
+ color: var(--studio-info-text, var(--muted));
6144
+ font-size: 13px;
6145
+ line-height: 1.5;
6146
+ white-space: pre-wrap;
6147
+ overflow-wrap: anywhere;
6148
+ }
6149
+
6150
+ .studio-decision-input {
6151
+ width: 100%;
6152
+ margin-top: 16px;
6153
+ padding: 9px 10px;
6154
+ border: 1px solid var(--control-border);
6155
+ border-radius: 8px;
6156
+ background: var(--panel-2);
6157
+ color: var(--text);
6158
+ font-family: var(--font-mono);
6159
+ font-size: 13px;
6160
+ }
6161
+
6162
+ .studio-decision-input[hidden] {
6163
+ display: none !important;
6164
+ }
6165
+
6166
+ .studio-decision-input:focus {
6167
+ border-color: var(--accent);
6168
+ outline: 2px solid var(--accent-soft);
6169
+ outline-offset: 1px;
6170
+ }
6171
+
6172
+ .studio-decision-actions {
6173
+ display: flex;
6174
+ flex-wrap: wrap;
6175
+ justify-content: flex-end;
6176
+ gap: 8px;
6177
+ margin-top: 18px;
6178
+ }
6179
+
6180
+ .studio-decision-confirm:not(.is-destructive) {
6181
+ border-color: var(--accent);
6182
+ background: var(--accent);
6183
+ color: var(--accent-contrast);
6184
+ font-weight: 650;
6185
+ }
6186
+
6187
+ .studio-decision-confirm.is-destructive {
6188
+ border-color: var(--warn-border);
6189
+ background: color-mix(in srgb, var(--warn) 15%, var(--panel));
6190
+ color: var(--warn);
6191
+ font-weight: 650;
6192
+ }
6193
+
6194
+ .studio-decision-confirm.is-destructive:hover,
6195
+ .studio-decision-confirm.is-destructive:focus-visible {
6196
+ background: color-mix(in srgb, var(--warn) 24%, var(--panel));
6197
+ }
6198
+
6199
+ body.studio-decision-open {
6200
+ overflow: hidden;
6201
+ }
6202
+
6104
6203
  .studio-preview-link-menu {
6105
6204
  position: fixed;
6106
6205
  z-index: 12000;
package/index.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js";
31
31
  import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
32
32
  import { createStudioPandocHtmlResourceFlagResolver } from "./shared/studio-pandoc-resource-flag.js";
33
+ import { prepareStudioLatexForPandoc } from "./shared/studio-latex-pandoc-compat.js";
33
34
  import { isStudioCmuxSession, openStudioUrlInBrowser } from "./shared/studio-browser-launcher.js";
34
35
  import { buildStudioReplTmuxStartArgs } from "./shared/studio-repl-tmux.js";
35
36
  import { buildStudioForwardingHint, buildStudioSshTunnelHint, isStudioSshSession as isSshSession } from "./shared/studio-ssh-hint.js";
@@ -648,6 +649,7 @@ const STUDIO_QUIZ_CONTEXT_MAX_FILES = 18;
648
649
  const STUDIO_QUIZ_SNIPPET_MAX_CHARS = 8_000;
649
650
  const STUDIO_QUIZ_DISCUSSION_MAX_CHARS = 6_000;
650
651
  const REQUEST_BODY_MAX_BYTES = 1_000_000;
652
+ const STUDIO_IMPORT_FILE_MAX_BYTES = 10_000_000;
651
653
  const STUDIO_WORKSPACE_STATE_REQUEST_MAX_BYTES = 4_000_000;
652
654
  const RESPONSE_HISTORY_LIMIT = 30;
653
655
  const CMUX_NOTIFY_TIMEOUT_MS = 1200;
@@ -6127,6 +6129,31 @@ function decorateStudioPandocSyntaxHtml(html: string): string {
6127
6129
  );
6128
6130
  }
6129
6131
 
6132
+ function buildStudioLatexPandocCompatibilityWarning(omittedPackages: Array<{ name: string }>): string | undefined {
6133
+ const packageNames = Array.from(new Set(
6134
+ omittedPackages
6135
+ .map((entry) => String(entry?.name ?? "").trim())
6136
+ .filter(Boolean),
6137
+ ));
6138
+ if (packageNames.length === 0) return undefined;
6139
+ const packageLabel = packageNames
6140
+ .map((name) => name.toLowerCase().endsWith(".sty") ? name : `${name}.sty`)
6141
+ .join(", ");
6142
+ const noun = packageNames.length === 1 ? "package" : "packages";
6143
+ const subject = packageNames.length === 1 ? "it redefines" : "they redefine";
6144
+ return `Studio omitted local LaTeX ${noun} ${packageLabel} from this Pandoc rendering because ${subject} document startup. Package-specific layout may be absent; compile the source directly with LaTeX for authoritative output.`;
6145
+ }
6146
+
6147
+ function combineStudioWarnings(...warnings: Array<string | undefined>): string | undefined {
6148
+ const messages = Array.from(new Set(warnings.map((warning) => String(warning ?? "").trim()).filter(Boolean)));
6149
+ return messages.length > 0 ? messages.join(" ") : undefined;
6150
+ }
6151
+
6152
+ function renderStudioLatexPandocCompatibilityWarningHtml(warning: string | undefined): string {
6153
+ if (!warning) return "";
6154
+ return `<div class="preview-warning studio-latex-compatibility-warning" role="note">${escapeStudioHtmlText(warning)}</div>`;
6155
+ }
6156
+
6130
6157
  const resolveStudioPandocHtmlResourceFlag = createStudioPandocHtmlResourceFlagResolver(async (pandocCommand: string) => {
6131
6158
  const result = await runStudioSubprocess(pandocCommand, ["--help"], {
6132
6159
  timeoutMs: 5_000,
@@ -6150,8 +6177,15 @@ function preprocessStudioLatexFootnotemarksForPreview(latex: string): string {
6150
6177
 
6151
6178
  async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string, sourcePath?: string): Promise<string> {
6152
6179
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
6153
- const latexPreviewSource = isLatex ? preprocessStudioLatexFootnotemarksForPreview(markdown) : markdown;
6154
- const markdownWithNormalizedFences = isLatex ? latexPreviewSource : normalizeStudioMarkdownSmartFences(markdown);
6180
+ const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath)
6181
+ ?? resolveStudioPandocWorkingDir(sourcePath ? dirname(sourcePath) : undefined);
6182
+ const latexPandocCompatibility = isLatex
6183
+ ? prepareStudioLatexForPandoc(markdown, pandocWorkingDir)
6184
+ : { source: markdown, omittedPackages: [] as Array<{ name: string; path: string }> };
6185
+ const latexPandocCompatibilityWarning = buildStudioLatexPandocCompatibilityWarning(latexPandocCompatibility.omittedPackages);
6186
+ const pandocInputSource = latexPandocCompatibility.source;
6187
+ const latexPreviewSource = isLatex ? preprocessStudioLatexFootnotemarksForPreview(pandocInputSource) : pandocInputSource;
6188
+ const markdownWithNormalizedFences = isLatex ? latexPreviewSource : normalizeStudioMarkdownSmartFences(pandocInputSource);
6155
6189
  const markdownWithoutHtmlComments = isLatex ? markdownWithNormalizedFences : stripStudioMarkdownHtmlCommentsPreservingYamlFrontMatter(markdownWithNormalizedFences);
6156
6190
  const markdownWithPreviewPageBreaks = isLatex ? markdownWithoutHtmlComments : replaceStudioPreviewPageBreakCommands(markdownWithoutHtmlComments);
6157
6191
  const latexSubfigurePreviewTransform = isLatex
@@ -6164,7 +6198,7 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
6164
6198
  ? preprocessStudioLatexReferences(latexAlgorithmPreviewTransform.markdown, sourcePath, resourcePath)
6165
6199
  : markdownWithPreviewPageBreaks;
6166
6200
  const inputFormat = isLatex ? "latex" : "markdown+lists_without_preceding_blankline-blank_before_blockquote-blank_before_header+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris-raw_html";
6167
- const bibliographyArgs = buildStudioPandocBibliographyArgs(markdown, isLatex, resourcePath);
6201
+ const bibliographyArgs = buildStudioPandocBibliographyArgs(pandocInputSource, isLatex, resourcePath);
6168
6202
  const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none", ...bibliographyArgs];
6169
6203
  let htmlTemplateDir: string | null = null;
6170
6204
  const useStudioHtmlTemplate = Boolean(resourcePath || isLatex);
@@ -6185,7 +6219,6 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
6185
6219
  const normalizedMarkdown = isLatex
6186
6220
  ? sourceWithResolvedRefs
6187
6221
  : normalizeStudioMarkdownFencedBlocks(prepareStudioMarkdownForPandoc(sourceWithResolvedRefs));
6188
- const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath);
6189
6222
 
6190
6223
  let pandocResult: StudioSubprocessResult;
6191
6224
  try {
@@ -6230,6 +6263,7 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
6230
6263
  renderedHtml = decorateStudioPreviewPageBreakHtml(renderedHtml);
6231
6264
  }
6232
6265
  renderedHtml = decorateStudioPandocSyntaxHtml(renderedHtml);
6266
+ renderedHtml = renderStudioLatexPandocCompatibilityWarningHtml(latexPandocCompatibilityWarning) + renderedHtml;
6233
6267
  return stripMathMlAnnotationTags(renderedHtml);
6234
6268
  }
6235
6269
 
@@ -6616,8 +6650,15 @@ async function renderStudioStandaloneHtmlWithPandoc(
6616
6650
  sourcePath?: string,
6617
6651
  options?: StudioHtmlRenderOptions,
6618
6652
  ): Promise<{ html: Buffer; warning?: string }> {
6619
- const delimitedMarkdown = isLatex ? null : formatStudioDelimitedTextAsMarkdown(markdown, editorLanguage);
6620
- const input = delimitedMarkdown ?? markdown;
6653
+ const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath)
6654
+ ?? resolveStudioPandocWorkingDir(sourcePath ? dirname(sourcePath) : undefined);
6655
+ const latexPandocCompatibility = isLatex
6656
+ ? prepareStudioLatexForPandoc(markdown, pandocWorkingDir)
6657
+ : { source: markdown, omittedPackages: [] as Array<{ name: string; path: string }> };
6658
+ const latexPandocCompatibilityWarning = buildStudioLatexPandocCompatibilityWarning(latexPandocCompatibility.omittedPackages);
6659
+ const pandocCompatibleMarkdown = latexPandocCompatibility.source;
6660
+ const delimitedMarkdown = isLatex ? null : formatStudioDelimitedTextAsMarkdown(pandocCompatibleMarkdown, editorLanguage);
6661
+ const input = delimitedMarkdown ?? pandocCompatibleMarkdown;
6621
6662
  const effectiveEditorLanguage = delimitedMarkdown ? "markdown" : inferStudioPdfLanguage(input, editorLanguage);
6622
6663
  if (!isLatex && isLikelyStandaloneStudioHtml(input, effectiveEditorLanguage)) {
6623
6664
  return { html: Buffer.from(String(input ?? ""), "utf-8") };
@@ -6634,8 +6675,12 @@ async function renderStudioStandaloneHtmlWithPandoc(
6634
6675
  let renderedHtml = await renderStudioMarkdownWithPandoc(pdfPrepared.markdown, isLatex, resourcePath, sourcePath);
6635
6676
  renderedHtml = renderStudioPdfBlocksInHtml(renderedHtml, pdfPrepared.blocks, sourcePath, resourcePath);
6636
6677
  renderedHtml = applyStudioAnnotationPlaceholdersToHtml(renderedHtml, annotationPrepared.placeholders);
6678
+ renderedHtml = renderStudioLatexPandocCompatibilityWarningHtml(latexPandocCompatibilityWarning) + renderedHtml;
6637
6679
  const standaloneHtml = buildStudioStandaloneHtmlDocument(renderedHtml, resourcePath, options);
6638
- return { html: Buffer.from(standaloneHtml, "utf-8") };
6680
+ return {
6681
+ html: Buffer.from(standaloneHtml, "utf-8"),
6682
+ warning: latexPandocCompatibilityWarning,
6683
+ };
6639
6684
  }
6640
6685
 
6641
6686
  async function renderStudioLiteralTextPdf(text: string, title = "Studio export", options?: StudioPdfRenderOptions): Promise<Buffer> {
@@ -7024,19 +7069,26 @@ async function renderStudioPdfWithPandoc(
7024
7069
  ): Promise<{ pdf: Buffer; warning?: string }> {
7025
7070
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
7026
7071
  const pdfEngine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
7072
+ const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath)
7073
+ ?? resolveStudioPandocWorkingDir(sourcePath ? dirname(sourcePath) : undefined);
7074
+ const latexPandocCompatibility = isLatex
7075
+ ? prepareStudioLatexForPandoc(markdown, pandocWorkingDir)
7076
+ : { source: markdown, omittedPackages: [] as Array<{ name: string; path: string }> };
7077
+ const latexPandocCompatibilityWarning = buildStudioLatexPandocCompatibilityWarning(latexPandocCompatibility.omittedPackages);
7078
+ const pandocInputSource = latexPandocCompatibility.source;
7027
7079
  const latexSubfigurePdfTransform = isLatex
7028
- ? preprocessStudioLatexSubfiguresForPdf(markdown)
7029
- : { markdown, groups: [] };
7080
+ ? preprocessStudioLatexSubfiguresForPdf(pandocInputSource)
7081
+ : { markdown: pandocInputSource, groups: [] };
7030
7082
  const latexPdfSource = isLatex
7031
7083
  ? preprocessStudioLatexAlgorithmsForPdf(
7032
7084
  latexSubfigurePdfTransform.markdown,
7033
7085
  sourcePath,
7034
7086
  resourcePath,
7035
7087
  )
7036
- : markdown;
7088
+ : pandocInputSource;
7037
7089
  const sourceWithResolvedRefs = isLatex
7038
7090
  ? injectStudioLatexEquationTags(preprocessStudioLatexReferences(latexPdfSource, sourcePath, resourcePath), sourcePath, resourcePath)
7039
- : markdown;
7091
+ : pandocInputSource;
7040
7092
  const effectiveEditorLanguage = inferStudioPdfLanguage(sourceWithResolvedRefs, editorPdfLanguage);
7041
7093
  const pdfCalloutTransform = !isLatex && (!effectiveEditorLanguage || effectiveEditorLanguage === "markdown")
7042
7094
  ? preprocessStudioMarkdownCalloutsForPdf(sourceWithResolvedRefs)
@@ -7044,8 +7096,7 @@ async function renderStudioPdfWithPandoc(
7044
7096
  const pdfAlignedImageTransform = !isLatex && (!effectiveEditorLanguage || effectiveEditorLanguage === "markdown")
7045
7097
  ? preprocessStudioMarkdownImageAlignmentForPdf(pdfCalloutTransform.markdown)
7046
7098
  : { markdown: pdfCalloutTransform.markdown, blocks: [] as StudioPdfAlignedImageBlock[] };
7047
- const pandocWorkingDir = resolveStudioPandocWorkingDir(resourcePath);
7048
- const bibliographyArgs = buildStudioPandocBibliographyArgs(markdown, isLatex, resourcePath);
7099
+ const bibliographyArgs = buildStudioPandocBibliographyArgs(pandocInputSource, isLatex, resourcePath);
7049
7100
 
7050
7101
  const runPandocPdfExport = async (
7051
7102
  inputFormat: string,
@@ -7098,7 +7149,7 @@ async function renderStudioPdfWithPandoc(
7098
7149
  };
7099
7150
 
7100
7151
  if (isLatex && (latexSubfigurePdfTransform.groups.length > 0 || collectStudioInlineAnnotationMarkers(sourceWithResolvedRefs).length > 0)) {
7101
- return await renderStudioPdfFromGeneratedLatex(
7152
+ const rendered = await renderStudioPdfFromGeneratedLatex(
7102
7153
  sourceWithResolvedRefs,
7103
7154
  pandocCommand,
7104
7155
  pdfEngine,
@@ -7114,6 +7165,10 @@ async function renderStudioPdfWithPandoc(
7114
7165
  "",
7115
7166
  themeStyle,
7116
7167
  );
7168
+ return {
7169
+ pdf: rendered.pdf,
7170
+ warning: combineStudioWarnings(latexPandocCompatibilityWarning, rendered.warning),
7171
+ };
7117
7172
  }
7118
7173
 
7119
7174
  if (!isLatex && effectiveEditorLanguage === "diff") {
@@ -7187,7 +7242,10 @@ async function renderStudioPdfWithPandoc(
7187
7242
  themeStyle,
7188
7243
  );
7189
7244
  await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
7190
- return { pdf: rendered.pdf, warning: mermaidPrepared.warning ?? rendered.warning };
7245
+ return {
7246
+ pdf: rendered.pdf,
7247
+ warning: combineStudioWarnings(latexPandocCompatibilityWarning, mermaidPrepared.warning, rendered.warning),
7248
+ };
7191
7249
  }
7192
7250
 
7193
7251
  const hasYamlHeaderIncludesForPdf = !isLatex && hasStudioYamlHeaderIncludes(markdownForPdf);
@@ -7222,7 +7280,10 @@ async function renderStudioPdfWithPandoc(
7222
7280
  throw new Error(`pandoc PDF export failed with exit code ${pandocResult.code}${stderr ? `: ${stderr}` : ""}${hint}`);
7223
7281
  }
7224
7282
 
7225
- return { pdf: await readFile(outputPath), warning: mermaidPrepared.warning };
7283
+ return {
7284
+ pdf: await readFile(outputPath),
7285
+ warning: combineStudioWarnings(latexPandocCompatibilityWarning, mermaidPrepared.warning),
7286
+ };
7226
7287
  } finally {
7227
7288
  await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
7228
7289
  }
@@ -7532,6 +7593,64 @@ function revealStudioLocalFile(filePath: string): { ok: true; message: string }
7532
7593
  return { ok: true, message: process.platform === "linux" ? "Opened containing folder." : "Revealed resource in file manager." };
7533
7594
  }
7534
7595
 
7596
+ async function handleImportStudioFileCopyRequest(req: IncomingMessage, res: ServerResponse, studioCwd: string): Promise<void> {
7597
+ const method = (req.method ?? "GET").toUpperCase();
7598
+ if (method !== "POST") {
7599
+ res.setHeader("Allow", "POST");
7600
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
7601
+ return;
7602
+ }
7603
+
7604
+ const rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
7605
+ let payload: Record<string, unknown> = {};
7606
+ try {
7607
+ payload = rawBody ? JSON.parse(rawBody) : {};
7608
+ } catch {
7609
+ respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
7610
+ return;
7611
+ }
7612
+
7613
+ const requestedPath = typeof payload.path === "string" ? payload.path : "";
7614
+ const resolved = resolveStudioPath(requestedPath, studioCwd);
7615
+ if (resolved.ok === false) {
7616
+ respondJson(res, 400, { ok: false, error: resolved.message });
7617
+ return;
7618
+ }
7619
+
7620
+ try {
7621
+ const stats = statSync(resolved.resolved);
7622
+ if (!stats.isFile()) {
7623
+ respondJson(res, 400, { ok: false, error: `Path is not a file: ${resolved.label}` });
7624
+ return;
7625
+ }
7626
+ if (stats.size > STUDIO_IMPORT_FILE_MAX_BYTES) {
7627
+ respondJson(res, 413, {
7628
+ ok: false,
7629
+ error: `File is too large to import into Studio (${stats.size} bytes; limit ${STUDIO_IMPORT_FILE_MAX_BYTES} bytes).`,
7630
+ });
7631
+ return;
7632
+ }
7633
+ } catch (error) {
7634
+ respondJson(res, 404, {
7635
+ ok: false,
7636
+ error: `Could not access file: ${resolved.label} (${error instanceof Error ? error.message : String(error)})`,
7637
+ });
7638
+ return;
7639
+ }
7640
+
7641
+ const file = readStudioFile(resolved.resolved, studioCwd);
7642
+ if (file.ok === false) {
7643
+ respondJson(res, 400, { ok: false, error: file.message });
7644
+ return;
7645
+ }
7646
+ respondJson(res, 200, {
7647
+ ok: true,
7648
+ text: file.text,
7649
+ filename: basename(file.resolvedPath),
7650
+ resolvedPath: file.resolvedPath,
7651
+ });
7652
+ }
7653
+
7535
7654
  async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res: ServerResponse, studioCwd: string): Promise<void> {
7536
7655
  const method = (req.method ?? "GET").toUpperCase();
7537
7656
  if (method !== "POST") {
@@ -7567,6 +7686,50 @@ async function handleRevealLocalPreviewResourceRequest(req: IncomingMessage, res
7567
7686
  }
7568
7687
  }
7569
7688
 
7689
+ async function handleOpenLocalPreviewResourceRequest(req: IncomingMessage, res: ServerResponse, studioCwd: string): Promise<void> {
7690
+ const method = (req.method ?? "GET").toUpperCase();
7691
+ if (method !== "POST") {
7692
+ res.setHeader("Allow", "POST");
7693
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
7694
+ return;
7695
+ }
7696
+ if (isSshSession()) {
7697
+ respondJson(res, 409, { ok: false, error: "Cannot open a system PDF viewer from an SSH/headless Studio session. Copy the path instead." });
7698
+ return;
7699
+ }
7700
+
7701
+ const rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
7702
+ let payload: Record<string, unknown> = {};
7703
+ try {
7704
+ payload = rawBody ? JSON.parse(rawBody) : {};
7705
+ } catch {
7706
+ respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
7707
+ return;
7708
+ }
7709
+
7710
+ try {
7711
+ const resource = resolveStudioLocalPreviewResourcePath(
7712
+ typeof payload.path === "string" ? payload.path : "",
7713
+ typeof payload.sourcePath === "string" ? payload.sourcePath : undefined,
7714
+ typeof payload.resourceDir === "string" ? payload.resourceDir : undefined,
7715
+ studioCwd,
7716
+ );
7717
+ if (resource.kind !== "pdf") {
7718
+ respondJson(res, 400, { ok: false, error: "Only local PDF previews can be opened in the system PDF viewer." });
7719
+ return;
7720
+ }
7721
+ await openPathInDefaultViewer(resource.filePath);
7722
+ respondJson(res, 200, {
7723
+ ok: true,
7724
+ message: "Opened PDF in the system viewer.",
7725
+ path: resource.filePath,
7726
+ label: resource.label,
7727
+ });
7728
+ } catch (error) {
7729
+ respondJson(res, 404, { ok: false, error: `Local PDF unavailable: ${error instanceof Error ? error.message : String(error)}` });
7730
+ }
7731
+ }
7732
+
7570
7733
  function openPathInDefaultViewer(path: string): Promise<void> {
7571
7734
  const openCommand =
7572
7735
  process.platform === "darwin"
@@ -10589,7 +10752,8 @@ ${cssVarsBlock}
10589
10752
  <button id="saveOverBtn" type="button" title="Overwrite current file with editor content. Shortcut: Cmd/Ctrl+S.">Save editor</button>
10590
10753
  <button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
10591
10754
  <button id="clearWorkspaceBtn" type="button" title="Clear editor text and reset this tab to a fresh blank draft. Saved files and responses are not changed.">Reset editor</button>
10592
- <label class="file-label" title="Browser import: load a selected text file as a detached copy. Use Save editor as… to attach this copy to a file path and make it file-backed, or use the Files view to open a refreshable file-backed document directly.">Import file copy…<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>
10755
+ <button id="importFileBtn" type="button" title="Import a file as an editable copy.">Import file copy…</button>
10756
+ <input id="fileInput" class="file-input-hidden" type="file" tabindex="-1" aria-hidden="true" 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" />
10593
10757
  <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
10594
10758
  <button id="zenModeBtn" class="zen-mode-btn" type="button" title="Hide secondary Studio controls. Shortcut: F9.">Zen</button>
10595
10759
  </div>
@@ -14909,6 +15073,19 @@ export default function (pi: ExtensionAPI) {
14909
15073
  return;
14910
15074
  }
14911
15075
 
15076
+ if (requestUrl.pathname === "/import-file-copy") {
15077
+ const token = requestUrl.searchParams.get("token") ?? "";
15078
+ if (token !== serverState.token) {
15079
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
15080
+ return;
15081
+ }
15082
+
15083
+ void handleImportStudioFileCopyRequest(req, res, studioCwd).catch((error) => {
15084
+ respondJson(res, 500, { ok: false, error: `File import failed: ${error instanceof Error ? error.message : String(error)}` });
15085
+ });
15086
+ return;
15087
+ }
15088
+
14912
15089
  if (requestUrl.pathname === "/local-preview-link") {
14913
15090
  const token = requestUrl.searchParams.get("token") ?? "";
14914
15091
  if (token !== serverState.token) {
@@ -14945,6 +15122,19 @@ export default function (pi: ExtensionAPI) {
14945
15122
  return;
14946
15123
  }
14947
15124
 
15125
+ if (requestUrl.pathname === "/open-local-resource") {
15126
+ const token = requestUrl.searchParams.get("token") ?? "";
15127
+ if (token !== serverState.token) {
15128
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
15129
+ return;
15130
+ }
15131
+
15132
+ void handleOpenLocalPreviewResourceRequest(req, res, studioCwd).catch((error) => {
15133
+ respondJson(res, 500, { ok: false, error: `Open in system viewer failed: ${error instanceof Error ? error.message : String(error)}` });
15134
+ });
15135
+ return;
15136
+ }
15137
+
14948
15138
  if (requestUrl.pathname === "/pdf-resource") {
14949
15139
  const token = requestUrl.searchParams.get("token") ?? "";
14950
15140
  if (token !== serverState.token) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.47",
3
+ "version": "0.9.48",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",