pi-studio 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to `pi-studio` are documented here.
4
4
 
5
+ ## [0.4.3] — 2026-03-04
6
+
7
+ ### Added
8
+ - **Export right preview as PDF** action in Studio response controls, using server-side pandoc + LaTeX (`xelatex`) for high-quality math/typesetting output.
9
+ - Footer metadata now includes model **and thinking level** (e.g., `provider/model (xhigh)`) plus terminal/session label.
10
+ - Footer braille-dot activity spinner (`⠋⠙⠹…`) driven by existing websocket lifecycle state.
11
+
12
+ ### Changed
13
+ - Footer layout is now two-line and less crowded: status/meta on the left with shortcuts aligned to the right.
14
+ - Status text is now user-facing (removed `WS:` jargon and redundant `Ready` wording).
15
+
5
16
  ## [0.4.2] — 2026-03-03
6
17
 
7
18
  ### Added
package/README.md CHANGED
@@ -67,8 +67,10 @@ Experimental extension for [pi](https://github.com/badlogic/pi-mono) that opens
67
67
  - **Working directory**: "Set working dir" button for uploaded files — resolves relative image paths and enables "Save editor" for uploaded content
68
68
  - **Live theme sync**: changing the pi theme in the terminal updates the studio browser UI automatically (polled every 2 seconds)
69
69
  - Separate syntax highlight toggles for editor and response Raw views, with local preference persistence
70
+ - **PDF export**: export the current right-pane preview (`Response (Preview)` or `Editor (Preview)`) via pandoc + LaTeX (`xelatex`) for high-quality math/typesetting output
70
71
  - Keyboard shortcuts: `Cmd/Ctrl+Enter` runs **Run editor text** when editor pane is active; `Cmd/Ctrl+Esc` / `F10` toggles focus mode; `Esc` exits focus mode
71
- - Footer status reflects Studio/terminal activity phases (connecting, ready, submitting, terminal activity)
72
+ - Footer status reflects Studio/terminal activity phases (connecting, ready, submitting, terminal activity), with a braille-dot spinner during agent activity
73
+ - Footer metadata includes current model and terminal/session label for easier terminal tab switching
72
74
  - Theme-aware browser UI derived from current pi theme
73
75
  - View mode selectors integrated into panel headers for a cleaner layout
74
76
 
@@ -116,8 +118,9 @@ pi -e https://github.com/omaclaren/pi-studio
116
118
  - Mermaid fenced `mermaid` code blocks are rendered client-side in preview mode (Mermaid v11 loaded from jsDelivr), with palette-driven defaults for better theme fit.
117
119
  - If Mermaid cannot load or a diagram fails to render, preview shows an inline warning and keeps source text visible.
118
120
  - Preview rendering normalizes Obsidian wiki-image syntax (`![[path]]`, `![[path|alt]]`) into standard markdown images.
119
- - Install pandoc for full preview rendering (`brew install pandoc` on macOS).
120
- - If `pandoc` is unavailable, preview falls back to plain markdown text with an inline warning.
121
+ - Install pandoc for full preview rendering and PDF export (`brew install pandoc` on macOS).
122
+ - PDF export uses pandoc + LaTeX (`xelatex` by default; override with `PANDOC_PDF_ENGINE`). Install TeX Live/MacTeX for PDF generation.
123
+ - If `pandoc` is unavailable, preview falls back to plain markdown text with an inline warning, and PDF export returns an error.
121
124
 
122
125
  ## License
123
126
 
package/index.ts CHANGED
@@ -2,7 +2,9 @@ import type { ExtensionAPI, ExtensionCommandContext, SessionEntry, Theme } from
2
2
  import { spawn } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { readFileSync, statSync, writeFileSync } from "node:fs";
5
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
6
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
7
+ import { tmpdir } from "node:os";
6
8
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
7
9
  import { URL } from "node:url";
8
10
  import { WebSocketServer, WebSocket, type RawData } from "ws";
@@ -110,8 +112,21 @@ type IncomingStudioMessage =
110
112
 
111
113
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
112
114
  const PREVIEW_RENDER_MAX_CHARS = 400_000;
115
+ const PDF_EXPORT_MAX_CHARS = 400_000;
113
116
  const REQUEST_BODY_MAX_BYTES = 1_000_000;
114
117
 
118
+ const PDF_PREAMBLE = `\\usepackage{titlesec}
119
+ \\titleformat{\\section}{\\Large\\bfseries\\sffamily}{}{0pt}{}[\\vspace{2pt}\\titlerule]
120
+ \\titleformat{\\subsection}{\\large\\bfseries\\sffamily}{}{0pt}{}
121
+ \\titleformat{\\subsubsection}{\\normalsize\\bfseries\\sffamily}{}{0pt}{}
122
+ \\titlespacing*{\\section}{0pt}{1.5ex plus 0.5ex minus 0.2ex}{1ex plus 0.2ex}
123
+ \\titlespacing*{\\subsection}{0pt}{1.2ex plus 0.4ex minus 0.2ex}{0.6ex plus 0.1ex}
124
+ \\usepackage{enumitem}
125
+ \\setlist[itemize]{nosep, leftmargin=1.5em}
126
+ \\setlist[enumerate]{nosep, leftmargin=1.5em}
127
+ \\usepackage{parskip}
128
+ `;
129
+
115
130
  type StudioThemeMode = "dark" | "light";
116
131
 
117
132
  interface StudioPalette {
@@ -800,6 +815,85 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
800
815
  });
801
816
  }
802
817
 
818
+ async function renderStudioPdfWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string): Promise<Buffer> {
819
+ const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
820
+ const pdfEngine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
821
+ const inputFormat = isLatex
822
+ ? "latex"
823
+ : "gfm+tex_math_dollars+autolink_bare_uris+superscript+subscript-raw_html";
824
+ const normalizedMarkdown = isLatex ? markdown : normalizeObsidianImages(normalizeMathDelimiters(markdown));
825
+
826
+ const tempDir = join(tmpdir(), `pi-studio-pdf-${Date.now()}-${randomUUID()}`);
827
+ const preamblePath = join(tempDir, "_pdf_preamble.tex");
828
+ const outputPath = join(tempDir, "studio-export.pdf");
829
+
830
+ await mkdir(tempDir, { recursive: true });
831
+ await writeFile(preamblePath, PDF_PREAMBLE, "utf-8");
832
+
833
+ const args = [
834
+ "-f", inputFormat,
835
+ "-o", outputPath,
836
+ `--pdf-engine=${pdfEngine}`,
837
+ "-V", "geometry:margin=2.2cm",
838
+ "-V", "fontsize=11pt",
839
+ "-V", "linestretch=1.25",
840
+ "-V", "urlcolor=blue",
841
+ "-V", "linkcolor=blue",
842
+ "--include-in-header", preamblePath,
843
+ ];
844
+ if (resourcePath) args.push(`--resource-path=${resourcePath}`);
845
+
846
+ try {
847
+ await new Promise<void>((resolve, reject) => {
848
+ const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"] });
849
+ const stderrChunks: Buffer[] = [];
850
+ let settled = false;
851
+
852
+ const fail = (error: Error) => {
853
+ if (settled) return;
854
+ settled = true;
855
+ reject(error);
856
+ };
857
+
858
+ child.stderr.on("data", (chunk: Buffer | string) => {
859
+ stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
860
+ });
861
+
862
+ child.once("error", (error) => {
863
+ const errno = error as NodeJS.ErrnoException;
864
+ if (errno.code === "ENOENT") {
865
+ const commandHint = pandocCommand === "pandoc"
866
+ ? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
867
+ : `${pandocCommand} was not found. Check PANDOC_PATH.`;
868
+ fail(new Error(commandHint));
869
+ return;
870
+ }
871
+ fail(error);
872
+ });
873
+
874
+ child.once("close", (code) => {
875
+ if (settled) return;
876
+ if (code === 0) {
877
+ settled = true;
878
+ resolve();
879
+ return;
880
+ }
881
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
882
+ const hint = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
883
+ ? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
884
+ : "";
885
+ fail(new Error(`pandoc PDF export failed with exit code ${code}${stderr ? `: ${stderr}` : ""}${hint}`));
886
+ });
887
+
888
+ child.stdin.end(normalizedMarkdown);
889
+ });
890
+
891
+ return await readFile(outputPath);
892
+ } finally {
893
+ await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
894
+ }
895
+ }
896
+
803
897
  function readRequestBody(req: IncomingMessage, maxBytes: number): Promise<string> {
804
898
  return new Promise((resolve, reject) => {
805
899
  const chunks: Buffer[] = [];
@@ -1292,6 +1386,50 @@ function buildStudioUrl(port: number, token: string): string {
1292
1386
  return `http://127.0.0.1:${port}/?token=${encoded}`;
1293
1387
  }
1294
1388
 
1389
+ function formatModelLabel(model: { provider?: string; id?: string } | undefined): string {
1390
+ const provider = typeof model?.provider === "string" ? model.provider.trim() : "";
1391
+ const id = typeof model?.id === "string" ? model.id.trim() : "";
1392
+ if (provider && id) return `${provider}/${id}`;
1393
+ if (id) return id;
1394
+ return "none";
1395
+ }
1396
+
1397
+ function formatModelLabelWithThinking(modelLabel: string, thinkingLevel?: string): string {
1398
+ const base = String(modelLabel || "").replace(/\s*\([^)]*\)\s*$/, "").trim() || "none";
1399
+ if (base === "none") return "none";
1400
+ const level = String(thinkingLevel ?? "").trim();
1401
+ if (!level) return base;
1402
+ return `${base} (${level})`;
1403
+ }
1404
+
1405
+ function buildTerminalSessionLabel(cwd: string, sessionName?: string): string {
1406
+ const cwdBase = basename(cwd || process.cwd() || "") || cwd || "~";
1407
+ const termProgram = String(process.env.TERM_PROGRAM ?? "").trim();
1408
+ const name = String(sessionName ?? "").trim();
1409
+ const parts: string[] = [];
1410
+ if (termProgram) parts.push(termProgram);
1411
+ if (name) parts.push(name);
1412
+ parts.push(cwdBase);
1413
+ return parts.join(" · ");
1414
+ }
1415
+
1416
+ function sanitizePdfFilename(input: string | undefined): string {
1417
+ const fallback = "studio-preview.pdf";
1418
+ const raw = String(input ?? "").trim();
1419
+ if (!raw) return fallback;
1420
+
1421
+ const noPath = raw.split(/[\\/]/).pop() ?? raw;
1422
+ const cleaned = noPath
1423
+ .replace(/[\x00-\x1f\x7f]+/g, "")
1424
+ .replace(/[<>:"|?*]+/g, "-")
1425
+ .trim();
1426
+ if (!cleaned) return fallback;
1427
+
1428
+ const ensuredExt = cleaned.toLowerCase().endsWith(".pdf") ? cleaned : `${cleaned}.pdf`;
1429
+ if (ensuredExt.length <= 160) return ensuredExt;
1430
+ return `${ensuredExt.slice(0, 156)}.pdf`;
1431
+ }
1432
+
1295
1433
  function buildThemeCssVars(style: StudioThemeStyle): Record<string, string> {
1296
1434
  const panelShadow =
1297
1435
  style.mode === "light"
@@ -1358,11 +1496,18 @@ function buildThemeCssVars(style: StudioThemeStyle): Record<string, string> {
1358
1496
  };
1359
1497
  }
1360
1498
 
1361
- function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?: Theme): string {
1499
+ function buildStudioHtml(
1500
+ initialDocument: InitialStudioDocument | null,
1501
+ theme?: Theme,
1502
+ initialModelLabel?: string,
1503
+ initialTerminalLabel?: string,
1504
+ ): string {
1362
1505
  const initialText = escapeHtmlForInline(initialDocument?.text ?? "");
1363
1506
  const initialSource = initialDocument?.source ?? "blank";
1364
1507
  const initialLabel = escapeHtmlForInline(initialDocument?.label ?? "blank");
1365
1508
  const initialPath = escapeHtmlForInline(initialDocument?.path ?? "");
1509
+ const initialModel = escapeHtmlForInline(initialModelLabel ?? "none");
1510
+ const initialTerminal = escapeHtmlForInline(initialTerminalLabel ?? "unknown");
1366
1511
  const style = getStudioThemeStyle(theme);
1367
1512
  const vars = buildThemeCssVars(style);
1368
1513
  const mermaidConfig = {
@@ -2186,28 +2331,104 @@ ${cssVarsBlock}
2186
2331
  font-size: 12px;
2187
2332
  min-height: 32px;
2188
2333
  background: var(--panel);
2189
- display: flex;
2334
+ display: grid;
2335
+ grid-template-columns: minmax(0, 1fr) auto;
2336
+ grid-template-areas:
2337
+ "status hint"
2338
+ "meta hint";
2339
+ column-gap: 12px;
2340
+ row-gap: 3px;
2341
+ align-items: start;
2342
+ }
2343
+
2344
+ #statusLine {
2345
+ grid-area: status;
2346
+ display: inline-flex;
2190
2347
  align-items: center;
2191
- justify-content: space-between;
2192
- gap: 10px;
2193
- flex-wrap: wrap;
2348
+ gap: 0;
2349
+ min-width: 0;
2350
+ justify-self: start;
2351
+ text-align: left;
2352
+ }
2353
+
2354
+ #statusLine.with-spinner {
2355
+ gap: 6px;
2356
+ }
2357
+
2358
+ #statusSpinner {
2359
+ width: 0;
2360
+ max-width: 0;
2361
+ overflow: hidden;
2362
+ opacity: 0;
2363
+ text-align: center;
2364
+ color: var(--accent);
2365
+ font-family: var(--font-mono);
2366
+ flex: 0 0 auto;
2367
+ transition: opacity 120ms ease;
2368
+ }
2369
+
2370
+ #statusLine.with-spinner #statusSpinner {
2371
+ width: 1.1em;
2372
+ max-width: 1.1em;
2373
+ opacity: 1;
2194
2374
  }
2195
2375
 
2196
2376
  #status {
2197
- flex: 1 1 auto;
2198
- min-width: 240px;
2377
+ min-width: 0;
2378
+ white-space: nowrap;
2379
+ overflow: hidden;
2380
+ text-overflow: ellipsis;
2381
+ text-align: left;
2382
+ }
2383
+
2384
+ .footer-meta {
2385
+ grid-area: meta;
2386
+ justify-self: start;
2387
+ color: var(--muted);
2388
+ font-size: 11px;
2389
+ white-space: nowrap;
2390
+ overflow: hidden;
2391
+ text-overflow: ellipsis;
2392
+ text-align: left;
2393
+ max-width: 100%;
2199
2394
  }
2200
2395
 
2201
2396
  .shortcut-hint {
2397
+ grid-area: hint;
2398
+ justify-self: end;
2399
+ align-self: center;
2202
2400
  color: var(--muted);
2203
2401
  font-size: 11px;
2204
2402
  white-space: nowrap;
2403
+ text-align: right;
2205
2404
  font-style: normal;
2405
+ opacity: 0.9;
2206
2406
  }
2207
2407
 
2208
- footer.error { color: var(--error); }
2209
- footer.warning { color: var(--warn); }
2210
- footer.success { color: var(--ok); }
2408
+ #status.error { color: var(--error); }
2409
+ #status.warning { color: var(--warn); }
2410
+ #status.success { color: var(--ok); }
2411
+
2412
+ @media (max-width: 980px) {
2413
+ footer {
2414
+ grid-template-columns: 1fr;
2415
+ grid-template-areas:
2416
+ "status"
2417
+ "meta"
2418
+ "hint";
2419
+ }
2420
+
2421
+ .footer-meta {
2422
+ justify-self: start;
2423
+ max-width: 100%;
2424
+ }
2425
+
2426
+ .shortcut-hint {
2427
+ justify-self: start;
2428
+ text-align: left;
2429
+ white-space: normal;
2430
+ }
2431
+ }
2211
2432
 
2212
2433
  @media (max-width: 1080px) {
2213
2434
  main {
@@ -2216,7 +2437,7 @@ ${cssVarsBlock}
2216
2437
  }
2217
2438
  </style>
2218
2439
  </head>
2219
- <body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}">
2440
+ <body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}">
2220
2441
  <header>
2221
2442
  <h1><span class="app-logo" aria-hidden="true">π</span> Pi Studio <span class="app-subtitle">Editor & Response Workspace</span></h1>
2222
2443
  <div class="controls">
@@ -2327,13 +2548,15 @@ ${cssVarsBlock}
2327
2548
  <button id="loadCritiqueNotesBtn" type="button" hidden>Load critique notes into editor</button>
2328
2549
  <button id="loadCritiqueFullBtn" type="button" hidden>Load full critique into editor</button>
2329
2550
  <button id="copyResponseBtn" type="button">Copy response text</button>
2551
+ <button id="exportPdfBtn" type="button" title="Export the current right-pane preview as PDF via pandoc + xelatex.">Export right preview as PDF</button>
2330
2552
  </div>
2331
2553
  </div>
2332
2554
  </section>
2333
2555
  </main>
2334
2556
 
2335
2557
  <footer>
2336
- <span id="status">Booting studio…</span>
2558
+ <span id="statusLine"><span id="statusSpinner" aria-hidden="true"> </span><span id="status">Booting studio…</span></span>
2559
+ <span id="footerMeta" class="footer-meta">Model: ${initialModel} · Terminal: ${initialTerminal}</span>
2337
2560
  <span class="shortcut-hint">Focus pane: Cmd/Ctrl+Esc (or F10), Esc to exit · Run editor text: Cmd/Ctrl+Enter</span>
2338
2561
  </footer>
2339
2562
 
@@ -2341,15 +2564,31 @@ ${cssVarsBlock}
2341
2564
  <script defer src="https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.min.js"></script>
2342
2565
  <script>
2343
2566
  (() => {
2567
+ const statusLineEl = document.getElementById("statusLine");
2344
2568
  const statusEl = document.getElementById("status");
2569
+ const statusSpinnerEl = document.getElementById("statusSpinner");
2570
+ const footerMetaEl = document.getElementById("footerMeta");
2571
+ const BRAILLE_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2572
+ let spinnerTimer = null;
2573
+ let spinnerFrameIndex = 0;
2345
2574
  if (statusEl) {
2346
- statusEl.textContent = "WS: Connecting · Studio script starting…";
2575
+ statusEl.textContent = "Connecting · Studio script starting…";
2347
2576
  }
2348
2577
 
2349
2578
  function hardFail(prefix, error) {
2350
2579
  const details = error && error.message ? error.message : String(error || "unknown error");
2580
+ if (spinnerTimer) {
2581
+ window.clearInterval(spinnerTimer);
2582
+ spinnerTimer = null;
2583
+ }
2584
+ if (statusLineEl && statusLineEl.classList) {
2585
+ statusLineEl.classList.remove("with-spinner");
2586
+ }
2587
+ if (statusSpinnerEl) {
2588
+ statusSpinnerEl.textContent = "";
2589
+ }
2351
2590
  if (statusEl) {
2352
- statusEl.textContent = "WS: Disconnected · " + prefix + ": " + details;
2591
+ statusEl.textContent = "Disconnected · " + prefix + ": " + details;
2353
2592
  statusEl.className = "error";
2354
2593
  }
2355
2594
  }
@@ -2391,6 +2630,7 @@ ${cssVarsBlock}
2391
2630
  const loadCritiqueNotesBtn = document.getElementById("loadCritiqueNotesBtn");
2392
2631
  const loadCritiqueFullBtn = document.getElementById("loadCritiqueFullBtn");
2393
2632
  const copyResponseBtn = document.getElementById("copyResponseBtn");
2633
+ const exportPdfBtn = document.getElementById("exportPdfBtn");
2394
2634
  const saveAsBtn = document.getElementById("saveAsBtn");
2395
2635
  const saveOverBtn = document.getElementById("saveOverBtn");
2396
2636
  const sendEditorBtn = document.getElementById("sendEditorBtn");
@@ -2408,7 +2648,7 @@ ${cssVarsBlock}
2408
2648
 
2409
2649
  let ws = null;
2410
2650
  let wsState = "Connecting";
2411
- let statusMessage = "Studio script starting…";
2651
+ let statusMessage = "Connecting · Studio script starting…";
2412
2652
  let statusLevel = "";
2413
2653
  let pendingRequestId = null;
2414
2654
  let pendingKind = null;
@@ -2432,6 +2672,9 @@ ${cssVarsBlock}
2432
2672
  let terminalActivityLabel = "";
2433
2673
  let lastSpecificToolLabel = "";
2434
2674
  let uiBusy = false;
2675
+ let pdfExportInProgress = false;
2676
+ let modelLabel = (document.body && document.body.dataset && document.body.dataset.modelLabel) || "none";
2677
+ let terminalSessionLabel = (document.body && document.body.dataset && document.body.dataset.terminalLabel) || "unknown";
2435
2678
  let sourceState = {
2436
2679
  source: initialSourceState.source,
2437
2680
  label: initialSourceState.label,
@@ -2547,6 +2790,8 @@ ${cssVarsBlock}
2547
2790
  if (typeof message.terminalPhase === "string") summary.terminalPhase = message.terminalPhase;
2548
2791
  if (typeof message.terminalToolName === "string") summary.terminalToolName = message.terminalToolName;
2549
2792
  if (typeof message.terminalActivityLabel === "string") summary.terminalActivityLabel = message.terminalActivityLabel;
2793
+ if (typeof message.modelLabel === "string") summary.modelLabel = message.modelLabel;
2794
+ if (typeof message.terminalSessionLabel === "string") summary.terminalSessionLabel = message.terminalSessionLabel;
2550
2795
  if (typeof message.stopReason === "string") summary.stopReason = message.stopReason;
2551
2796
  if (typeof message.markdown === "string") summary.markdownLength = message.markdown.length;
2552
2797
  if (typeof message.label === "string") summary.label = message.label;
@@ -2555,7 +2800,7 @@ ${cssVarsBlock}
2555
2800
  }
2556
2801
 
2557
2802
  function getIdleStatus() {
2558
- return "Ready. Edit, load, or annotate text, then run, save, send to pi editor, or critique.";
2803
+ return "Edit, load, or annotate text, then run, save, send to pi editor, or critique.";
2559
2804
  }
2560
2805
 
2561
2806
  function normalizeTerminalPhase(phase) {
@@ -2595,6 +2840,8 @@ ${cssVarsBlock}
2595
2840
  if (terminalActivityPhase === "idle") {
2596
2841
  lastSpecificToolLabel = "";
2597
2842
  }
2843
+
2844
+ syncFooterSpinnerState();
2598
2845
  }
2599
2846
 
2600
2847
  function getTerminalBusyStatus() {
@@ -2650,20 +2897,67 @@ ${cssVarsBlock}
2650
2897
  return "Studio: " + action + "…";
2651
2898
  }
2652
2899
 
2900
+ function shouldAnimateFooterSpinner() {
2901
+ return wsState !== "Disconnected" && (uiBusy || agentBusyFromServer || terminalActivityPhase !== "idle");
2902
+ }
2903
+
2904
+ function updateFooterMeta() {
2905
+ if (!footerMetaEl) return;
2906
+ const modelText = modelLabel && modelLabel.trim() ? modelLabel.trim() : "none";
2907
+ const terminalText = terminalSessionLabel && terminalSessionLabel.trim() ? terminalSessionLabel.trim() : "unknown";
2908
+ footerMetaEl.textContent = "Model: " + modelText + " · Terminal: " + terminalText;
2909
+ }
2910
+
2911
+ function stopFooterSpinner() {
2912
+ if (spinnerTimer) {
2913
+ window.clearInterval(spinnerTimer);
2914
+ spinnerTimer = null;
2915
+ }
2916
+ }
2917
+
2918
+ function startFooterSpinner() {
2919
+ if (spinnerTimer) return;
2920
+ spinnerTimer = window.setInterval(() => {
2921
+ spinnerFrameIndex = (spinnerFrameIndex + 1) % BRAILLE_SPINNER_FRAMES.length;
2922
+ renderStatus();
2923
+ }, 80);
2924
+ }
2925
+
2926
+ function syncFooterSpinnerState() {
2927
+ if (shouldAnimateFooterSpinner()) {
2928
+ startFooterSpinner();
2929
+ } else {
2930
+ stopFooterSpinner();
2931
+ }
2932
+ }
2933
+
2653
2934
  function renderStatus() {
2654
- const prefix = "WS: " + wsState;
2655
- statusEl.textContent = prefix + " · " + statusMessage;
2935
+ statusEl.textContent = statusMessage;
2656
2936
  statusEl.className = statusLevel || "";
2937
+
2938
+ const spinnerActive = shouldAnimateFooterSpinner();
2939
+ if (statusLineEl && statusLineEl.classList) {
2940
+ statusLineEl.classList.toggle("with-spinner", spinnerActive);
2941
+ }
2942
+ if (statusSpinnerEl) {
2943
+ statusSpinnerEl.textContent = spinnerActive
2944
+ ? (BRAILLE_SPINNER_FRAMES[spinnerFrameIndex % BRAILLE_SPINNER_FRAMES.length] || "")
2945
+ : "";
2946
+ }
2947
+
2948
+ updateFooterMeta();
2657
2949
  }
2658
2950
 
2659
2951
  function setWsState(nextState) {
2660
2952
  wsState = nextState || "Disconnected";
2953
+ syncFooterSpinnerState();
2661
2954
  renderStatus();
2662
2955
  }
2663
2956
 
2664
2957
  function setStatus(message, level) {
2665
2958
  statusMessage = message;
2666
2959
  statusLevel = level || "";
2960
+ syncFooterSpinnerState();
2667
2961
  renderStatus();
2668
2962
  debugTrace("status", {
2669
2963
  wsState,
@@ -3097,6 +3391,126 @@ ${cssVarsBlock}
3097
3391
  return payload.html;
3098
3392
  }
3099
3393
 
3394
+ function parseContentDispositionFilename(headerValue) {
3395
+ if (!headerValue || typeof headerValue !== "string") return "";
3396
+
3397
+ const utfMatch = headerValue.match(/filename\\*=UTF-8''([^;]+)/i);
3398
+ if (utfMatch && utfMatch[1]) {
3399
+ try {
3400
+ return decodeURIComponent(utfMatch[1].trim());
3401
+ } catch {
3402
+ return utfMatch[1].trim();
3403
+ }
3404
+ }
3405
+
3406
+ const quotedMatch = headerValue.match(/filename="([^"]+)"/i);
3407
+ if (quotedMatch && quotedMatch[1]) return quotedMatch[1].trim();
3408
+
3409
+ const plainMatch = headerValue.match(/filename=([^;]+)/i);
3410
+ if (plainMatch && plainMatch[1]) return plainMatch[1].trim();
3411
+
3412
+ return "";
3413
+ }
3414
+
3415
+ async function exportRightPanePdf() {
3416
+ if (uiBusy || pdfExportInProgress) {
3417
+ setStatus("Studio is busy.", "warning");
3418
+ return;
3419
+ }
3420
+
3421
+ const token = getToken();
3422
+ if (!token) {
3423
+ setStatus("Missing Studio token in URL. Re-run /studio.", "error");
3424
+ return;
3425
+ }
3426
+
3427
+ const rightPaneShowsPreview = rightView === "preview" || rightView === "editor-preview";
3428
+ if (!rightPaneShowsPreview) {
3429
+ setStatus("Switch right pane to Response (Preview) or Editor (Preview) to export PDF.", "warning");
3430
+ return;
3431
+ }
3432
+
3433
+ const markdown = rightView === "editor-preview" ? sourceTextEl.value : latestResponseMarkdown;
3434
+ if (!markdown || !markdown.trim()) {
3435
+ setStatus("Nothing to export yet.", "warning");
3436
+ return;
3437
+ }
3438
+
3439
+ const sourcePath = sourceState.path || "";
3440
+ const resourceDir = (!sourceState.path && resourceDirInput) ? resourceDirInput.value.trim() : "";
3441
+ const isLatex = /\\\\documentclass\\b|\\\\begin\\{document\\}/.test(markdown);
3442
+ let filenameHint = rightView === "editor-preview" ? "studio-editor-preview.pdf" : "studio-response-preview.pdf";
3443
+ if (sourceState.path) {
3444
+ const baseName = sourceState.path.split(/[\\\\/]/).pop() || "studio";
3445
+ const stem = baseName.replace(/\\.[^.]+$/, "") || "studio";
3446
+ filenameHint = stem + "-preview.pdf";
3447
+ }
3448
+
3449
+ pdfExportInProgress = true;
3450
+ updateResultActionButtons();
3451
+ setStatus("Exporting PDF…", "warning");
3452
+
3453
+ try {
3454
+ const response = await fetch("/export-pdf?token=" + encodeURIComponent(token), {
3455
+ method: "POST",
3456
+ headers: {
3457
+ "Content-Type": "application/json",
3458
+ },
3459
+ body: JSON.stringify({
3460
+ markdown: String(markdown || ""),
3461
+ sourcePath: sourcePath,
3462
+ resourceDir: resourceDir,
3463
+ isLatex: isLatex,
3464
+ filenameHint: filenameHint,
3465
+ }),
3466
+ });
3467
+
3468
+ if (!response.ok) {
3469
+ const contentType = String(response.headers.get("content-type") || "").toLowerCase();
3470
+ let message = "PDF export failed with HTTP " + response.status + ".";
3471
+ if (contentType.includes("application/json")) {
3472
+ const payload = await response.json().catch(() => null);
3473
+ if (payload && typeof payload.error === "string") {
3474
+ message = payload.error;
3475
+ }
3476
+ } else {
3477
+ const text = await response.text().catch(() => "");
3478
+ if (text && text.trim()) {
3479
+ message = text.trim();
3480
+ }
3481
+ }
3482
+ throw new Error(message);
3483
+ }
3484
+
3485
+ const blob = await response.blob();
3486
+ const headerFilename = parseContentDispositionFilename(response.headers.get("content-disposition"));
3487
+ let downloadName = headerFilename || filenameHint || "studio-preview.pdf";
3488
+ if (!/\\.pdf$/i.test(downloadName)) {
3489
+ downloadName += ".pdf";
3490
+ }
3491
+
3492
+ const blobUrl = URL.createObjectURL(blob);
3493
+ const link = document.createElement("a");
3494
+ link.href = blobUrl;
3495
+ link.download = downloadName;
3496
+ link.rel = "noopener";
3497
+ document.body.appendChild(link);
3498
+ link.click();
3499
+ link.remove();
3500
+ window.setTimeout(() => {
3501
+ URL.revokeObjectURL(blobUrl);
3502
+ }, 1800);
3503
+
3504
+ setStatus("Exported PDF: " + downloadName, "success");
3505
+ } catch (error) {
3506
+ const detail = error && error.message ? error.message : String(error || "unknown error");
3507
+ setStatus("PDF export failed: " + detail, "error");
3508
+ } finally {
3509
+ pdfExportInProgress = false;
3510
+ updateResultActionButtons();
3511
+ }
3512
+ }
3513
+
3100
3514
  async function applyRenderedMarkdown(targetEl, markdown, pane, nonce) {
3101
3515
  try {
3102
3516
  const renderedHtml = await renderMarkdownWithPandoc(markdown);
@@ -3270,6 +3684,20 @@ ${cssVarsBlock}
3270
3684
 
3271
3685
  copyResponseBtn.disabled = uiBusy || !hasResponse;
3272
3686
 
3687
+ const rightPaneShowsPreview = rightView === "preview" || rightView === "editor-preview";
3688
+ const exportText = rightView === "editor-preview" ? sourceTextEl.value : latestResponseMarkdown;
3689
+ const canExportPdf = rightPaneShowsPreview && Boolean(String(exportText || "").trim());
3690
+ if (exportPdfBtn) {
3691
+ exportPdfBtn.disabled = uiBusy || pdfExportInProgress || !canExportPdf;
3692
+ if (rightView === "markdown") {
3693
+ exportPdfBtn.title = "Switch right pane to Response (Preview) or Editor (Preview) to export PDF.";
3694
+ } else if (!canExportPdf) {
3695
+ exportPdfBtn.title = "Nothing to export yet.";
3696
+ } else {
3697
+ exportPdfBtn.title = "Export the current right-pane preview as PDF via pandoc + xelatex.";
3698
+ }
3699
+ }
3700
+
3273
3701
  pullLatestBtn.disabled = uiBusy || followLatest;
3274
3702
  pullLatestBtn.textContent = queuedLatestResponse ? "Get latest response *" : "Get latest response";
3275
3703
 
@@ -3331,6 +3759,8 @@ ${cssVarsBlock}
3331
3759
 
3332
3760
  function setBusy(busy) {
3333
3761
  uiBusy = Boolean(busy);
3762
+ syncFooterSpinnerState();
3763
+ renderStatus();
3334
3764
  syncActionButtons();
3335
3765
  }
3336
3766
 
@@ -4058,6 +4488,13 @@ ${cssVarsBlock}
4058
4488
  const busy = Boolean(message.busy);
4059
4489
  agentBusyFromServer = Boolean(message.agentBusy);
4060
4490
  updateTerminalActivityState(message.terminalPhase, message.terminalToolName, message.terminalActivityLabel);
4491
+ if (typeof message.modelLabel === "string") {
4492
+ modelLabel = message.modelLabel;
4493
+ }
4494
+ if (typeof message.terminalSessionLabel === "string") {
4495
+ terminalSessionLabel = message.terminalSessionLabel;
4496
+ }
4497
+ updateFooterMeta();
4061
4498
  setBusy(busy);
4062
4499
  setWsState(busy ? "Submitting" : "Ready");
4063
4500
  if (typeof message.activeRequestId === "string" && message.activeRequestId.length > 0) {
@@ -4249,6 +4686,13 @@ ${cssVarsBlock}
4249
4686
  const busy = Boolean(message.busy);
4250
4687
  agentBusyFromServer = Boolean(message.agentBusy);
4251
4688
  updateTerminalActivityState(message.terminalPhase, message.terminalToolName, message.terminalActivityLabel);
4689
+ if (typeof message.modelLabel === "string") {
4690
+ modelLabel = message.modelLabel;
4691
+ }
4692
+ if (typeof message.terminalSessionLabel === "string") {
4693
+ terminalSessionLabel = message.terminalSessionLabel;
4694
+ }
4695
+ updateFooterMeta();
4252
4696
 
4253
4697
  if (typeof message.activeRequestId === "string" && message.activeRequestId.length > 0) {
4254
4698
  pendingRequestId = message.activeRequestId;
@@ -4479,6 +4923,9 @@ ${cssVarsBlock}
4479
4923
  }
4480
4924
 
4481
4925
  window.addEventListener("keydown", handlePaneShortcut);
4926
+ window.addEventListener("beforeunload", () => {
4927
+ stopFooterSpinner();
4928
+ });
4482
4929
 
4483
4930
  editorViewSelect.addEventListener("change", () => {
4484
4931
  setEditorView(editorViewSelect.value);
@@ -4624,6 +5071,12 @@ ${cssVarsBlock}
4624
5071
  }
4625
5072
  });
4626
5073
 
5074
+ if (exportPdfBtn) {
5075
+ exportPdfBtn.addEventListener("click", () => {
5076
+ void exportRightPanePdf();
5077
+ });
5078
+ }
5079
+
4627
5080
  saveAsBtn.addEventListener("click", () => {
4628
5081
  const content = sourceTextEl.value;
4629
5082
  if (!content.trim()) {
@@ -4892,9 +5345,37 @@ export default function (pi: ExtensionAPI) {
4892
5345
  let terminalActivityToolName: string | null = null;
4893
5346
  let terminalActivityLabel: string | null = null;
4894
5347
  let lastSpecificToolActivityLabel: string | null = null;
5348
+ let currentModelLabel = "none";
5349
+ let terminalSessionLabel = buildTerminalSessionLabel(studioCwd);
4895
5350
 
4896
5351
  const isStudioBusy = () => agentBusy || activeRequest !== null;
4897
5352
 
5353
+ const getSessionNameSafe = (): string | undefined => {
5354
+ try {
5355
+ return pi.getSessionName();
5356
+ } catch {
5357
+ return undefined;
5358
+ }
5359
+ };
5360
+
5361
+ const getThinkingLevelSafe = (): string | undefined => {
5362
+ try {
5363
+ return pi.getThinkingLevel();
5364
+ } catch {
5365
+ return undefined;
5366
+ }
5367
+ };
5368
+
5369
+ const refreshRuntimeMetadata = (ctx?: { cwd?: string; model?: { provider?: string; id?: string } | undefined }) => {
5370
+ if (ctx?.cwd) {
5371
+ studioCwd = ctx.cwd;
5372
+ }
5373
+ const model = ctx?.model ?? lastCommandCtx?.model;
5374
+ const baseModelLabel = formatModelLabel(model);
5375
+ currentModelLabel = formatModelLabelWithThinking(baseModelLabel, getThinkingLevelSafe());
5376
+ terminalSessionLabel = buildTerminalSessionLabel(studioCwd, getSessionNameSafe());
5377
+ };
5378
+
4898
5379
  const notifyStudio = (message: string, level: "info" | "warning" | "error" = "info") => {
4899
5380
  if (!lastCommandCtx) return;
4900
5381
  lastCommandCtx.ui.notify(message, level);
@@ -4986,6 +5467,8 @@ export default function (pi: ExtensionAPI) {
4986
5467
  };
4987
5468
 
4988
5469
  const broadcastState = () => {
5470
+ terminalSessionLabel = buildTerminalSessionLabel(studioCwd, getSessionNameSafe());
5471
+ currentModelLabel = formatModelLabelWithThinking(currentModelLabel, getThinkingLevelSafe());
4989
5472
  broadcast({
4990
5473
  type: "studio_state",
4991
5474
  busy: isStudioBusy(),
@@ -4993,6 +5476,8 @@ export default function (pi: ExtensionAPI) {
4993
5476
  terminalPhase: terminalActivityPhase,
4994
5477
  terminalToolName: terminalActivityToolName,
4995
5478
  terminalActivityLabel,
5479
+ modelLabel: currentModelLabel,
5480
+ terminalSessionLabel,
4996
5481
  activeRequestId: activeRequest?.id ?? null,
4997
5482
  activeRequestKind: activeRequest?.kind ?? null,
4998
5483
  });
@@ -5086,6 +5571,8 @@ export default function (pi: ExtensionAPI) {
5086
5571
  terminalPhase: terminalActivityPhase,
5087
5572
  terminalToolName: terminalActivityToolName,
5088
5573
  terminalActivityLabel,
5574
+ modelLabel: currentModelLabel,
5575
+ terminalSessionLabel,
5089
5576
  activeRequestId: activeRequest?.id ?? null,
5090
5577
  activeRequestKind: activeRequest?.kind ?? null,
5091
5578
  lastResponse: lastStudioResponse,
@@ -5412,6 +5899,84 @@ export default function (pi: ExtensionAPI) {
5412
5899
  }
5413
5900
  };
5414
5901
 
5902
+ const handleExportPdfRequest = async (req: IncomingMessage, res: ServerResponse) => {
5903
+ let rawBody = "";
5904
+ try {
5905
+ rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
5906
+ } catch (error) {
5907
+ const message = error instanceof Error ? error.message : String(error);
5908
+ const status = message.includes("exceeds") ? 413 : 400;
5909
+ respondJson(res, status, { ok: false, error: message });
5910
+ return;
5911
+ }
5912
+
5913
+ let parsedBody: unknown;
5914
+ try {
5915
+ parsedBody = rawBody ? JSON.parse(rawBody) : {};
5916
+ } catch {
5917
+ respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
5918
+ return;
5919
+ }
5920
+
5921
+ const markdown =
5922
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { markdown?: unknown }).markdown === "string"
5923
+ ? (parsedBody as { markdown: string }).markdown
5924
+ : null;
5925
+ if (markdown === null) {
5926
+ respondJson(res, 400, { ok: false, error: "Missing markdown string in request body." });
5927
+ return;
5928
+ }
5929
+
5930
+ if (markdown.length > PDF_EXPORT_MAX_CHARS) {
5931
+ respondJson(res, 413, {
5932
+ ok: false,
5933
+ error: `PDF export text exceeds ${PDF_EXPORT_MAX_CHARS} characters.`,
5934
+ });
5935
+ return;
5936
+ }
5937
+
5938
+ const sourcePath =
5939
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { sourcePath?: unknown }).sourcePath === "string"
5940
+ ? (parsedBody as { sourcePath: string }).sourcePath
5941
+ : "";
5942
+ const userResourceDir =
5943
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { resourceDir?: unknown }).resourceDir === "string"
5944
+ ? (parsedBody as { resourceDir: string }).resourceDir
5945
+ : "";
5946
+ const resourcePath = sourcePath ? dirname(sourcePath) : (userResourceDir || studioCwd || undefined);
5947
+ const requestedIsLatex =
5948
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { isLatex?: unknown }).isLatex === "boolean"
5949
+ ? (parsedBody as { isLatex: boolean }).isLatex
5950
+ : null;
5951
+ const isLatex = requestedIsLatex ?? /\\documentclass\b|\\begin\{document\}/.test(markdown);
5952
+ const requestedFilename =
5953
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { filenameHint?: unknown }).filenameHint === "string"
5954
+ ? (parsedBody as { filenameHint: string }).filenameHint
5955
+ : "";
5956
+ const filename = sanitizePdfFilename(requestedFilename || (isLatex ? "studio-latex-preview.pdf" : "studio-preview.pdf"));
5957
+
5958
+ try {
5959
+ const pdf = await renderStudioPdfWithPandoc(markdown, isLatex, resourcePath);
5960
+ const safeAsciiName = filename
5961
+ .replace(/[\x00-\x1f\x7f]/g, "")
5962
+ .replace(/[;"\\]/g, "_")
5963
+ .replace(/\s+/g, " ")
5964
+ .trim() || "studio-preview.pdf";
5965
+
5966
+ res.writeHead(200, {
5967
+ "Content-Type": "application/pdf",
5968
+ "Cache-Control": "no-store",
5969
+ "X-Content-Type-Options": "nosniff",
5970
+ "Content-Disposition": `attachment; filename="${safeAsciiName}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
5971
+ "Content-Length": String(pdf.length),
5972
+ });
5973
+ res.end(pdf);
5974
+ } catch (error) {
5975
+ const message = error instanceof Error ? error.message : String(error);
5976
+ respondJson(res, 500, { ok: false, error: `PDF export failed: ${message}` });
5977
+ }
5978
+ };
5979
+
5415
5980
  const handleHttpRequest = (req: IncomingMessage, res: ServerResponse) => {
5416
5981
  if (!serverState) {
5417
5982
  respondText(res, 503, "Studio server not ready");
@@ -5461,6 +6026,29 @@ export default function (pi: ExtensionAPI) {
5461
6026
  return;
5462
6027
  }
5463
6028
 
6029
+ if (requestUrl.pathname === "/export-pdf") {
6030
+ const token = requestUrl.searchParams.get("token") ?? "";
6031
+ if (token !== serverState.token) {
6032
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
6033
+ return;
6034
+ }
6035
+
6036
+ const method = (req.method ?? "GET").toUpperCase();
6037
+ if (method !== "POST") {
6038
+ res.setHeader("Allow", "POST");
6039
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
6040
+ return;
6041
+ }
6042
+
6043
+ void handleExportPdfRequest(req, res).catch((error) => {
6044
+ respondJson(res, 500, {
6045
+ ok: false,
6046
+ error: `PDF export failed: ${error instanceof Error ? error.message : String(error)}`,
6047
+ });
6048
+ });
6049
+ return;
6050
+ }
6051
+
5464
6052
  if (requestUrl.pathname !== "/") {
5465
6053
  respondText(res, 404, "Not found");
5466
6054
  return;
@@ -5480,7 +6068,7 @@ export default function (pi: ExtensionAPI) {
5480
6068
  "Cross-Origin-Opener-Policy": "same-origin",
5481
6069
  "Cross-Origin-Resource-Policy": "same-origin",
5482
6070
  });
5483
- res.end(buildStudioHtml(initialStudioDocument, lastCommandCtx?.ui.theme));
6071
+ res.end(buildStudioHtml(initialStudioDocument, lastCommandCtx?.ui.theme, currentModelLabel, terminalSessionLabel));
5484
6072
  };
5485
6073
 
5486
6074
  const ensureServer = async (): Promise<StudioServerState> => {
@@ -5572,9 +6160,22 @@ export default function (pi: ExtensionAPI) {
5572
6160
 
5573
6161
  serverState = state;
5574
6162
 
5575
- // Periodically check for theme changes and push to all clients
6163
+ // Periodically check for theme/model metadata changes and push to all clients
5576
6164
  const themeCheckInterval = setInterval(() => {
5577
- if (!lastCommandCtx?.ui?.theme || !serverState || serverState.clients.size === 0) return;
6165
+ if (!serverState || serverState.clients.size === 0) return;
6166
+
6167
+ try {
6168
+ const previousModelLabel = currentModelLabel;
6169
+ const previousTerminalLabel = terminalSessionLabel;
6170
+ refreshRuntimeMetadata();
6171
+ if (currentModelLabel !== previousModelLabel || terminalSessionLabel !== previousTerminalLabel) {
6172
+ broadcastState();
6173
+ }
6174
+ } catch {
6175
+ // Ignore metadata read errors
6176
+ }
6177
+
6178
+ if (!lastCommandCtx?.ui?.theme) return;
5578
6179
  try {
5579
6180
  const style = getStudioThemeStyle(lastCommandCtx.ui.theme);
5580
6181
  const vars = buildThemeCssVars(style);
@@ -5632,7 +6233,12 @@ export default function (pi: ExtensionAPI) {
5632
6233
  pi.on("session_start", async (_event, ctx) => {
5633
6234
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
5634
6235
  agentBusy = false;
5635
- emitDebugEvent("session_start", { entryCount: ctx.sessionManager.getBranch().length });
6236
+ refreshRuntimeMetadata(ctx);
6237
+ emitDebugEvent("session_start", {
6238
+ entryCount: ctx.sessionManager.getBranch().length,
6239
+ modelLabel: currentModelLabel,
6240
+ terminalSessionLabel,
6241
+ });
5636
6242
  setTerminalActivity("idle");
5637
6243
  });
5638
6244
 
@@ -5641,10 +6247,25 @@ export default function (pi: ExtensionAPI) {
5641
6247
  lastCommandCtx = null;
5642
6248
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
5643
6249
  agentBusy = false;
5644
- emitDebugEvent("session_switch", { entryCount: ctx.sessionManager.getBranch().length });
6250
+ refreshRuntimeMetadata(ctx);
6251
+ emitDebugEvent("session_switch", {
6252
+ entryCount: ctx.sessionManager.getBranch().length,
6253
+ modelLabel: currentModelLabel,
6254
+ terminalSessionLabel,
6255
+ });
5645
6256
  setTerminalActivity("idle");
5646
6257
  });
5647
6258
 
6259
+ pi.on("model_select", async (event, ctx) => {
6260
+ refreshRuntimeMetadata({ cwd: ctx.cwd, model: event.model });
6261
+ emitDebugEvent("model_select", {
6262
+ modelLabel: currentModelLabel,
6263
+ source: event.source,
6264
+ previousModel: formatModelLabel(event.previousModel),
6265
+ });
6266
+ broadcastState();
6267
+ });
6268
+
5648
6269
  pi.on("agent_start", async () => {
5649
6270
  agentBusy = true;
5650
6271
  emitDebugEvent("agent_start", { activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
@@ -5813,7 +6434,8 @@ export default function (pi: ExtensionAPI) {
5813
6434
 
5814
6435
  await ctx.waitForIdle();
5815
6436
  lastCommandCtx = ctx;
5816
- studioCwd = ctx.cwd;
6437
+ refreshRuntimeMetadata(ctx);
6438
+ broadcastState();
5817
6439
  // Seed theme vars so first ping doesn't trigger a false update
5818
6440
  try {
5819
6441
  const currentStyle = getStudioThemeStyle(ctx.ui.theme);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Browser GUI for structured critique workflows in pi",
5
5
  "type": "module",
6
6
  "license": "MIT",