pi-studio 0.2.6 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to `pi-studio` are documented here.
4
4
 
5
+ ## [0.3.0] — 2026-03-02
6
+
7
+ ### Added
8
+ - **Editor Preview in response pane**: new `Right: Editor (Preview)` view mode renders editor text in the right pane with debounced live updates — enables Overleaf-style side-by-side source/rendered editing without a model round-trip.
9
+ - Code-language aware: Editor Preview renders syntax-highlighted code when a non-markdown language is selected.
10
+ - Response badge shows "Previewing: editor text" in editor-preview mode, with "· response updated HH:MM:SS" when a model response arrives in the background.
11
+ - Right pane section header updates to "Editor Preview" when in editor-preview mode.
12
+
13
+ ### Changed
14
+ - View toggle labels now use `Left: Source (Mode)` / `Right: Source (Mode)` format for unambiguous pane identification (e.g., `Left: Editor (Raw)`, `Right: Response (Preview)`, `Right: Editor (Preview)`).
15
+ - Sync badge wording: `Edited since response` → `Out of sync with response` (direction-neutral, accurate regardless of which side changed).
16
+ - Critique load buttons now include destination: `Load critique notes into editor` / `Load full critique into editor` (consistent with `Load response into editor`).
17
+ - Critique loaded-state labels updated: `Critique (full) already in editor` → `Full critique already in editor`.
18
+
5
19
  ## [0.2.4] — 2026-03-02
6
20
 
7
21
  ### Changed
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)
@@ -50,19 +48,27 @@ Status: experimental alpha.
50
48
  - **Critique editor text** requests structured critique (auto/writing/code focus)
51
49
  - Response load helpers:
52
50
  - non-critique: **Load response into editor**
53
- - critique: **Load critique (notes)** / **Load critique (full)**
54
- - File actions: **Save As…**, **Save file**, **Load file content**
55
- - View toggles: `Editor: Raw|Preview`, `Response: Raw|Preview`
51
+ - critique: **Load critique notes into editor** / **Load full critique into editor**
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)`
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
56
55
  - Preview mode supports MathML equations and Mermaid fenced diagrams
57
56
  - **Language-aware syntax highlighting** with selectable language mode:
58
57
  - Markdown (default): headings, links, code fences, lists, quotes, inline code
59
- - 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
60
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
61
61
  - Language auto-detected from file extension on file load; manually selectable via `Lang:` dropdown
62
62
  - Applies to both editor Raw view (highlight overlay) and fenced code blocks in markdown
63
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)
64
69
  - Separate syntax highlight toggles for editor and response Raw views, with local preference persistence
65
70
  - Theme-aware browser UI derived from current pi theme
71
+ - View mode selectors integrated into panel headers for a cleaner layout
66
72
 
67
73
  ## Commands
68
74
 
@@ -102,7 +108,8 @@ pi -e https://github.com/omaclaren/pi-studio
102
108
  - One studio request at a time.
103
109
  - Pi Studio supports both markdown workflows (model responses, plans, and notes) and code file editing with language-aware syntax highlighting.
104
110
  - Studio URLs include a token query parameter; avoid sharing full Studio URLs.
105
- - 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.
106
113
  - Preview markdown/code colors are mapped from active theme markdown (`md*`) and syntax (`syntax*`) tokens for closer terminal-vs-browser parity.
107
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.
108
115
  - If Mermaid cannot load or a diagram fails to render, preview shows an inline warning and keeps source text visible.
package/WORKFLOW.md CHANGED
@@ -75,7 +75,7 @@ Rules:
75
75
  ## Required UI elements
76
76
 
77
77
  - Header actions: **Save As…**, **Save file** (file-backed), **Load file in editor**
78
- - Header view toggles: `Editor: Markdown|Preview`, `Response: Markdown|Preview`
78
+ - Header view toggles: `Left: Editor (Raw|Preview)`, `Right: Response (Raw|Preview) | Editor (Preview)`
79
79
  - Preview mode uses server-side `pandoc` rendering (math-aware) with plain-markdown fallback when renderer is unavailable.
80
80
  - Editor actions: **Insert annotation header**, **Run editor text**, **Critique editor text** (+ critique focus), **Send to pi editor**, **Copy editor text**
81
81
  - Response actions include `Auto-update response: On|Off` + **Get latest response**
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,29 +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>Editor: Raw</option>
1912
- <option value="preview">Editor: Preview</option>
1913
- </select>
1914
- <select id="rightViewSelect" aria-label="Response view mode">
1915
- <option value="markdown">Response: Raw</option>
1916
- <option value="preview" selected>Response: Preview</option>
1917
- </select>
1918
- <button id="saveAsBtn" type="button" title="Save editor text to a new file path.">Save As…</button>
1919
- <button id="saveOverBtn" type="button" title="Overwrite current file with editor text." disabled>Save file</button>
1920
- <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>
1921
2068
  </div>
1922
2069
  </header>
1923
2070
 
1924
2071
  <main>
1925
2072
  <section id="leftPane">
1926
- <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>
1927
2079
  <div class="source-wrap">
1928
2080
  <div class="source-meta">
1929
2081
  <div class="badge-row">
1930
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>
1931
2089
  <span id="syncBadge" class="source-badge sync-badge">No response loaded</span>
1932
2090
  </div>
1933
2091
  <div class="source-actions">
@@ -1959,6 +2117,19 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
1959
2117
  <option value="fortran">Lang: Fortran</option>
1960
2118
  <option value="r">Lang: R</option>
1961
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>
1962
2133
  </select>
1963
2134
  </div>
1964
2135
  </div>
@@ -1971,7 +2142,13 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
1971
2142
  </section>
1972
2143
 
1973
2144
  <section id="rightPane">
1974
- <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>
1975
2152
  <div class="reference-meta">
1976
2153
  <span id="referenceBadge" class="source-badge">Latest response: none</span>
1977
2154
  </div>
@@ -1988,8 +2165,8 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
1988
2165
  </select>
1989
2166
  <button id="pullLatestBtn" type="button" title="Fetch the latest assistant response when auto-update is off.">Get latest response</button>
1990
2167
  <button id="loadResponseBtn" type="button">Load response into editor</button>
1991
- <button id="loadCritiqueNotesBtn" type="button" hidden>Load critique (notes)</button>
1992
- <button id="loadCritiqueFullBtn" type="button" hidden>Load critique (full)</button>
2168
+ <button id="loadCritiqueNotesBtn" type="button" hidden>Load critique notes into editor</button>
2169
+ <button id="loadCritiqueFullBtn" type="button" hidden>Load full critique into editor</button>
1993
2170
  <button id="copyResponseBtn" type="button">Copy response text</button>
1994
2171
  </div>
1995
2172
  </div>
@@ -2033,11 +2210,9 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2033
2210
  const sourcePreviewEl = document.getElementById("sourcePreview");
2034
2211
  const leftPaneEl = document.getElementById("leftPane");
2035
2212
  const rightPaneEl = document.getElementById("rightPane");
2036
- const leftSectionHeaderEl = document.getElementById("leftSectionHeader");
2037
2213
  const sourceBadgeEl = document.getElementById("sourceBadge");
2038
2214
  const syncBadgeEl = document.getElementById("syncBadge");
2039
2215
  const critiqueViewEl = document.getElementById("critiqueView");
2040
- const rightSectionHeaderEl = document.getElementById("rightSectionHeader");
2041
2216
  const referenceBadgeEl = document.getElementById("referenceBadge");
2042
2217
  const editorViewSelect = document.getElementById("editorViewSelect");
2043
2218
  const rightViewSelect = document.getElementById("rightViewSelect");
@@ -2048,6 +2223,11 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2048
2223
  const critiqueBtn = document.getElementById("critiqueBtn");
2049
2224
  const lensSelect = document.getElementById("lensSelect");
2050
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");
2051
2231
  const loadResponseBtn = document.getElementById("loadResponseBtn");
2052
2232
  const loadCritiqueNotesBtn = document.getElementById("loadCritiqueNotesBtn");
2053
2233
  const loadCritiqueFullBtn = document.getElementById("loadCritiqueFullBtn");
@@ -2092,12 +2272,50 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2092
2272
  const EDITOR_HIGHLIGHT_MAX_CHARS = 80_000;
2093
2273
  const EDITOR_HIGHLIGHT_STORAGE_KEY = "piStudio.editorHighlightEnabled";
2094
2274
  const EDITOR_LANGUAGE_STORAGE_KEY = "piStudio.editorLanguage";
2095
- 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);
2096
2313
  const RESPONSE_HIGHLIGHT_MAX_CHARS = 120_000;
2097
2314
  const RESPONSE_HIGHLIGHT_STORAGE_KEY = "piStudio.responseHighlightEnabled";
2098
2315
  let sourcePreviewRenderTimer = null;
2099
2316
  let sourcePreviewRenderNonce = 0;
2100
2317
  let responsePreviewRenderNonce = 0;
2318
+ let responseEditorPreviewTimer = null;
2101
2319
  let editorHighlightEnabled = false;
2102
2320
  let editorLanguage = "markdown";
2103
2321
  let responseHighlightEnabled = false;
@@ -2135,6 +2353,27 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2135
2353
  function updateSourceBadge() {
2136
2354
  const label = sourceState && sourceState.label ? sourceState.label : "blank";
2137
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
+ }
2138
2377
  }
2139
2378
 
2140
2379
  function applyPaneFocusClasses() {
@@ -2242,6 +2481,18 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2242
2481
  function updateReferenceBadge() {
2243
2482
  if (!referenceBadgeEl) return;
2244
2483
 
2484
+ if (rightView === "editor-preview") {
2485
+ const hasResponse = Boolean(latestResponseMarkdown && latestResponseMarkdown.trim());
2486
+ if (hasResponse) {
2487
+ const time = formatReferenceTime(latestResponseTimestamp);
2488
+ const suffix = time ? " · response updated " + time : " · response available";
2489
+ referenceBadgeEl.textContent = "Previewing: editor text" + suffix;
2490
+ } else {
2491
+ referenceBadgeEl.textContent = "Previewing: editor text";
2492
+ }
2493
+ return;
2494
+ }
2495
+
2245
2496
  const hasResponse = Boolean(latestResponseMarkdown && latestResponseMarkdown.trim());
2246
2497
  if (!hasResponse) {
2247
2498
  referenceBadgeEl.textContent = "Latest response: none";
@@ -2285,7 +2536,7 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2285
2536
  syncBadgeEl.classList.add("sync");
2286
2537
  syncBadgeEl.classList.remove("edited");
2287
2538
  } else {
2288
- syncBadgeEl.textContent = "Edited since response";
2539
+ syncBadgeEl.textContent = "Out of sync with response";
2289
2540
  syncBadgeEl.classList.add("edited");
2290
2541
  syncBadgeEl.classList.remove("sync");
2291
2542
  }
@@ -2332,6 +2583,15 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2332
2583
  targetEl.appendChild(warningEl);
2333
2584
  }
2334
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
+
2335
2595
  async function getMermaidApi() {
2336
2596
  if (mermaidModulePromise) {
2337
2597
  return mermaidModulePromise;
@@ -2424,7 +2684,11 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2424
2684
  headers: {
2425
2685
  "Content-Type": "application/json",
2426
2686
  },
2427
- 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
+ }),
2428
2692
  signal: controller ? controller.signal : undefined,
2429
2693
  });
2430
2694
  } catch (error) {
@@ -2470,16 +2734,25 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2470
2734
  if (pane === "source") {
2471
2735
  if (nonce !== sourcePreviewRenderNonce || editorView !== "preview") return;
2472
2736
  } else {
2473
- if (nonce !== responsePreviewRenderNonce || rightView !== "preview") return;
2737
+ if (nonce !== responsePreviewRenderNonce || (rightView !== "preview" && rightView !== "editor-preview")) return;
2474
2738
  }
2475
2739
 
2476
2740
  targetEl.innerHTML = sanitizeRenderedHtml(renderedHtml, markdown);
2477
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
+ }
2478
2751
  } catch (error) {
2479
2752
  if (pane === "source") {
2480
2753
  if (nonce !== sourcePreviewRenderNonce || editorView !== "preview") return;
2481
2754
  } else {
2482
- if (nonce !== responsePreviewRenderNonce || rightView !== "preview") return;
2755
+ if (nonce !== responsePreviewRenderNonce || (rightView !== "preview" && rightView !== "editor-preview")) return;
2483
2756
  }
2484
2757
 
2485
2758
  const detail = error && error.message ? error.message : String(error || "unknown error");
@@ -2490,7 +2763,7 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2490
2763
  function renderSourcePreviewNow() {
2491
2764
  if (editorView !== "preview") return;
2492
2765
  const text = sourceTextEl.value || "";
2493
- if (editorLanguage && editorLanguage !== "markdown") {
2766
+ if (editorLanguage && editorLanguage !== "markdown" && editorLanguage !== "latex") {
2494
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>";
2495
2768
  return;
2496
2769
  }
@@ -2521,9 +2794,43 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2521
2794
  if (editorHighlightEnabled && editorView === "markdown") {
2522
2795
  scheduleEditorHighlightRender();
2523
2796
  }
2797
+ if (rightView === "editor-preview") {
2798
+ scheduleResponseEditorPreviewRender(0);
2799
+ }
2800
+ }
2801
+
2802
+ function scheduleResponseEditorPreviewRender(delayMs) {
2803
+ if (responseEditorPreviewTimer) {
2804
+ window.clearTimeout(responseEditorPreviewTimer);
2805
+ responseEditorPreviewTimer = null;
2806
+ }
2807
+
2808
+ if (rightView !== "editor-preview") return;
2809
+
2810
+ const delay = typeof delayMs === "number" ? Math.max(0, delayMs) : 180;
2811
+ responseEditorPreviewTimer = window.setTimeout(() => {
2812
+ responseEditorPreviewTimer = null;
2813
+ renderActiveResult();
2814
+ }, delay);
2524
2815
  }
2525
2816
 
2526
2817
  function renderActiveResult() {
2818
+ if (rightView === "editor-preview") {
2819
+ const editorText = sourceTextEl.value || "";
2820
+ if (!editorText.trim()) {
2821
+ critiqueViewEl.innerHTML = "<pre class='plain-markdown'>Editor is empty.</pre>";
2822
+ return;
2823
+ }
2824
+ if (editorLanguage && editorLanguage !== "markdown" && editorLanguage !== "latex") {
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>";
2826
+ return;
2827
+ }
2828
+ const nonce = ++responsePreviewRenderNonce;
2829
+ critiqueViewEl.innerHTML = "<div class='preview-loading'>Rendering preview…</div>";
2830
+ void applyRenderedMarkdown(critiqueViewEl, editorText, "response", nonce);
2831
+ return;
2832
+ }
2833
+
2527
2834
  const markdown = latestResponseMarkdown;
2528
2835
  if (!markdown || !markdown.trim()) {
2529
2836
  critiqueViewEl.innerHTML = "<pre class='plain-markdown'>No response yet. Run editor text or critique editor text.</pre>";
@@ -2570,10 +2877,10 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2570
2877
  loadResponseBtn.textContent = responseLoaded ? "Response already in editor" : "Load response into editor";
2571
2878
 
2572
2879
  loadCritiqueNotesBtn.disabled = uiBusy || !isCritiqueResponse || !critiqueNotes || critiqueNotesLoaded;
2573
- loadCritiqueNotesBtn.textContent = critiqueNotesLoaded ? "Critique notes already in editor" : "Load critique (notes)";
2880
+ loadCritiqueNotesBtn.textContent = critiqueNotesLoaded ? "Critique notes already in editor" : "Load critique notes into editor";
2574
2881
 
2575
2882
  loadCritiqueFullBtn.disabled = uiBusy || !isCritiqueResponse || responseLoaded;
2576
- loadCritiqueFullBtn.textContent = responseLoaded ? "Critique (full) already in editor" : "Load critique (full)";
2883
+ loadCritiqueFullBtn.textContent = responseLoaded ? "Full critique already in editor" : "Load full critique into editor";
2577
2884
 
2578
2885
  copyResponseBtn.disabled = uiBusy || !hasResponse;
2579
2886
 
@@ -2584,35 +2891,37 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2584
2891
  }
2585
2892
 
2586
2893
  function refreshResponseUi() {
2587
- if (leftSectionHeaderEl) {
2588
- leftSectionHeaderEl.textContent = "Editor";
2589
- }
2590
-
2591
- if (rightSectionHeaderEl) {
2592
- rightSectionHeaderEl.textContent = "Response";
2593
- }
2594
-
2595
2894
  updateSourceBadge();
2596
2895
  updateReferenceBadge();
2597
2896
  renderActiveResult();
2598
2897
  updateResultActionButtons();
2599
2898
  }
2600
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
+
2601
2911
  function updateSaveFileTooltip() {
2602
2912
  if (!saveOverBtn) return;
2603
2913
 
2604
- const isFileBacked = sourceState.source === "file" && Boolean(sourceState.path);
2605
- if (isFileBacked) {
2606
- const target = sourceState.label || sourceState.path;
2607
- saveOverBtn.title = "Overwrite current file: " + target;
2914
+ var effectivePath = getEffectiveSavePath();
2915
+ if (effectivePath) {
2916
+ saveOverBtn.title = "Overwrite file: " + effectivePath;
2608
2917
  return;
2609
2918
  }
2610
2919
 
2611
- 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…";
2612
2921
  }
2613
2922
 
2614
2923
  function syncActionButtons() {
2615
- const canSaveOver = sourceState.source === "file" && Boolean(sourceState.path);
2924
+ const canSaveOver = Boolean(getEffectiveSavePath());
2616
2925
 
2617
2926
  fileInput.disabled = uiBusy;
2618
2927
  saveAsBtn.disabled = uiBusy;
@@ -2672,9 +2981,15 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2672
2981
  }
2673
2982
 
2674
2983
  function setRightView(nextView) {
2675
- rightView = nextView === "preview" ? "preview" : "markdown";
2984
+ rightView = nextView === "preview" ? "preview" : (nextView === "editor-preview" ? "editor-preview" : "markdown");
2676
2985
  rightViewSelect.value = rightView;
2677
- renderActiveResult();
2986
+
2987
+ if (rightView !== "editor-preview" && responseEditorPreviewTimer) {
2988
+ window.clearTimeout(responseEditorPreviewTimer);
2989
+ responseEditorPreviewTimer = null;
2990
+ }
2991
+
2992
+ refreshResponseUi();
2678
2993
  syncActionButtons();
2679
2994
  }
2680
2995
 
@@ -2749,6 +3064,7 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2749
3064
 
2750
3065
  const first = raw.split(/\\s+/)[0].replace(/^\\./, "").toLowerCase();
2751
3066
 
3067
+ // Explicit aliases that don't match extension names
2752
3068
  if (first === "js" || first === "javascript" || first === "jsx" || first === "node") return "javascript";
2753
3069
  if (first === "ts" || first === "typescript" || first === "tsx") return "typescript";
2754
3070
  if (first === "py" || first === "python") return "python";
@@ -2761,8 +3077,11 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2761
3077
  if (first === "fortran" || first === "f90" || first === "f95" || first === "f03" || first === "f" || first === "for") return "fortran";
2762
3078
  if (first === "r") return "r";
2763
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";
2764
3082
 
2765
- return "";
3083
+ // Fall back to the unified extension->language map
3084
+ return EXT_TO_LANG[first] || "";
2766
3085
  }
2767
3086
 
2768
3087
  function highlightCodeTokens(line, pattern, classifyMatch) {
@@ -2927,6 +3246,31 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
2927
3246
  return "<span class='hl-code'>" + highlighted + "</span>";
2928
3247
  }
2929
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
+
2930
3274
  return wrapHighlight("hl-code", source);
2931
3275
  }
2932
3276
 
@@ -3013,23 +3357,10 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3013
3357
 
3014
3358
  function detectLanguageFromName(name) {
3015
3359
  if (!name) return "";
3016
- const dot = name.lastIndexOf(".");
3360
+ var dot = name.lastIndexOf(".");
3017
3361
  if (dot < 0) return "";
3018
- const ext = name.slice(dot + 1).toLowerCase();
3019
- if (ext === "js" || ext === "mjs" || ext === "cjs" || ext === "jsx") return "javascript";
3020
- if (ext === "ts" || ext === "mts" || ext === "cts" || ext === "tsx") return "typescript";
3021
- if (ext === "py" || ext === "pyw") return "python";
3022
- if (ext === "sh" || ext === "bash" || ext === "zsh") return "bash";
3023
- if (ext === "json" || ext === "jsonc" || ext === "json5") return "json";
3024
- if (ext === "rs") return "rust";
3025
- if (ext === "c" || ext === "h") return "c";
3026
- if (ext === "cpp" || ext === "cxx" || ext === "cc" || ext === "hpp" || ext === "hxx") return "cpp";
3027
- if (ext === "jl") return "julia";
3028
- if (ext === "f90" || ext === "f95" || ext === "f03" || ext === "f" || ext === "for") return "fortran";
3029
- if (ext === "r" || ext === "R") return "r";
3030
- if (ext === "m") return "matlab";
3031
- if (ext === "md" || ext === "markdown" || ext === "mdx") return "markdown";
3032
- return "";
3362
+ var ext = name.slice(dot + 1).toLowerCase();
3363
+ return EXT_TO_LANG[ext] || "";
3033
3364
  }
3034
3365
 
3035
3366
  function renderEditorHighlightNow() {
@@ -3473,6 +3804,17 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3473
3804
  setStatus(message.message);
3474
3805
  }
3475
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
+ }
3476
3818
  }
3477
3819
 
3478
3820
  function connect() {
@@ -3748,8 +4090,8 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3748
4090
 
3749
4091
  sourceTextEl.value = latestResponseMarkdown;
3750
4092
  renderSourcePreview();
3751
- setSourceState({ source: "blank", label: "critique (full)", path: null });
3752
- setStatus("Loaded critique (full) into editor.", "success");
4093
+ setSourceState({ source: "blank", label: "full critique", path: null });
4094
+ setStatus("Loaded full critique into editor.", "success");
3753
4095
  });
3754
4096
 
3755
4097
  copyResponseBtn.addEventListener("click", async () => {
@@ -3773,8 +4115,10 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3773
4115
  return;
3774
4116
  }
3775
4117
 
3776
- const suggested = sourceState.path || "./draft.md";
3777
- 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);
3778
4122
  if (!path) return;
3779
4123
 
3780
4124
  const requestId = beginUiAction("save_as");
@@ -3795,21 +4139,24 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3795
4139
  });
3796
4140
 
3797
4141
  saveOverBtn.addEventListener("click", () => {
3798
- if (!(sourceState.source === "file" && sourceState.path)) {
3799
- 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");
3800
4145
  return;
3801
4146
  }
3802
4147
 
3803
- if (!window.confirm("Overwrite " + sourceState.label + "?")) {
4148
+ if (!window.confirm("Overwrite " + effectivePath + "?")) {
3804
4149
  return;
3805
4150
  }
3806
4151
 
3807
4152
  const requestId = beginUiAction("save_over");
3808
4153
  if (!requestId) return;
3809
4154
 
4155
+ // Use save_as with the effective path for both file-backed and derived paths
3810
4156
  const sent = sendMessage({
3811
- type: "save_over_request",
4157
+ type: "save_as_request",
3812
4158
  requestId,
4159
+ path: effectivePath,
3813
4160
  content: sourceTextEl.value,
3814
4161
  });
3815
4162
 
@@ -3881,6 +4228,67 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
3881
4228
  }
3882
4229
  });
3883
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
+
3884
4292
  fileInput.addEventListener("change", () => {
3885
4293
  const file = fileInput.files && fileInput.files[0];
3886
4294
  if (!file) return;
@@ -3944,6 +4352,7 @@ export default function (pi: ExtensionAPI) {
3944
4352
  let initialStudioDocument: InitialStudioDocument | null = null;
3945
4353
  let studioCwd = process.cwd();
3946
4354
  let lastCommandCtx: ExtensionCommandContext | null = null;
4355
+ let lastThemeVarsJson = "";
3947
4356
  let agentBusy = false;
3948
4357
 
3949
4358
  const isStudioBusy = () => agentBusy || activeRequest !== null;
@@ -4315,7 +4724,17 @@ export default function (pi: ExtensionAPI) {
4315
4724
  }
4316
4725
 
4317
4726
  try {
4318
- 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);
4319
4738
  respondJson(res, 200, { ok: true, html, renderer: "pandoc" });
4320
4739
  } catch (error) {
4321
4740
  const message = error instanceof Error ? error.message : String(error);
@@ -4482,6 +4901,27 @@ export default function (pi: ExtensionAPI) {
4482
4901
  state.port = address.port;
4483
4902
 
4484
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
+
4485
4925
  return state;
4486
4926
  };
4487
4927
 
@@ -4630,6 +5070,11 @@ export default function (pi: ExtensionAPI) {
4630
5070
  await ctx.waitForIdle();
4631
5071
  lastCommandCtx = ctx;
4632
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 */ }
4633
5078
 
4634
5079
  const latestAssistant =
4635
5080
  extractLatestAssistantFromEntries(ctx.sessionManager.getBranch())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.2.6",
3
+ "version": "0.4.0",
4
4
  "description": "Browser GUI for structured critique workflows in pi",
5
5
  "type": "module",
6
6
  "license": "MIT",