pi-studio 0.9.33 → 0.9.35

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,18 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.35] — 2026-07-13
8
+
9
+ ### Added
10
+ - Added Files-view sort options for name, newest/oldest modified time, and largest/smallest file size.
11
+
12
+ ## [0.9.34] — 2026-07-02
13
+
14
+ ### Added
15
+ - Added a bottom-right Studio theme menu for switching the active Pi theme without returning to the terminal.
16
+ - 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.
17
+ - Added refreshed README screenshots plus a reproducible gradient-descent screenshot fixture under `assets/demo/`.
18
+
7
19
  ## [0.9.33] — 2026-06-16
8
20
 
9
21
  ### Added
package/README.md CHANGED
@@ -6,25 +6,35 @@ 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+P` / `Cmd/Ctrl+Alt+E` / `Cmd/Ctrl+Alt+W` switch directly between Response Preview, Editor Preview, and 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
- - 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
37
+ - Includes a right-pane **Files** view for browsing the current Pi session/resource directory, sorting by name/modified time/size, 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
29
39
  - Includes a local persistent scratchpad for quick notes you want to keep out of the main editor until you're ready to copy or insert them, with a **Recent…** picker for recovering scratchpads saved under earlier file/draft identities
30
40
  - Includes a docked **Outline** rail for navigating document structure in the current editor text, with clickable entries that jump in the raw editor and reveal matching preview locations when available
@@ -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 or Cmd/Ctrl+Alt+E switches directly to Preview."
273
- : "Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+P switches directly to Response Preview; Cmd/Ctrl+Alt+E switches directly to Editor 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;
@@ -2056,6 +2104,35 @@
2056
2104
  const DEFAULT_RESPONSE_FONT_SIZE = studioUiRefreshEnabled ? 13.5 : 15;
2057
2105
  let editorFontSize = DEFAULT_EDITOR_FONT_SIZE;
2058
2106
  let responseFontSize = DEFAULT_RESPONSE_FONT_SIZE;
2107
+ function normalizeFileBrowserSortMode(sort) {
2108
+ const value = String(sort || "").trim().toLowerCase();
2109
+ if (value === "mtime-desc" || value === "modified-desc" || value === "newest") return "mtime-desc";
2110
+ if (value === "mtime-asc" || value === "modified-asc" || value === "oldest") return "mtime-asc";
2111
+ if (value === "size-desc" || value === "largest") return "size-desc";
2112
+ if (value === "size-asc" || value === "smallest") return "size-asc";
2113
+ return "name";
2114
+ }
2115
+
2116
+ function readFileBrowserSortMode() {
2117
+ try {
2118
+ const stored = window.localStorage ? window.localStorage.getItem("piStudio.fileBrowserSort") : "";
2119
+ return normalizeFileBrowserSortMode(stored);
2120
+ } catch {
2121
+ return "name";
2122
+ }
2123
+ }
2124
+
2125
+ function writeFileBrowserSortMode(sort) {
2126
+ const normalized = normalizeFileBrowserSortMode(sort);
2127
+ try {
2128
+ if (window.localStorage) window.localStorage.setItem("piStudio.fileBrowserSort", normalized);
2129
+ } catch {
2130
+ // Ignore storage failures.
2131
+ }
2132
+ return normalized;
2133
+ }
2134
+
2135
+ let fileBrowserSortMode = readFileBrowserSortMode();
2059
2136
  let fileBrowserState = {
2060
2137
  rootDir: "",
2061
2138
  currentDir: "",
@@ -2064,6 +2141,7 @@
2064
2141
  entries: [],
2065
2142
  omitted: 0,
2066
2143
  omittedIgnored: 0,
2144
+ sort: fileBrowserSortMode,
2067
2145
  loading: false,
2068
2146
  error: "",
2069
2147
  loaded: false,
@@ -3273,6 +3351,7 @@
3273
3351
 
3274
3352
  function setFooterModelMenuOpen(open) {
3275
3353
  footerModelMenuOpen = Boolean(open);
3354
+ if (footerModelMenuOpen) setFooterThemeMenuOpen(false);
3276
3355
  if (footerModelMenuEl) footerModelMenuEl.hidden = !footerModelMenuOpen;
3277
3356
  if (footerMetaModelEl) {
3278
3357
  footerMetaModelEl.classList.toggle("is-open", footerModelMenuOpen);
@@ -3298,6 +3377,80 @@
3298
3377
  if (sent) setStatus("Setting Pi thinking level…", "warning");
3299
3378
  }
3300
3379
 
3380
+ function normalizePiThemeOptions(options) {
3381
+ return Array.isArray(options)
3382
+ ? options.map((option) => ({
3383
+ name: String(option && option.name || "").trim(),
3384
+ path: String(option && option.path || "").trim(),
3385
+ })).filter((option) => option.name)
3386
+ : [];
3387
+ }
3388
+
3389
+ function updatePiThemeState(message) {
3390
+ if (!message || typeof message !== "object") return;
3391
+ if (Array.isArray(message.piThemes)) {
3392
+ piThemeOptions = normalizePiThemeOptions(message.piThemes);
3393
+ }
3394
+ if (message.currentTheme && typeof message.currentTheme === "object") {
3395
+ const name = String(message.currentTheme.name || "").trim();
3396
+ if (name) piCurrentTheme = name;
3397
+ } else if (typeof message.currentTheme === "string" && message.currentTheme.trim()) {
3398
+ piCurrentTheme = message.currentTheme.trim();
3399
+ }
3400
+ updateFooterThemeButton();
3401
+ renderFooterThemeMenu();
3402
+ }
3403
+
3404
+ function updateFooterThemeButton() {
3405
+ if (!footerThemeBtn) return;
3406
+ const label = piCurrentTheme && piCurrentTheme.trim() ? piCurrentTheme.trim() : "theme";
3407
+ footerThemeBtn.textContent = "Theme: " + label;
3408
+ footerThemeBtn.title = "Switch the active Pi theme. Current theme: " + label + ". This affects terminal Pi and Studio.";
3409
+ footerThemeBtn.setAttribute("aria-expanded", footerThemeMenuOpen ? "true" : "false");
3410
+ }
3411
+
3412
+ function renderFooterThemeMenu() {
3413
+ if (!footerThemeMenuEl) return;
3414
+ const current = piCurrentTheme && piCurrentTheme.trim() ? piCurrentTheme.trim() : "";
3415
+ const optionNames = new Set(piThemeOptions.map((option) => option.name));
3416
+ const options = piThemeOptions.slice();
3417
+ if (current && !optionNames.has(current)) {
3418
+ options.unshift({ name: current, path: "" });
3419
+ }
3420
+ const optionsHtml = options.map((option) => {
3421
+ const selected = option.name === current ? " selected" : "";
3422
+ const title = option.path ? " title='" + escapeHtml(option.path) + "'" : "";
3423
+ return "<option value='" + escapeHtml(option.name) + "'" + selected + title + ">" + escapeHtml(option.name) + "</option>";
3424
+ });
3425
+ footerThemeMenuEl.innerHTML = ""
3426
+ + "<div class='footer-model-menu-heading'>Pi theme</div>"
3427
+ + (optionsHtml.length
3428
+ ? "<label class='footer-model-menu-field'><span>Active theme</span><select id='footerPiThemeSelect'>" + optionsHtml.join("") + "</select></label>"
3429
+ : "<div class='footer-model-menu-note'>No Pi themes are available yet.</div>")
3430
+ + "<div class='footer-model-menu-note'>Switches the active Pi theme and persists it to Pi settings.</div>";
3431
+ }
3432
+
3433
+ function setFooterThemeMenuOpen(open) {
3434
+ footerThemeMenuOpen = Boolean(open);
3435
+ if (footerThemeMenuOpen) setFooterModelMenuOpen(false);
3436
+ if (footerThemeMenuEl) footerThemeMenuEl.hidden = !footerThemeMenuOpen;
3437
+ if (footerThemeBtn) {
3438
+ footerThemeBtn.classList.toggle("is-open", footerThemeMenuOpen);
3439
+ footerThemeBtn.setAttribute("aria-expanded", footerThemeMenuOpen ? "true" : "false");
3440
+ }
3441
+ if (footerThemeMenuOpen) renderFooterThemeMenu();
3442
+ }
3443
+
3444
+ function requestPiThemeSelection(themeName) {
3445
+ const normalized = String(themeName || "").trim();
3446
+ if (!normalized) {
3447
+ setStatus("No Pi theme selected.", "warning");
3448
+ return;
3449
+ }
3450
+ const sent = sendMessage({ type: "pi_theme_select_request", theme: normalized });
3451
+ if (sent) setStatus("Switching Pi theme…", "warning");
3452
+ }
3453
+
3301
3454
  function updateFooterMeta() {
3302
3455
  const modelText = modelLabel && modelLabel.trim() ? modelLabel.trim() : "none";
3303
3456
  const terminalText = terminalSessionLabel && terminalSessionLabel.trim() ? terminalSessionLabel.trim() : "unknown";
@@ -3795,33 +3948,33 @@
3795
3948
  setStatus("Right pane content focused.");
3796
3949
  }
3797
3950
 
3798
- function switchRightPaneToPrimaryPreview() {
3799
- const targetView = isEditorOnlyMode ? "editor-preview" : "preview";
3951
+ function switchRightPaneToView(nextView, options) {
3952
+ const requestedView = canonicalRightViewValue(nextView);
3953
+ const label = getRightViewDisplayLabel(requestedView);
3954
+ if (!isRightViewAvailableInCurrentMode(requestedView)) {
3955
+ setStatus(label + " is unavailable in editor-only Studio views.", "warning");
3956
+ return false;
3957
+ }
3800
3958
  const snapshot = snapshotStudioScrollablePositions();
3801
- setRightView(targetView);
3959
+ setRightView(requestedView);
3802
3960
  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") + ".");
3961
+ if (!options || options.announce !== false) {
3962
+ setStatus("Right pane view: " + label + ".");
3963
+ }
3964
+ return true;
3965
+ }
3966
+
3967
+ function switchRightPaneToPrimaryPreview() {
3968
+ const targetView = isEditorOnlyMode ? "editor-preview" : "preview";
3969
+ switchRightPaneToView(targetView);
3807
3970
  }
3808
3971
 
3809
3972
  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.");
3973
+ switchRightPaneToView("trace");
3818
3974
  }
3819
3975
 
3820
3976
  function switchRightPaneToEditorPreview() {
3821
- const snapshot = snapshotStudioScrollablePositions();
3822
- setRightView("editor-preview");
3823
- scheduleStudioScrollablePositionRestore(snapshot);
3824
- setStatus("Right pane view: Editor (Preview).");
3977
+ switchRightPaneToView("editor-preview");
3825
3978
  }
3826
3979
 
3827
3980
  function cycleActivePaneView(direction) {
@@ -4122,6 +4275,17 @@
4122
4275
  return;
4123
4276
  }
4124
4277
 
4278
+ const numericRightViewShortcut = (event.metaKey || event.ctrlKey)
4279
+ && event.altKey
4280
+ && !event.shiftKey
4281
+ ? (RIGHT_VIEW_NUMERIC_SHORTCUTS[code] || RIGHT_VIEW_NUMERIC_SHORTCUTS["Digit" + key])
4282
+ : null;
4283
+ if (numericRightViewShortcut) {
4284
+ event.preventDefault();
4285
+ switchRightPaneToView(numericRightViewShortcut);
4286
+ return;
4287
+ }
4288
+
4125
4289
  const isPreviewShortcut = (key.toLowerCase() === "p" || code === "KeyP")
4126
4290
  && (event.metaKey || event.ctrlKey)
4127
4291
  && event.altKey
@@ -8011,6 +8175,9 @@
8011
8175
  critiqueViewEl.addEventListener("click", handleFilesPaneClick);
8012
8176
  critiqueViewEl.addEventListener("click", handleGitChangesPaneClick);
8013
8177
  critiqueViewEl.addEventListener("change", handleReplPaneChange);
8178
+ critiqueViewEl.addEventListener("change", (event) => {
8179
+ void handleFilesPaneChange(event);
8180
+ });
8014
8181
  }
8015
8182
 
8016
8183
  function replaceResponsePaneWithClone() {
@@ -9831,6 +9998,24 @@
9831
9998
  return entry.extension ? entry.extension.replace(/^\./, "") : "file";
9832
9999
  }
9833
10000
 
10001
+ function getFileBrowserSortOptions() {
10002
+ return [
10003
+ { value: "name", label: "Sort: Name" },
10004
+ { value: "mtime-desc", label: "Sort: Newest" },
10005
+ { value: "mtime-asc", label: "Sort: Oldest" },
10006
+ { value: "size-desc", label: "Sort: Largest" },
10007
+ { value: "size-asc", label: "Sort: Smallest" },
10008
+ ];
10009
+ }
10010
+
10011
+ function buildFileBrowserSortSelectHtml() {
10012
+ const currentSort = normalizeFileBrowserSortMode(fileBrowserSortMode || (fileBrowserState && fileBrowserState.sort));
10013
+ const options = getFileBrowserSortOptions().map((option) => {
10014
+ return "<option value='" + escapeHtml(option.value) + "'" + (option.value === currentSort ? " selected" : "") + ">" + escapeHtml(option.label) + "</option>";
10015
+ }).join("");
10016
+ return "<select class='files-sort-select' data-files-sort aria-label='Files sort order' title='Sort file browser entries. Folders remain grouped first.'>" + options + "</select>";
10017
+ }
10018
+
9834
10019
  function buildFileBrowserPanelHtml() {
9835
10020
  const state = fileBrowserState || {};
9836
10021
  const entries = Array.isArray(state.entries) ? state.entries : [];
@@ -9885,6 +10070,7 @@
9885
10070
  + "<div class='files-toolbar'>"
9886
10071
  + "<div class='files-path-group'><span class='files-label'>Files</span><span class='files-path' title='" + escapeHtml(currentDir) + "'>" + escapeHtml(relativeDir || ".") + "</span></div>"
9887
10072
  + "<div class='files-toolbar-actions'>"
10073
+ + buildFileBrowserSortSelectHtml()
9888
10074
  + "<button type='button' data-files-action='parent'" + parentDisabled + ">Parent</button>"
9889
10075
  + "<button type='button' data-files-action='refresh'>Refresh</button>"
9890
10076
  + (currentDir ? "<button type='button' data-files-action='copy-current' data-files-path='" + escapeHtml(currentDir) + "'>Copy path</button>" : "")
@@ -9911,6 +10097,7 @@
9911
10097
  entries: [],
9912
10098
  omitted: 0,
9913
10099
  omittedIgnored: 0,
10100
+ sort: fileBrowserSortMode,
9914
10101
  loading: false,
9915
10102
  error: "",
9916
10103
  loaded: false,
@@ -9945,8 +10132,10 @@
9945
10132
  if (dir) query.dir = String(dir);
9946
10133
  if (context.sourcePath) query.sourcePath = context.sourcePath;
9947
10134
  if (context.resourceDir) query.resourceDir = context.resourceDir;
10135
+ query.sort = normalizeFileBrowserSortMode(fileBrowserSortMode);
9948
10136
  const payload = await fetchStudioJson("/file-browser", { query });
9949
10137
  if (nonce !== fileBrowserLoadNonce) return;
10138
+ fileBrowserSortMode = normalizeFileBrowserSortMode(payload.sort || fileBrowserSortMode);
9950
10139
  fileBrowserState = {
9951
10140
  rootDir: typeof payload.rootDir === "string" ? payload.rootDir : "",
9952
10141
  currentDir: typeof payload.currentDir === "string" ? payload.currentDir : "",
@@ -9955,6 +10144,7 @@
9955
10144
  entries: Array.isArray(payload.entries) ? payload.entries : [],
9956
10145
  omitted: Number(payload.omitted) || 0,
9957
10146
  omittedIgnored: Number(payload.omittedIgnored) || 0,
10147
+ sort: normalizeFileBrowserSortMode(payload.sort || fileBrowserSortMode),
9958
10148
  loading: false,
9959
10149
  error: "",
9960
10150
  loaded: true,
@@ -10057,6 +10247,17 @@
10057
10247
  setStatus(payload && payload.message ? payload.message : "Opened folder in file manager.", "success");
10058
10248
  }
10059
10249
 
10250
+ async function handleFilesPaneChange(event) {
10251
+ if (rightView !== "files") return;
10252
+ const target = event.target;
10253
+ const sortSelect = target instanceof Element ? target.closest("[data-files-sort]") : null;
10254
+ if (!sortSelect || !("value" in sortSelect)) return;
10255
+ const nextSort = writeFileBrowserSortMode(sortSelect.value);
10256
+ if (nextSort === fileBrowserSortMode && fileBrowserState.loaded && !fileBrowserState.loading) return;
10257
+ fileBrowserSortMode = nextSort;
10258
+ await loadFileBrowserDirectory(fileBrowserState.currentDir || "", { user: true });
10259
+ }
10260
+
10060
10261
  async function handleFilesPaneClick(event) {
10061
10262
  if (rightView !== "files") return;
10062
10263
  const target = event.target;
@@ -18827,6 +19028,7 @@
18827
19028
  updateCompletionSuggestionModelOptions(message.suggestionModels);
18828
19029
  }
18829
19030
  updatePiSessionModelState(message);
19031
+ updatePiThemeState(message);
18830
19032
  if (typeof message.terminalSessionLabel === "string") {
18831
19033
  terminalSessionLabel = message.terminalSessionLabel;
18832
19034
  }
@@ -19403,6 +19605,7 @@
19403
19605
  updateCompletionSuggestionModelOptions(message.suggestionModels);
19404
19606
  }
19405
19607
  updatePiSessionModelState(message);
19608
+ updatePiThemeState(message);
19406
19609
  if (typeof message.terminalSessionLabel === "string") {
19407
19610
  terminalSessionLabel = message.terminalSessionLabel;
19408
19611
  }
@@ -19536,6 +19739,7 @@
19536
19739
  root.style.setProperty(key, message.vars[key]);
19537
19740
  }
19538
19741
  });
19742
+ updatePiThemeState(message);
19539
19743
  updateDocumentTitle();
19540
19744
  }
19541
19745
  }
@@ -20283,6 +20487,7 @@
20283
20487
  closeExportPreviewMenu();
20284
20488
  closePreviewLinkMenu();
20285
20489
  setFooterModelMenuOpen(false);
20490
+ setFooterThemeMenuOpen(false);
20286
20491
  }
20287
20492
  });
20288
20493
 
@@ -20309,11 +20514,36 @@
20309
20514
  }
20310
20515
  });
20311
20516
  }
20517
+ if (footerThemeBtn) {
20518
+ footerThemeBtn.addEventListener("click", (event) => {
20519
+ event.preventDefault();
20520
+ event.stopPropagation();
20521
+ setFooterThemeMenuOpen(!footerThemeMenuOpen);
20522
+ });
20523
+ }
20524
+ if (footerThemeMenuEl) {
20525
+ footerThemeMenuEl.addEventListener("click", (event) => {
20526
+ event.stopPropagation();
20527
+ });
20528
+ footerThemeMenuEl.addEventListener("change", (event) => {
20529
+ const target = event.target;
20530
+ if (!(target instanceof HTMLSelectElement)) return;
20531
+ if (target.id === "footerPiThemeSelect") {
20532
+ requestPiThemeSelection(target.value);
20533
+ setFooterThemeMenuOpen(false);
20534
+ }
20535
+ });
20536
+ }
20312
20537
  document.addEventListener("click", (event) => {
20313
20538
  const target = event.target;
20314
20539
  if (target instanceof Element && (target.closest("#footerModelMenu") || target.closest("#footerMetaModel"))) return;
20315
20540
  setFooterModelMenuOpen(false);
20316
20541
  });
20542
+ document.addEventListener("click", (event) => {
20543
+ const target = event.target;
20544
+ if (target instanceof Element && (target.closest("#footerThemeMenu") || target.closest("#footerThemeBtn"))) return;
20545
+ setFooterThemeMenuOpen(false);
20546
+ });
20317
20547
 
20318
20548
  saveAsBtn.addEventListener("click", () => {
20319
20549
  const content = sourceTextEl.value;
package/client/studio.css CHANGED
@@ -3290,11 +3290,21 @@
3290
3290
  }
3291
3291
 
3292
3292
  .files-toolbar-actions button,
3293
- .files-actions button {
3293
+ .files-actions button,
3294
+ .files-sort-select {
3294
3295
  padding: 4px 7px;
3295
3296
  font-size: 11px;
3296
3297
  }
3297
3298
 
3299
+ .files-sort-select {
3300
+ max-width: 150px;
3301
+ border: 1px solid var(--control-border);
3302
+ border-radius: 7px;
3303
+ background: var(--panel);
3304
+ color: var(--text);
3305
+ font-family: var(--font-ui);
3306
+ }
3307
+
3298
3308
  .files-subtitle,
3299
3309
  .files-notice,
3300
3310
  .files-empty {
@@ -4277,11 +4287,10 @@
4277
4287
  outline: none;
4278
4288
  }
4279
4289
 
4280
- .footer-model-menu {
4290
+ .footer-model-menu,
4291
+ .footer-theme-menu {
4281
4292
  position: fixed;
4282
- left: 12px;
4283
4293
  bottom: 38px;
4284
- width: min(440px, calc(100vw - 24px));
4285
4294
  padding: 10px;
4286
4295
  border: 1px solid var(--panel-border);
4287
4296
  border-radius: 12px;
@@ -4291,7 +4300,18 @@
4291
4300
  z-index: 110;
4292
4301
  }
4293
4302
 
4294
- .footer-model-menu[hidden] {
4303
+ .footer-model-menu {
4304
+ left: 12px;
4305
+ width: min(440px, calc(100vw - 24px));
4306
+ }
4307
+
4308
+ .footer-theme-menu {
4309
+ right: 12px;
4310
+ width: min(320px, calc(100vw - 24px));
4311
+ }
4312
+
4313
+ .footer-model-menu[hidden],
4314
+ .footer-theme-menu[hidden] {
4295
4315
  display: none !important;
4296
4316
  }
4297
4317
 
@@ -4348,10 +4368,18 @@
4348
4368
  opacity: 0.72;
4349
4369
  }
4350
4370
 
4351
- .shortcut-hint {
4371
+ .footer-actions {
4352
4372
  grid-area: hint;
4353
4373
  justify-self: end;
4354
4374
  align-self: center;
4375
+ display: inline-flex;
4376
+ align-items: center;
4377
+ justify-content: flex-end;
4378
+ gap: 6px;
4379
+ min-width: 0;
4380
+ }
4381
+
4382
+ .shortcut-hint {
4355
4383
  display: inline-flex;
4356
4384
  align-items: center;
4357
4385
  gap: 6px;
@@ -4366,12 +4394,21 @@
4366
4394
  opacity: 0.9;
4367
4395
  }
4368
4396
 
4397
+ .footer-theme-btn {
4398
+ max-width: min(28ch, 36vw);
4399
+ min-width: 0;
4400
+ overflow: hidden;
4401
+ text-overflow: ellipsis;
4402
+ }
4403
+
4369
4404
  .shortcut-hint:not(:disabled):hover,
4370
- .shortcut-hint:focus-visible {
4405
+ .shortcut-hint:focus-visible,
4406
+ .shortcut-hint.is-open {
4371
4407
  background: var(--panel-2);
4372
4408
  border-color: var(--control-border);
4373
4409
  color: var(--text);
4374
4410
  opacity: 1;
4411
+ outline: none;
4375
4412
  }
4376
4413
 
4377
4414
  .footer-compact-btn {
@@ -4398,7 +4435,7 @@
4398
4435
  }
4399
4436
 
4400
4437
  body.studio-zen-mode .footer-meta,
4401
- body.studio-zen-mode .shortcut-hint {
4438
+ body.studio-zen-mode .footer-actions {
4402
4439
  display: none !important;
4403
4440
  }
4404
4441
 
@@ -4993,8 +5030,12 @@
4993
5030
  max-width: 100%;
4994
5031
  }
4995
5032
 
4996
- .shortcut-hint {
5033
+ .footer-actions {
4997
5034
  justify-self: start;
5035
+ flex-wrap: wrap;
5036
+ }
5037
+
5038
+ .shortcut-hint {
4998
5039
  text-align: left;
4999
5040
  white-space: normal;
5000
5041
  flex-wrap: wrap;
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
@@ -2746,6 +2752,7 @@ const STUDIO_FILE_BROWSER_IGNORED_DIRS = new Set([
2746
2752
  ]);
2747
2753
 
2748
2754
  type StudioLocalPreviewResourceKind = "pdf" | "text" | "image" | "office" | "other";
2755
+ type StudioFileBrowserSortMode = "name" | "mtime-desc" | "mtime-asc" | "size-desc" | "size-asc";
2749
2756
 
2750
2757
  interface StudioLocalPreviewResource {
2751
2758
  filePath: string;
@@ -2767,6 +2774,29 @@ interface StudioFileBrowserEntry {
2767
2774
  hidden: boolean;
2768
2775
  }
2769
2776
 
2777
+ function normalizeStudioFileBrowserSortMode(sort: string | null | undefined): StudioFileBrowserSortMode {
2778
+ const value = typeof sort === "string" ? sort.trim().toLowerCase() : "";
2779
+ if (value === "mtime-desc" || value === "modified-desc" || value === "newest") return "mtime-desc";
2780
+ if (value === "mtime-asc" || value === "modified-asc" || value === "oldest") return "mtime-asc";
2781
+ if (value === "size-desc" || value === "largest") return "size-desc";
2782
+ if (value === "size-asc" || value === "smallest") return "size-asc";
2783
+ return "name";
2784
+ }
2785
+
2786
+ function compareStudioFileBrowserEntryNames(a: StudioFileBrowserEntry, b: StudioFileBrowserEntry): number {
2787
+ return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true });
2788
+ }
2789
+
2790
+ function compareStudioFileBrowserEntries(a: StudioFileBrowserEntry, b: StudioFileBrowserEntry, sort: StudioFileBrowserSortMode): number {
2791
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
2792
+ if (a.hidden !== b.hidden) return a.hidden ? 1 : -1;
2793
+ if (sort === "mtime-desc") return (b.mtimeMs - a.mtimeMs) || compareStudioFileBrowserEntryNames(a, b);
2794
+ if (sort === "mtime-asc") return (a.mtimeMs - b.mtimeMs) || compareStudioFileBrowserEntryNames(a, b);
2795
+ if (sort === "size-desc" && a.type === "file") return (b.size - a.size) || compareStudioFileBrowserEntryNames(a, b);
2796
+ if (sort === "size-asc" && a.type === "file") return (a.size - b.size) || compareStudioFileBrowserEntryNames(a, b);
2797
+ return compareStudioFileBrowserEntryNames(a, b);
2798
+ }
2799
+
2770
2800
  function resolveStudioPdfResourcePath(pdfPath: string | undefined, sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
2771
2801
  const rawPath = typeof pdfPath === "string" ? pdfPath.trim() : "";
2772
2802
  if (!rawPath) throw new Error("Missing PDF path.");
@@ -2916,8 +2946,10 @@ function listStudioFileBrowserDirectory(
2916
2946
  sourcePath: string | undefined,
2917
2947
  resourceDir: string | undefined,
2918
2948
  fallbackCwd: string,
2919
- ): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null; entries: StudioFileBrowserEntry[]; omitted: number; omittedIgnored: number } {
2949
+ sortMode?: string | null,
2950
+ ): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null; entries: StudioFileBrowserEntry[]; omitted: number; omittedIgnored: number; sort: StudioFileBrowserSortMode } {
2920
2951
  const context = resolveStudioFileBrowserDirectory(dirPath, sourcePath, resourceDir, fallbackCwd);
2952
+ const sort = normalizeStudioFileBrowserSortMode(sortMode);
2921
2953
  const entries: StudioFileBrowserEntry[] = [];
2922
2954
  let omitted = 0;
2923
2955
  let omittedIgnored = 0;
@@ -2956,14 +2988,10 @@ function listStudioFileBrowserDirectory(
2956
2988
  omitted += 1;
2957
2989
  }
2958
2990
  }
2959
- entries.sort((a, b) => {
2960
- if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
2961
- if (a.hidden !== b.hidden) return a.hidden ? 1 : -1;
2962
- return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true });
2963
- });
2991
+ entries.sort((a, b) => compareStudioFileBrowserEntries(a, b, sort));
2964
2992
  const limitedEntries = entries.slice(0, STUDIO_FILE_BROWSER_MAX_ENTRIES);
2965
2993
  omitted += Math.max(0, entries.length - limitedEntries.length);
2966
- return { ...context, entries: limitedEntries, omitted, omittedIgnored };
2994
+ return { ...context, entries: limitedEntries, omitted, omittedIgnored, sort };
2967
2995
  }
2968
2996
 
2969
2997
  function resolveStudioHtmlPreviewResourcePath(
@@ -8425,6 +8453,16 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
8425
8453
  }
8426
8454
  }
8427
8455
 
8456
+ if (msg.type === "pi_theme_select_request" && typeof msg.theme === "string") {
8457
+ const theme = msg.theme.trim();
8458
+ if (theme && theme.length <= 200) {
8459
+ return {
8460
+ type: "pi_theme_select_request",
8461
+ theme,
8462
+ };
8463
+ }
8464
+ }
8465
+
8428
8466
  if (msg.type === "completion_suggestion_request" && typeof msg.requestId === "string" && typeof msg.text === "string") {
8429
8467
  const textLength = msg.text.length;
8430
8468
  const rawStart = typeof msg.selectionStart === "number" && Number.isFinite(msg.selectionStart) ? msg.selectionStart : textLength;
@@ -10277,6 +10315,7 @@ function buildStudioHtml(
10277
10315
  const initialModel = escapeHtmlForInline(initialModelLabel ?? "none");
10278
10316
  const initialTerminal = escapeHtmlForInline(initialTerminalLabel ?? "unknown");
10279
10317
  const initialTerminalDetailAttr = escapeHtmlForInline(initialTerminalDetail ?? initialTerminalLabel ?? "unknown");
10318
+ const initialTheme = escapeHtmlForInline(theme?.name ?? "theme");
10280
10319
  const initialContextTokens =
10281
10320
  typeof initialContextUsage?.tokens === "number" && Number.isFinite(initialContextUsage.tokens)
10282
10321
  ? String(initialContextUsage.tokens)
@@ -10344,7 +10383,7 @@ ${cssVarsBlock}
10344
10383
  </style>
10345
10384
  <link rel="stylesheet" href="${stylesheetHref}" />
10346
10385
  </head>
10347
- <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}">
10386
+ <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}">
10348
10387
  <header>
10349
10388
  <h1><span class="app-logo" aria-hidden="true">π</span> Studio <span class="app-subtitle">${appSubtitle}</span></h1>
10350
10389
  <div class="controls">
@@ -10561,7 +10600,7 @@ ${cssVarsBlock}
10561
10600
  <section id="rightPane">
10562
10601
  <div id="rightSectionHeader" class="section-header">
10563
10602
  <div class="section-header-main">
10564
- <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 Response Preview; Cmd/Ctrl+Alt+E switches directly to Editor Preview; Cmd/Ctrl+Alt+W switches directly to Working.">
10603
+ <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.">
10565
10604
  <option value="markdown">Response (Raw)</option>
10566
10605
  <option value="preview" selected>Response (Preview)</option>
10567
10606
  <option value="editor-preview">Editor (Preview)</option>
@@ -10637,7 +10676,11 @@ ${cssVarsBlock}
10637
10676
  <span id="statusLine"><span id="statusSpinner" aria-hidden="true"> </span><span id="status">Booting studio…</span></span>
10638
10677
  <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>
10639
10678
  <div id="footerModelMenu" class="footer-model-menu" hidden></div>
10640
- <button id="shortcutsBtn" class="shortcut-hint" type="button" title="Show Studio keyboard shortcuts. Press ? when not editing text.">Shortcuts (?)</button>
10679
+ <div id="footerThemeMenu" class="footer-theme-menu" hidden></div>
10680
+ <span class="footer-actions">
10681
+ <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>
10682
+ <button id="shortcutsBtn" class="shortcut-hint" type="button" title="Show Studio keyboard shortcuts. Press ? when not editing text.">Shortcuts (?)</button>
10683
+ </span>
10641
10684
  </footer>
10642
10685
 
10643
10686
  <div id="shortcutsOverlay" class="shortcuts-overlay" hidden>
@@ -10655,7 +10698,8 @@ ${cssVarsBlock}
10655
10698
  <dl>
10656
10699
  <div><dt>F6</dt><dd>Switch between editor and right pane</dd></div>
10657
10700
  <div><dt>F7 / Shift+F7</dt><dd>Cycle the active pane's view</dd></div>
10658
- <div><dt>Cmd/Ctrl+Alt+P</dt><dd>Switch the right pane directly to Response Preview</dd></div>
10701
+ <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>
10702
+ <div><dt>Cmd/Ctrl+Alt+P</dt><dd>Switch the right pane directly to Response Preview; in editor-only views, Editor Preview</dd></div>
10659
10703
  <div><dt>Cmd/Ctrl+Alt+E</dt><dd>Switch the right pane directly to Editor Preview</dd></div>
10660
10704
  <div><dt>Cmd/Ctrl+Alt+W</dt><dd>Switch the right pane directly to Working</dd></div>
10661
10705
  <div><dt>F8</dt><dd>Focus editor text</dd></div>
@@ -11770,6 +11814,25 @@ export default function (pi: ExtensionAPI) {
11770
11814
  }
11771
11815
  : null;
11772
11816
 
11817
+ const getStudioThemeOptions = () => {
11818
+ try {
11819
+ const themes = lastCommandCtx?.ui?.getAllThemes?.() ?? [];
11820
+ return themes
11821
+ .map((theme) => ({
11822
+ name: typeof theme.name === "string" ? theme.name.trim() : "",
11823
+ path: typeof theme.path === "string" ? theme.path : undefined,
11824
+ }))
11825
+ .filter((theme) => theme.name);
11826
+ } catch {
11827
+ return [];
11828
+ }
11829
+ };
11830
+
11831
+ const getCurrentStudioThemeDescriptor = () => {
11832
+ const name = typeof lastCommandCtx?.ui?.theme?.name === "string" ? lastCommandCtx.ui.theme.name.trim() : "";
11833
+ return name ? { name } : null;
11834
+ };
11835
+
11773
11836
  const broadcastState = () => {
11774
11837
  terminalSessionLabel = buildTerminalSessionLabel(studioCwd, getSessionNameSafe());
11775
11838
  terminalSessionDetail = buildTerminalSessionDetail(studioCwd, getSessionNameSafe());
@@ -11788,6 +11851,8 @@ export default function (pi: ExtensionAPI) {
11788
11851
  thinkingLevel: getThinkingLevelSafe() ?? "off",
11789
11852
  piModels: modelOptions,
11790
11853
  suggestionModels: modelOptions,
11854
+ currentTheme: getCurrentStudioThemeDescriptor(),
11855
+ piThemes: getStudioThemeOptions(),
11791
11856
  terminalSessionLabel,
11792
11857
  terminalSessionDetail,
11793
11858
  contextTokens: contextUsageSnapshot.tokens,
@@ -12087,6 +12152,8 @@ export default function (pi: ExtensionAPI) {
12087
12152
  thinkingLevel: getThinkingLevelSafe() ?? "off",
12088
12153
  piModels: getStudioModelOptions(),
12089
12154
  suggestionModels: getStudioModelOptions(),
12155
+ currentTheme: getCurrentStudioThemeDescriptor(),
12156
+ piThemes: getStudioThemeOptions(),
12090
12157
  terminalSessionLabel,
12091
12158
  terminalSessionDetail,
12092
12159
  contextTokens: contextUsageSnapshot.tokens,
@@ -12146,6 +12213,36 @@ export default function (pi: ExtensionAPI) {
12146
12213
  return;
12147
12214
  }
12148
12215
 
12216
+ if (msg.type === "pi_theme_select_request") {
12217
+ const ui = lastCommandCtx?.ui;
12218
+ if (!ui || typeof ui.setTheme !== "function") {
12219
+ sendToClient(client, { type: "info", level: "warning", message: "Pi theme controls are not available yet." });
12220
+ return;
12221
+ }
12222
+ const themeName = msg.theme.trim();
12223
+ const availableThemes = getStudioThemeOptions();
12224
+ if (availableThemes.length && !availableThemes.some((theme) => theme.name === themeName)) {
12225
+ sendToClient(client, { type: "info", level: "warning", message: `Pi theme not found: ${themeName}` });
12226
+ return;
12227
+ }
12228
+ try {
12229
+ const result = ui.setTheme(themeName);
12230
+ if (!result.success) {
12231
+ sendToClient(client, { type: "info", level: "error", message: `Theme switch failed: ${result.error ?? themeName}` });
12232
+ return;
12233
+ }
12234
+ const vars = buildThemeCssVars(getStudioThemeStyle(ui.theme));
12235
+ lastThemeVarsJson = JSON.stringify(vars);
12236
+ syncCmuxStudioStatus();
12237
+ broadcast({ type: "theme_update", vars, currentTheme: getCurrentStudioThemeDescriptor(), piThemes: getStudioThemeOptions() });
12238
+ broadcastState();
12239
+ sendToClient(client, { type: "info", level: "info", message: `Pi theme switched to ${themeName}.` });
12240
+ } catch (error) {
12241
+ sendToClient(client, { type: "info", level: "error", message: `Theme switch failed: ${error instanceof Error ? error.message : String(error)}` });
12242
+ }
12243
+ return;
12244
+ }
12245
+
12149
12246
  if (msg.type === "get_latest_response") {
12150
12247
  if (!lastStudioResponse) {
12151
12248
  sendToClient(client, { type: "info", message: "No latest assistant response is available yet." });
@@ -14075,6 +14172,7 @@ export default function (pi: ExtensionAPI) {
14075
14172
  requestUrl.searchParams.get("sourcePath") ?? undefined,
14076
14173
  requestUrl.searchParams.get("resourceDir") ?? undefined,
14077
14174
  studioCwd,
14175
+ requestUrl.searchParams.get("sort") ?? undefined,
14078
14176
  );
14079
14177
  respondJson(res, 200, { ok: true, ...listing, entries: method === "HEAD" ? [] : listing.entries });
14080
14178
  } catch (error) {
@@ -14335,7 +14433,7 @@ export default function (pi: ExtensionAPI) {
14335
14433
  lastThemeVarsJson = json;
14336
14434
  syncCmuxStudioStatus();
14337
14435
  for (const client of serverState.clients) {
14338
- sendToClient(client, { type: "theme_update", vars });
14436
+ sendToClient(client, { type: "theme_update", vars, currentTheme: getCurrentStudioThemeDescriptor(), piThemes: getStudioThemeOptions() });
14339
14437
  }
14340
14438
  }
14341
14439
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.33",
3
+ "version": "0.9.35",
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",