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 +11 -0
- package/README.md +6 -3
- package/index.ts +646 -24
- package/package.json +1 -1
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
|
-
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
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
|
-
|
|
2198
|
-
|
|
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
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
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 = "
|
|
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 = "
|
|
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 "
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|