pi-studio 0.3.0 → 0.4.1

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 (4) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +12 -6
  3. package/index.ts +586 -152
  4. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to `pi-studio` are documented here.
4
4
 
5
+ ## [0.4.1] — 2026-03-03
6
+
7
+ ### Changed
8
+ - Editor input keeps preview refreshes immediate (no added typing debounce) while keeping editor syntax highlighting immediate in Raw view.
9
+ - Response/sync state checks now reuse cached normalized response data and critique-note extracts instead of recomputing on each keystroke.
10
+ - Editor action/sync UI updates are now coalesced with `requestAnimationFrame` during typing.
11
+
5
12
  ## [0.3.0] — 2026-03-02
6
13
 
7
14
  ### Added
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");
@@ -2082,6 +2261,10 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2082
2261
  let latestResponseTimestamp = 0;
2083
2262
  let latestResponseKind = "annotation";
2084
2263
  let latestResponseIsStructuredCritique = false;
2264
+ let latestResponseHasContent = false;
2265
+ let latestResponseNormalized = "";
2266
+ let latestCritiqueNotes = "";
2267
+ let latestCritiqueNotesNormalized = "";
2085
2268
  let uiBusy = false;
2086
2269
  let sourceState = {
2087
2270
  source: initialSourceState.source,
@@ -2093,13 +2276,52 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2093
2276
  const EDITOR_HIGHLIGHT_MAX_CHARS = 80_000;
2094
2277
  const EDITOR_HIGHLIGHT_STORAGE_KEY = "piStudio.editorHighlightEnabled";
2095
2278
  const EDITOR_LANGUAGE_STORAGE_KEY = "piStudio.editorLanguage";
2096
- const SUPPORTED_LANGUAGES = ["markdown", "javascript", "typescript", "python", "bash", "json", "rust", "c", "cpp", "julia", "fortran", "r", "matlab"];
2279
+ // Single source of truth: language -> file extensions (and display label)
2280
+ var LANG_EXT_MAP = {
2281
+ markdown: { label: "Markdown", exts: ["md", "markdown", "mdx"] },
2282
+ javascript: { label: "JavaScript", exts: ["js", "mjs", "cjs", "jsx"] },
2283
+ typescript: { label: "TypeScript", exts: ["ts", "mts", "cts", "tsx"] },
2284
+ python: { label: "Python", exts: ["py", "pyw"] },
2285
+ bash: { label: "Bash", exts: ["sh", "bash", "zsh"] },
2286
+ json: { label: "JSON", exts: ["json", "jsonc", "json5"] },
2287
+ rust: { label: "Rust", exts: ["rs"] },
2288
+ c: { label: "C", exts: ["c", "h"] },
2289
+ cpp: { label: "C++", exts: ["cpp", "cxx", "cc", "hpp", "hxx"] },
2290
+ julia: { label: "Julia", exts: ["jl"] },
2291
+ fortran: { label: "Fortran", exts: ["f90", "f95", "f03", "f", "for"] },
2292
+ r: { label: "R", exts: ["r", "R"] },
2293
+ matlab: { label: "MATLAB", exts: ["m"] },
2294
+ latex: { label: "LaTeX", exts: ["tex", "latex"] },
2295
+ diff: { label: "Diff", exts: ["diff", "patch"] },
2296
+ // Languages accepted for upload/detect but without syntax highlighting
2297
+ java: { label: "Java", exts: ["java"] },
2298
+ go: { label: "Go", exts: ["go"] },
2299
+ ruby: { label: "Ruby", exts: ["rb"] },
2300
+ swift: { label: "Swift", exts: ["swift"] },
2301
+ html: { label: "HTML", exts: ["html", "htm"] },
2302
+ css: { label: "CSS", exts: ["css"] },
2303
+ xml: { label: "XML", exts: ["xml"] },
2304
+ yaml: { label: "YAML", exts: ["yaml", "yml"] },
2305
+ toml: { label: "TOML", exts: ["toml"] },
2306
+ lua: { label: "Lua", exts: ["lua"] },
2307
+ text: { label: "Plain Text", exts: ["txt", "rst", "adoc"] },
2308
+ };
2309
+ // Build reverse map: extension -> language
2310
+ var EXT_TO_LANG = {};
2311
+ Object.keys(LANG_EXT_MAP).forEach(function(lang) {
2312
+ LANG_EXT_MAP[lang].exts.forEach(function(ext) { EXT_TO_LANG[ext.toLowerCase()] = lang; });
2313
+ });
2314
+ // Languages that have syntax highlighting support
2315
+ var HIGHLIGHTED_LANGUAGES = ["markdown", "javascript", "typescript", "python", "bash", "json", "rust", "c", "cpp", "julia", "fortran", "r", "matlab", "latex"];
2316
+ var SUPPORTED_LANGUAGES = Object.keys(LANG_EXT_MAP);
2097
2317
  const RESPONSE_HIGHLIGHT_MAX_CHARS = 120_000;
2098
2318
  const RESPONSE_HIGHLIGHT_STORAGE_KEY = "piStudio.responseHighlightEnabled";
2319
+ const PREVIEW_INPUT_DEBOUNCE_MS = 0;
2099
2320
  let sourcePreviewRenderTimer = null;
2100
2321
  let sourcePreviewRenderNonce = 0;
2101
2322
  let responsePreviewRenderNonce = 0;
2102
2323
  let responseEditorPreviewTimer = null;
2324
+ let editorMetaUpdateRaf = null;
2103
2325
  let editorHighlightEnabled = false;
2104
2326
  let editorLanguage = "markdown";
2105
2327
  let responseHighlightEnabled = false;
@@ -2137,6 +2359,27 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2137
2359
  function updateSourceBadge() {
2138
2360
  const label = sourceState && sourceState.label ? sourceState.label : "blank";
2139
2361
  sourceBadgeEl.textContent = "Editor origin: " + label;
2362
+ // Show "Set working dir" button when not file-backed
2363
+ var isFileBacked = sourceState.source === "file" && Boolean(sourceState.path);
2364
+ if (isFileBacked) {
2365
+ if (resourceDirInput) resourceDirInput.value = "";
2366
+ if (resourceDirLabel) resourceDirLabel.textContent = "";
2367
+ if (resourceDirBtn) resourceDirBtn.hidden = true;
2368
+ if (resourceDirLabel) resourceDirLabel.hidden = true;
2369
+ if (resourceDirInputWrap) resourceDirInputWrap.classList.remove("visible");
2370
+ } else {
2371
+ // Restore to label if dir is set, otherwise show button
2372
+ var dir = resourceDirInput ? resourceDirInput.value.trim() : "";
2373
+ if (dir) {
2374
+ if (resourceDirBtn) resourceDirBtn.hidden = true;
2375
+ if (resourceDirLabel) { resourceDirLabel.textContent = "Working dir: " + dir; resourceDirLabel.hidden = false; }
2376
+ if (resourceDirInputWrap) resourceDirInputWrap.classList.remove("visible");
2377
+ } else {
2378
+ if (resourceDirBtn) resourceDirBtn.hidden = false;
2379
+ if (resourceDirLabel) resourceDirLabel.hidden = true;
2380
+ if (resourceDirInputWrap) resourceDirInputWrap.classList.remove("visible");
2381
+ }
2382
+ }
2140
2383
  }
2141
2384
 
2142
2385
  function applyPaneFocusClasses() {
@@ -2277,23 +2520,19 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2277
2520
  return normalizeForCompare(a) === normalizeForCompare(b);
2278
2521
  }
2279
2522
 
2280
- function getCurrentResponseMarkdown() {
2281
- return latestResponseMarkdown;
2282
- }
2283
-
2284
- function updateSyncBadge() {
2523
+ function updateSyncBadge(normalizedEditorText) {
2285
2524
  if (!syncBadgeEl) return;
2286
2525
 
2287
- const response = getCurrentResponseMarkdown();
2288
- const hasResponse = Boolean(response && response.trim());
2289
-
2290
- if (!hasResponse) {
2526
+ if (!latestResponseHasContent) {
2291
2527
  syncBadgeEl.textContent = "No response loaded";
2292
2528
  syncBadgeEl.classList.remove("sync", "edited");
2293
2529
  return;
2294
2530
  }
2295
2531
 
2296
- const inSync = isTextEquivalent(sourceTextEl.value, response);
2532
+ const normalizedEditor = typeof normalizedEditorText === "string"
2533
+ ? normalizedEditorText
2534
+ : normalizeForCompare(sourceTextEl.value);
2535
+ const inSync = normalizedEditor === latestResponseNormalized;
2297
2536
  if (inSync) {
2298
2537
  syncBadgeEl.textContent = "In sync with response";
2299
2538
  syncBadgeEl.classList.add("sync");
@@ -2346,6 +2585,15 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2346
2585
  targetEl.appendChild(warningEl);
2347
2586
  }
2348
2587
 
2588
+ function appendPreviewNotice(targetEl, message) {
2589
+ if (!targetEl || typeof targetEl.querySelector !== "function" || typeof targetEl.appendChild !== "function") return;
2590
+ if (targetEl.querySelector(".preview-image-warning")) return;
2591
+ const el = document.createElement("div");
2592
+ el.className = "preview-warning preview-image-warning";
2593
+ el.textContent = String(message || "");
2594
+ targetEl.appendChild(el);
2595
+ }
2596
+
2349
2597
  async function getMermaidApi() {
2350
2598
  if (mermaidModulePromise) {
2351
2599
  return mermaidModulePromise;
@@ -2438,7 +2686,11 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2438
2686
  headers: {
2439
2687
  "Content-Type": "application/json",
2440
2688
  },
2441
- body: JSON.stringify({ markdown: String(markdown || "") }),
2689
+ body: JSON.stringify({
2690
+ markdown: String(markdown || ""),
2691
+ sourcePath: sourceState.path || "",
2692
+ resourceDir: (!sourceState.path && resourceDirInput) ? resourceDirInput.value.trim() : "",
2693
+ }),
2442
2694
  signal: controller ? controller.signal : undefined,
2443
2695
  });
2444
2696
  } catch (error) {
@@ -2489,6 +2741,15 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2489
2741
 
2490
2742
  targetEl.innerHTML = sanitizeRenderedHtml(renderedHtml, markdown);
2491
2743
  await renderMermaidInElement(targetEl);
2744
+
2745
+ // Warn if relative images are present but unlikely to resolve (non-file-backed content)
2746
+ if (!sourceState.path && !(resourceDirInput && resourceDirInput.value.trim())) {
2747
+ var hasRelativeImages = /!\\[.*?\\]\\((?!https?:\\/\\/|data:)[^)]+\\)/.test(markdown || "");
2748
+ var hasLatexImages = /\\\\includegraphics/.test(markdown || "");
2749
+ if (hasRelativeImages || hasLatexImages) {
2750
+ appendPreviewNotice(targetEl, "Images not displaying? Set working dir in the editor pane or open via /studio <path>.");
2751
+ }
2752
+ }
2492
2753
  } catch (error) {
2493
2754
  if (pane === "source") {
2494
2755
  if (nonce !== sourcePreviewRenderNonce || editorView !== "preview") return;
@@ -2504,7 +2765,7 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2504
2765
  function renderSourcePreviewNow() {
2505
2766
  if (editorView !== "preview") return;
2506
2767
  const text = sourceTextEl.value || "";
2507
- if (editorLanguage && editorLanguage !== "markdown") {
2768
+ if (editorLanguage && editorLanguage !== "markdown" && editorLanguage !== "latex") {
2508
2769
  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
2770
  return;
2510
2771
  }
@@ -2528,15 +2789,20 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2528
2789
  }, delay);
2529
2790
  }
2530
2791
 
2531
- function renderSourcePreview() {
2792
+ function renderSourcePreview(options) {
2793
+ const previewDelayMs =
2794
+ options && typeof options.previewDelayMs === "number"
2795
+ ? Math.max(0, options.previewDelayMs)
2796
+ : 0;
2797
+
2532
2798
  if (editorView === "preview") {
2533
- scheduleSourcePreviewRender(0);
2799
+ scheduleSourcePreviewRender(previewDelayMs);
2534
2800
  }
2535
2801
  if (editorHighlightEnabled && editorView === "markdown") {
2536
2802
  scheduleEditorHighlightRender();
2537
2803
  }
2538
2804
  if (rightView === "editor-preview") {
2539
- scheduleResponseEditorPreviewRender(0);
2805
+ scheduleResponseEditorPreviewRender(previewDelayMs);
2540
2806
  }
2541
2807
  }
2542
2808
 
@@ -2562,7 +2828,7 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2562
2828
  critiqueViewEl.innerHTML = "<pre class='plain-markdown'>Editor is empty.</pre>";
2563
2829
  return;
2564
2830
  }
2565
- if (editorLanguage && editorLanguage !== "markdown") {
2831
+ if (editorLanguage && editorLanguage !== "markdown" && editorLanguage !== "latex") {
2566
2832
  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
2833
  return;
2568
2834
  }
@@ -2601,14 +2867,16 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2601
2867
  critiqueViewEl.innerHTML = buildPlainMarkdownHtml(markdown);
2602
2868
  }
2603
2869
 
2604
- function updateResultActionButtons() {
2605
- const responseMarkdown = getCurrentResponseMarkdown();
2606
- const hasResponse = Boolean(responseMarkdown && responseMarkdown.trim());
2607
- const responseLoaded = hasResponse && isTextEquivalent(sourceTextEl.value, responseMarkdown);
2870
+ function updateResultActionButtons(normalizedEditorText) {
2871
+ const hasResponse = latestResponseHasContent;
2872
+ const normalizedEditor = typeof normalizedEditorText === "string"
2873
+ ? normalizedEditorText
2874
+ : normalizeForCompare(sourceTextEl.value);
2875
+ const responseLoaded = hasResponse && normalizedEditor === latestResponseNormalized;
2608
2876
  const isCritiqueResponse = hasResponse && latestResponseIsStructuredCritique;
2609
2877
 
2610
- const critiqueNotes = isCritiqueResponse ? buildCritiqueNotesMarkdown(responseMarkdown) : "";
2611
- const critiqueNotesLoaded = Boolean(critiqueNotes) && isTextEquivalent(sourceTextEl.value, critiqueNotes);
2878
+ const critiqueNotes = isCritiqueResponse ? latestCritiqueNotes : "";
2879
+ const critiqueNotesLoaded = Boolean(critiqueNotes) && normalizedEditor === latestCritiqueNotesNormalized;
2612
2880
 
2613
2881
  loadResponseBtn.hidden = isCritiqueResponse;
2614
2882
  loadCritiqueNotesBtn.hidden = !isCritiqueResponse;
@@ -2628,39 +2896,41 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2628
2896
  pullLatestBtn.disabled = uiBusy || followLatest;
2629
2897
  pullLatestBtn.textContent = queuedLatestResponse ? "Get latest response *" : "Get latest response";
2630
2898
 
2631
- updateSyncBadge();
2899
+ updateSyncBadge(normalizedEditor);
2632
2900
  }
2633
2901
 
2634
2902
  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
2903
  updateSourceBadge();
2644
2904
  updateReferenceBadge();
2645
2905
  renderActiveResult();
2646
2906
  updateResultActionButtons();
2647
2907
  }
2648
2908
 
2909
+ function getEffectiveSavePath() {
2910
+ // File-backed: use the original path
2911
+ if (sourceState.source === "file" && sourceState.path) return sourceState.path;
2912
+ // Upload with working dir + filename: derive path
2913
+ if (sourceState.source === "upload" && sourceState.label && resourceDirInput && resourceDirInput.value.trim()) {
2914
+ var name = sourceState.label.replace(/^upload:\\s*/i, "");
2915
+ if (name) return resourceDirInput.value.trim().replace(/\\/$/, "") + "/" + name;
2916
+ }
2917
+ return null;
2918
+ }
2919
+
2649
2920
  function updateSaveFileTooltip() {
2650
2921
  if (!saveOverBtn) return;
2651
2922
 
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;
2923
+ var effectivePath = getEffectiveSavePath();
2924
+ if (effectivePath) {
2925
+ saveOverBtn.title = "Overwrite file: " + effectivePath;
2656
2926
  return;
2657
2927
  }
2658
2928
 
2659
- saveOverBtn.title = "Save file is available after opening a file or using Save As…";
2929
+ saveOverBtn.title = "Save editor is available after opening a file, setting a working dir, or using Save editor as…";
2660
2930
  }
2661
2931
 
2662
2932
  function syncActionButtons() {
2663
- const canSaveOver = sourceState.source === "file" && Boolean(sourceState.path);
2933
+ const canSaveOver = Boolean(getEffectiveSavePath());
2664
2934
 
2665
2935
  fileInput.disabled = uiBusy;
2666
2936
  saveAsBtn.disabled = uiBusy;
@@ -2803,6 +3073,7 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2803
3073
 
2804
3074
  const first = raw.split(/\\s+/)[0].replace(/^\\./, "").toLowerCase();
2805
3075
 
3076
+ // Explicit aliases that don't match extension names
2806
3077
  if (first === "js" || first === "javascript" || first === "jsx" || first === "node") return "javascript";
2807
3078
  if (first === "ts" || first === "typescript" || first === "tsx") return "typescript";
2808
3079
  if (first === "py" || first === "python") return "python";
@@ -2815,8 +3086,11 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2815
3086
  if (first === "fortran" || first === "f90" || first === "f95" || first === "f03" || first === "f" || first === "for") return "fortran";
2816
3087
  if (first === "r") return "r";
2817
3088
  if (first === "matlab" || first === "m") return "matlab";
3089
+ if (first === "latex" || first === "tex") return "latex";
3090
+ if (first === "diff" || first === "patch" || first === "udiff") return "diff";
2818
3091
 
2819
- return "";
3092
+ // Fall back to the unified extension->language map
3093
+ return EXT_TO_LANG[first] || "";
2820
3094
  }
2821
3095
 
2822
3096
  function highlightCodeTokens(line, pattern, classifyMatch) {
@@ -2981,6 +3255,31 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2981
3255
  return "<span class='hl-code'>" + highlighted + "</span>";
2982
3256
  }
2983
3257
 
3258
+ if (lang === "latex") {
3259
+ 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;
3260
+ const highlighted = highlightCodeTokens(source, texPattern, (match) => {
3261
+ if (match[1]) return "hl-code-com";
3262
+ if (match[2]) return "hl-code-kw";
3263
+ if (match[3]) return "hl-code-fn";
3264
+ if (match[4]) return "hl-code-op";
3265
+ if (match[5]) return "hl-code-str";
3266
+ if (match[6]) return "hl-code-num";
3267
+ return "hl-code";
3268
+ });
3269
+ return highlighted;
3270
+ }
3271
+
3272
+ if (lang === "diff") {
3273
+ var escaped = escapeHtml(source);
3274
+ if (/^@@/.test(source)) return "<span class=\\"hl-code-fn\\">" + escaped + "</span>";
3275
+ if (/^\\+\\+\\+|^---/.test(source)) return "<span class=\\"hl-code-kw\\">" + escaped + "</span>";
3276
+ if (/^\\+/.test(source)) return "<span class=\\"hl-diff-add\\">" + escaped + "</span>";
3277
+ if (/^-/.test(source)) return "<span class=\\"hl-diff-del\\">" + escaped + "</span>";
3278
+ if (/^diff /.test(source)) return "<span class=\\"hl-code-kw\\">" + escaped + "</span>";
3279
+ if (/^index /.test(source)) return "<span class=\\"hl-code-com\\">" + escaped + "</span>";
3280
+ return escaped;
3281
+ }
3282
+
2984
3283
  return wrapHighlight("hl-code", source);
2985
3284
  }
2986
3285
 
@@ -3067,23 +3366,10 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3067
3366
 
3068
3367
  function detectLanguageFromName(name) {
3069
3368
  if (!name) return "";
3070
- const dot = name.lastIndexOf(".");
3369
+ var dot = name.lastIndexOf(".");
3071
3370
  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 "";
3371
+ var ext = name.slice(dot + 1).toLowerCase();
3372
+ return EXT_TO_LANG[ext] || "";
3087
3373
  }
3088
3374
 
3089
3375
  function renderEditorHighlightNow() {
@@ -3134,6 +3420,31 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3134
3420
  sourceHighlightEl.scrollLeft = sourceTextEl.scrollLeft;
3135
3421
  }
3136
3422
 
3423
+ function runEditorMetaUpdateNow() {
3424
+ const normalizedEditor = normalizeForCompare(sourceTextEl.value);
3425
+ updateResultActionButtons(normalizedEditor);
3426
+ }
3427
+
3428
+ function scheduleEditorMetaUpdate() {
3429
+ if (editorMetaUpdateRaf !== null) {
3430
+ if (typeof window.cancelAnimationFrame === "function") {
3431
+ window.cancelAnimationFrame(editorMetaUpdateRaf);
3432
+ } else {
3433
+ window.clearTimeout(editorMetaUpdateRaf);
3434
+ }
3435
+ editorMetaUpdateRaf = null;
3436
+ }
3437
+
3438
+ const schedule = typeof window.requestAnimationFrame === "function"
3439
+ ? window.requestAnimationFrame.bind(window)
3440
+ : (cb) => window.setTimeout(cb, 16);
3441
+
3442
+ editorMetaUpdateRaf = schedule(() => {
3443
+ editorMetaUpdateRaf = null;
3444
+ runEditorMetaUpdateNow();
3445
+ });
3446
+ }
3447
+
3137
3448
  function readStoredToggle(storageKey) {
3138
3449
  if (!window.localStorage) return null;
3139
3450
  try {
@@ -3320,9 +3631,18 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3320
3631
  latestResponseKind = kind === "critique" ? "critique" : "annotation";
3321
3632
  latestResponseTimestamp = responseTimestamp;
3322
3633
  latestResponseIsStructuredCritique = isStructuredCritique(markdown);
3634
+ latestResponseHasContent = Boolean(markdown && markdown.trim());
3635
+ latestResponseNormalized = normalizeForCompare(markdown);
3636
+
3637
+ if (latestResponseIsStructuredCritique) {
3638
+ latestCritiqueNotes = buildCritiqueNotesMarkdown(markdown);
3639
+ latestCritiqueNotesNormalized = normalizeForCompare(latestCritiqueNotes);
3640
+ } else {
3641
+ latestCritiqueNotes = "";
3642
+ latestCritiqueNotesNormalized = "";
3643
+ }
3323
3644
 
3324
3645
  refreshResponseUi();
3325
- syncActionButtons();
3326
3646
  }
3327
3647
 
3328
3648
  function applyLatestPayload(payload) {
@@ -3527,6 +3847,17 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3527
3847
  setStatus(message.message);
3528
3848
  }
3529
3849
  }
3850
+
3851
+ if (message.type === "theme_update" && message.vars && typeof message.vars === "object") {
3852
+ var root = document.documentElement;
3853
+ Object.keys(message.vars).forEach(function(key) {
3854
+ if (key === "color-scheme") {
3855
+ root.style.colorScheme = message.vars[key];
3856
+ } else {
3857
+ root.style.setProperty(key, message.vars[key]);
3858
+ }
3859
+ });
3860
+ }
3530
3861
  }
3531
3862
 
3532
3863
  function connect() {
@@ -3728,8 +4059,8 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3728
4059
  });
3729
4060
 
3730
4061
  sourceTextEl.addEventListener("input", () => {
3731
- renderSourcePreview();
3732
- updateResultActionButtons();
4062
+ renderSourcePreview({ previewDelayMs: PREVIEW_INPUT_DEBOUNCE_MS });
4063
+ scheduleEditorMetaUpdate();
3733
4064
  });
3734
4065
 
3735
4066
  sourceTextEl.addEventListener("scroll", () => {
@@ -3827,8 +4158,10 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3827
4158
  return;
3828
4159
  }
3829
4160
 
3830
- const suggested = sourceState.path || "./draft.md";
3831
- const path = window.prompt("Save editor as path:", suggested);
4161
+ var suggestedName = sourceState.label ? sourceState.label.replace(/^upload:\\s*/i, "") : "draft.md";
4162
+ var suggestedDir = resourceDirInput && resourceDirInput.value.trim() ? resourceDirInput.value.trim().replace(/\\/$/, "") + "/" : "./";
4163
+ const suggested = sourceState.path || (suggestedDir + suggestedName);
4164
+ const path = window.prompt("Save editor content as:", suggested);
3832
4165
  if (!path) return;
3833
4166
 
3834
4167
  const requestId = beginUiAction("save_as");
@@ -3849,21 +4182,24 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3849
4182
  });
3850
4183
 
3851
4184
  saveOverBtn.addEventListener("click", () => {
3852
- if (!(sourceState.source === "file" && sourceState.path)) {
3853
- setStatus("Save file is only available when source is a file path.", "warning");
4185
+ var effectivePath = getEffectiveSavePath();
4186
+ if (!effectivePath) {
4187
+ setStatus("Save editor requires a file path. Open via /studio <path>, set a working dir, or use Save editor as…", "warning");
3854
4188
  return;
3855
4189
  }
3856
4190
 
3857
- if (!window.confirm("Overwrite " + sourceState.label + "?")) {
4191
+ if (!window.confirm("Overwrite " + effectivePath + "?")) {
3858
4192
  return;
3859
4193
  }
3860
4194
 
3861
4195
  const requestId = beginUiAction("save_over");
3862
4196
  if (!requestId) return;
3863
4197
 
4198
+ // Use save_as with the effective path for both file-backed and derived paths
3864
4199
  const sent = sendMessage({
3865
- type: "save_over_request",
4200
+ type: "save_as_request",
3866
4201
  requestId,
4202
+ path: effectivePath,
3867
4203
  content: sourceTextEl.value,
3868
4204
  });
3869
4205
 
@@ -3935,6 +4271,67 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3935
4271
  }
3936
4272
  });
3937
4273
 
4274
+ // Working directory controls — three states: button | input | label
4275
+ function showResourceDirState(state) {
4276
+ // state: "button" | "input" | "label"
4277
+ if (resourceDirBtn) resourceDirBtn.hidden = state !== "button";
4278
+ if (resourceDirInputWrap) {
4279
+ if (state === "input") resourceDirInputWrap.classList.add("visible");
4280
+ else resourceDirInputWrap.classList.remove("visible");
4281
+ }
4282
+ if (resourceDirLabel) resourceDirLabel.hidden = state !== "label";
4283
+ }
4284
+ function applyResourceDir() {
4285
+ var dir = resourceDirInput ? resourceDirInput.value.trim() : "";
4286
+ if (dir) {
4287
+ if (resourceDirLabel) resourceDirLabel.textContent = "Working dir: " + dir;
4288
+ showResourceDirState("label");
4289
+ } else {
4290
+ showResourceDirState("button");
4291
+ }
4292
+ updateSaveFileTooltip();
4293
+ syncActionButtons();
4294
+ renderSourcePreview();
4295
+ }
4296
+ if (resourceDirBtn) {
4297
+ resourceDirBtn.addEventListener("click", () => {
4298
+ showResourceDirState("input");
4299
+ if (resourceDirInput) resourceDirInput.focus();
4300
+ });
4301
+ }
4302
+ if (resourceDirLabel) {
4303
+ resourceDirLabel.addEventListener("click", () => {
4304
+ showResourceDirState("input");
4305
+ if (resourceDirInput) resourceDirInput.focus();
4306
+ });
4307
+ }
4308
+ if (resourceDirInput) {
4309
+ resourceDirInput.addEventListener("keydown", (e) => {
4310
+ if (e.key === "Enter") {
4311
+ e.preventDefault();
4312
+ applyResourceDir();
4313
+ } else if (e.key === "Escape") {
4314
+ e.preventDefault();
4315
+ var dir = resourceDirInput.value.trim();
4316
+ if (dir) {
4317
+ showResourceDirState("label");
4318
+ } else {
4319
+ showResourceDirState("button");
4320
+ }
4321
+ }
4322
+ });
4323
+ }
4324
+ if (resourceDirClearBtn) {
4325
+ resourceDirClearBtn.addEventListener("click", () => {
4326
+ if (resourceDirInput) resourceDirInput.value = "";
4327
+ if (resourceDirLabel) resourceDirLabel.textContent = "";
4328
+ showResourceDirState("button");
4329
+ updateSaveFileTooltip();
4330
+ syncActionButtons();
4331
+ renderSourcePreview();
4332
+ });
4333
+ }
4334
+
3938
4335
  fileInput.addEventListener("change", () => {
3939
4336
  const file = fileInput.files && fileInput.files[0];
3940
4337
  if (!file) return;
@@ -3998,6 +4395,7 @@ export default function (pi: ExtensionAPI) {
3998
4395
  let initialStudioDocument: InitialStudioDocument | null = null;
3999
4396
  let studioCwd = process.cwd();
4000
4397
  let lastCommandCtx: ExtensionCommandContext | null = null;
4398
+ let lastThemeVarsJson = "";
4001
4399
  let agentBusy = false;
4002
4400
 
4003
4401
  const isStudioBusy = () => agentBusy || activeRequest !== null;
@@ -4369,7 +4767,17 @@ export default function (pi: ExtensionAPI) {
4369
4767
  }
4370
4768
 
4371
4769
  try {
4372
- const html = await renderStudioMarkdownWithPandoc(markdown);
4770
+ const sourcePath =
4771
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { sourcePath?: unknown }).sourcePath === "string"
4772
+ ? (parsedBody as { sourcePath: string }).sourcePath
4773
+ : "";
4774
+ const userResourceDir =
4775
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { resourceDir?: unknown }).resourceDir === "string"
4776
+ ? (parsedBody as { resourceDir: string }).resourceDir
4777
+ : "";
4778
+ const resourcePath = sourcePath ? dirname(sourcePath) : (userResourceDir || studioCwd || undefined);
4779
+ const isLatex = /\\documentclass\b|\\begin\{document\}/.test(markdown);
4780
+ const html = await renderStudioMarkdownWithPandoc(markdown, isLatex, resourcePath);
4373
4781
  respondJson(res, 200, { ok: true, html, renderer: "pandoc" });
4374
4782
  } catch (error) {
4375
4783
  const message = error instanceof Error ? error.message : String(error);
@@ -4536,6 +4944,27 @@ export default function (pi: ExtensionAPI) {
4536
4944
  state.port = address.port;
4537
4945
 
4538
4946
  serverState = state;
4947
+
4948
+ // Periodically check for theme changes and push to all clients
4949
+ const themeCheckInterval = setInterval(() => {
4950
+ if (!lastCommandCtx?.ui?.theme || !serverState || serverState.clients.size === 0) return;
4951
+ try {
4952
+ const style = getStudioThemeStyle(lastCommandCtx.ui.theme);
4953
+ const vars = buildThemeCssVars(style);
4954
+ const json = JSON.stringify(vars);
4955
+ if (json !== lastThemeVarsJson) {
4956
+ lastThemeVarsJson = json;
4957
+ for (const client of serverState.clients) {
4958
+ sendToClient(client, { type: "theme_update", vars });
4959
+ }
4960
+ }
4961
+ } catch {
4962
+ // Ignore theme read errors
4963
+ }
4964
+ }, 2000);
4965
+ // Clean up interval if server closes
4966
+ server.once("close", () => clearInterval(themeCheckInterval));
4967
+
4539
4968
  return state;
4540
4969
  };
4541
4970
 
@@ -4684,6 +5113,11 @@ export default function (pi: ExtensionAPI) {
4684
5113
  await ctx.waitForIdle();
4685
5114
  lastCommandCtx = ctx;
4686
5115
  studioCwd = ctx.cwd;
5116
+ // Seed theme vars so first ping doesn't trigger a false update
5117
+ try {
5118
+ const currentStyle = getStudioThemeStyle(ctx.ui.theme);
5119
+ lastThemeVarsJson = JSON.stringify(buildThemeCssVars(currentStyle));
5120
+ } catch { /* ignore */ }
4687
5121
 
4688
5122
  const latestAssistant =
4689
5123
  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.1",
4
4
  "description": "Browser GUI for structured critique workflows in pi",
5
5
  "type": "module",
6
6
  "license": "MIT",