pi-studio 0.3.0 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +12 -6
  2. package/index.ts +520 -129
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,8 +2,6 @@
2
2
 
3
3
  Experimental extension for [pi](https://github.com/badlogic/pi-mono) that opens a local browser workspace for annotating model responses/files, running edited prompts, and requesting critiques.
4
4
 
5
- Status: experimental alpha.
6
-
7
5
  ## Screenshots
8
6
 
9
7
  **Markdown workspace — dark** (syntax-highlighted editor + rendered preview with Julia code block, inline math, blockquotes)
@@ -51,19 +49,26 @@ Status: experimental alpha.
51
49
  - Response load helpers:
52
50
  - non-critique: **Load response into editor**
53
51
  - critique: **Load critique notes into editor** / **Load full critique into editor**
54
- - File actions: **Save As…**, **Save file**, **Load file content**
55
- - View toggles: `Left: Editor (Raw|Preview)`, `Right: Response (Raw|Preview) | Editor (Preview)`
52
+ - File actions: **Save editor as…**, **Save editor**, **Load file content**
53
+ - View toggles: panel header dropdowns for `Editor (Raw|Preview)` and `Response (Raw|Preview) | Editor (Preview)`
56
54
  - **Editor Preview in response pane**: side-by-side source/rendered view (Overleaf-style) — select `Right: Editor (Preview)` to render editor text in the right pane with live debounced updates
57
55
  - Preview mode supports MathML equations and Mermaid fenced diagrams
58
56
  - **Language-aware syntax highlighting** with selectable language mode:
59
57
  - Markdown (default): headings, links, code fences, lists, quotes, inline code
60
- - Code languages: JavaScript, TypeScript, Python, Bash, JSON, Rust, C, C++, Julia, Fortran, R, MATLAB
58
+ - Code languages: JavaScript, TypeScript, Python, Bash, JSON, Rust, C, C++, Julia, Fortran, R, MATLAB, LaTeX, Diff
61
59
  - Keywords, strings, comments, numbers, and variables highlighted using theme syntax color tokens
60
+ - **Diff highlighting**: added/removed lines shown with green/red backgrounds in both raw and preview modes
62
61
  - Language auto-detected from file extension on file load; manually selectable via `Lang:` dropdown
63
62
  - Applies to both editor Raw view (highlight overlay) and fenced code blocks in markdown
64
63
  - Preview mode renders syntax-highlighted code when a non-markdown language is selected
64
+ - **LaTeX file support**: `.tex`/`.latex` files detected by content, rendered via pandoc with proper title block (title, author, date, abstract) styling
65
+ - **Diff file support**: `.diff`/`.patch` files rendered with coloured add/remove line backgrounds
66
+ - **Image embedding**: images in markdown and LaTeX files embedded as base64 data URIs via pandoc `--embed-resources`, with no external file serving required
67
+ - **Working directory**: "Set working dir" button for uploaded files — resolves relative image paths and enables "Save editor" for uploaded content
68
+ - **Live theme sync**: changing the pi theme in the terminal updates the studio browser UI automatically (polled every 2 seconds)
65
69
  - Separate syntax highlight toggles for editor and response Raw views, with local preference persistence
66
70
  - Theme-aware browser UI derived from current pi theme
71
+ - View mode selectors integrated into panel headers for a cleaner layout
67
72
 
68
73
  ## Commands
69
74
 
@@ -103,7 +108,8 @@ pi -e https://github.com/omaclaren/pi-studio
103
108
  - One studio request at a time.
104
109
  - Pi Studio supports both markdown workflows (model responses, plans, and notes) and code file editing with language-aware syntax highlighting.
105
110
  - Studio URLs include a token query parameter; avoid sharing full Studio URLs.
106
- - Preview panes render markdown via `pandoc` (`gfm+tex_math_dollars` → HTML5 + MathML), including pandoc code syntax highlighting, sanitized in-browser with `dompurify`.
111
+ - Preview panes render markdown via `pandoc` (`gfm+tex_math_dollars` → HTML5 + MathML), including pandoc code syntax highlighting, sanitized in-browser with `dompurify`. LaTeX files are rendered from `latex` input format with title block styling.
112
+ - Images referenced in markdown or via `\includegraphics` in LaTeX are embedded as base64 data URIs when a file path or working directory is available. For uploaded files without a working directory set, a notice suggests setting one.
107
113
  - Preview markdown/code colors are mapped from active theme markdown (`md*`) and syntax (`syntax*`) tokens for closer terminal-vs-browser parity.
108
114
  - Mermaid fenced `mermaid` code blocks are rendered client-side in preview mode (Mermaid v11 loaded from jsDelivr), with palette-driven defaults for better theme fit.
109
115
  - If Mermaid cannot load or a diagram fails to render, preview shows an inline warning and keeps source text visible.
package/index.ts CHANGED
@@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { readFileSync, statSync, writeFileSync } from "node:fs";
5
5
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
6
- import { isAbsolute, join, resolve } from "node:path";
6
+ import { dirname, isAbsolute, join, resolve } from "node:path";
7
7
  import { URL } from "node:url";
8
8
  import { WebSocketServer, WebSocket, type RawData } from "ws";
9
9
 
@@ -610,10 +610,22 @@ function readStudioFile(pathArg: string, cwd: string):
610
610
  }
611
611
 
612
612
  try {
613
- const text = readFileSync(resolved.resolved, "utf-8");
614
- if (text.includes("\u0000")) {
613
+ // Read raw bytes first to detect binary content before UTF-8 decode
614
+ const buf = readFileSync(resolved.resolved);
615
+ // Heuristic: check the first 8KB for binary indicators
616
+ const sample = buf.subarray(0, 8192);
617
+ let nulCount = 0;
618
+ let controlCount = 0;
619
+ for (let i = 0; i < sample.length; i++) {
620
+ const b = sample[i];
621
+ if (b === 0x00) nulCount++;
622
+ // Control chars excluding tab (0x09), newline (0x0A), carriage return (0x0D)
623
+ else if (b < 0x08 || (b > 0x0D && b < 0x20 && b !== 0x1B)) controlCount++;
624
+ }
625
+ if (nulCount > 0 || (sample.length > 0 && controlCount / sample.length > 0.1)) {
615
626
  return { ok: false, message: `File appears to be binary: ${resolved.label}` };
616
627
  }
628
+ const text = buf.toString("utf-8");
617
629
  return { ok: true, text, label: resolved.label, resolvedPath: resolved.resolved };
618
630
  } catch (error) {
619
631
  return {
@@ -710,15 +722,22 @@ function stripMathMlAnnotationTags(html: string): string {
710
722
  }
711
723
 
712
724
  function normalizeObsidianImages(markdown: string): string {
725
+ // Use angle-bracket destinations so paths with spaces/special chars are safe for Pandoc
713
726
  return markdown
714
- .replace(/!\[\[([^|\]]+)\|([^\]]+)\]\]/g, "![$2]($1)")
715
- .replace(/!\[\[([^\]]+)\]\]/g, "![]($1)");
727
+ .replace(/!\[\[([^|\]]+)\|([^\]]+)\]\]/g, (_m, path, alt) => `![${alt}](<${path}>)`)
728
+ .replace(/!\[\[([^\]]+)\]\]/g, (_m, path) => `![](<${path}>)`);
716
729
  }
717
730
 
718
- async function renderStudioMarkdownWithPandoc(markdown: string): Promise<string> {
731
+ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string): Promise<string> {
719
732
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
720
- const args = ["-f", "gfm+tex_math_dollars-raw_html", "-t", "html5", "--mathml"];
721
- const normalizedMarkdown = normalizeObsidianImages(normalizeMathDelimiters(markdown));
733
+ const inputFormat = isLatex ? "latex" : "gfm+tex_math_dollars-raw_html";
734
+ const args = ["-f", inputFormat, "-t", "html5", "--mathml"];
735
+ if (resourcePath) {
736
+ args.push(`--resource-path=${resourcePath}`);
737
+ // Embed images as data URIs so they render in the browser preview
738
+ args.push("--embed-resources", "--standalone");
739
+ }
740
+ const normalizedMarkdown = isLatex ? markdown : normalizeObsidianImages(normalizeMathDelimiters(markdown));
722
741
 
723
742
  return await new Promise<string>((resolve, reject) => {
724
743
  const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"] });
@@ -757,7 +776,12 @@ async function renderStudioMarkdownWithPandoc(markdown: string): Promise<string>
757
776
  child.once("close", (code) => {
758
777
  if (settled) return;
759
778
  if (code === 0) {
760
- const renderedHtml = Buffer.concat(stdoutChunks).toString("utf-8");
779
+ let renderedHtml = Buffer.concat(stdoutChunks).toString("utf-8");
780
+ // When --standalone was used, extract only the <body> content
781
+ if (resourcePath) {
782
+ const bodyMatch = renderedHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
783
+ if (bodyMatch) renderedHtml = bodyMatch[1];
784
+ }
761
785
  succeed(stripMathMlAnnotationTags(renderedHtml));
762
786
  return;
763
787
  }
@@ -1124,12 +1148,79 @@ function buildStudioUrl(port: number, token: string): string {
1124
1148
  return `http://127.0.0.1:${port}/?token=${encoded}`;
1125
1149
  }
1126
1150
 
1151
+ function buildThemeCssVars(style: StudioThemeStyle): Record<string, string> {
1152
+ const panelShadow =
1153
+ style.mode === "light"
1154
+ ? "0 1px 2px rgba(15, 23, 42, 0.03), 0 4px 14px rgba(15, 23, 42, 0.04)"
1155
+ : "0 1px 2px rgba(0, 0, 0, 0.36), 0 6px 18px rgba(0, 0, 0, 0.22)";
1156
+ const accentContrast = style.mode === "light" ? "#ffffff" : "#0e1616";
1157
+ const blockquoteBg = withAlpha(
1158
+ style.palette.mdQuoteBorder,
1159
+ style.mode === "light" ? 0.10 : 0.16,
1160
+ style.mode === "light" ? "rgba(15, 23, 42, 0.04)" : "rgba(255, 255, 255, 0.05)",
1161
+ );
1162
+ const tableAltBg = withAlpha(
1163
+ style.palette.mdCodeBlockBorder,
1164
+ style.mode === "light" ? 0.10 : 0.14,
1165
+ style.mode === "light" ? "rgba(15, 23, 42, 0.03)" : "rgba(255, 255, 255, 0.04)",
1166
+ );
1167
+ const editorBg = style.mode === "light"
1168
+ ? blendColors(style.palette.panel, "#ffffff", 0.5)
1169
+ : style.palette.panel;
1170
+
1171
+ return {
1172
+ "color-scheme": style.mode,
1173
+ "--bg": style.palette.bg,
1174
+ "--panel": style.palette.panel,
1175
+ "--panel-2": style.palette.panel2,
1176
+ "--border": style.palette.border,
1177
+ "--border-muted": style.palette.borderMuted,
1178
+ "--text": style.palette.text,
1179
+ "--muted": style.palette.muted,
1180
+ "--accent": style.palette.accent,
1181
+ "--warn": style.palette.warn,
1182
+ "--error": style.palette.error,
1183
+ "--ok": style.palette.ok,
1184
+ "--marker-bg": style.palette.markerBg,
1185
+ "--marker-border": style.palette.markerBorder,
1186
+ "--accent-soft": style.palette.accentSoft,
1187
+ "--accent-soft-strong": style.palette.accentSoftStrong,
1188
+ "--ok-border": style.palette.okBorder,
1189
+ "--warn-border": style.palette.warnBorder,
1190
+ "--md-heading": style.palette.mdHeading,
1191
+ "--md-link": style.palette.mdLink,
1192
+ "--md-link-url": style.palette.mdLinkUrl,
1193
+ "--md-code": style.palette.mdCode,
1194
+ "--md-codeblock": style.palette.mdCodeBlock,
1195
+ "--md-codeblock-border": style.palette.mdCodeBlockBorder,
1196
+ "--md-quote": style.palette.mdQuote,
1197
+ "--md-quote-border": style.palette.mdQuoteBorder,
1198
+ "--md-hr": style.palette.mdHr,
1199
+ "--md-list-bullet": style.palette.mdListBullet,
1200
+ "--syntax-comment": style.palette.syntaxComment,
1201
+ "--syntax-keyword": style.palette.syntaxKeyword,
1202
+ "--syntax-function": style.palette.syntaxFunction,
1203
+ "--syntax-variable": style.palette.syntaxVariable,
1204
+ "--syntax-string": style.palette.syntaxString,
1205
+ "--syntax-number": style.palette.syntaxNumber,
1206
+ "--syntax-type": style.palette.syntaxType,
1207
+ "--syntax-operator": style.palette.syntaxOperator,
1208
+ "--syntax-punctuation": style.palette.syntaxPunctuation,
1209
+ "--panel-shadow": panelShadow,
1210
+ "--accent-contrast": accentContrast,
1211
+ "--blockquote-bg": blockquoteBg,
1212
+ "--table-alt-bg": tableAltBg,
1213
+ "--editor-bg": editorBg,
1214
+ };
1215
+ }
1216
+
1127
1217
  function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?: Theme): string {
1128
1218
  const initialText = escapeHtmlForInline(initialDocument?.text ?? "");
1129
1219
  const initialSource = initialDocument?.source ?? "blank";
1130
1220
  const initialLabel = escapeHtmlForInline(initialDocument?.label ?? "blank");
1131
1221
  const initialPath = escapeHtmlForInline(initialDocument?.path ?? "");
1132
1222
  const style = getStudioThemeStyle(theme);
1223
+ const vars = buildThemeCssVars(style);
1133
1224
  const mermaidConfig = {
1134
1225
  startOnLoad: false,
1135
1226
  theme: "base",
@@ -1157,24 +1248,7 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
1157
1248
  titleColor: style.palette.mdHeading,
1158
1249
  },
1159
1250
  };
1160
- const panelShadow =
1161
- style.mode === "light"
1162
- ? "0 1px 2px rgba(15, 23, 42, 0.03), 0 4px 14px rgba(15, 23, 42, 0.04)"
1163
- : "0 1px 2px rgba(0, 0, 0, 0.36), 0 6px 18px rgba(0, 0, 0, 0.22)";
1164
- const accentContrast = style.mode === "light" ? "#ffffff" : "#0e1616";
1165
- const blockquoteBg = withAlpha(
1166
- style.palette.mdQuoteBorder,
1167
- style.mode === "light" ? 0.10 : 0.16,
1168
- style.mode === "light" ? "rgba(15, 23, 42, 0.04)" : "rgba(255, 255, 255, 0.05)",
1169
- );
1170
- const tableAltBg = withAlpha(
1171
- style.palette.mdCodeBlockBorder,
1172
- style.mode === "light" ? 0.10 : 0.14,
1173
- style.mode === "light" ? "rgba(15, 23, 42, 0.03)" : "rgba(255, 255, 255, 0.04)",
1174
- );
1175
- const editorBg = style.mode === "light"
1176
- ? blendColors(style.palette.panel, "#ffffff", 0.5)
1177
- : style.palette.panel;
1251
+ const cssVarsBlock = Object.entries(vars).map(([k, v]) => ` ${k}: ${v};`).join("\n");
1178
1252
 
1179
1253
  return `<!doctype html>
1180
1254
  <html>
@@ -1184,48 +1258,7 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
1184
1258
  <title>Pi Studio: Feedback Workspace</title>
1185
1259
  <style>
1186
1260
  :root {
1187
- color-scheme: ${style.mode};
1188
- --bg: ${style.palette.bg};
1189
- --panel: ${style.palette.panel};
1190
- --panel-2: ${style.palette.panel2};
1191
- --border: ${style.palette.border};
1192
- --border-muted: ${style.palette.borderMuted};
1193
- --text: ${style.palette.text};
1194
- --muted: ${style.palette.muted};
1195
- --accent: ${style.palette.accent};
1196
- --warn: ${style.palette.warn};
1197
- --error: ${style.palette.error};
1198
- --ok: ${style.palette.ok};
1199
- --marker-bg: ${style.palette.markerBg};
1200
- --marker-border: ${style.palette.markerBorder};
1201
- --accent-soft: ${style.palette.accentSoft};
1202
- --accent-soft-strong: ${style.palette.accentSoftStrong};
1203
- --ok-border: ${style.palette.okBorder};
1204
- --warn-border: ${style.palette.warnBorder};
1205
- --md-heading: ${style.palette.mdHeading};
1206
- --md-link: ${style.palette.mdLink};
1207
- --md-link-url: ${style.palette.mdLinkUrl};
1208
- --md-code: ${style.palette.mdCode};
1209
- --md-codeblock: ${style.palette.mdCodeBlock};
1210
- --md-codeblock-border: ${style.palette.mdCodeBlockBorder};
1211
- --md-quote: ${style.palette.mdQuote};
1212
- --md-quote-border: ${style.palette.mdQuoteBorder};
1213
- --md-hr: ${style.palette.mdHr};
1214
- --md-list-bullet: ${style.palette.mdListBullet};
1215
- --syntax-comment: ${style.palette.syntaxComment};
1216
- --syntax-keyword: ${style.palette.syntaxKeyword};
1217
- --syntax-function: ${style.palette.syntaxFunction};
1218
- --syntax-variable: ${style.palette.syntaxVariable};
1219
- --syntax-string: ${style.palette.syntaxString};
1220
- --syntax-number: ${style.palette.syntaxNumber};
1221
- --syntax-type: ${style.palette.syntaxType};
1222
- --syntax-operator: ${style.palette.syntaxOperator};
1223
- --syntax-punctuation: ${style.palette.syntaxPunctuation};
1224
- --panel-shadow: ${panelShadow};
1225
- --accent-contrast: ${accentContrast};
1226
- --blockquote-bg: ${blockquoteBg};
1227
- --table-alt-bg: ${tableAltBg};
1228
- --editor-bg: ${editorBg};
1261
+ ${cssVarsBlock}
1229
1262
  }
1230
1263
 
1231
1264
  * { box-sizing: border-box; }
@@ -1243,7 +1276,7 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
1243
1276
  min-height: 100%;
1244
1277
  }
1245
1278
 
1246
- header {
1279
+ body > header {
1247
1280
  border-bottom: 1px solid var(--border-muted);
1248
1281
  padding: 12px 16px;
1249
1282
  background: var(--panel);
@@ -1387,6 +1420,20 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
1387
1420
  font-size: 14px;
1388
1421
  }
1389
1422
 
1423
+ .section-header select {
1424
+ font-weight: 600;
1425
+ font-size: 14px;
1426
+ border: none;
1427
+ border-radius: 4px;
1428
+ background: inherit;
1429
+ color: inherit;
1430
+ cursor: pointer;
1431
+ padding: 2px 4px;
1432
+ margin: 0;
1433
+ -webkit-appearance: menulist;
1434
+ appearance: menulist;
1435
+ }
1436
+
1390
1437
  .reference-meta {
1391
1438
  padding: 8px 10px;
1392
1439
  border-bottom: 1px solid var(--border-muted);
@@ -1464,6 +1511,56 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
1464
1511
  font-size: 12px;
1465
1512
  }
1466
1513
 
1514
+ .resource-dir-btn {
1515
+ padding: 4px 10px;
1516
+ font-size: 12px;
1517
+ border: 1px solid var(--border-muted);
1518
+ border-radius: 999px;
1519
+ background: var(--card);
1520
+ color: var(--fg-muted);
1521
+ cursor: pointer;
1522
+ white-space: nowrap;
1523
+ font-family: inherit;
1524
+ }
1525
+ .resource-dir-btn:hover {
1526
+ color: var(--fg);
1527
+ border-color: var(--fg-muted);
1528
+ }
1529
+ .resource-dir-label {
1530
+ cursor: pointer;
1531
+ max-width: 300px;
1532
+ overflow: hidden;
1533
+ text-overflow: ellipsis;
1534
+ white-space: nowrap;
1535
+ }
1536
+ .resource-dir-label:hover {
1537
+ color: var(--fg);
1538
+ border-color: var(--fg-muted);
1539
+ }
1540
+ .resource-dir-input-wrap {
1541
+ display: none;
1542
+ gap: 3px;
1543
+ align-items: center;
1544
+ }
1545
+ .resource-dir-input-wrap.visible {
1546
+ display: inline-flex;
1547
+ }
1548
+ .resource-dir-input-wrap input[type="text"] {
1549
+ width: 260px;
1550
+ padding: 2px 6px;
1551
+ font-size: 11px;
1552
+ border: 1px solid var(--border-muted);
1553
+ border-radius: 4px;
1554
+ background: var(--editor-bg);
1555
+ color: var(--fg);
1556
+ font-family: var(--font-mono);
1557
+ }
1558
+ .resource-dir-input-wrap button {
1559
+ padding: 2px 6px;
1560
+ font-size: 11px;
1561
+ cursor: pointer;
1562
+ }
1563
+
1467
1564
  .editor-highlight-wrap {
1468
1565
  position: relative;
1469
1566
  display: flex;
@@ -1556,6 +1653,20 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
1556
1653
  color: var(--syntax-variable);
1557
1654
  }
1558
1655
 
1656
+ .hl-diff-add {
1657
+ color: var(--ok);
1658
+ background: rgba(46, 160, 67, 0.12);
1659
+ display: inline-block;
1660
+ width: 100%;
1661
+ }
1662
+
1663
+ .hl-diff-del {
1664
+ color: var(--error);
1665
+ background: rgba(248, 81, 73, 0.12);
1666
+ display: inline-block;
1667
+ width: 100%;
1668
+ }
1669
+
1559
1670
  .hl-list {
1560
1671
  color: var(--md-list-bullet);
1561
1672
  font-weight: 600;
@@ -1623,6 +1734,27 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
1623
1734
  padding-bottom: 0;
1624
1735
  }
1625
1736
 
1737
+ .rendered-markdown #title-block-header {
1738
+ text-align: center;
1739
+ margin-bottom: 2em;
1740
+ }
1741
+ .rendered-markdown #title-block-header .title {
1742
+ margin-bottom: 0.25em;
1743
+ }
1744
+ .rendered-markdown #title-block-header .author,
1745
+ .rendered-markdown #title-block-header .date {
1746
+ margin-bottom: 0.15em;
1747
+ color: var(--fg-muted);
1748
+ }
1749
+ .rendered-markdown #title-block-header .abstract {
1750
+ text-align: left;
1751
+ margin-top: 1em;
1752
+ }
1753
+ .rendered-markdown #title-block-header .abstract-title {
1754
+ font-weight: 600;
1755
+ margin-bottom: 0.25em;
1756
+ }
1757
+
1626
1758
  .rendered-markdown p,
1627
1759
  .rendered-markdown ul,
1628
1760
  .rendered-markdown ol,
@@ -1741,6 +1873,29 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
1741
1873
  font-weight: 600;
1742
1874
  }
1743
1875
 
1876
+ /* Diff-specific overrides for pandoc code blocks */
1877
+ .rendered-markdown pre.sourceCode.diff code > span:has(> .va) {
1878
+ color: var(--ok);
1879
+ background: rgba(46, 160, 67, 0.12);
1880
+ }
1881
+ .rendered-markdown pre.sourceCode.diff code > span:has(> .st) {
1882
+ color: var(--error);
1883
+ background: rgba(248, 81, 73, 0.12);
1884
+ }
1885
+ .rendered-markdown pre.sourceCode.diff code > span:has(> .dt) {
1886
+ color: var(--syntax-function);
1887
+ }
1888
+ .rendered-markdown pre.sourceCode.diff code > span:has(> .kw) {
1889
+ color: var(--syntax-keyword);
1890
+ }
1891
+ .rendered-markdown pre.sourceCode.diff .va,
1892
+ .rendered-markdown pre.sourceCode.diff .st,
1893
+ .rendered-markdown pre.sourceCode.diff .dt,
1894
+ .rendered-markdown pre.sourceCode.diff .kw {
1895
+ color: inherit;
1896
+ font-weight: inherit;
1897
+ }
1898
+
1744
1899
  .rendered-markdown table {
1745
1900
  border-collapse: collapse;
1746
1901
  display: block;
@@ -1905,30 +2060,32 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
1905
2060
  </head>
1906
2061
  <body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}">
1907
2062
  <header>
1908
- <h1><span class="app-logo" aria-hidden="true">π</span> Pi Studio <span class="app-subtitle">Feedback Workspace</span></h1>
2063
+ <h1><span class="app-logo" aria-hidden="true">π</span> Pi Studio <span class="app-subtitle">Editor & Response Workspace</span></h1>
1909
2064
  <div class="controls">
1910
- <select id="editorViewSelect" aria-label="Editor view mode">
1911
- <option value="markdown" selected>Left: Editor (Raw)</option>
1912
- <option value="preview">Left: Editor (Preview)</option>
1913
- </select>
1914
- <select id="rightViewSelect" aria-label="Response view mode">
1915
- <option value="markdown">Right: Response (Raw)</option>
1916
- <option value="preview" selected>Right: Response (Preview)</option>
1917
- <option value="editor-preview">Right: Editor (Preview)</option>
1918
- </select>
1919
- <button id="saveAsBtn" type="button" title="Save editor text to a new file path.">Save As…</button>
1920
- <button id="saveOverBtn" type="button" title="Overwrite current file with editor text." disabled>Save file</button>
1921
- <label class="file-label" title="Load a local file into editor text.">Load file content<input id="fileInput" type="file" accept=".txt,.md,.markdown,.rst,.adoc,.tex,.json,.js,.ts,.py,.java,.c,.cpp,.h,.hpp,.go,.rs,.rb,.swift,.sh,.html,.css,.xml,.yaml,.yml,.toml,.jl,.f90,.f95,.f03,.f,.for,.r,.R,.m,.lua" /></label>
2065
+ <button id="saveAsBtn" type="button" title="Save editor content to a new file path.">Save editor as…</button>
2066
+ <button id="saveOverBtn" type="button" title="Overwrite current file with editor content." disabled>Save editor</button>
2067
+ <label class="file-label" title="Load a local file into editor text.">Load file content<input id="fileInput" type="file" accept=".md,.markdown,.mdx,.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>
1922
2068
  </div>
1923
2069
  </header>
1924
2070
 
1925
2071
  <main>
1926
2072
  <section id="leftPane">
1927
- <div id="leftSectionHeader" class="section-header">Editor</div>
2073
+ <div id="leftSectionHeader" class="section-header">
2074
+ <select id="editorViewSelect" aria-label="Editor view mode">
2075
+ <option value="markdown" selected>Editor (Raw)</option>
2076
+ <option value="preview">Editor (Preview)</option>
2077
+ </select>
2078
+ </div>
1928
2079
  <div class="source-wrap">
1929
2080
  <div class="source-meta">
1930
2081
  <div class="badge-row">
1931
2082
  <span id="sourceBadge" class="source-badge">Editor origin: ${initialLabel}</span>
2083
+ <button id="resourceDirBtn" type="button" class="resource-dir-btn" hidden title="Set working directory for resolving relative paths in preview">Set working dir</button>
2084
+ <span id="resourceDirLabel" class="source-badge resource-dir-label" hidden title="Click to change working directory"></span>
2085
+ <span id="resourceDirInputWrap" class="resource-dir-input-wrap">
2086
+ <input id="resourceDirInput" type="text" placeholder="/path/to/working/directory" title="Absolute path to working directory" />
2087
+ <button id="resourceDirClearBtn" type="button" title="Clear working directory">✕</button>
2088
+ </span>
1932
2089
  <span id="syncBadge" class="source-badge sync-badge">No response loaded</span>
1933
2090
  </div>
1934
2091
  <div class="source-actions">
@@ -1960,6 +2117,19 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
1960
2117
  <option value="fortran">Lang: Fortran</option>
1961
2118
  <option value="r">Lang: R</option>
1962
2119
  <option value="matlab">Lang: MATLAB</option>
2120
+ <option value="latex">Lang: LaTeX</option>
2121
+ <option value="diff">Lang: Diff</option>
2122
+ <option value="java">Lang: Java</option>
2123
+ <option value="go">Lang: Go</option>
2124
+ <option value="ruby">Lang: Ruby</option>
2125
+ <option value="swift">Lang: Swift</option>
2126
+ <option value="html">Lang: HTML</option>
2127
+ <option value="css">Lang: CSS</option>
2128
+ <option value="xml">Lang: XML</option>
2129
+ <option value="yaml">Lang: YAML</option>
2130
+ <option value="toml">Lang: TOML</option>
2131
+ <option value="lua">Lang: Lua</option>
2132
+ <option value="text">Lang: Plain Text</option>
1963
2133
  </select>
1964
2134
  </div>
1965
2135
  </div>
@@ -1972,7 +2142,13 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
1972
2142
  </section>
1973
2143
 
1974
2144
  <section id="rightPane">
1975
- <div id="rightSectionHeader" class="section-header">Response</div>
2145
+ <div id="rightSectionHeader" class="section-header">
2146
+ <select id="rightViewSelect" aria-label="Response view mode">
2147
+ <option value="markdown">Response (Raw)</option>
2148
+ <option value="preview" selected>Response (Preview)</option>
2149
+ <option value="editor-preview">Editor (Preview)</option>
2150
+ </select>
2151
+ </div>
1976
2152
  <div class="reference-meta">
1977
2153
  <span id="referenceBadge" class="source-badge">Latest response: none</span>
1978
2154
  </div>
@@ -2034,11 +2210,9 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2034
2210
  const sourcePreviewEl = document.getElementById("sourcePreview");
2035
2211
  const leftPaneEl = document.getElementById("leftPane");
2036
2212
  const rightPaneEl = document.getElementById("rightPane");
2037
- const leftSectionHeaderEl = document.getElementById("leftSectionHeader");
2038
2213
  const sourceBadgeEl = document.getElementById("sourceBadge");
2039
2214
  const syncBadgeEl = document.getElementById("syncBadge");
2040
2215
  const critiqueViewEl = document.getElementById("critiqueView");
2041
- const rightSectionHeaderEl = document.getElementById("rightSectionHeader");
2042
2216
  const referenceBadgeEl = document.getElementById("referenceBadge");
2043
2217
  const editorViewSelect = document.getElementById("editorViewSelect");
2044
2218
  const rightViewSelect = document.getElementById("rightViewSelect");
@@ -2049,6 +2223,11 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2049
2223
  const critiqueBtn = document.getElementById("critiqueBtn");
2050
2224
  const lensSelect = document.getElementById("lensSelect");
2051
2225
  const fileInput = document.getElementById("fileInput");
2226
+ const resourceDirBtn = document.getElementById("resourceDirBtn");
2227
+ const resourceDirLabel = document.getElementById("resourceDirLabel");
2228
+ const resourceDirInputWrap = document.getElementById("resourceDirInputWrap");
2229
+ const resourceDirInput = document.getElementById("resourceDirInput");
2230
+ const resourceDirClearBtn = document.getElementById("resourceDirClearBtn");
2052
2231
  const loadResponseBtn = document.getElementById("loadResponseBtn");
2053
2232
  const loadCritiqueNotesBtn = document.getElementById("loadCritiqueNotesBtn");
2054
2233
  const loadCritiqueFullBtn = document.getElementById("loadCritiqueFullBtn");
@@ -2093,7 +2272,44 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2093
2272
  const EDITOR_HIGHLIGHT_MAX_CHARS = 80_000;
2094
2273
  const EDITOR_HIGHLIGHT_STORAGE_KEY = "piStudio.editorHighlightEnabled";
2095
2274
  const EDITOR_LANGUAGE_STORAGE_KEY = "piStudio.editorLanguage";
2096
- const SUPPORTED_LANGUAGES = ["markdown", "javascript", "typescript", "python", "bash", "json", "rust", "c", "cpp", "julia", "fortran", "r", "matlab"];
2275
+ // Single source of truth: language -> file extensions (and display label)
2276
+ var LANG_EXT_MAP = {
2277
+ markdown: { label: "Markdown", exts: ["md", "markdown", "mdx"] },
2278
+ javascript: { label: "JavaScript", exts: ["js", "mjs", "cjs", "jsx"] },
2279
+ typescript: { label: "TypeScript", exts: ["ts", "mts", "cts", "tsx"] },
2280
+ python: { label: "Python", exts: ["py", "pyw"] },
2281
+ bash: { label: "Bash", exts: ["sh", "bash", "zsh"] },
2282
+ json: { label: "JSON", exts: ["json", "jsonc", "json5"] },
2283
+ rust: { label: "Rust", exts: ["rs"] },
2284
+ c: { label: "C", exts: ["c", "h"] },
2285
+ cpp: { label: "C++", exts: ["cpp", "cxx", "cc", "hpp", "hxx"] },
2286
+ julia: { label: "Julia", exts: ["jl"] },
2287
+ fortran: { label: "Fortran", exts: ["f90", "f95", "f03", "f", "for"] },
2288
+ r: { label: "R", exts: ["r", "R"] },
2289
+ matlab: { label: "MATLAB", exts: ["m"] },
2290
+ latex: { label: "LaTeX", exts: ["tex", "latex"] },
2291
+ diff: { label: "Diff", exts: ["diff", "patch"] },
2292
+ // Languages accepted for upload/detect but without syntax highlighting
2293
+ java: { label: "Java", exts: ["java"] },
2294
+ go: { label: "Go", exts: ["go"] },
2295
+ ruby: { label: "Ruby", exts: ["rb"] },
2296
+ swift: { label: "Swift", exts: ["swift"] },
2297
+ html: { label: "HTML", exts: ["html", "htm"] },
2298
+ css: { label: "CSS", exts: ["css"] },
2299
+ xml: { label: "XML", exts: ["xml"] },
2300
+ yaml: { label: "YAML", exts: ["yaml", "yml"] },
2301
+ toml: { label: "TOML", exts: ["toml"] },
2302
+ lua: { label: "Lua", exts: ["lua"] },
2303
+ text: { label: "Plain Text", exts: ["txt", "rst", "adoc"] },
2304
+ };
2305
+ // Build reverse map: extension -> language
2306
+ var EXT_TO_LANG = {};
2307
+ Object.keys(LANG_EXT_MAP).forEach(function(lang) {
2308
+ LANG_EXT_MAP[lang].exts.forEach(function(ext) { EXT_TO_LANG[ext.toLowerCase()] = lang; });
2309
+ });
2310
+ // Languages that have syntax highlighting support
2311
+ var HIGHLIGHTED_LANGUAGES = ["markdown", "javascript", "typescript", "python", "bash", "json", "rust", "c", "cpp", "julia", "fortran", "r", "matlab", "latex"];
2312
+ var SUPPORTED_LANGUAGES = Object.keys(LANG_EXT_MAP);
2097
2313
  const RESPONSE_HIGHLIGHT_MAX_CHARS = 120_000;
2098
2314
  const RESPONSE_HIGHLIGHT_STORAGE_KEY = "piStudio.responseHighlightEnabled";
2099
2315
  let sourcePreviewRenderTimer = null;
@@ -2137,6 +2353,27 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2137
2353
  function updateSourceBadge() {
2138
2354
  const label = sourceState && sourceState.label ? sourceState.label : "blank";
2139
2355
  sourceBadgeEl.textContent = "Editor origin: " + label;
2356
+ // Show "Set working dir" button when not file-backed
2357
+ var isFileBacked = sourceState.source === "file" && Boolean(sourceState.path);
2358
+ if (isFileBacked) {
2359
+ if (resourceDirInput) resourceDirInput.value = "";
2360
+ if (resourceDirLabel) resourceDirLabel.textContent = "";
2361
+ if (resourceDirBtn) resourceDirBtn.hidden = true;
2362
+ if (resourceDirLabel) resourceDirLabel.hidden = true;
2363
+ if (resourceDirInputWrap) resourceDirInputWrap.classList.remove("visible");
2364
+ } else {
2365
+ // Restore to label if dir is set, otherwise show button
2366
+ var dir = resourceDirInput ? resourceDirInput.value.trim() : "";
2367
+ if (dir) {
2368
+ if (resourceDirBtn) resourceDirBtn.hidden = true;
2369
+ if (resourceDirLabel) { resourceDirLabel.textContent = "Working dir: " + dir; resourceDirLabel.hidden = false; }
2370
+ if (resourceDirInputWrap) resourceDirInputWrap.classList.remove("visible");
2371
+ } else {
2372
+ if (resourceDirBtn) resourceDirBtn.hidden = false;
2373
+ if (resourceDirLabel) resourceDirLabel.hidden = true;
2374
+ if (resourceDirInputWrap) resourceDirInputWrap.classList.remove("visible");
2375
+ }
2376
+ }
2140
2377
  }
2141
2378
 
2142
2379
  function applyPaneFocusClasses() {
@@ -2346,6 +2583,15 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2346
2583
  targetEl.appendChild(warningEl);
2347
2584
  }
2348
2585
 
2586
+ function appendPreviewNotice(targetEl, message) {
2587
+ if (!targetEl || typeof targetEl.querySelector !== "function" || typeof targetEl.appendChild !== "function") return;
2588
+ if (targetEl.querySelector(".preview-image-warning")) return;
2589
+ const el = document.createElement("div");
2590
+ el.className = "preview-warning preview-image-warning";
2591
+ el.textContent = String(message || "");
2592
+ targetEl.appendChild(el);
2593
+ }
2594
+
2349
2595
  async function getMermaidApi() {
2350
2596
  if (mermaidModulePromise) {
2351
2597
  return mermaidModulePromise;
@@ -2438,7 +2684,11 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2438
2684
  headers: {
2439
2685
  "Content-Type": "application/json",
2440
2686
  },
2441
- body: JSON.stringify({ markdown: String(markdown || "") }),
2687
+ body: JSON.stringify({
2688
+ markdown: String(markdown || ""),
2689
+ sourcePath: sourceState.path || "",
2690
+ resourceDir: (!sourceState.path && resourceDirInput) ? resourceDirInput.value.trim() : "",
2691
+ }),
2442
2692
  signal: controller ? controller.signal : undefined,
2443
2693
  });
2444
2694
  } catch (error) {
@@ -2489,6 +2739,15 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2489
2739
 
2490
2740
  targetEl.innerHTML = sanitizeRenderedHtml(renderedHtml, markdown);
2491
2741
  await renderMermaidInElement(targetEl);
2742
+
2743
+ // Warn if relative images are present but unlikely to resolve (non-file-backed content)
2744
+ if (!sourceState.path && !(resourceDirInput && resourceDirInput.value.trim())) {
2745
+ var hasRelativeImages = /!\\[.*?\\]\\((?!https?:\\/\\/|data:)[^)]+\\)/.test(markdown || "");
2746
+ var hasLatexImages = /\\\\includegraphics/.test(markdown || "");
2747
+ if (hasRelativeImages || hasLatexImages) {
2748
+ appendPreviewNotice(targetEl, "Images not displaying? Set working dir in the editor pane or open via /studio <path>.");
2749
+ }
2750
+ }
2492
2751
  } catch (error) {
2493
2752
  if (pane === "source") {
2494
2753
  if (nonce !== sourcePreviewRenderNonce || editorView !== "preview") return;
@@ -2504,7 +2763,7 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2504
2763
  function renderSourcePreviewNow() {
2505
2764
  if (editorView !== "preview") return;
2506
2765
  const text = sourceTextEl.value || "";
2507
- if (editorLanguage && editorLanguage !== "markdown") {
2766
+ if (editorLanguage && editorLanguage !== "markdown" && editorLanguage !== "latex") {
2508
2767
  sourcePreviewEl.innerHTML = "<div class='response-markdown-highlight' style='white-space:pre;font-family:var(--font-mono);font-size:13px;line-height:1.5;padding:16px;overflow:auto;'>" + highlightCode(text, editorLanguage) + "</div>";
2509
2768
  return;
2510
2769
  }
@@ -2562,7 +2821,7 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2562
2821
  critiqueViewEl.innerHTML = "<pre class='plain-markdown'>Editor is empty.</pre>";
2563
2822
  return;
2564
2823
  }
2565
- if (editorLanguage && editorLanguage !== "markdown") {
2824
+ if (editorLanguage && editorLanguage !== "markdown" && editorLanguage !== "latex") {
2566
2825
  critiqueViewEl.innerHTML = "<div class='response-markdown-highlight' style='white-space:pre;font-family:var(--font-mono);font-size:13px;line-height:1.5;padding:16px;overflow:auto;'>" + highlightCode(editorText, editorLanguage) + "</div>";
2567
2826
  return;
2568
2827
  }
@@ -2632,35 +2891,37 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2632
2891
  }
2633
2892
 
2634
2893
  function refreshResponseUi() {
2635
- if (leftSectionHeaderEl) {
2636
- leftSectionHeaderEl.textContent = "Editor";
2637
- }
2638
-
2639
- if (rightSectionHeaderEl) {
2640
- rightSectionHeaderEl.textContent = rightView === "editor-preview" ? "Editor Preview" : "Response";
2641
- }
2642
-
2643
2894
  updateSourceBadge();
2644
2895
  updateReferenceBadge();
2645
2896
  renderActiveResult();
2646
2897
  updateResultActionButtons();
2647
2898
  }
2648
2899
 
2900
+ function getEffectiveSavePath() {
2901
+ // File-backed: use the original path
2902
+ if (sourceState.source === "file" && sourceState.path) return sourceState.path;
2903
+ // Upload with working dir + filename: derive path
2904
+ if (sourceState.source === "upload" && sourceState.label && resourceDirInput && resourceDirInput.value.trim()) {
2905
+ var name = sourceState.label.replace(/^upload:\\s*/i, "");
2906
+ if (name) return resourceDirInput.value.trim().replace(/\\/$/, "") + "/" + name;
2907
+ }
2908
+ return null;
2909
+ }
2910
+
2649
2911
  function updateSaveFileTooltip() {
2650
2912
  if (!saveOverBtn) return;
2651
2913
 
2652
- const isFileBacked = sourceState.source === "file" && Boolean(sourceState.path);
2653
- if (isFileBacked) {
2654
- const target = sourceState.label || sourceState.path;
2655
- saveOverBtn.title = "Overwrite current file: " + target;
2914
+ var effectivePath = getEffectiveSavePath();
2915
+ if (effectivePath) {
2916
+ saveOverBtn.title = "Overwrite file: " + effectivePath;
2656
2917
  return;
2657
2918
  }
2658
2919
 
2659
- saveOverBtn.title = "Save file is available after opening a file or using Save As…";
2920
+ saveOverBtn.title = "Save editor is available after opening a file, setting a working dir, or using Save editor as…";
2660
2921
  }
2661
2922
 
2662
2923
  function syncActionButtons() {
2663
- const canSaveOver = sourceState.source === "file" && Boolean(sourceState.path);
2924
+ const canSaveOver = Boolean(getEffectiveSavePath());
2664
2925
 
2665
2926
  fileInput.disabled = uiBusy;
2666
2927
  saveAsBtn.disabled = uiBusy;
@@ -2803,6 +3064,7 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2803
3064
 
2804
3065
  const first = raw.split(/\\s+/)[0].replace(/^\\./, "").toLowerCase();
2805
3066
 
3067
+ // Explicit aliases that don't match extension names
2806
3068
  if (first === "js" || first === "javascript" || first === "jsx" || first === "node") return "javascript";
2807
3069
  if (first === "ts" || first === "typescript" || first === "tsx") return "typescript";
2808
3070
  if (first === "py" || first === "python") return "python";
@@ -2815,8 +3077,11 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2815
3077
  if (first === "fortran" || first === "f90" || first === "f95" || first === "f03" || first === "f" || first === "for") return "fortran";
2816
3078
  if (first === "r") return "r";
2817
3079
  if (first === "matlab" || first === "m") return "matlab";
3080
+ if (first === "latex" || first === "tex") return "latex";
3081
+ if (first === "diff" || first === "patch" || first === "udiff") return "diff";
2818
3082
 
2819
- return "";
3083
+ // Fall back to the unified extension->language map
3084
+ return EXT_TO_LANG[first] || "";
2820
3085
  }
2821
3086
 
2822
3087
  function highlightCodeTokens(line, pattern, classifyMatch) {
@@ -2981,6 +3246,31 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2981
3246
  return "<span class='hl-code'>" + highlighted + "</span>";
2982
3247
  }
2983
3248
 
3249
+ if (lang === "latex") {
3250
+ const texPattern = /(%.*$)|(\\\\(?:documentclass|usepackage|newtheorem|begin|end|section|subsection|subsubsection|chapter|part|title|author|date|maketitle|tableofcontents|includegraphics|caption|label|ref|eqref|cite|textbf|textit|texttt|emph|footnote|centering|newcommand|renewcommand|providecommand|bibliography|bibliographystyle|bibitem|item|input|include)\\b)|(\\\\[A-Za-z]+)|(\\{|\\})|(\\$\\$?(?:[^$\\\\]|\\\\.)+\\$\\$?)|(\\[(?:.*?)\\])/g;
3251
+ const highlighted = highlightCodeTokens(source, texPattern, (match) => {
3252
+ if (match[1]) return "hl-code-com";
3253
+ if (match[2]) return "hl-code-kw";
3254
+ if (match[3]) return "hl-code-fn";
3255
+ if (match[4]) return "hl-code-op";
3256
+ if (match[5]) return "hl-code-str";
3257
+ if (match[6]) return "hl-code-num";
3258
+ return "hl-code";
3259
+ });
3260
+ return highlighted;
3261
+ }
3262
+
3263
+ if (lang === "diff") {
3264
+ var escaped = escapeHtml(source);
3265
+ if (/^@@/.test(source)) return "<span class=\\"hl-code-fn\\">" + escaped + "</span>";
3266
+ if (/^\\+\\+\\+|^---/.test(source)) return "<span class=\\"hl-code-kw\\">" + escaped + "</span>";
3267
+ if (/^\\+/.test(source)) return "<span class=\\"hl-diff-add\\">" + escaped + "</span>";
3268
+ if (/^-/.test(source)) return "<span class=\\"hl-diff-del\\">" + escaped + "</span>";
3269
+ if (/^diff /.test(source)) return "<span class=\\"hl-code-kw\\">" + escaped + "</span>";
3270
+ if (/^index /.test(source)) return "<span class=\\"hl-code-com\\">" + escaped + "</span>";
3271
+ return escaped;
3272
+ }
3273
+
2984
3274
  return wrapHighlight("hl-code", source);
2985
3275
  }
2986
3276
 
@@ -3067,23 +3357,10 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3067
3357
 
3068
3358
  function detectLanguageFromName(name) {
3069
3359
  if (!name) return "";
3070
- const dot = name.lastIndexOf(".");
3360
+ var dot = name.lastIndexOf(".");
3071
3361
  if (dot < 0) return "";
3072
- const ext = name.slice(dot + 1).toLowerCase();
3073
- if (ext === "js" || ext === "mjs" || ext === "cjs" || ext === "jsx") return "javascript";
3074
- if (ext === "ts" || ext === "mts" || ext === "cts" || ext === "tsx") return "typescript";
3075
- if (ext === "py" || ext === "pyw") return "python";
3076
- if (ext === "sh" || ext === "bash" || ext === "zsh") return "bash";
3077
- if (ext === "json" || ext === "jsonc" || ext === "json5") return "json";
3078
- if (ext === "rs") return "rust";
3079
- if (ext === "c" || ext === "h") return "c";
3080
- if (ext === "cpp" || ext === "cxx" || ext === "cc" || ext === "hpp" || ext === "hxx") return "cpp";
3081
- if (ext === "jl") return "julia";
3082
- if (ext === "f90" || ext === "f95" || ext === "f03" || ext === "f" || ext === "for") return "fortran";
3083
- if (ext === "r" || ext === "R") return "r";
3084
- if (ext === "m") return "matlab";
3085
- if (ext === "md" || ext === "markdown" || ext === "mdx") return "markdown";
3086
- return "";
3362
+ var ext = name.slice(dot + 1).toLowerCase();
3363
+ return EXT_TO_LANG[ext] || "";
3087
3364
  }
3088
3365
 
3089
3366
  function renderEditorHighlightNow() {
@@ -3527,6 +3804,17 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3527
3804
  setStatus(message.message);
3528
3805
  }
3529
3806
  }
3807
+
3808
+ if (message.type === "theme_update" && message.vars && typeof message.vars === "object") {
3809
+ var root = document.documentElement;
3810
+ Object.keys(message.vars).forEach(function(key) {
3811
+ if (key === "color-scheme") {
3812
+ root.style.colorScheme = message.vars[key];
3813
+ } else {
3814
+ root.style.setProperty(key, message.vars[key]);
3815
+ }
3816
+ });
3817
+ }
3530
3818
  }
3531
3819
 
3532
3820
  function connect() {
@@ -3827,8 +4115,10 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3827
4115
  return;
3828
4116
  }
3829
4117
 
3830
- const suggested = sourceState.path || "./draft.md";
3831
- const path = window.prompt("Save editor as path:", suggested);
4118
+ var suggestedName = sourceState.label ? sourceState.label.replace(/^upload:\\s*/i, "") : "draft.md";
4119
+ var suggestedDir = resourceDirInput && resourceDirInput.value.trim() ? resourceDirInput.value.trim().replace(/\\/$/, "") + "/" : "./";
4120
+ const suggested = sourceState.path || (suggestedDir + suggestedName);
4121
+ const path = window.prompt("Save editor content as:", suggested);
3832
4122
  if (!path) return;
3833
4123
 
3834
4124
  const requestId = beginUiAction("save_as");
@@ -3849,21 +4139,24 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3849
4139
  });
3850
4140
 
3851
4141
  saveOverBtn.addEventListener("click", () => {
3852
- if (!(sourceState.source === "file" && sourceState.path)) {
3853
- setStatus("Save file is only available when source is a file path.", "warning");
4142
+ var effectivePath = getEffectiveSavePath();
4143
+ if (!effectivePath) {
4144
+ setStatus("Save editor requires a file path. Open via /studio <path>, set a working dir, or use Save editor as…", "warning");
3854
4145
  return;
3855
4146
  }
3856
4147
 
3857
- if (!window.confirm("Overwrite " + sourceState.label + "?")) {
4148
+ if (!window.confirm("Overwrite " + effectivePath + "?")) {
3858
4149
  return;
3859
4150
  }
3860
4151
 
3861
4152
  const requestId = beginUiAction("save_over");
3862
4153
  if (!requestId) return;
3863
4154
 
4155
+ // Use save_as with the effective path for both file-backed and derived paths
3864
4156
  const sent = sendMessage({
3865
- type: "save_over_request",
4157
+ type: "save_as_request",
3866
4158
  requestId,
4159
+ path: effectivePath,
3867
4160
  content: sourceTextEl.value,
3868
4161
  });
3869
4162
 
@@ -3935,6 +4228,67 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3935
4228
  }
3936
4229
  });
3937
4230
 
4231
+ // Working directory controls — three states: button | input | label
4232
+ function showResourceDirState(state) {
4233
+ // state: "button" | "input" | "label"
4234
+ if (resourceDirBtn) resourceDirBtn.hidden = state !== "button";
4235
+ if (resourceDirInputWrap) {
4236
+ if (state === "input") resourceDirInputWrap.classList.add("visible");
4237
+ else resourceDirInputWrap.classList.remove("visible");
4238
+ }
4239
+ if (resourceDirLabel) resourceDirLabel.hidden = state !== "label";
4240
+ }
4241
+ function applyResourceDir() {
4242
+ var dir = resourceDirInput ? resourceDirInput.value.trim() : "";
4243
+ if (dir) {
4244
+ if (resourceDirLabel) resourceDirLabel.textContent = "Working dir: " + dir;
4245
+ showResourceDirState("label");
4246
+ } else {
4247
+ showResourceDirState("button");
4248
+ }
4249
+ updateSaveFileTooltip();
4250
+ syncActionButtons();
4251
+ renderSourcePreview();
4252
+ }
4253
+ if (resourceDirBtn) {
4254
+ resourceDirBtn.addEventListener("click", () => {
4255
+ showResourceDirState("input");
4256
+ if (resourceDirInput) resourceDirInput.focus();
4257
+ });
4258
+ }
4259
+ if (resourceDirLabel) {
4260
+ resourceDirLabel.addEventListener("click", () => {
4261
+ showResourceDirState("input");
4262
+ if (resourceDirInput) resourceDirInput.focus();
4263
+ });
4264
+ }
4265
+ if (resourceDirInput) {
4266
+ resourceDirInput.addEventListener("keydown", (e) => {
4267
+ if (e.key === "Enter") {
4268
+ e.preventDefault();
4269
+ applyResourceDir();
4270
+ } else if (e.key === "Escape") {
4271
+ e.preventDefault();
4272
+ var dir = resourceDirInput.value.trim();
4273
+ if (dir) {
4274
+ showResourceDirState("label");
4275
+ } else {
4276
+ showResourceDirState("button");
4277
+ }
4278
+ }
4279
+ });
4280
+ }
4281
+ if (resourceDirClearBtn) {
4282
+ resourceDirClearBtn.addEventListener("click", () => {
4283
+ if (resourceDirInput) resourceDirInput.value = "";
4284
+ if (resourceDirLabel) resourceDirLabel.textContent = "";
4285
+ showResourceDirState("button");
4286
+ updateSaveFileTooltip();
4287
+ syncActionButtons();
4288
+ renderSourcePreview();
4289
+ });
4290
+ }
4291
+
3938
4292
  fileInput.addEventListener("change", () => {
3939
4293
  const file = fileInput.files && fileInput.files[0];
3940
4294
  if (!file) return;
@@ -3998,6 +4352,7 @@ export default function (pi: ExtensionAPI) {
3998
4352
  let initialStudioDocument: InitialStudioDocument | null = null;
3999
4353
  let studioCwd = process.cwd();
4000
4354
  let lastCommandCtx: ExtensionCommandContext | null = null;
4355
+ let lastThemeVarsJson = "";
4001
4356
  let agentBusy = false;
4002
4357
 
4003
4358
  const isStudioBusy = () => agentBusy || activeRequest !== null;
@@ -4369,7 +4724,17 @@ export default function (pi: ExtensionAPI) {
4369
4724
  }
4370
4725
 
4371
4726
  try {
4372
- const html = await renderStudioMarkdownWithPandoc(markdown);
4727
+ const sourcePath =
4728
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { sourcePath?: unknown }).sourcePath === "string"
4729
+ ? (parsedBody as { sourcePath: string }).sourcePath
4730
+ : "";
4731
+ const userResourceDir =
4732
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { resourceDir?: unknown }).resourceDir === "string"
4733
+ ? (parsedBody as { resourceDir: string }).resourceDir
4734
+ : "";
4735
+ const resourcePath = sourcePath ? dirname(sourcePath) : (userResourceDir || studioCwd || undefined);
4736
+ const isLatex = /\\documentclass\b|\\begin\{document\}/.test(markdown);
4737
+ const html = await renderStudioMarkdownWithPandoc(markdown, isLatex, resourcePath);
4373
4738
  respondJson(res, 200, { ok: true, html, renderer: "pandoc" });
4374
4739
  } catch (error) {
4375
4740
  const message = error instanceof Error ? error.message : String(error);
@@ -4536,6 +4901,27 @@ export default function (pi: ExtensionAPI) {
4536
4901
  state.port = address.port;
4537
4902
 
4538
4903
  serverState = state;
4904
+
4905
+ // Periodically check for theme changes and push to all clients
4906
+ const themeCheckInterval = setInterval(() => {
4907
+ if (!lastCommandCtx?.ui?.theme || !serverState || serverState.clients.size === 0) return;
4908
+ try {
4909
+ const style = getStudioThemeStyle(lastCommandCtx.ui.theme);
4910
+ const vars = buildThemeCssVars(style);
4911
+ const json = JSON.stringify(vars);
4912
+ if (json !== lastThemeVarsJson) {
4913
+ lastThemeVarsJson = json;
4914
+ for (const client of serverState.clients) {
4915
+ sendToClient(client, { type: "theme_update", vars });
4916
+ }
4917
+ }
4918
+ } catch {
4919
+ // Ignore theme read errors
4920
+ }
4921
+ }, 2000);
4922
+ // Clean up interval if server closes
4923
+ server.once("close", () => clearInterval(themeCheckInterval));
4924
+
4539
4925
  return state;
4540
4926
  };
4541
4927
 
@@ -4684,6 +5070,11 @@ export default function (pi: ExtensionAPI) {
4684
5070
  await ctx.waitForIdle();
4685
5071
  lastCommandCtx = ctx;
4686
5072
  studioCwd = ctx.cwd;
5073
+ // Seed theme vars so first ping doesn't trigger a false update
5074
+ try {
5075
+ const currentStyle = getStudioThemeStyle(ctx.ui.theme);
5076
+ lastThemeVarsJson = JSON.stringify(buildThemeCssVars(currentStyle));
5077
+ } catch { /* ignore */ }
4687
5078
 
4688
5079
  const latestAssistant =
4689
5080
  extractLatestAssistantFromEntries(ctx.sessionManager.getBranch())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Browser GUI for structured critique workflows in pi",
5
5
  "type": "module",
6
6
  "license": "MIT",