pi-studio 0.9.32 → 0.9.34

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
@@ -4,6 +4,27 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.34] — 2026-07-02
8
+
9
+ ### Added
10
+ - Added a bottom-right Studio theme menu for switching the active Pi theme without returning to the terminal.
11
+ - Added `Cmd/Ctrl+Alt+1–7` shortcuts for switching directly between all right-pane views, with editor-only views warning on unavailable full-session panes.
12
+ - Added refreshed README screenshots plus a reproducible gradient-descent screenshot fixture under `assets/demo/`.
13
+
14
+ ## [0.9.33] — 2026-06-16
15
+
16
+ ### Added
17
+ - Added `Cmd/Ctrl+Alt+E` to switch the right pane directly to Editor Preview.
18
+
19
+ ### Changed
20
+ - Calmed annotation and comment affordance styling so annotations read more like highlighted feedback and less like heavy badges.
21
+ - Slightly muted active Stop button styling while keeping it in the theme error colour family.
22
+ - Derived PDF annotation colours from the active theme accent hue while keeping a light, print-friendly background.
23
+
24
+ ### Fixed
25
+ - Preserved the Working view scroll position when switching between `All`, `Thinking`, and `Tools` filters unless the view was already pinned to the bottom.
26
+ - Rendered standalone long `[an: ...]` annotations correctly in LaTeX-mode PDF exports even when Pandoc wraps the escaped marker across generated LaTeX lines.
27
+
7
28
  ## [0.9.32] — 2026-06-10
8
29
 
9
30
  ### Added
package/README.md CHANGED
@@ -6,23 +6,33 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
6
6
 
7
7
  [Watch the 2-minute demo (MP4, 2x speed, no audio)](https://github.com/omaclaren/pi-studio/releases/latest/download/pi-studio-demo-2min.mp4)
8
8
 
9
+ _The video shows an earlier version of the Studio interface. The basic workflow is the same, but there have been minor UI changes since it was recorded; the screenshots show the more recent version._
10
+
9
11
  ## Screenshots
10
12
 
11
- **Dark**
13
+ **Dark workspace**
12
14
 
13
15
  ![Pi Studio workspace (dark)](./assets/screenshots/dark-workspace.png)
14
16
 
15
- **Light**
17
+ **Light workspace**
16
18
 
17
19
  ![Pi Studio workspace (light)](./assets/screenshots/light-workspace.png)
18
20
 
21
+ **Zen mode**
22
+
23
+ ![Pi Studio Zen mode (dark)](./assets/screenshots/dark-zen.png)
24
+
25
+ **Zen mode with a custom theme and rendered code preview**
26
+
27
+ ![Pi Studio Zen mode with custom theme and rendered code preview](./assets/screenshots/theme-zen-code.png)
28
+
19
29
  ## What it does
20
30
 
21
31
  - Opens a two-pane browser workspace: **Editor** (left) + **Response/Working/Editor Preview** (right)
22
32
  - Supports one canonical full Studio view per Pi session, plus additional editor-only companion views when you want extra editing/preview surfaces; editor-only views can also browse files and use the Studio REPL send controls without taking over the full Studio session view
23
33
  - Includes a global **Zen** mode for hiding secondary Studio chrome without changing the current left/right pane layout
24
34
  - Runs editor text directly, asks for structured critique (auto/writing/code focus), offers a manual **Suggest completion** action for short cursor-aware continuations (`Option/Alt+Tab` where available or `Cmd/Ctrl+Shift+Space` from the editor, `Tab` to insert a visible suggestion) with an optional editor-plus-latest-response context mode, or opens **Quiz me** for a Studio-native active-recall loop over the current editor text, selection, current file, folder, or repo, with optional focus guidance for shaping question selection
25
- - Includes a live **Working** view for following current model/tool activity, with `All` / `Thinking` / `Tools` filters, image previews for image-producing tool outputs, plus **Load visible into editor** and **Copy visible** actions; when cycling response history, Working follows saved working details for the selected response when available, and `Cmd/Ctrl+Alt+W` switches the right pane directly to Working
35
+ - Includes a live **Working** view for following current model/tool activity, with `All` / `Thinking` / `Tools` filters, image previews for image-producing tool outputs, plus **Load visible into editor** and **Copy visible** actions; when cycling response history, Working follows saved working details for the selected response when available, and `Cmd/Ctrl+Alt+1–7` switches directly between right-pane views while `Cmd/Ctrl+Alt+P` / `Cmd/Ctrl+Alt+E` / `Cmd/Ctrl+Alt+W` keep quick mnemonic shortcuts for Response Preview, Editor Preview, and Working
26
36
  - Includes a right-pane **Changes** view for browsing the current git diff by file, previewing per-file diffs, opening changed files, loading the full diff into the editor, and copying the diff
27
37
  - Includes a right-pane **Files** view for browsing the current Pi session/resource directory, opening folders, opening the Files root in Finder/the system file manager, loading text/code/CSV/TSV documents into the editor, previewing PDFs/images, opening PDF/image previews in a new Studio tab, converting DOCX/ODT documents to editable Markdown when Pandoc is available after confirmation, copying paths, setting the current folder as the Studio working directory, and revealing files in the file manager
28
38
  - Includes an optional tmux-backed **REPL** view for Shell, Python, IPython, Julia, R, GHCi, and Clojure sessions, with Raw/Literate send modes, `Cmd/Ctrl+Shift+Enter` **Send to REPL**, session start/stop/interrupt controls, a compact refresh-persistent **Studio REPL Record** of user and Pi-sent code, a secondary raw tmux mirror, agent-facing `studio_repl_status` / `studio_repl_send` tools, and Markdown/PDF/HTML export
@@ -46,7 +56,7 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
46
56
  - Ships optional `pi-studio-dark` and `pi-studio-light` themes tuned for Studio's browser workspace
47
57
  - Exports right-pane preview as PDF (pandoc + LaTeX) or standalone HTML into the source file directory, Studio working directory, or Pi session directory; PDF export can open in a Studio preview tab or the default PDF viewer, and HTML export can open in the default browser or in a new Studio editor tab for inspection/commenting, while preserving authored HTML previews as HTML and rendering CSV/TSV editor previews as tables
48
58
  - Exports local files headlessly via `/studio-pdf <path>` to `<name>.studio.pdf` or `/studio-html <path>` to `<name>.studio.html`; without a path, those commands export the last model response to a timestamped file. Agent tools `studio_export_pdf` and `studio_export_html` expose the same export pipeline for remote/Telegram-style sessions.
49
- - Shows model/session/context usage in the footer, plus a compact-context action
59
+ - Shows model/session/context usage in the footer, plus compact-context and active-theme controls
50
60
 
51
61
  ## Commands
52
62
 
@@ -0,0 +1,17 @@
1
+ # pi Studio screenshot demo
2
+
3
+ This directory contains the source document and generated figures used for pi Studio README screenshots.
4
+
5
+ Regenerate figures from the repository root:
6
+
7
+ ```bash
8
+ python3 assets/demo/gradient_descent_demo.py
9
+ ```
10
+
11
+ Open the Markdown fixture in Studio:
12
+
13
+ ```bash
14
+ /studio assets/demo/gradient-descent-demo.md
15
+ ```
16
+
17
+ Then switch the right pane to **Editor (Preview)** and capture dark/light screenshots as needed.
@@ -0,0 +1,80 @@
1
+ # Gradient descent on a non-convex surface
2
+
3
+ This is a compact **pi Studio** demo mixing prose, display math, generated figures, Python code, and inline annotations.
4
+
5
+ ## 1. The objective
6
+
7
+ Consider the function
8
+
9
+ $$
10
+ f(x,y) = 0.35x^2 + 1.2y^2 + 0.8\sin(1.5x)\cos(2y).
11
+ $$
12
+
13
+ It is smooth, but not purely quadratic, so the sinusoidal term creates local ripples [an: define formally] in the loss landscape.
14
+
15
+ The gradient is
16
+
17
+ $$
18
+ \nabla f(x,y) =
19
+ \begin{pmatrix}
20
+ 0.7x + 1.2\cos(1.5x)\cos(2y) \\
21
+ 2.4y - 1.6\sin(1.5x)\sin(2y)
22
+ \end{pmatrix}.
23
+ $$
24
+
25
+ Starting from an initial point $p_0 = (x_0, y_0)$, gradient descent updates via
26
+
27
+ $$
28
+ p_{k+1} = p_k - \eta \, \nabla f(p_k),
29
+ $$
30
+
31
+ where $\eta > 0$ is the learning rate.
32
+
33
+ ## 2. Optimization path in parameter space
34
+
35
+ The contour plot below shows the objective landscape together with the iterates of gradient descent. [an: did you actually run?]
36
+
37
+ ![Gradient descent contour plot](generated/gradient_descent_contour.png){ width=420px }
38
+
39
+ The red path shows how the algorithm follows the local slope downhill. Because the surface is non-convex, the geometry is more interesting than a simple bowl-shaped quadratic.
40
+
41
+ ## 3. Objective value by iteration
42
+
43
+ The next figure shows the scalar objective value $f(x_k,y_k)$ as the method runs.
44
+
45
+ ![Gradient descent convergence plot](generated/gradient_descent_convergence.png){ width=400px }
46
+
47
+ A simple first-order method can still make steady progress when the step size is chosen reasonably.
48
+
49
+ ## 4. Minimal Python snippet
50
+
51
+ ```python
52
+ import numpy as np
53
+
54
+ def f(x, y):
55
+ return 0.35 * x**2 + 1.2 * y**2 + 0.8 * np.sin(1.5 * x) * np.cos(2.0 * y)
56
+
57
+ def grad(x, y):
58
+ dfdx = 0.7 * x + 1.2 * np.cos(1.5 * x) * np.cos(2.0 * y)
59
+ dfdy = 2.4 * y - 1.6 * np.sin(1.5 * x) * np.sin(2.0 * y)
60
+ return np.array([dfdx, dfdy])
61
+
62
+ p = np.array([-3.4, 2.8], dtype=float)
63
+ eta = 0.14
64
+ for _ in range(30):
65
+ p = p - eta * grad(*p)
66
+ ```
67
+
68
+ ## 5. Why this works well as a Studio demo
69
+
70
+ - the math is recognizable but not too heavy
71
+ - the figures are visually appealing
72
+ - the code is short and readable
73
+ - the preview shows equations, prose, code, images, annotations, and outline navigation together in one place
74
+
75
+ Demo assets:
76
+
77
+ - `assets/demo/gradient-descent-demo.md`
78
+ - `assets/demo/gradient_descent_demo.py`
79
+ - `assets/demo/generated/gradient_descent_contour.png`
80
+ - `assets/demo/generated/gradient_descent_convergence.png`
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env python3
2
+ """Generate figures for the pi Studio README demo.
3
+
4
+ Run from the repository root:
5
+
6
+ python3 assets/demo/gradient_descent_demo.py
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import matplotlib.pyplot as plt
15
+
16
+
17
+ OUTPUT_DIR = Path(__file__).resolve().parent / "generated"
18
+
19
+
20
+ def f(x: np.ndarray | float, y: np.ndarray | float) -> np.ndarray | float:
21
+ return 0.35 * x**2 + 1.2 * y**2 + 0.8 * np.sin(1.5 * x) * np.cos(2.0 * y)
22
+
23
+
24
+ def grad(x: float, y: float) -> np.ndarray:
25
+ dfdx = 0.7 * x + 1.2 * np.cos(1.5 * x) * np.cos(2.0 * y)
26
+ dfdy = 2.4 * y - 1.6 * np.sin(1.5 * x) * np.sin(2.0 * y)
27
+ return np.array([dfdx, dfdy])
28
+
29
+
30
+ def run_gradient_descent(start: tuple[float, float] = (-3.4, 2.8), eta: float = 0.14, steps: int = 30) -> np.ndarray:
31
+ path = [np.array(start, dtype=float)]
32
+ p = path[0].copy()
33
+ for _ in range(steps):
34
+ p = p - eta * grad(float(p[0]), float(p[1]))
35
+ path.append(p.copy())
36
+ return np.vstack(path)
37
+
38
+
39
+ def save_contour_plot(path: np.ndarray) -> None:
40
+ xs = np.linspace(-4.5, 4.5, 260)
41
+ ys = np.linspace(-3.4, 3.4, 220)
42
+ X, Y = np.meshgrid(xs, ys)
43
+ Z = f(X, Y)
44
+
45
+ fig, ax = plt.subplots(figsize=(6.2, 4.6), dpi=160)
46
+ levels = np.linspace(float(Z.min()), float(Z.max()), 34)
47
+ contour = ax.contourf(X, Y, Z, levels=levels, cmap="viridis")
48
+ ax.contour(X, Y, Z, levels=levels[::2], colors="white", linewidths=0.35, alpha=0.45)
49
+ ax.plot(path[:, 0], path[:, 1], color="#ff6b6b", linewidth=2.0, marker="o", markersize=2.2)
50
+ ax.scatter(path[0, 0], path[0, 1], s=36, color="#ffd166", edgecolor="black", linewidth=0.5, label="start", zorder=5)
51
+ ax.scatter(path[-1, 0], path[-1, 1], s=36, color="#06d6a0", edgecolor="black", linewidth=0.5, label="final", zorder=5)
52
+ ax.set_title("Gradient descent on a non-convex surface", fontsize=10)
53
+ ax.set_xlabel("x")
54
+ ax.set_ylabel("y")
55
+ ax.legend(loc="upper right", frameon=True, fontsize=7)
56
+ fig.colorbar(contour, ax=ax, label="f(x, y)")
57
+ fig.tight_layout()
58
+ fig.savefig(OUTPUT_DIR / "gradient_descent_contour.png", bbox_inches="tight")
59
+ plt.close(fig)
60
+
61
+
62
+ def save_convergence_plot(path: np.ndarray) -> None:
63
+ values = np.array([f(float(x), float(y)) for x, y in path])
64
+ iterations = np.arange(len(values))
65
+
66
+ fig, ax = plt.subplots(figsize=(5.8, 3.4), dpi=160)
67
+ ax.plot(iterations, values, color="#2a9d8f", linewidth=2.2, marker="o", markersize=3)
68
+ ax.set_title("Objective value by iteration", fontsize=10)
69
+ ax.set_xlabel("iteration")
70
+ ax.set_ylabel("f(x_k, y_k)")
71
+ ax.grid(True, color="#d0d7de", linewidth=0.7, alpha=0.8)
72
+ ax.margins(x=0.02)
73
+ fig.tight_layout()
74
+ fig.savefig(OUTPUT_DIR / "gradient_descent_convergence.png", bbox_inches="tight")
75
+ plt.close(fig)
76
+
77
+
78
+ def main() -> None:
79
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
80
+ path = run_gradient_descent()
81
+ save_contour_plot(path)
82
+ save_convergence_plot(path)
83
+ print(f"Wrote {OUTPUT_DIR / 'gradient_descent_contour.png'}")
84
+ print(f"Wrote {OUTPUT_DIR / 'gradient_descent_convergence.png'}")
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()
Binary file
@@ -8,6 +8,8 @@
8
8
  const footerMetaTerminalEl = document.getElementById("footerMetaTerminal");
9
9
  const footerMetaContextEl = document.getElementById("footerMetaContext");
10
10
  const footerModelMenuEl = document.getElementById("footerModelMenu");
11
+ const footerThemeBtn = document.getElementById("footerThemeBtn");
12
+ const footerThemeMenuEl = document.getElementById("footerThemeMenu");
11
13
  let faviconLinkEl = document.querySelector('link[rel="icon"], link[rel="shortcut icon"]');
12
14
  if (!faviconLinkEl) {
13
15
  faviconLinkEl = document.createElement("link");
@@ -178,6 +180,32 @@
178
180
  : "full";
179
181
  const isEditorOnlyMode = studioMode === "editor-only";
180
182
  const isSshStudioSession = Boolean(document.body && document.body.dataset && document.body.dataset.sshSession === "1");
183
+ const EDITOR_ONLY_RIGHT_VIEW_ALLOWED = new Set(["editor-preview", "files", "changes", "repl"]);
184
+ const RIGHT_VIEW_LABELS = {
185
+ markdown: "Response (Raw)",
186
+ preview: "Response (Preview)",
187
+ "editor-preview": "Editor (Preview)",
188
+ trace: "Working",
189
+ changes: "Changes",
190
+ files: "Files",
191
+ repl: "REPL",
192
+ };
193
+ const RIGHT_VIEW_NUMERIC_SHORTCUTS = {
194
+ Digit1: "markdown",
195
+ Digit2: "preview",
196
+ Digit3: "editor-preview",
197
+ Digit4: "trace",
198
+ Digit5: "changes",
199
+ Digit6: "files",
200
+ Digit7: "repl",
201
+ Numpad1: "markdown",
202
+ Numpad2: "preview",
203
+ Numpad3: "editor-preview",
204
+ Numpad4: "trace",
205
+ Numpad5: "changes",
206
+ Numpad6: "files",
207
+ Numpad7: "repl",
208
+ };
181
209
 
182
210
  const initialQueryParams = new URLSearchParams(window.location.search || "");
183
211
  const skipInitialWorkspaceRestore = initialQueryParams.get("skipWorkspaceRestore") === "1";
@@ -242,9 +270,9 @@
242
270
  let sourceOpenCurrentTextCopyTabBtn = null;
243
271
  let sourceSessionSummaryEl = null;
244
272
  let initialDocumentApplied = false;
245
- function normalizeRightViewValue(nextView) {
273
+ function canonicalRightViewValue(nextView) {
246
274
  const raw = String(nextView || "").trim();
247
- const normalized = raw === "preview"
275
+ return raw === "preview"
248
276
  ? "preview"
249
277
  : (raw === "editor-preview"
250
278
  ? "editor-preview"
@@ -255,22 +283,39 @@
255
283
  : (raw === "changes"
256
284
  ? "changes"
257
285
  : ((raw === "trace" || raw === "thinking") ? "trace" : "markdown")))));
258
- if (isEditorOnlyMode && normalized !== "editor-preview" && normalized !== "files" && normalized !== "changes" && normalized !== "repl") {
286
+ }
287
+
288
+ function normalizeRightViewValue(nextView) {
289
+ const normalized = canonicalRightViewValue(nextView);
290
+ if (isEditorOnlyMode && !EDITOR_ONLY_RIGHT_VIEW_ALLOWED.has(normalized)) {
259
291
  return "editor-preview";
260
292
  }
261
293
  return normalized;
262
294
  }
263
295
 
296
+ function isRightViewAvailableInCurrentMode(view) {
297
+ const normalized = canonicalRightViewValue(view);
298
+ return !isEditorOnlyMode || EDITOR_ONLY_RIGHT_VIEW_ALLOWED.has(normalized);
299
+ }
300
+
301
+ function getRightViewDisplayLabel(view) {
302
+ const normalized = canonicalRightViewValue(view);
303
+ if (rightViewSelect && rightViewSelect.options) {
304
+ const option = Array.from(rightViewSelect.options).find((candidate) => candidate && candidate.value === normalized);
305
+ if (option && option.textContent) return option.textContent;
306
+ }
307
+ return RIGHT_VIEW_LABELS[normalized] || normalized;
308
+ }
309
+
264
310
  function syncRightViewModeOptions() {
265
311
  if (!rightViewSelect || !rightViewSelect.options) return;
266
- const editorOnlyAllowed = new Set(["editor-preview", "files", "changes", "repl"]);
267
312
  Array.from(rightViewSelect.options).forEach((option) => {
268
313
  if (!option) return;
269
- option.disabled = isEditorOnlyMode && !editorOnlyAllowed.has(option.value);
314
+ option.disabled = isEditorOnlyMode && !EDITOR_ONLY_RIGHT_VIEW_ALLOWED.has(option.value);
270
315
  });
271
316
  rightViewSelect.title = isEditorOnlyMode
272
- ? "Editor-only views: editor preview, Changes, Files, or REPL. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+P switches directly to Preview."
273
- : "Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+P switches directly to Preview; Cmd/Ctrl+Alt+W switches directly to Working.";
317
+ ? "Editor-only views: Editor Preview, Changes, Files, or REPL. F7 cycles; Cmd/Ctrl+Alt+3/5/6/7 switch directly to available right-pane views."
318
+ : "Right pane view mode. F7 cycles; Cmd/Ctrl+Alt+1–7 switches directly between all right-pane views. Cmd/Ctrl+Alt+P/E/W keep their mnemonic Preview/Editor Preview/Working shortcuts.";
274
319
  }
275
320
 
276
321
  function getInitialRightView(source) {
@@ -528,6 +573,9 @@
528
573
  let piCurrentModel = null;
529
574
  let piThinkingLevel = "";
530
575
  let footerModelMenuOpen = false;
576
+ let piThemeOptions = [];
577
+ let piCurrentTheme = (document.body && document.body.dataset && document.body.dataset.themeName) || "";
578
+ let footerThemeMenuOpen = false;
531
579
  let terminalSessionLabel = (document.body && document.body.dataset && document.body.dataset.terminalLabel) || "unknown";
532
580
  let terminalSessionDetail = (document.body && document.body.dataset && document.body.dataset.terminalDetail) || terminalSessionLabel;
533
581
  let contextTokens = null;
@@ -859,7 +907,7 @@
859
907
  const normalized = normalizeTraceFilter(nextFilter);
860
908
  if (traceFilter === normalized) return;
861
909
  traceFilter = normalized;
862
- traceAutoScroll = true;
910
+ traceAutoScroll = rightView === "trace" ? shouldStickTraceToBottom() : traceAutoScroll;
863
911
  renderTraceViewIfActive();
864
912
  }
865
913
 
@@ -3273,6 +3321,7 @@
3273
3321
 
3274
3322
  function setFooterModelMenuOpen(open) {
3275
3323
  footerModelMenuOpen = Boolean(open);
3324
+ if (footerModelMenuOpen) setFooterThemeMenuOpen(false);
3276
3325
  if (footerModelMenuEl) footerModelMenuEl.hidden = !footerModelMenuOpen;
3277
3326
  if (footerMetaModelEl) {
3278
3327
  footerMetaModelEl.classList.toggle("is-open", footerModelMenuOpen);
@@ -3298,6 +3347,80 @@
3298
3347
  if (sent) setStatus("Setting Pi thinking level…", "warning");
3299
3348
  }
3300
3349
 
3350
+ function normalizePiThemeOptions(options) {
3351
+ return Array.isArray(options)
3352
+ ? options.map((option) => ({
3353
+ name: String(option && option.name || "").trim(),
3354
+ path: String(option && option.path || "").trim(),
3355
+ })).filter((option) => option.name)
3356
+ : [];
3357
+ }
3358
+
3359
+ function updatePiThemeState(message) {
3360
+ if (!message || typeof message !== "object") return;
3361
+ if (Array.isArray(message.piThemes)) {
3362
+ piThemeOptions = normalizePiThemeOptions(message.piThemes);
3363
+ }
3364
+ if (message.currentTheme && typeof message.currentTheme === "object") {
3365
+ const name = String(message.currentTheme.name || "").trim();
3366
+ if (name) piCurrentTheme = name;
3367
+ } else if (typeof message.currentTheme === "string" && message.currentTheme.trim()) {
3368
+ piCurrentTheme = message.currentTheme.trim();
3369
+ }
3370
+ updateFooterThemeButton();
3371
+ renderFooterThemeMenu();
3372
+ }
3373
+
3374
+ function updateFooterThemeButton() {
3375
+ if (!footerThemeBtn) return;
3376
+ const label = piCurrentTheme && piCurrentTheme.trim() ? piCurrentTheme.trim() : "theme";
3377
+ footerThemeBtn.textContent = "Theme: " + label;
3378
+ footerThemeBtn.title = "Switch the active Pi theme. Current theme: " + label + ". This affects terminal Pi and Studio.";
3379
+ footerThemeBtn.setAttribute("aria-expanded", footerThemeMenuOpen ? "true" : "false");
3380
+ }
3381
+
3382
+ function renderFooterThemeMenu() {
3383
+ if (!footerThemeMenuEl) return;
3384
+ const current = piCurrentTheme && piCurrentTheme.trim() ? piCurrentTheme.trim() : "";
3385
+ const optionNames = new Set(piThemeOptions.map((option) => option.name));
3386
+ const options = piThemeOptions.slice();
3387
+ if (current && !optionNames.has(current)) {
3388
+ options.unshift({ name: current, path: "" });
3389
+ }
3390
+ const optionsHtml = options.map((option) => {
3391
+ const selected = option.name === current ? " selected" : "";
3392
+ const title = option.path ? " title='" + escapeHtml(option.path) + "'" : "";
3393
+ return "<option value='" + escapeHtml(option.name) + "'" + selected + title + ">" + escapeHtml(option.name) + "</option>";
3394
+ });
3395
+ footerThemeMenuEl.innerHTML = ""
3396
+ + "<div class='footer-model-menu-heading'>Pi theme</div>"
3397
+ + (optionsHtml.length
3398
+ ? "<label class='footer-model-menu-field'><span>Active theme</span><select id='footerPiThemeSelect'>" + optionsHtml.join("") + "</select></label>"
3399
+ : "<div class='footer-model-menu-note'>No Pi themes are available yet.</div>")
3400
+ + "<div class='footer-model-menu-note'>Switches the active Pi theme and persists it to Pi settings.</div>";
3401
+ }
3402
+
3403
+ function setFooterThemeMenuOpen(open) {
3404
+ footerThemeMenuOpen = Boolean(open);
3405
+ if (footerThemeMenuOpen) setFooterModelMenuOpen(false);
3406
+ if (footerThemeMenuEl) footerThemeMenuEl.hidden = !footerThemeMenuOpen;
3407
+ if (footerThemeBtn) {
3408
+ footerThemeBtn.classList.toggle("is-open", footerThemeMenuOpen);
3409
+ footerThemeBtn.setAttribute("aria-expanded", footerThemeMenuOpen ? "true" : "false");
3410
+ }
3411
+ if (footerThemeMenuOpen) renderFooterThemeMenu();
3412
+ }
3413
+
3414
+ function requestPiThemeSelection(themeName) {
3415
+ const normalized = String(themeName || "").trim();
3416
+ if (!normalized) {
3417
+ setStatus("No Pi theme selected.", "warning");
3418
+ return;
3419
+ }
3420
+ const sent = sendMessage({ type: "pi_theme_select_request", theme: normalized });
3421
+ if (sent) setStatus("Switching Pi theme…", "warning");
3422
+ }
3423
+
3301
3424
  function updateFooterMeta() {
3302
3425
  const modelText = modelLabel && modelLabel.trim() ? modelLabel.trim() : "none";
3303
3426
  const terminalText = terminalSessionLabel && terminalSessionLabel.trim() ? terminalSessionLabel.trim() : "unknown";
@@ -3795,26 +3918,33 @@
3795
3918
  setStatus("Right pane content focused.");
3796
3919
  }
3797
3920
 
3798
- function switchRightPaneToPrimaryPreview() {
3799
- const targetView = isEditorOnlyMode ? "editor-preview" : "preview";
3921
+ function switchRightPaneToView(nextView, options) {
3922
+ const requestedView = canonicalRightViewValue(nextView);
3923
+ const label = getRightViewDisplayLabel(requestedView);
3924
+ if (!isRightViewAvailableInCurrentMode(requestedView)) {
3925
+ setStatus(label + " is unavailable in editor-only Studio views.", "warning");
3926
+ return false;
3927
+ }
3800
3928
  const snapshot = snapshotStudioScrollablePositions();
3801
- setRightView(targetView);
3929
+ setRightView(requestedView);
3802
3930
  scheduleStudioScrollablePositionRestore(snapshot);
3803
- const label = rightViewSelect && rightViewSelect.selectedOptions && rightViewSelect.selectedOptions[0]
3804
- ? rightViewSelect.selectedOptions[0].textContent
3805
- : (isEditorOnlyMode ? "Editor (Preview)" : "Response (Preview)");
3806
- setStatus("Right pane view: " + String(label || "Preview") + ".");
3931
+ if (!options || options.announce !== false) {
3932
+ setStatus("Right pane view: " + label + ".");
3933
+ }
3934
+ return true;
3935
+ }
3936
+
3937
+ function switchRightPaneToPrimaryPreview() {
3938
+ const targetView = isEditorOnlyMode ? "editor-preview" : "preview";
3939
+ switchRightPaneToView(targetView);
3807
3940
  }
3808
3941
 
3809
3942
  function switchRightPaneToWorking() {
3810
- if (isEditorOnlyMode) {
3811
- setStatus("Working view is unavailable in editor-only Studio views.", "warning");
3812
- return;
3813
- }
3814
- const snapshot = snapshotStudioScrollablePositions();
3815
- setRightView("trace");
3816
- scheduleStudioScrollablePositionRestore(snapshot);
3817
- setStatus("Right pane view: Working.");
3943
+ switchRightPaneToView("trace");
3944
+ }
3945
+
3946
+ function switchRightPaneToEditorPreview() {
3947
+ switchRightPaneToView("editor-preview");
3818
3948
  }
3819
3949
 
3820
3950
  function cycleActivePaneView(direction) {
@@ -4115,6 +4245,17 @@
4115
4245
  return;
4116
4246
  }
4117
4247
 
4248
+ const numericRightViewShortcut = (event.metaKey || event.ctrlKey)
4249
+ && event.altKey
4250
+ && !event.shiftKey
4251
+ ? (RIGHT_VIEW_NUMERIC_SHORTCUTS[code] || RIGHT_VIEW_NUMERIC_SHORTCUTS["Digit" + key])
4252
+ : null;
4253
+ if (numericRightViewShortcut) {
4254
+ event.preventDefault();
4255
+ switchRightPaneToView(numericRightViewShortcut);
4256
+ return;
4257
+ }
4258
+
4118
4259
  const isPreviewShortcut = (key.toLowerCase() === "p" || code === "KeyP")
4119
4260
  && (event.metaKey || event.ctrlKey)
4120
4261
  && event.altKey
@@ -4135,6 +4276,16 @@
4135
4276
  return;
4136
4277
  }
4137
4278
 
4279
+ const isEditorPreviewShortcut = (key.toLowerCase() === "e" || code === "KeyE")
4280
+ && (event.metaKey || event.ctrlKey)
4281
+ && event.altKey
4282
+ && !event.shiftKey;
4283
+ if (isEditorPreviewShortcut) {
4284
+ event.preventDefault();
4285
+ switchRightPaneToEditorPreview();
4286
+ return;
4287
+ }
4288
+
4138
4289
  const isContentFocusShortcut = key === "F8" && !event.metaKey && !event.ctrlKey && !event.altKey;
4139
4290
  if (isContentFocusShortcut) {
4140
4291
  event.preventDefault();
@@ -18810,6 +18961,7 @@
18810
18961
  updateCompletionSuggestionModelOptions(message.suggestionModels);
18811
18962
  }
18812
18963
  updatePiSessionModelState(message);
18964
+ updatePiThemeState(message);
18813
18965
  if (typeof message.terminalSessionLabel === "string") {
18814
18966
  terminalSessionLabel = message.terminalSessionLabel;
18815
18967
  }
@@ -19386,6 +19538,7 @@
19386
19538
  updateCompletionSuggestionModelOptions(message.suggestionModels);
19387
19539
  }
19388
19540
  updatePiSessionModelState(message);
19541
+ updatePiThemeState(message);
19389
19542
  if (typeof message.terminalSessionLabel === "string") {
19390
19543
  terminalSessionLabel = message.terminalSessionLabel;
19391
19544
  }
@@ -19519,6 +19672,7 @@
19519
19672
  root.style.setProperty(key, message.vars[key]);
19520
19673
  }
19521
19674
  });
19675
+ updatePiThemeState(message);
19522
19676
  updateDocumentTitle();
19523
19677
  }
19524
19678
  }
@@ -20266,6 +20420,7 @@
20266
20420
  closeExportPreviewMenu();
20267
20421
  closePreviewLinkMenu();
20268
20422
  setFooterModelMenuOpen(false);
20423
+ setFooterThemeMenuOpen(false);
20269
20424
  }
20270
20425
  });
20271
20426
 
@@ -20292,11 +20447,36 @@
20292
20447
  }
20293
20448
  });
20294
20449
  }
20450
+ if (footerThemeBtn) {
20451
+ footerThemeBtn.addEventListener("click", (event) => {
20452
+ event.preventDefault();
20453
+ event.stopPropagation();
20454
+ setFooterThemeMenuOpen(!footerThemeMenuOpen);
20455
+ });
20456
+ }
20457
+ if (footerThemeMenuEl) {
20458
+ footerThemeMenuEl.addEventListener("click", (event) => {
20459
+ event.stopPropagation();
20460
+ });
20461
+ footerThemeMenuEl.addEventListener("change", (event) => {
20462
+ const target = event.target;
20463
+ if (!(target instanceof HTMLSelectElement)) return;
20464
+ if (target.id === "footerPiThemeSelect") {
20465
+ requestPiThemeSelection(target.value);
20466
+ setFooterThemeMenuOpen(false);
20467
+ }
20468
+ });
20469
+ }
20295
20470
  document.addEventListener("click", (event) => {
20296
20471
  const target = event.target;
20297
20472
  if (target instanceof Element && (target.closest("#footerModelMenu") || target.closest("#footerMetaModel"))) return;
20298
20473
  setFooterModelMenuOpen(false);
20299
20474
  });
20475
+ document.addEventListener("click", (event) => {
20476
+ const target = event.target;
20477
+ if (target instanceof Element && (target.closest("#footerThemeMenu") || target.closest("#footerThemeBtn"))) return;
20478
+ setFooterThemeMenuOpen(false);
20479
+ });
20300
20480
 
20301
20481
  saveAsBtn.addEventListener("click", () => {
20302
20482
  const content = sourceTextEl.value;
package/client/studio.css CHANGED
@@ -210,15 +210,17 @@
210
210
 
211
211
  #sendRunBtn.request-stop-active,
212
212
  #critiqueBtn.request-stop-active {
213
- background: var(--error);
214
- border-color: var(--error);
213
+ background: color-mix(in srgb, var(--error) 82%, var(--panel));
214
+ border-color: color-mix(in srgb, var(--error) 88%, var(--panel));
215
215
  color: var(--error-contrast);
216
- font-weight: 600;
216
+ font-weight: 700;
217
217
  }
218
218
 
219
219
  #sendRunBtn.request-stop-active:not(:disabled):hover,
220
220
  #critiqueBtn.request-stop-active:not(:disabled):hover {
221
- filter: brightness(0.95);
221
+ background: var(--error);
222
+ border-color: var(--error);
223
+ filter: none;
222
224
  }
223
225
 
224
226
  .file-label {
@@ -930,17 +932,17 @@
930
932
  padding: 0 10px;
931
933
  font-size: 12px;
932
934
  line-height: 1;
933
- background: var(--accent-soft);
934
- border-color: var(--accent);
935
- color: var(--accent);
935
+ background: color-mix(in srgb, var(--panel) 88%, var(--accent-soft));
936
+ border-color: var(--control-border);
937
+ color: var(--text);
936
938
  box-shadow: 0 1px 2px var(--shadow-color);
937
939
  }
938
940
 
939
941
  .editor-selection-action-btn:hover,
940
942
  .editor-selection-action-btn:focus-visible {
941
- background: var(--accent-soft-strong);
943
+ background: var(--accent-soft);
942
944
  color: var(--accent);
943
- border-color: var(--accent);
945
+ border-color: color-mix(in srgb, var(--accent) 45%, var(--control-border));
944
946
  }
945
947
 
946
948
  .editor-selection-actions[hidden],
@@ -1043,12 +1045,12 @@
1043
1045
  }
1044
1046
 
1045
1047
  .hl-annotation {
1046
- color: var(--accent);
1047
- background: var(--accent-soft);
1048
+ color: color-mix(in srgb, var(--accent) 72%, var(--text));
1049
+ background: color-mix(in srgb, var(--accent-soft) 58%, transparent);
1048
1050
  border: 0;
1049
- border-radius: 4px;
1051
+ border-radius: 3px;
1050
1052
  padding: 0;
1051
- box-shadow: inset 0 0 0 1px var(--marker-border);
1053
+ box-shadow: inset 0 -0.08em 0 color-mix(in srgb, var(--accent) 26%, transparent);
1052
1054
  }
1053
1055
 
1054
1056
  .hl-annotation-muted {
@@ -1057,10 +1059,10 @@
1057
1059
  }
1058
1060
 
1059
1061
  .annotation-preview-marker {
1060
- color: var(--accent);
1061
- background: var(--accent-soft);
1062
- border: 1px solid var(--marker-border);
1063
- border-radius: 4px;
1062
+ color: color-mix(in srgb, var(--accent) 72%, var(--text));
1063
+ background: color-mix(in srgb, var(--accent-soft) 58%, transparent);
1064
+ border: 1px solid color-mix(in srgb, var(--marker-border) 26%, transparent);
1065
+ border-radius: 3px;
1064
1066
  display: inline-block;
1065
1067
  max-width: 100%;
1066
1068
  padding: 0 4px;
@@ -1068,6 +1070,20 @@
1068
1070
  vertical-align: baseline;
1069
1071
  }
1070
1072
 
1073
+ .annotation-preview-marker strong {
1074
+ color: color-mix(in srgb, var(--accent) 82%, var(--text));
1075
+ font-weight: 750;
1076
+ }
1077
+
1078
+ .annotation-preview-marker em {
1079
+ color: color-mix(in srgb, currentColor 82%, var(--muted));
1080
+ }
1081
+
1082
+ .annotation-preview-marker s {
1083
+ color: color-mix(in srgb, currentColor 70%, var(--muted));
1084
+ text-decoration-thickness: 0.08em;
1085
+ }
1086
+
1071
1087
  .annotation-preview-marker code {
1072
1088
  font-family: var(--mono-font, ui-monospace, SFMono-Regular, Menlo, monospace);
1073
1089
  font-size: 0.95em;
@@ -1216,9 +1232,9 @@
1216
1232
  padding: 0 10px;
1217
1233
  font-size: 12px;
1218
1234
  line-height: 1;
1219
- background: var(--accent-soft);
1220
- border-color: var(--accent);
1221
- color: var(--accent);
1235
+ background: color-mix(in srgb, var(--panel) 88%, var(--accent-soft));
1236
+ border-color: var(--control-border);
1237
+ color: var(--text);
1222
1238
  box-shadow: 0 1px 2px var(--shadow-color);
1223
1239
  pointer-events: auto;
1224
1240
  }
@@ -1227,9 +1243,9 @@
1227
1243
  .preview-comment-add:focus-visible,
1228
1244
  .preview-comment-jump:hover,
1229
1245
  .preview-comment-jump:focus-visible {
1230
- background: var(--accent-soft-strong);
1246
+ background: var(--accent-soft);
1231
1247
  color: var(--accent);
1232
- border-color: var(--accent);
1248
+ border-color: color-mix(in srgb, var(--accent) 45%, var(--control-border));
1233
1249
  }
1234
1250
 
1235
1251
  .preview-jump-highlight {
@@ -4261,11 +4277,10 @@
4261
4277
  outline: none;
4262
4278
  }
4263
4279
 
4264
- .footer-model-menu {
4280
+ .footer-model-menu,
4281
+ .footer-theme-menu {
4265
4282
  position: fixed;
4266
- left: 12px;
4267
4283
  bottom: 38px;
4268
- width: min(440px, calc(100vw - 24px));
4269
4284
  padding: 10px;
4270
4285
  border: 1px solid var(--panel-border);
4271
4286
  border-radius: 12px;
@@ -4275,7 +4290,18 @@
4275
4290
  z-index: 110;
4276
4291
  }
4277
4292
 
4278
- .footer-model-menu[hidden] {
4293
+ .footer-model-menu {
4294
+ left: 12px;
4295
+ width: min(440px, calc(100vw - 24px));
4296
+ }
4297
+
4298
+ .footer-theme-menu {
4299
+ right: 12px;
4300
+ width: min(320px, calc(100vw - 24px));
4301
+ }
4302
+
4303
+ .footer-model-menu[hidden],
4304
+ .footer-theme-menu[hidden] {
4279
4305
  display: none !important;
4280
4306
  }
4281
4307
 
@@ -4332,10 +4358,18 @@
4332
4358
  opacity: 0.72;
4333
4359
  }
4334
4360
 
4335
- .shortcut-hint {
4361
+ .footer-actions {
4336
4362
  grid-area: hint;
4337
4363
  justify-self: end;
4338
4364
  align-self: center;
4365
+ display: inline-flex;
4366
+ align-items: center;
4367
+ justify-content: flex-end;
4368
+ gap: 6px;
4369
+ min-width: 0;
4370
+ }
4371
+
4372
+ .shortcut-hint {
4339
4373
  display: inline-flex;
4340
4374
  align-items: center;
4341
4375
  gap: 6px;
@@ -4350,12 +4384,21 @@
4350
4384
  opacity: 0.9;
4351
4385
  }
4352
4386
 
4387
+ .footer-theme-btn {
4388
+ max-width: min(28ch, 36vw);
4389
+ min-width: 0;
4390
+ overflow: hidden;
4391
+ text-overflow: ellipsis;
4392
+ }
4393
+
4353
4394
  .shortcut-hint:not(:disabled):hover,
4354
- .shortcut-hint:focus-visible {
4395
+ .shortcut-hint:focus-visible,
4396
+ .shortcut-hint.is-open {
4355
4397
  background: var(--panel-2);
4356
4398
  border-color: var(--control-border);
4357
4399
  color: var(--text);
4358
4400
  opacity: 1;
4401
+ outline: none;
4359
4402
  }
4360
4403
 
4361
4404
  .footer-compact-btn {
@@ -4382,7 +4425,7 @@
4382
4425
  }
4383
4426
 
4384
4427
  body.studio-zen-mode .footer-meta,
4385
- body.studio-zen-mode .shortcut-hint {
4428
+ body.studio-zen-mode .footer-actions {
4386
4429
  display: none !important;
4387
4430
  }
4388
4431
 
@@ -4977,8 +5020,12 @@
4977
5020
  max-width: 100%;
4978
5021
  }
4979
5022
 
4980
- .shortcut-hint {
5023
+ .footer-actions {
4981
5024
  justify-self: start;
5025
+ flex-wrap: wrap;
5026
+ }
5027
+
5028
+ .shortcut-hint {
4982
5029
  text-align: left;
4983
5030
  white-space: normal;
4984
5031
  flex-wrap: wrap;
@@ -5779,10 +5826,10 @@
5779
5826
  }
5780
5827
 
5781
5828
  body.studio-ui-refresh .studio-refresh-review-btn.request-stop-active {
5782
- background: var(--error);
5783
- border-color: var(--error);
5829
+ background: color-mix(in srgb, var(--error) 82%, var(--panel));
5830
+ border-color: color-mix(in srgb, var(--error) 88%, var(--panel));
5784
5831
  color: var(--error-contrast);
5785
- font-weight: 600;
5832
+ font-weight: 700;
5786
5833
  }
5787
5834
 
5788
5835
  body.studio-ui-refresh .studio-refresh-review-btn.request-stop-active::after {
@@ -5790,10 +5837,10 @@
5790
5837
  }
5791
5838
 
5792
5839
  body.studio-ui-refresh .studio-refresh-menu #critiqueBtn.request-stop-active {
5793
- background: var(--error);
5794
- border-color: var(--error);
5840
+ background: color-mix(in srgb, var(--error) 82%, var(--panel));
5841
+ border-color: color-mix(in srgb, var(--error) 88%, var(--panel));
5795
5842
  color: var(--error-contrast);
5796
- font-weight: 600;
5843
+ font-weight: 700;
5797
5844
  }
5798
5845
 
5799
5846
  .studio-preview-link-menu {
package/index.ts CHANGED
@@ -361,6 +361,11 @@ interface PiThinkingLevelRequestMessage {
361
361
  level: ModelThinkingLevel;
362
362
  }
363
363
 
364
+ interface PiThemeSelectRequestMessage {
365
+ type: "pi_theme_select_request";
366
+ theme: string;
367
+ }
368
+
364
369
  interface QuizGenerateRequestMessage {
365
370
  type: "quiz_generate_request";
366
371
  requestId: string;
@@ -506,6 +511,7 @@ type IncomingStudioMessage =
506
511
  | CompletionSuggestionCancelRequestMessage
507
512
  | PiModelSelectRequestMessage
508
513
  | PiThinkingLevelRequestMessage
514
+ | PiThemeSelectRequestMessage
509
515
  | QuizGenerateRequestMessage
510
516
  | QuizAnswerRequestMessage
511
517
  | QuizDiscussRequestMessage
@@ -1066,7 +1072,23 @@ function buildStudioPdfCalloutTitleSizeCommand(options?: StudioPdfRenderOptions)
1066
1072
  return "\\footnotesize";
1067
1073
  }
1068
1074
 
1069
- function buildStudioPdfPreamble(options?: StudioPdfRenderOptions, extraPreamble = ""): string {
1075
+ function formatStudioPdfHtmlColor(color: string, fallback: string): string {
1076
+ return hexToRgb(color) ? color.replace(/^#/, "").toUpperCase() : fallback;
1077
+ }
1078
+
1079
+ function buildStudioPdfAnnotationColors(style: StudioThemeStyle): { bg: string; border: string; text: string } {
1080
+ const accent = hexToRgb(style.palette.accent) ? style.palette.accent : "#2F6FEB";
1081
+ const page = "#FFFFFF";
1082
+ const darkText = "#0E1616";
1083
+ return {
1084
+ bg: formatStudioPdfHtmlColor(blendColors(accent, page, 0.91), "F1F7FF"),
1085
+ border: formatStudioPdfHtmlColor(blendColors(accent, page, 0.68), "C7DDFF"),
1086
+ text: formatStudioPdfHtmlColor(blendColors(accent, darkText, style.mode === "light" ? 0.40 : 0.34), "1F5FBF"),
1087
+ };
1088
+ }
1089
+
1090
+ function buildStudioPdfPreamble(options?: StudioPdfRenderOptions, extraPreamble = "", themeStyle?: StudioThemeStyle): string {
1091
+ const annotationColors = buildStudioPdfAnnotationColors(themeStyle ?? getStudioThemeStyle());
1070
1092
  const sectionHeadingSize = buildStudioPdfHeadingSizeCommand(options?.sectionSize, "\\Large");
1071
1093
  const subsectionHeadingSize = buildStudioPdfHeadingSizeCommand(options?.subsectionSize, "\\large");
1072
1094
  const subsubsectionHeadingSize = buildStudioPdfHeadingSizeCommand(options?.subsubsectionSize, "\\normalsize");
@@ -1088,9 +1110,9 @@ function buildStudioPdfPreamble(options?: StudioPdfRenderOptions, extraPreamble
1088
1110
  \\usepackage{xcolor}
1089
1111
  \\usepackage{varwidth}
1090
1112
  \\usepackage[normalem]{ulem}
1091
- \\definecolor{StudioAnnotationBg}{HTML}{EAF3FF}
1092
- \\definecolor{StudioAnnotationBorder}{HTML}{8CB8FF}
1093
- \\definecolor{StudioAnnotationText}{HTML}{1F5FBF}
1113
+ \\definecolor{StudioAnnotationBg}{HTML}{${annotationColors.bg}}
1114
+ \\definecolor{StudioAnnotationBorder}{HTML}{${annotationColors.border}}
1115
+ \\definecolor{StudioAnnotationText}{HTML}{${annotationColors.text}}
1094
1116
  \\definecolor{StudioCodeBlockBg}{HTML}{F6F8FA}
1095
1117
  \\definecolor{StudioDiffAddText}{HTML}{1A7F37}
1096
1118
  \\definecolor{StudioDiffDelText}{HTML}{CF222E}
@@ -6501,9 +6523,20 @@ ${literalPdfConfig.fontSizeCommand}\\section*{${title.replace(/[{}\\]/g, "").tri
6501
6523
  function replaceStudioAnnotationMarkersInGeneratedLatex(latex: string): string {
6502
6524
  const lines = String(latex ?? "").split("\n");
6503
6525
  const out: string[] = [];
6526
+ const pendingTextLines: string[] = [];
6504
6527
  const rawEnvStack: string[] = [];
6505
6528
  const rawEnvNames = new Set(["verbatim", "Verbatim", "Highlighting", "lstlisting"]);
6506
6529
 
6530
+ const hasRawEnvBoundary = (line: string): boolean => {
6531
+ const envPattern = /\\(begin|end)\{([^}]+)\}/g;
6532
+ let match: RegExpExecArray | null;
6533
+ while ((match = envPattern.exec(line)) !== null) {
6534
+ const envName = match[2];
6535
+ if (envName && rawEnvNames.has(envName)) return true;
6536
+ }
6537
+ return false;
6538
+ };
6539
+
6507
6540
  const updateRawEnvStack = (line: string) => {
6508
6541
  const envPattern = /\\(begin|end)\{([^}]+)\}/g;
6509
6542
  let match: RegExpExecArray | null;
@@ -6524,6 +6557,12 @@ function replaceStudioAnnotationMarkersInGeneratedLatex(latex: string): string {
6524
6557
  }
6525
6558
  };
6526
6559
 
6560
+ const flushPendingText = () => {
6561
+ if (pendingTextLines.length === 0) return;
6562
+ out.push(replaceStudioAnnotationMarkersForPdfInSegment(pendingTextLines.join("\n")));
6563
+ pendingTextLines.length = 0;
6564
+ };
6565
+
6527
6566
  for (const line of lines) {
6528
6567
  if (rawEnvStack.length > 0) {
6529
6568
  out.push(line);
@@ -6531,10 +6570,17 @@ function replaceStudioAnnotationMarkersInGeneratedLatex(latex: string): string {
6531
6570
  continue;
6532
6571
  }
6533
6572
 
6534
- out.push(replaceStudioAnnotationMarkersForPdfInSegment(line));
6535
- updateRawEnvStack(line);
6573
+ if (hasRawEnvBoundary(line)) {
6574
+ flushPendingText();
6575
+ out.push(line);
6576
+ updateRawEnvStack(line);
6577
+ continue;
6578
+ }
6579
+
6580
+ pendingTextLines.push(line);
6536
6581
  }
6537
6582
 
6583
+ flushPendingText();
6538
6584
  return out.join("\n");
6539
6585
  }
6540
6586
 
@@ -6727,6 +6773,7 @@ async function renderStudioPdfFromGeneratedLatex(
6727
6773
  alignedImageBlocks: StudioPdfAlignedImageBlock[] = [],
6728
6774
  pdfOptions?: StudioPdfRenderOptions,
6729
6775
  extraPreamble = "",
6776
+ themeStyle?: StudioThemeStyle,
6730
6777
  ): Promise<{ pdf: Buffer; warning?: string }> {
6731
6778
  const tempDir = join(tmpdir(), `pi-studio-pdf-${Date.now()}-${randomUUID()}`);
6732
6779
  const preamblePath = join(tempDir, "_pdf_preamble.tex");
@@ -6734,7 +6781,7 @@ async function renderStudioPdfFromGeneratedLatex(
6734
6781
  const outputPath = join(tempDir, "studio-export.pdf");
6735
6782
 
6736
6783
  await mkdir(tempDir, { recursive: true });
6737
- await writeFile(preamblePath, buildStudioPdfPreamble(pdfOptions, extraPreamble), "utf-8");
6784
+ await writeFile(preamblePath, buildStudioPdfPreamble(pdfOptions, extraPreamble, themeStyle), "utf-8");
6738
6785
 
6739
6786
  const pandocArgs = [
6740
6787
  "-f", inputFormat,
@@ -6805,6 +6852,7 @@ async function renderStudioPdfWithPandoc(
6805
6852
  editorPdfLanguage?: string,
6806
6853
  sourcePath?: string,
6807
6854
  pdfOptions?: StudioPdfRenderOptions,
6855
+ themeStyle?: StudioThemeStyle,
6808
6856
  ): Promise<{ pdf: Buffer; warning?: string }> {
6809
6857
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
6810
6858
  const pdfEngine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
@@ -6842,7 +6890,7 @@ async function renderStudioPdfWithPandoc(
6842
6890
  const outputPath = join(tempDir, "studio-export.pdf");
6843
6891
 
6844
6892
  await mkdir(tempDir, { recursive: true });
6845
- await writeFile(preamblePath, buildStudioPdfPreamble(pdfOptions), "utf-8");
6893
+ await writeFile(preamblePath, buildStudioPdfPreamble(pdfOptions, "", themeStyle), "utf-8");
6846
6894
 
6847
6895
  const hasYamlHeaderIncludesForPdf = inputFormat !== "latex" && hasStudioYamlHeaderIncludes(markdownForPdf);
6848
6896
  const headerIncludeArgs = hasYamlHeaderIncludesForPdf ? [] : ["--include-in-header", preamblePath];
@@ -6895,6 +6943,8 @@ async function renderStudioPdfWithPandoc(
6895
6943
  [],
6896
6944
  [],
6897
6945
  pdfOptions,
6946
+ "",
6947
+ themeStyle,
6898
6948
  );
6899
6949
  }
6900
6950
 
@@ -6915,6 +6965,8 @@ async function renderStudioPdfWithPandoc(
6915
6965
  [],
6916
6966
  [],
6917
6967
  pdfOptions,
6968
+ "",
6969
+ themeStyle,
6918
6970
  );
6919
6971
  } catch {
6920
6972
  const fenced = parseStudioSingleFencedCodeBlock(diffMarkdown);
@@ -6941,7 +6993,7 @@ async function renderStudioPdfWithPandoc(
6941
6993
  const outputPath = join(tempDir, "studio-export.pdf");
6942
6994
 
6943
6995
  await mkdir(tempDir, { recursive: true });
6944
- await writeFile(preamblePath, buildStudioPdfPreamble(pdfOptions, extraPdfPreamble), "utf-8");
6996
+ await writeFile(preamblePath, buildStudioPdfPreamble(pdfOptions, extraPdfPreamble, themeStyle), "utf-8");
6945
6997
 
6946
6998
  const mermaidPrepared: StudioMermaidPdfPreprocessResult = isLatex
6947
6999
  ? { markdown: normalizedMarkdownBody, found: 0, replaced: 0, failed: 0, missingCli: false }
@@ -6964,6 +7016,7 @@ async function renderStudioPdfWithPandoc(
6964
7016
  pdfAlignedImageTransform.blocks,
6965
7017
  pdfOptions,
6966
7018
  extraPdfPreamble,
7019
+ themeStyle,
6967
7020
  );
6968
7021
  await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
6969
7022
  return { pdf: rendered.pdf, warning: mermaidPrepared.warning ?? rendered.warning };
@@ -8378,6 +8431,16 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
8378
8431
  }
8379
8432
  }
8380
8433
 
8434
+ if (msg.type === "pi_theme_select_request" && typeof msg.theme === "string") {
8435
+ const theme = msg.theme.trim();
8436
+ if (theme && theme.length <= 200) {
8437
+ return {
8438
+ type: "pi_theme_select_request",
8439
+ theme,
8440
+ };
8441
+ }
8442
+ }
8443
+
8381
8444
  if (msg.type === "completion_suggestion_request" && typeof msg.requestId === "string" && typeof msg.text === "string") {
8382
8445
  const textLength = msg.text.length;
8383
8446
  const rawStart = typeof msg.selectionStart === "number" && Number.isFinite(msg.selectionStart) ? msg.selectionStart : textLength;
@@ -10230,6 +10293,7 @@ function buildStudioHtml(
10230
10293
  const initialModel = escapeHtmlForInline(initialModelLabel ?? "none");
10231
10294
  const initialTerminal = escapeHtmlForInline(initialTerminalLabel ?? "unknown");
10232
10295
  const initialTerminalDetailAttr = escapeHtmlForInline(initialTerminalDetail ?? initialTerminalLabel ?? "unknown");
10296
+ const initialTheme = escapeHtmlForInline(theme?.name ?? "theme");
10233
10297
  const initialContextTokens =
10234
10298
  typeof initialContextUsage?.tokens === "number" && Number.isFinite(initialContextUsage.tokens)
10235
10299
  ? String(initialContextUsage.tokens)
@@ -10297,7 +10361,7 @@ ${cssVarsBlock}
10297
10361
  </style>
10298
10362
  <link rel="stylesheet" href="${stylesheetHref}" />
10299
10363
  </head>
10300
- <body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-initial-draft-id="${initialDraftId}" data-initial-resource-dir="${initialResourceDir}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}" data-terminal-detail="${initialTerminalDetailAttr}" data-context-tokens="${initialContextTokens}" data-context-window="${initialContextWindow}" data-context-percent="${initialContextPercent}" data-studio-mode="${studioMode}" data-ssh-session="${initialSshSession}">
10364
+ <body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-initial-draft-id="${initialDraftId}" data-initial-resource-dir="${initialResourceDir}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}" data-terminal-detail="${initialTerminalDetailAttr}" data-theme-name="${initialTheme}" data-context-tokens="${initialContextTokens}" data-context-window="${initialContextWindow}" data-context-percent="${initialContextPercent}" data-studio-mode="${studioMode}" data-ssh-session="${initialSshSession}">
10301
10365
  <header>
10302
10366
  <h1><span class="app-logo" aria-hidden="true">π</span> Studio <span class="app-subtitle">${appSubtitle}</span></h1>
10303
10367
  <div class="controls">
@@ -10514,7 +10578,7 @@ ${cssVarsBlock}
10514
10578
  <section id="rightPane">
10515
10579
  <div id="rightSectionHeader" class="section-header">
10516
10580
  <div class="section-header-main">
10517
- <select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+P switches directly to Preview; Cmd/Ctrl+Alt+W switches directly to Working.">
10581
+ <select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+1–7 switches directly between all right-pane views. Cmd/Ctrl+Alt+P/E/W keep their mnemonic Preview/Editor Preview/Working shortcuts.">
10518
10582
  <option value="markdown">Response (Raw)</option>
10519
10583
  <option value="preview" selected>Response (Preview)</option>
10520
10584
  <option value="editor-preview">Editor (Preview)</option>
@@ -10590,7 +10654,11 @@ ${cssVarsBlock}
10590
10654
  <span id="statusLine"><span id="statusSpinner" aria-hidden="true"> </span><span id="status">Booting studio…</span></span>
10591
10655
  <span id="footerMeta" class="footer-meta"><span id="footerMetaText" class="footer-meta-text"><button id="footerMetaModel" class="footer-meta-part footer-meta-model footer-model-btn" type="button" aria-haspopup="menu" aria-expanded="false">${initialModel}</button><span class="footer-meta-sep">·</span><span id="footerMetaTerminal" class="footer-meta-part footer-meta-terminal">${initialTerminal}</span><span class="footer-meta-sep">·</span><span id="footerMetaContext" class="footer-meta-part footer-meta-context">unknown</span></span><button id="compactBtn" class="footer-compact-btn" type="button" title="Trigger pi context compaction now.">Compact</button></span>
10592
10656
  <div id="footerModelMenu" class="footer-model-menu" hidden></div>
10593
- <button id="shortcutsBtn" class="shortcut-hint" type="button" title="Show Studio keyboard shortcuts. Press ? when not editing text.">Shortcuts (?)</button>
10657
+ <div id="footerThemeMenu" class="footer-theme-menu" hidden></div>
10658
+ <span class="footer-actions">
10659
+ <button id="footerThemeBtn" class="shortcut-hint footer-theme-btn" type="button" aria-haspopup="menu" aria-expanded="false" title="Switch the active Pi theme. This affects terminal Pi and Studio.">Theme: ${initialTheme}</button>
10660
+ <button id="shortcutsBtn" class="shortcut-hint" type="button" title="Show Studio keyboard shortcuts. Press ? when not editing text.">Shortcuts (?)</button>
10661
+ </span>
10594
10662
  </footer>
10595
10663
 
10596
10664
  <div id="shortcutsOverlay" class="shortcuts-overlay" hidden>
@@ -10608,7 +10676,9 @@ ${cssVarsBlock}
10608
10676
  <dl>
10609
10677
  <div><dt>F6</dt><dd>Switch between editor and right pane</dd></div>
10610
10678
  <div><dt>F7 / Shift+F7</dt><dd>Cycle the active pane's view</dd></div>
10611
- <div><dt>Cmd/Ctrl+Alt+P</dt><dd>Switch the right pane directly to Preview</dd></div>
10679
+ <div><dt>Cmd/Ctrl+Alt+1–7</dt><dd>Switch the right pane directly: Response Raw, Response Preview, Editor Preview, Working, Changes, Files, REPL</dd></div>
10680
+ <div><dt>Cmd/Ctrl+Alt+P</dt><dd>Switch the right pane directly to Response Preview; in editor-only views, Editor Preview</dd></div>
10681
+ <div><dt>Cmd/Ctrl+Alt+E</dt><dd>Switch the right pane directly to Editor Preview</dd></div>
10612
10682
  <div><dt>Cmd/Ctrl+Alt+W</dt><dd>Switch the right pane directly to Working</dd></div>
10613
10683
  <div><dt>F8</dt><dd>Focus editor text</dd></div>
10614
10684
  <div><dt>Shift+F8</dt><dd>Focus right-pane content</dd></div>
@@ -11722,6 +11792,25 @@ export default function (pi: ExtensionAPI) {
11722
11792
  }
11723
11793
  : null;
11724
11794
 
11795
+ const getStudioThemeOptions = () => {
11796
+ try {
11797
+ const themes = lastCommandCtx?.ui?.getAllThemes?.() ?? [];
11798
+ return themes
11799
+ .map((theme) => ({
11800
+ name: typeof theme.name === "string" ? theme.name.trim() : "",
11801
+ path: typeof theme.path === "string" ? theme.path : undefined,
11802
+ }))
11803
+ .filter((theme) => theme.name);
11804
+ } catch {
11805
+ return [];
11806
+ }
11807
+ };
11808
+
11809
+ const getCurrentStudioThemeDescriptor = () => {
11810
+ const name = typeof lastCommandCtx?.ui?.theme?.name === "string" ? lastCommandCtx.ui.theme.name.trim() : "";
11811
+ return name ? { name } : null;
11812
+ };
11813
+
11725
11814
  const broadcastState = () => {
11726
11815
  terminalSessionLabel = buildTerminalSessionLabel(studioCwd, getSessionNameSafe());
11727
11816
  terminalSessionDetail = buildTerminalSessionDetail(studioCwd, getSessionNameSafe());
@@ -11740,6 +11829,8 @@ export default function (pi: ExtensionAPI) {
11740
11829
  thinkingLevel: getThinkingLevelSafe() ?? "off",
11741
11830
  piModels: modelOptions,
11742
11831
  suggestionModels: modelOptions,
11832
+ currentTheme: getCurrentStudioThemeDescriptor(),
11833
+ piThemes: getStudioThemeOptions(),
11743
11834
  terminalSessionLabel,
11744
11835
  terminalSessionDetail,
11745
11836
  contextTokens: contextUsageSnapshot.tokens,
@@ -12039,6 +12130,8 @@ export default function (pi: ExtensionAPI) {
12039
12130
  thinkingLevel: getThinkingLevelSafe() ?? "off",
12040
12131
  piModels: getStudioModelOptions(),
12041
12132
  suggestionModels: getStudioModelOptions(),
12133
+ currentTheme: getCurrentStudioThemeDescriptor(),
12134
+ piThemes: getStudioThemeOptions(),
12042
12135
  terminalSessionLabel,
12043
12136
  terminalSessionDetail,
12044
12137
  contextTokens: contextUsageSnapshot.tokens,
@@ -12098,6 +12191,36 @@ export default function (pi: ExtensionAPI) {
12098
12191
  return;
12099
12192
  }
12100
12193
 
12194
+ if (msg.type === "pi_theme_select_request") {
12195
+ const ui = lastCommandCtx?.ui;
12196
+ if (!ui || typeof ui.setTheme !== "function") {
12197
+ sendToClient(client, { type: "info", level: "warning", message: "Pi theme controls are not available yet." });
12198
+ return;
12199
+ }
12200
+ const themeName = msg.theme.trim();
12201
+ const availableThemes = getStudioThemeOptions();
12202
+ if (availableThemes.length && !availableThemes.some((theme) => theme.name === themeName)) {
12203
+ sendToClient(client, { type: "info", level: "warning", message: `Pi theme not found: ${themeName}` });
12204
+ return;
12205
+ }
12206
+ try {
12207
+ const result = ui.setTheme(themeName);
12208
+ if (!result.success) {
12209
+ sendToClient(client, { type: "info", level: "error", message: `Theme switch failed: ${result.error ?? themeName}` });
12210
+ return;
12211
+ }
12212
+ const vars = buildThemeCssVars(getStudioThemeStyle(ui.theme));
12213
+ lastThemeVarsJson = JSON.stringify(vars);
12214
+ syncCmuxStudioStatus();
12215
+ broadcast({ type: "theme_update", vars, currentTheme: getCurrentStudioThemeDescriptor(), piThemes: getStudioThemeOptions() });
12216
+ broadcastState();
12217
+ sendToClient(client, { type: "info", level: "info", message: `Pi theme switched to ${themeName}.` });
12218
+ } catch (error) {
12219
+ sendToClient(client, { type: "info", level: "error", message: `Theme switch failed: ${error instanceof Error ? error.message : String(error)}` });
12220
+ }
12221
+ return;
12222
+ }
12223
+
12101
12224
  if (msg.type === "get_latest_response") {
12102
12225
  if (!lastStudioResponse) {
12103
12226
  sendToClient(client, { type: "info", message: "No latest assistant response is available yet." });
@@ -13553,7 +13676,7 @@ export default function (pi: ExtensionAPI) {
13553
13676
  const filename = sanitizePdfFilename(requestedFilename || (isLatex ? "studio-latex-preview.pdf" : "studio-preview.pdf"));
13554
13677
 
13555
13678
  try {
13556
- const { pdf, warning } = await renderStudioPdfWithPandoc(markdown, isLatex, resourcePath, editorPdfLanguage, sourcePath || undefined);
13679
+ const { pdf, warning } = await renderStudioPdfWithPandoc(markdown, isLatex, resourcePath, editorPdfLanguage, sourcePath || undefined, undefined, getStudioThemeStyle(lastCommandCtx?.ui?.theme));
13557
13680
  const writeResult = writeStudioPreviewExportFile(buildStudioPreviewExportPath(sourcePath || undefined, userResourceDir || undefined, studioCwd, filename), pdf);
13558
13681
  const exportId = storePreparedPdfExport(pdf, filename, warning, writeResult.filePath ?? undefined);
13559
13682
  const token = serverState?.token ?? "";
@@ -14287,7 +14410,7 @@ export default function (pi: ExtensionAPI) {
14287
14410
  lastThemeVarsJson = json;
14288
14411
  syncCmuxStudioStatus();
14289
14412
  for (const client of serverState.clients) {
14290
- sendToClient(client, { type: "theme_update", vars });
14413
+ sendToClient(client, { type: "theme_update", vars, currentTheme: getCurrentStudioThemeDescriptor(), piThemes: getStudioThemeOptions() });
14291
14414
  }
14292
14415
  }
14293
14416
  } catch {
@@ -14984,6 +15107,7 @@ export default function (pi: ExtensionAPI) {
14984
15107
  editorPdfLanguage,
14985
15108
  source.sourcePath,
14986
15109
  pdfOptions,
15110
+ getStudioThemeStyle(ctx.ui.theme),
14987
15111
  );
14988
15112
  await writeFile(source.outputPath, pdf);
14989
15113
  const openError = await maybeOpenStudioExportPath(source.outputPath, params.open);
@@ -15296,6 +15420,7 @@ export default function (pi: ExtensionAPI) {
15296
15420
  editorPdfLanguage,
15297
15421
  undefined,
15298
15422
  pdfOptions,
15423
+ getStudioThemeStyle(ctx.ui.theme),
15299
15424
  );
15300
15425
  await writeFile(outputPath, pdf);
15301
15426
 
@@ -15354,6 +15479,7 @@ export default function (pi: ExtensionAPI) {
15354
15479
  editorPdfLanguage,
15355
15480
  file.resolvedPath,
15356
15481
  pdfOptions,
15482
+ getStudioThemeStyle(ctx.ui.theme),
15357
15483
  );
15358
15484
  await writeFile(outputPath, pdf);
15359
15485
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.32",
3
+ "version": "0.9.34",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,7 +25,8 @@
25
25
  "README.md",
26
26
  "CHANGELOG.md",
27
27
  "WORKFLOW.md",
28
- "assets/screenshots"
28
+ "assets/screenshots",
29
+ "assets/demo"
29
30
  ],
30
31
  "scripts": {
31
32
  "test": "node --test",