pi-studio 0.4.1 → 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.
Files changed (4) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +9 -4
  3. package/index.ts +1371 -48
  4. package/package.json +1 -1
package/index.ts CHANGED
@@ -2,8 +2,10 @@ 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";
6
- import { dirname, isAbsolute, join, resolve } from "node:path";
7
+ import { tmpdir } from "node:os";
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";
9
11
 
@@ -11,6 +13,7 @@ type Lens = "writing" | "code";
11
13
  type RequestedLens = Lens | "auto";
12
14
  type StudioRequestKind = "critique" | "annotation" | "direct";
13
15
  type StudioSourceKind = "file" | "last-response" | "blank";
16
+ type TerminalActivityPhase = "idle" | "running" | "tool" | "responding";
14
17
 
15
18
  interface StudioServerState {
16
19
  server: Server;
@@ -90,6 +93,11 @@ interface SendToEditorRequestMessage {
90
93
  content: string;
91
94
  }
92
95
 
96
+ interface GetFromEditorRequestMessage {
97
+ type: "get_from_editor_request";
98
+ requestId: string;
99
+ }
100
+
93
101
  type IncomingStudioMessage =
94
102
  | HelloMessage
95
103
  | PingMessage
@@ -99,12 +107,26 @@ type IncomingStudioMessage =
99
107
  | SendRunRequestMessage
100
108
  | SaveAsRequestMessage
101
109
  | SaveOverRequestMessage
102
- | SendToEditorRequestMessage;
110
+ | SendToEditorRequestMessage
111
+ | GetFromEditorRequestMessage;
103
112
 
104
113
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
105
114
  const PREVIEW_RENDER_MAX_CHARS = 400_000;
115
+ const PDF_EXPORT_MAX_CHARS = 400_000;
106
116
  const REQUEST_BODY_MAX_BYTES = 1_000_000;
107
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
+
108
130
  type StudioThemeMode = "dark" | "light";
109
131
 
110
132
  interface StudioPalette {
@@ -793,6 +815,85 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
793
815
  });
794
816
  }
795
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
+
796
897
  function readRequestBody(req: IncomingMessage, maxBytes: number): Promise<string> {
797
898
  return new Promise((resolve, reject) => {
798
899
  const chunks: Buffer[] = [];
@@ -1133,9 +1234,146 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
1133
1234
  };
1134
1235
  }
1135
1236
 
1237
+ if (msg.type === "get_from_editor_request" && typeof msg.requestId === "string") {
1238
+ return {
1239
+ type: "get_from_editor_request",
1240
+ requestId: msg.requestId,
1241
+ };
1242
+ }
1243
+
1136
1244
  return null;
1137
1245
  }
1138
1246
 
1247
+ function normalizeActivityLabel(label: string): string | null {
1248
+ const compact = String(label || "").replace(/\s+/g, " ").trim();
1249
+ if (!compact) return null;
1250
+ if (compact.length <= 96) return compact;
1251
+ return `${compact.slice(0, 93).trimEnd()}…`;
1252
+ }
1253
+
1254
+ function isGenericToolActivityLabel(label: string | null | undefined): boolean {
1255
+ const normalized = String(label || "").trim().toLowerCase();
1256
+ if (!normalized) return true;
1257
+ return normalized.startsWith("running ")
1258
+ || normalized === "reading file"
1259
+ || normalized === "writing file"
1260
+ || normalized === "editing file";
1261
+ }
1262
+
1263
+ function deriveBashActivityLabel(command: string): string | null {
1264
+ const normalized = String(command || "").trim();
1265
+ if (!normalized) return null;
1266
+ const lower = normalized.toLowerCase();
1267
+
1268
+ const segments = lower
1269
+ .split(/(?:&&|\|\||;|\n)+/g)
1270
+ .map((segment) => segment.trim())
1271
+ .filter((segment) => segment.length > 0);
1272
+
1273
+ let hasPwd = false;
1274
+ let hasLsCurrent = false;
1275
+ let hasLsParent = false;
1276
+ let hasFind = false;
1277
+ let hasFindCurrentListing = false;
1278
+ let hasFindParentListing = false;
1279
+
1280
+ for (const segment of segments) {
1281
+ if (/\bpwd\b/.test(segment)) hasPwd = true;
1282
+
1283
+ if (/\bls\b/.test(segment)) {
1284
+ if (/\.\./.test(segment)) hasLsParent = true;
1285
+ else hasLsCurrent = true;
1286
+ }
1287
+
1288
+ if (/\bfind\b/.test(segment)) {
1289
+ hasFind = true;
1290
+ const pathMatch = segment.match(/\bfind\s+([^\s]+)/);
1291
+ const pathToken = pathMatch ? pathMatch[1] : "";
1292
+ const hasSelector = /-(?:name|iname|regex|path|ipath|newer|mtime|mmin|size|user|group)\b/.test(segment);
1293
+ const listingLike = /-maxdepth\s+\d+\b/.test(segment) && !hasSelector;
1294
+
1295
+ if (listingLike) {
1296
+ if (pathToken === ".." || pathToken === "../") {
1297
+ hasFindParentListing = true;
1298
+ } else if (pathToken === "." || pathToken === "./" || pathToken === "") {
1299
+ hasFindCurrentListing = true;
1300
+ }
1301
+ }
1302
+ }
1303
+ }
1304
+
1305
+ const hasCurrentListing = hasLsCurrent || hasFindCurrentListing;
1306
+ const hasParentListing = hasLsParent || hasFindParentListing;
1307
+
1308
+ if (hasCurrentListing && hasParentListing) {
1309
+ return "Listing directory and parent directory files";
1310
+ }
1311
+ if (hasPwd && hasCurrentListing) {
1312
+ return "Listing current directory files";
1313
+ }
1314
+ if (hasParentListing) {
1315
+ return "Listing parent directory files";
1316
+ }
1317
+ if (hasCurrentListing || /\bls\b/.test(lower)) {
1318
+ return "Listing directory files";
1319
+ }
1320
+ if (hasFind || /\bfind\b/.test(lower)) {
1321
+ return "Searching files";
1322
+ }
1323
+ if (/\brg\b/.test(lower) || /\bgrep\b/.test(lower)) {
1324
+ return "Searching text in files";
1325
+ }
1326
+ if (/\bcat\b/.test(lower) || /\bsed\b/.test(lower) || /\bawk\b/.test(lower)) {
1327
+ return "Reading file content";
1328
+ }
1329
+ if (/\bgit\s+status\b/.test(lower)) {
1330
+ return "Checking git status";
1331
+ }
1332
+ if (/\bgit\s+diff\b/.test(lower)) {
1333
+ return "Reviewing git changes";
1334
+ }
1335
+ if (/\bgit\b/.test(lower)) {
1336
+ return "Running git command";
1337
+ }
1338
+ if (/\bnpm\b/.test(lower)) {
1339
+ return "Running npm command";
1340
+ }
1341
+ if (/\bpython3?\b/.test(lower)) {
1342
+ return "Running Python command";
1343
+ }
1344
+ if (/\bnode\b/.test(lower)) {
1345
+ return "Running Node.js command";
1346
+ }
1347
+ return "Running shell command";
1348
+ }
1349
+
1350
+ function deriveToolActivityLabel(toolName: string, args: unknown): string | null {
1351
+ const normalizedTool = String(toolName || "").trim().toLowerCase();
1352
+ const payload = (args && typeof args === "object") ? (args as Record<string, unknown>) : {};
1353
+
1354
+ if (normalizedTool === "bash") {
1355
+ const command = typeof payload.command === "string" ? payload.command : "";
1356
+ return deriveBashActivityLabel(command);
1357
+ }
1358
+ if (normalizedTool === "read") {
1359
+ const path = typeof payload.path === "string" ? payload.path : "";
1360
+ return path ? `Reading ${basename(path)}` : "Reading file";
1361
+ }
1362
+ if (normalizedTool === "write") {
1363
+ const path = typeof payload.path === "string" ? payload.path : "";
1364
+ return path ? `Writing ${basename(path)}` : "Writing file";
1365
+ }
1366
+ if (normalizedTool === "edit") {
1367
+ const path = typeof payload.path === "string" ? payload.path : "";
1368
+ return path ? `Editing ${basename(path)}` : "Editing file";
1369
+ }
1370
+ if (normalizedTool === "find") return "Searching files";
1371
+ if (normalizedTool === "grep") return "Searching text in files";
1372
+ if (normalizedTool === "ls") return "Listing directory files";
1373
+
1374
+ return normalizeActivityLabel(`Running ${normalizedTool || "tool"}`);
1375
+ }
1376
+
1139
1377
  function isAllowedOrigin(_origin: string | undefined, _port: number): boolean {
1140
1378
  // For local-only studio, token auth is the primary guard. In practice,
1141
1379
  // browser origin headers can vary (or be omitted) across wrappers/browsers,
@@ -1148,6 +1386,50 @@ function buildStudioUrl(port: number, token: string): string {
1148
1386
  return `http://127.0.0.1:${port}/?token=${encoded}`;
1149
1387
  }
1150
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
+
1151
1433
  function buildThemeCssVars(style: StudioThemeStyle): Record<string, string> {
1152
1434
  const panelShadow =
1153
1435
  style.mode === "light"
@@ -1214,11 +1496,18 @@ function buildThemeCssVars(style: StudioThemeStyle): Record<string, string> {
1214
1496
  };
1215
1497
  }
1216
1498
 
1217
- 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 {
1218
1505
  const initialText = escapeHtmlForInline(initialDocument?.text ?? "");
1219
1506
  const initialSource = initialDocument?.source ?? "blank";
1220
1507
  const initialLabel = escapeHtmlForInline(initialDocument?.label ?? "blank");
1221
1508
  const initialPath = escapeHtmlForInline(initialDocument?.path ?? "");
1509
+ const initialModel = escapeHtmlForInline(initialModelLabel ?? "none");
1510
+ const initialTerminal = escapeHtmlForInline(initialTerminalLabel ?? "unknown");
1222
1511
  const style = getStudioThemeStyle(theme);
1223
1512
  const vars = buildThemeCssVars(style);
1224
1513
  const mermaidConfig = {
@@ -1696,6 +1985,7 @@ ${cssVarsBlock}
1696
1985
  }
1697
1986
 
1698
1987
  .panel-scroll {
1988
+ position: relative;
1699
1989
  min-height: 0;
1700
1990
  overflow: auto;
1701
1991
  padding: 12px;
@@ -1968,6 +2258,19 @@ ${cssVarsBlock}
1968
2258
  font-style: italic;
1969
2259
  }
1970
2260
 
2261
+ .panel-scroll.preview-pending::after {
2262
+ content: "Updating";
2263
+ position: absolute;
2264
+ top: 10px;
2265
+ right: 12px;
2266
+ color: var(--muted);
2267
+ font-size: 10px;
2268
+ line-height: 1.2;
2269
+ letter-spacing: 0.01em;
2270
+ pointer-events: none;
2271
+ opacity: 0.64;
2272
+ }
2273
+
1971
2274
  .preview-error {
1972
2275
  color: var(--warn);
1973
2276
  margin-bottom: 0.75em;
@@ -2028,28 +2331,104 @@ ${cssVarsBlock}
2028
2331
  font-size: 12px;
2029
2332
  min-height: 32px;
2030
2333
  background: var(--panel);
2031
- 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;
2032
2347
  align-items: center;
2033
- justify-content: space-between;
2034
- gap: 10px;
2035
- 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;
2036
2374
  }
2037
2375
 
2038
2376
  #status {
2039
- flex: 1 1 auto;
2040
- 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%;
2041
2394
  }
2042
2395
 
2043
2396
  .shortcut-hint {
2397
+ grid-area: hint;
2398
+ justify-self: end;
2399
+ align-self: center;
2044
2400
  color: var(--muted);
2045
2401
  font-size: 11px;
2046
2402
  white-space: nowrap;
2403
+ text-align: right;
2047
2404
  font-style: normal;
2405
+ opacity: 0.9;
2048
2406
  }
2049
2407
 
2050
- footer.error { color: var(--error); }
2051
- footer.warning { color: var(--warn); }
2052
- 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
+ }
2053
2432
 
2054
2433
  @media (max-width: 1080px) {
2055
2434
  main {
@@ -2058,7 +2437,7 @@ ${cssVarsBlock}
2058
2437
  }
2059
2438
  </style>
2060
2439
  </head>
2061
- <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}">
2062
2441
  <header>
2063
2442
  <h1><span class="app-logo" aria-hidden="true">π</span> Pi Studio <span class="app-subtitle">Editor & Response Workspace</span></h1>
2064
2443
  <div class="controls">
@@ -2089,7 +2468,7 @@ ${cssVarsBlock}
2089
2468
  <span id="syncBadge" class="source-badge sync-badge">No response loaded</span>
2090
2469
  </div>
2091
2470
  <div class="source-actions">
2092
- <button id="sendRunBtn" type="button" title="Send editor text directly to the model as-is.">Run editor text</button>
2471
+ <button id="sendRunBtn" type="button" title="Send editor text directly to the model as-is. Shortcut: Cmd/Ctrl+Enter when editor pane is active.">Run editor text</button>
2093
2472
  <button id="insertHeaderBtn" type="button" title="Prepends/updates the annotated-reply header in the editor.">Insert annotation header</button>
2094
2473
  <select id="lensSelect" aria-label="Critique focus">
2095
2474
  <option value="auto" selected>Critique focus: Auto</option>
@@ -2098,6 +2477,7 @@ ${cssVarsBlock}
2098
2477
  </select>
2099
2478
  <button id="critiqueBtn" type="button">Critique editor text</button>
2100
2479
  <button id="sendEditorBtn" type="button">Send to pi editor</button>
2480
+ <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
2101
2481
  <button id="copyDraftBtn" type="button">Copy editor text</button>
2102
2482
  <select id="highlightSelect" aria-label="Editor syntax highlighting">
2103
2483
  <option value="off">Syntax highlight: Off</option>
@@ -2168,29 +2548,47 @@ ${cssVarsBlock}
2168
2548
  <button id="loadCritiqueNotesBtn" type="button" hidden>Load critique notes into editor</button>
2169
2549
  <button id="loadCritiqueFullBtn" type="button" hidden>Load full critique into editor</button>
2170
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>
2171
2552
  </div>
2172
2553
  </div>
2173
2554
  </section>
2174
2555
  </main>
2175
2556
 
2176
2557
  <footer>
2177
- <span id="status">Booting studio…</span>
2178
- <span class="shortcut-hint">Focus pane: Cmd/Ctrl+Esc (or F10), Esc to exit</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>
2560
+ <span class="shortcut-hint">Focus pane: Cmd/Ctrl+Esc (or F10), Esc to exit · Run editor text: Cmd/Ctrl+Enter</span>
2179
2561
  </footer>
2180
2562
 
2181
2563
  <!-- Defer sanitizer script so studio can boot/connect even if CDN is slow or blocked. -->
2182
2564
  <script defer src="https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.min.js"></script>
2183
2565
  <script>
2184
2566
  (() => {
2567
+ const statusLineEl = document.getElementById("statusLine");
2185
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;
2186
2574
  if (statusEl) {
2187
- statusEl.textContent = "WS: Connecting · Studio script starting…";
2575
+ statusEl.textContent = "Connecting · Studio script starting…";
2188
2576
  }
2189
2577
 
2190
2578
  function hardFail(prefix, error) {
2191
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
+ }
2192
2590
  if (statusEl) {
2193
- statusEl.textContent = "WS: Disconnected · " + prefix + ": " + details;
2591
+ statusEl.textContent = "Disconnected · " + prefix + ": " + details;
2194
2592
  statusEl.className = "error";
2195
2593
  }
2196
2594
  }
@@ -2232,9 +2630,11 @@ ${cssVarsBlock}
2232
2630
  const loadCritiqueNotesBtn = document.getElementById("loadCritiqueNotesBtn");
2233
2631
  const loadCritiqueFullBtn = document.getElementById("loadCritiqueFullBtn");
2234
2632
  const copyResponseBtn = document.getElementById("copyResponseBtn");
2633
+ const exportPdfBtn = document.getElementById("exportPdfBtn");
2235
2634
  const saveAsBtn = document.getElementById("saveAsBtn");
2236
2635
  const saveOverBtn = document.getElementById("saveOverBtn");
2237
2636
  const sendEditorBtn = document.getElementById("sendEditorBtn");
2637
+ const getEditorBtn = document.getElementById("getEditorBtn");
2238
2638
  const sendRunBtn = document.getElementById("sendRunBtn");
2239
2639
  const copyDraftBtn = document.getElementById("copyDraftBtn");
2240
2640
  const highlightSelect = document.getElementById("highlightSelect");
@@ -2248,10 +2648,11 @@ ${cssVarsBlock}
2248
2648
 
2249
2649
  let ws = null;
2250
2650
  let wsState = "Connecting";
2251
- let statusMessage = "Studio script starting…";
2651
+ let statusMessage = "Connecting · Studio script starting…";
2252
2652
  let statusLevel = "";
2253
2653
  let pendingRequestId = null;
2254
2654
  let pendingKind = null;
2655
+ let stickyStudioKind = null;
2255
2656
  let initialDocumentApplied = false;
2256
2657
  let editorView = "markdown";
2257
2658
  let rightView = "preview";
@@ -2265,7 +2666,15 @@ ${cssVarsBlock}
2265
2666
  let latestResponseNormalized = "";
2266
2667
  let latestCritiqueNotes = "";
2267
2668
  let latestCritiqueNotesNormalized = "";
2669
+ let agentBusyFromServer = false;
2670
+ let terminalActivityPhase = "idle";
2671
+ let terminalActivityToolName = "";
2672
+ let terminalActivityLabel = "";
2673
+ let lastSpecificToolLabel = "";
2268
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";
2269
2678
  let sourceState = {
2270
2679
  source: initialSourceState.source,
2271
2680
  label: initialSourceState.label,
@@ -2317,6 +2726,8 @@ ${cssVarsBlock}
2317
2726
  const RESPONSE_HIGHLIGHT_MAX_CHARS = 120_000;
2318
2727
  const RESPONSE_HIGHLIGHT_STORAGE_KEY = "piStudio.responseHighlightEnabled";
2319
2728
  const PREVIEW_INPUT_DEBOUNCE_MS = 0;
2729
+ const PREVIEW_PENDING_BADGE_DELAY_MS = 220;
2730
+ const previewPendingTimers = new WeakMap();
2320
2731
  let sourcePreviewRenderTimer = null;
2321
2732
  let sourcePreviewRenderNonce = 0;
2322
2733
  let responsePreviewRenderNonce = 0;
@@ -2333,25 +2744,234 @@ ${cssVarsBlock}
2333
2744
  let mermaidModulePromise = null;
2334
2745
  let mermaidInitialized = false;
2335
2746
 
2747
+ const DEBUG_ENABLED = (() => {
2748
+ try {
2749
+ const query = new URLSearchParams(window.location.search || "");
2750
+ const hash = new URLSearchParams((window.location.hash || "").replace(/^#/, ""));
2751
+ const value = String(query.get("debug") || hash.get("debug") || "").trim().toLowerCase();
2752
+ return value === "1" || value === "true" || value === "yes" || value === "on";
2753
+ } catch {
2754
+ return false;
2755
+ }
2756
+ })();
2757
+ const DEBUG_LOG_MAX = 400;
2758
+ const debugLog = [];
2759
+
2760
+ function debugTrace(eventName, payload) {
2761
+ if (!DEBUG_ENABLED) return;
2762
+ const entry = {
2763
+ ts: Date.now(),
2764
+ event: String(eventName || ""),
2765
+ payload: payload || null,
2766
+ };
2767
+ debugLog.push(entry);
2768
+ if (debugLog.length > DEBUG_LOG_MAX) debugLog.shift();
2769
+ window.__piStudioDebugLog = debugLog.slice();
2770
+ try {
2771
+ console.debug("[pi-studio]", new Date(entry.ts).toISOString(), entry.event, entry.payload);
2772
+ } catch {
2773
+ // ignore console errors
2774
+ }
2775
+ }
2776
+
2777
+ function summarizeServerMessage(message) {
2778
+ if (!message || typeof message !== "object") return { type: "invalid" };
2779
+ const summary = {
2780
+ type: typeof message.type === "string" ? message.type : "unknown",
2781
+ };
2782
+ if (typeof message.requestId === "string") summary.requestId = message.requestId;
2783
+ if (typeof message.activeRequestId === "string") summary.activeRequestId = message.activeRequestId;
2784
+ if (typeof message.activeRequestKind === "string") summary.activeRequestKind = message.activeRequestKind;
2785
+ if (typeof message.kind === "string") summary.kind = message.kind;
2786
+ if (typeof message.event === "string") summary.event = message.event;
2787
+ if (typeof message.timestamp === "number") summary.timestamp = message.timestamp;
2788
+ if (typeof message.busy === "boolean") summary.busy = message.busy;
2789
+ if (typeof message.agentBusy === "boolean") summary.agentBusy = message.agentBusy;
2790
+ if (typeof message.terminalPhase === "string") summary.terminalPhase = message.terminalPhase;
2791
+ if (typeof message.terminalToolName === "string") summary.terminalToolName = message.terminalToolName;
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;
2795
+ if (typeof message.stopReason === "string") summary.stopReason = message.stopReason;
2796
+ if (typeof message.markdown === "string") summary.markdownLength = message.markdown.length;
2797
+ if (typeof message.label === "string") summary.label = message.label;
2798
+ if (typeof message.details === "object" && message.details !== null) summary.details = message.details;
2799
+ return summary;
2800
+ }
2801
+
2336
2802
  function getIdleStatus() {
2337
- return "Ready. Edit text, then run or critique (insert annotation header if needed).";
2803
+ return "Edit, load, or annotate text, then run, save, send to pi editor, or critique.";
2804
+ }
2805
+
2806
+ function normalizeTerminalPhase(phase) {
2807
+ if (phase === "running" || phase === "tool" || phase === "responding") return phase;
2808
+ return "idle";
2809
+ }
2810
+
2811
+ function normalizeActivityLabel(label) {
2812
+ if (typeof label !== "string") return "";
2813
+ return label.replace(/\\s+/g, " ").trim();
2814
+ }
2815
+
2816
+ function isGenericToolLabel(label) {
2817
+ const normalized = normalizeActivityLabel(label).toLowerCase();
2818
+ if (!normalized) return true;
2819
+ return normalized.startsWith("running ")
2820
+ || normalized === "reading file"
2821
+ || normalized === "writing file"
2822
+ || normalized === "editing file";
2823
+ }
2824
+
2825
+ function withEllipsis(text) {
2826
+ const value = String(text || "").trim();
2827
+ if (!value) return "";
2828
+ if (/[….!?]$/.test(value)) return value;
2829
+ return value + "…";
2830
+ }
2831
+
2832
+ function updateTerminalActivityState(phase, toolName, label) {
2833
+ terminalActivityPhase = normalizeTerminalPhase(phase);
2834
+ terminalActivityToolName = typeof toolName === "string" ? toolName.trim() : "";
2835
+ terminalActivityLabel = normalizeActivityLabel(label);
2836
+
2837
+ if (terminalActivityPhase === "tool" && terminalActivityLabel && !isGenericToolLabel(terminalActivityLabel)) {
2838
+ lastSpecificToolLabel = terminalActivityLabel;
2839
+ }
2840
+ if (terminalActivityPhase === "idle") {
2841
+ lastSpecificToolLabel = "";
2842
+ }
2843
+
2844
+ syncFooterSpinnerState();
2845
+ }
2846
+
2847
+ function getTerminalBusyStatus() {
2848
+ if (terminalActivityPhase === "tool") {
2849
+ if (terminalActivityLabel) {
2850
+ return "Terminal: " + withEllipsis(terminalActivityLabel);
2851
+ }
2852
+ return terminalActivityToolName
2853
+ ? "Terminal: running tool: " + terminalActivityToolName + "…"
2854
+ : "Terminal: running tool…";
2855
+ }
2856
+ if (terminalActivityPhase === "responding") {
2857
+ if (lastSpecificToolLabel) {
2858
+ return "Terminal: " + lastSpecificToolLabel + " (generating response)…";
2859
+ }
2860
+ return "Terminal: generating response…";
2861
+ }
2862
+ if (terminalActivityPhase === "running" && lastSpecificToolLabel) {
2863
+ return "Terminal: " + withEllipsis(lastSpecificToolLabel);
2864
+ }
2865
+ return "Terminal: running…";
2866
+ }
2867
+
2868
+ function getStudioActionLabel(kind) {
2869
+ if (kind === "annotation") return "sending annotated reply";
2870
+ if (kind === "critique") return "running critique";
2871
+ if (kind === "direct") return "running editor text";
2872
+ if (kind === "send_to_editor") return "sending to pi editor";
2873
+ if (kind === "get_from_editor") return "loading from pi editor";
2874
+ if (kind === "save_as" || kind === "save_over") return "saving editor text";
2875
+ return "submitting request";
2876
+ }
2877
+
2878
+ function getStudioBusyStatus(kind) {
2879
+ const action = getStudioActionLabel(kind);
2880
+ if (terminalActivityPhase === "tool") {
2881
+ if (terminalActivityLabel) {
2882
+ return "Studio: " + withEllipsis(terminalActivityLabel);
2883
+ }
2884
+ return terminalActivityToolName
2885
+ ? "Studio: " + action + " (tool: " + terminalActivityToolName + ")…"
2886
+ : "Studio: " + action + " (running tool)…";
2887
+ }
2888
+ if (terminalActivityPhase === "responding") {
2889
+ if (lastSpecificToolLabel) {
2890
+ return "Studio: " + lastSpecificToolLabel + " (generating response)…";
2891
+ }
2892
+ return "Studio: " + action + " (generating response)…";
2893
+ }
2894
+ if (terminalActivityPhase === "running" && lastSpecificToolLabel) {
2895
+ return "Studio: " + withEllipsis(lastSpecificToolLabel);
2896
+ }
2897
+ return "Studio: " + action + "…";
2898
+ }
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
+ }
2338
2932
  }
2339
2933
 
2340
2934
  function renderStatus() {
2341
- const prefix = "WS: " + wsState;
2342
- statusEl.textContent = prefix + " · " + statusMessage;
2935
+ statusEl.textContent = statusMessage;
2343
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();
2344
2949
  }
2345
2950
 
2346
2951
  function setWsState(nextState) {
2347
2952
  wsState = nextState || "Disconnected";
2953
+ syncFooterSpinnerState();
2348
2954
  renderStatus();
2349
2955
  }
2350
2956
 
2351
2957
  function setStatus(message, level) {
2352
2958
  statusMessage = message;
2353
2959
  statusLevel = level || "";
2960
+ syncFooterSpinnerState();
2354
2961
  renderStatus();
2962
+ debugTrace("status", {
2963
+ wsState,
2964
+ message: statusMessage,
2965
+ level: statusLevel,
2966
+ pendingRequestId,
2967
+ pendingKind,
2968
+ uiBusy,
2969
+ agentBusyFromServer,
2970
+ terminalPhase: terminalActivityPhase,
2971
+ terminalToolName: terminalActivityToolName,
2972
+ terminalActivityLabel,
2973
+ lastSpecificToolLabel,
2974
+ });
2355
2975
  }
2356
2976
 
2357
2977
  renderStatus();
@@ -2594,6 +3214,48 @@ ${cssVarsBlock}
2594
3214
  targetEl.appendChild(el);
2595
3215
  }
2596
3216
 
3217
+ function hasMeaningfulPreviewContent(targetEl) {
3218
+ if (!targetEl || typeof targetEl.querySelector !== "function") return false;
3219
+ if (targetEl.querySelector(".preview-loading")) return false;
3220
+ const text = typeof targetEl.textContent === "string" ? targetEl.textContent.trim() : "";
3221
+ return text.length > 0;
3222
+ }
3223
+
3224
+ function beginPreviewRender(targetEl) {
3225
+ if (!targetEl || !targetEl.classList) return;
3226
+
3227
+ const pendingTimer = previewPendingTimers.get(targetEl);
3228
+ if (pendingTimer !== undefined) {
3229
+ window.clearTimeout(pendingTimer);
3230
+ previewPendingTimers.delete(targetEl);
3231
+ }
3232
+
3233
+ if (hasMeaningfulPreviewContent(targetEl)) {
3234
+ targetEl.classList.remove("preview-pending");
3235
+ const timerId = window.setTimeout(() => {
3236
+ previewPendingTimers.delete(targetEl);
3237
+ if (!targetEl || !targetEl.classList) return;
3238
+ if (!hasMeaningfulPreviewContent(targetEl)) return;
3239
+ targetEl.classList.add("preview-pending");
3240
+ }, PREVIEW_PENDING_BADGE_DELAY_MS);
3241
+ previewPendingTimers.set(targetEl, timerId);
3242
+ return;
3243
+ }
3244
+
3245
+ targetEl.classList.remove("preview-pending");
3246
+ targetEl.innerHTML = "<div class='preview-loading'>Rendering preview…</div>";
3247
+ }
3248
+
3249
+ function finishPreviewRender(targetEl) {
3250
+ if (!targetEl || !targetEl.classList) return;
3251
+ const pendingTimer = previewPendingTimers.get(targetEl);
3252
+ if (pendingTimer !== undefined) {
3253
+ window.clearTimeout(pendingTimer);
3254
+ previewPendingTimers.delete(targetEl);
3255
+ }
3256
+ targetEl.classList.remove("preview-pending");
3257
+ }
3258
+
2597
3259
  async function getMermaidApi() {
2598
3260
  if (mermaidModulePromise) {
2599
3261
  return mermaidModulePromise;
@@ -2729,6 +3391,126 @@ ${cssVarsBlock}
2729
3391
  return payload.html;
2730
3392
  }
2731
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
+
2732
3514
  async function applyRenderedMarkdown(targetEl, markdown, pane, nonce) {
2733
3515
  try {
2734
3516
  const renderedHtml = await renderMarkdownWithPandoc(markdown);
@@ -2739,6 +3521,7 @@ ${cssVarsBlock}
2739
3521
  if (nonce !== responsePreviewRenderNonce || (rightView !== "preview" && rightView !== "editor-preview")) return;
2740
3522
  }
2741
3523
 
3524
+ finishPreviewRender(targetEl);
2742
3525
  targetEl.innerHTML = sanitizeRenderedHtml(renderedHtml, markdown);
2743
3526
  await renderMermaidInElement(targetEl);
2744
3527
 
@@ -2758,6 +3541,7 @@ ${cssVarsBlock}
2758
3541
  }
2759
3542
 
2760
3543
  const detail = error && error.message ? error.message : String(error || "unknown error");
3544
+ finishPreviewRender(targetEl);
2761
3545
  targetEl.innerHTML = buildPreviewErrorHtml("Preview renderer unavailable (" + detail + "). Showing plain markdown.", markdown);
2762
3546
  }
2763
3547
  }
@@ -2766,11 +3550,12 @@ ${cssVarsBlock}
2766
3550
  if (editorView !== "preview") return;
2767
3551
  const text = sourceTextEl.value || "";
2768
3552
  if (editorLanguage && editorLanguage !== "markdown" && editorLanguage !== "latex") {
3553
+ finishPreviewRender(sourcePreviewEl);
2769
3554
  sourcePreviewEl.innerHTML = "<div class='response-markdown-highlight' style='white-space:pre;font-family:var(--font-mono);font-size:13px;line-height:1.5;padding:16px;overflow:auto;'>" + highlightCode(text, editorLanguage) + "</div>";
2770
3555
  return;
2771
3556
  }
2772
3557
  const nonce = ++sourcePreviewRenderNonce;
2773
- sourcePreviewEl.innerHTML = "<div class='preview-loading'>Rendering preview…</div>";
3558
+ beginPreviewRender(sourcePreviewEl);
2774
3559
  void applyRenderedMarkdown(sourcePreviewEl, text, "source", nonce);
2775
3560
  }
2776
3561
 
@@ -2825,34 +3610,38 @@ ${cssVarsBlock}
2825
3610
  if (rightView === "editor-preview") {
2826
3611
  const editorText = sourceTextEl.value || "";
2827
3612
  if (!editorText.trim()) {
3613
+ finishPreviewRender(critiqueViewEl);
2828
3614
  critiqueViewEl.innerHTML = "<pre class='plain-markdown'>Editor is empty.</pre>";
2829
3615
  return;
2830
3616
  }
2831
3617
  if (editorLanguage && editorLanguage !== "markdown" && editorLanguage !== "latex") {
3618
+ finishPreviewRender(critiqueViewEl);
2832
3619
  critiqueViewEl.innerHTML = "<div class='response-markdown-highlight' style='white-space:pre;font-family:var(--font-mono);font-size:13px;line-height:1.5;padding:16px;overflow:auto;'>" + highlightCode(editorText, editorLanguage) + "</div>";
2833
3620
  return;
2834
3621
  }
2835
3622
  const nonce = ++responsePreviewRenderNonce;
2836
- critiqueViewEl.innerHTML = "<div class='preview-loading'>Rendering preview…</div>";
3623
+ beginPreviewRender(critiqueViewEl);
2837
3624
  void applyRenderedMarkdown(critiqueViewEl, editorText, "response", nonce);
2838
3625
  return;
2839
3626
  }
2840
3627
 
2841
3628
  const markdown = latestResponseMarkdown;
2842
3629
  if (!markdown || !markdown.trim()) {
3630
+ finishPreviewRender(critiqueViewEl);
2843
3631
  critiqueViewEl.innerHTML = "<pre class='plain-markdown'>No response yet. Run editor text or critique editor text.</pre>";
2844
3632
  return;
2845
3633
  }
2846
3634
 
2847
3635
  if (rightView === "preview") {
2848
3636
  const nonce = ++responsePreviewRenderNonce;
2849
- critiqueViewEl.innerHTML = "<div class='preview-loading'>Rendering preview…</div>";
3637
+ beginPreviewRender(critiqueViewEl);
2850
3638
  void applyRenderedMarkdown(critiqueViewEl, markdown, "response", nonce);
2851
3639
  return;
2852
3640
  }
2853
3641
 
2854
3642
  if (responseHighlightEnabled) {
2855
3643
  if (markdown.length > RESPONSE_HIGHLIGHT_MAX_CHARS) {
3644
+ finishPreviewRender(critiqueViewEl);
2856
3645
  critiqueViewEl.innerHTML = buildPreviewErrorHtml(
2857
3646
  "Response is too large for markdown highlighting. Showing plain markdown.",
2858
3647
  markdown,
@@ -2860,10 +3649,12 @@ ${cssVarsBlock}
2860
3649
  return;
2861
3650
  }
2862
3651
 
3652
+ finishPreviewRender(critiqueViewEl);
2863
3653
  critiqueViewEl.innerHTML = "<div class='response-markdown-highlight'>" + highlightMarkdown(markdown) + "</div>";
2864
3654
  return;
2865
3655
  }
2866
3656
 
3657
+ finishPreviewRender(critiqueViewEl);
2867
3658
  critiqueViewEl.innerHTML = buildPlainMarkdownHtml(markdown);
2868
3659
  }
2869
3660
 
@@ -2893,6 +3684,20 @@ ${cssVarsBlock}
2893
3684
 
2894
3685
  copyResponseBtn.disabled = uiBusy || !hasResponse;
2895
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
+
2896
3701
  pullLatestBtn.disabled = uiBusy || followLatest;
2897
3702
  pullLatestBtn.textContent = queuedLatestResponse ? "Get latest response *" : "Get latest response";
2898
3703
 
@@ -2936,6 +3741,7 @@ ${cssVarsBlock}
2936
3741
  saveAsBtn.disabled = uiBusy;
2937
3742
  saveOverBtn.disabled = uiBusy || !canSaveOver;
2938
3743
  sendEditorBtn.disabled = uiBusy;
3744
+ if (getEditorBtn) getEditorBtn.disabled = uiBusy;
2939
3745
  sendRunBtn.disabled = uiBusy;
2940
3746
  copyDraftBtn.disabled = uiBusy;
2941
3747
  if (highlightSelect) highlightSelect.disabled = uiBusy;
@@ -2953,6 +3759,8 @@ ${cssVarsBlock}
2953
3759
 
2954
3760
  function setBusy(busy) {
2955
3761
  uiBusy = Boolean(busy);
3762
+ syncFooterSpinnerState();
3763
+ renderStatus();
2956
3764
  syncActionButtons();
2957
3765
  }
2958
3766
 
@@ -2981,6 +3789,10 @@ ${cssVarsBlock}
2981
3789
  sourcePreviewRenderTimer = null;
2982
3790
  }
2983
3791
 
3792
+ if (!showPreview) {
3793
+ finishPreviewRender(sourcePreviewEl);
3794
+ }
3795
+
2984
3796
  if (showPreview) {
2985
3797
  renderSourcePreview();
2986
3798
  }
@@ -3665,14 +4477,37 @@ ${cssVarsBlock}
3665
4477
  function handleServerMessage(message) {
3666
4478
  if (!message || typeof message !== "object") return;
3667
4479
 
4480
+ debugTrace("server_message", summarizeServerMessage(message));
4481
+
4482
+ if (message.type === "debug_event") {
4483
+ debugTrace("server_debug_event", summarizeServerMessage(message));
4484
+ return;
4485
+ }
4486
+
3668
4487
  if (message.type === "hello_ack") {
3669
4488
  const busy = Boolean(message.busy);
4489
+ agentBusyFromServer = Boolean(message.agentBusy);
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();
3670
4498
  setBusy(busy);
3671
4499
  setWsState(busy ? "Submitting" : "Ready");
3672
- if (message.activeRequestId) {
3673
- pendingRequestId = String(message.activeRequestId);
3674
- pendingKind = "unknown";
3675
- setStatus("Request in progress…", "warning");
4500
+ if (typeof message.activeRequestId === "string" && message.activeRequestId.length > 0) {
4501
+ pendingRequestId = message.activeRequestId;
4502
+ if (typeof message.activeRequestKind === "string" && message.activeRequestKind.length > 0) {
4503
+ pendingKind = message.activeRequestKind;
4504
+ } else if (!pendingKind) {
4505
+ pendingKind = "unknown";
4506
+ }
4507
+ stickyStudioKind = pendingKind;
4508
+ } else {
4509
+ pendingRequestId = null;
4510
+ pendingKind = null;
3676
4511
  }
3677
4512
 
3678
4513
  let loadedInitialDocument = false;
@@ -3705,7 +4540,26 @@ ${cssVarsBlock}
3705
4540
  handleIncomingResponse(lastMarkdown, lastResponseKind, message.lastResponse.timestamp);
3706
4541
  }
3707
4542
 
3708
- if (!busy && !loadedInitialDocument) {
4543
+ if (pendingRequestId) {
4544
+ if (busy) {
4545
+ setStatus(getStudioBusyStatus(pendingKind), "warning");
4546
+ }
4547
+ return;
4548
+ }
4549
+
4550
+ if (busy) {
4551
+ if (agentBusyFromServer && stickyStudioKind) {
4552
+ setStatus(getStudioBusyStatus(stickyStudioKind), "warning");
4553
+ } else if (agentBusyFromServer) {
4554
+ setStatus(getTerminalBusyStatus(), "warning");
4555
+ } else {
4556
+ setStatus("Studio is busy.", "warning");
4557
+ }
4558
+ return;
4559
+ }
4560
+
4561
+ stickyStudioKind = null;
4562
+ if (!loadedInitialDocument) {
3709
4563
  refreshResponseUi();
3710
4564
  setStatus(getIdleStatus());
3711
4565
  }
@@ -3715,17 +4569,10 @@ ${cssVarsBlock}
3715
4569
  if (message.type === "request_started") {
3716
4570
  pendingRequestId = typeof message.requestId === "string" ? message.requestId : pendingRequestId;
3717
4571
  pendingKind = typeof message.kind === "string" ? message.kind : "unknown";
4572
+ stickyStudioKind = pendingKind;
3718
4573
  setBusy(true);
3719
4574
  setWsState("Submitting");
3720
- if (pendingKind === "annotation") {
3721
- setStatus("Sending annotated reply…", "warning");
3722
- } else if (pendingKind === "critique") {
3723
- setStatus("Running critique…", "warning");
3724
- } else if (pendingKind === "direct") {
3725
- setStatus("Running editor text…", "warning");
3726
- } else {
3727
- setStatus("Submitting…", "warning");
3728
- }
4575
+ setStatus(getStudioBusyStatus(pendingKind), "warning");
3729
4576
  return;
3730
4577
  }
3731
4578
 
@@ -3739,6 +4586,7 @@ ${cssVarsBlock}
3739
4586
  ? message.kind
3740
4587
  : (pendingKind === "critique" ? "critique" : "annotation");
3741
4588
 
4589
+ stickyStudioKind = responseKind;
3742
4590
  pendingRequestId = null;
3743
4591
  pendingKind = null;
3744
4592
  setBusy(false);
@@ -3810,13 +4658,78 @@ ${cssVarsBlock}
3810
4658
  return;
3811
4659
  }
3812
4660
 
4661
+ if (message.type === "editor_snapshot") {
4662
+ if (typeof message.requestId === "string" && pendingRequestId && message.requestId !== pendingRequestId) {
4663
+ return;
4664
+ }
4665
+ if (typeof message.requestId === "string" && pendingRequestId === message.requestId) {
4666
+ pendingRequestId = null;
4667
+ pendingKind = null;
4668
+ }
4669
+
4670
+ const content = typeof message.content === "string" ? message.content : "";
4671
+ sourceTextEl.value = content;
4672
+ renderSourcePreview();
4673
+ setSourceState({ source: "pi-editor", label: "pi editor draft", path: null });
4674
+ setBusy(false);
4675
+ setWsState("Ready");
4676
+ setStatus(
4677
+ content.trim()
4678
+ ? "Loaded draft from pi editor."
4679
+ : "pi editor is empty. Loaded blank text.",
4680
+ content.trim() ? "success" : "warning",
4681
+ );
4682
+ return;
4683
+ }
4684
+
3813
4685
  if (message.type === "studio_state") {
3814
4686
  const busy = Boolean(message.busy);
4687
+ agentBusyFromServer = Boolean(message.agentBusy);
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();
4696
+
4697
+ if (typeof message.activeRequestId === "string" && message.activeRequestId.length > 0) {
4698
+ pendingRequestId = message.activeRequestId;
4699
+ if (typeof message.activeRequestKind === "string" && message.activeRequestKind.length > 0) {
4700
+ pendingKind = message.activeRequestKind;
4701
+ } else if (!pendingKind) {
4702
+ pendingKind = "unknown";
4703
+ }
4704
+ stickyStudioKind = pendingKind;
4705
+ } else {
4706
+ pendingRequestId = null;
4707
+ pendingKind = null;
4708
+ }
4709
+
3815
4710
  setBusy(busy);
3816
4711
  setWsState(busy ? "Submitting" : "Ready");
3817
- if (!busy && !pendingRequestId) {
3818
- setStatus(getIdleStatus());
4712
+
4713
+ if (pendingRequestId) {
4714
+ if (busy) {
4715
+ setStatus(getStudioBusyStatus(pendingKind), "warning");
4716
+ }
4717
+ return;
4718
+ }
4719
+
4720
+ if (busy) {
4721
+ if (agentBusyFromServer && stickyStudioKind) {
4722
+ setStatus(getStudioBusyStatus(stickyStudioKind), "warning");
4723
+ } else if (agentBusyFromServer) {
4724
+ setStatus(getTerminalBusyStatus(), "warning");
4725
+ } else {
4726
+ setStatus("Studio is busy.", "warning");
4727
+ }
4728
+ return;
3819
4729
  }
4730
+
4731
+ stickyStudioKind = null;
4732
+ setStatus(getIdleStatus());
3820
4733
  return;
3821
4734
  }
3822
4735
 
@@ -3825,6 +4738,7 @@ ${cssVarsBlock}
3825
4738
  pendingRequestId = null;
3826
4739
  pendingKind = null;
3827
4740
  }
4741
+ stickyStudioKind = null;
3828
4742
  setBusy(false);
3829
4743
  setWsState("Ready");
3830
4744
  setStatus(typeof message.message === "string" ? message.message : "Studio is busy.", "warning");
@@ -3836,6 +4750,7 @@ ${cssVarsBlock}
3836
4750
  pendingRequestId = null;
3837
4751
  pendingKind = null;
3838
4752
  }
4753
+ stickyStudioKind = null;
3839
4754
  setBusy(false);
3840
4755
  setWsState("Ready");
3841
4756
  setStatus(typeof message.message === "string" ? message.message : "Request failed.", "error");
@@ -3870,7 +4785,7 @@ ${cssVarsBlock}
3870
4785
  }
3871
4786
 
3872
4787
  const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
3873
- const wsUrl = wsProtocol + "://" + window.location.host + "/ws?token=" + encodeURIComponent(token);
4788
+ const wsUrl = wsProtocol + "://" + window.location.host + "/ws?token=" + encodeURIComponent(token) + (DEBUG_ENABLED ? "&debug=1" : "");
3874
4789
 
3875
4790
  setWsState("Connecting");
3876
4791
  setStatus("Connecting to Studio server…");
@@ -3927,8 +4842,10 @@ ${cssVarsBlock}
3927
4842
  const requestId = makeRequestId();
3928
4843
  pendingRequestId = requestId;
3929
4844
  pendingKind = kind;
4845
+ stickyStudioKind = kind;
3930
4846
  setBusy(true);
3931
4847
  setWsState("Submitting");
4848
+ setStatus(getStudioBusyStatus(kind), "warning");
3932
4849
  return requestId;
3933
4850
  }
3934
4851
 
@@ -4006,6 +4923,9 @@ ${cssVarsBlock}
4006
4923
  }
4007
4924
 
4008
4925
  window.addEventListener("keydown", handlePaneShortcut);
4926
+ window.addEventListener("beforeunload", () => {
4927
+ stopFooterSpinner();
4928
+ });
4009
4929
 
4010
4930
  editorViewSelect.addEventListener("change", () => {
4011
4931
  setEditorView(editorViewSelect.value);
@@ -4151,6 +5071,12 @@ ${cssVarsBlock}
4151
5071
  }
4152
5072
  });
4153
5073
 
5074
+ if (exportPdfBtn) {
5075
+ exportPdfBtn.addEventListener("click", () => {
5076
+ void exportRightPanePdf();
5077
+ });
5078
+ }
5079
+
4154
5080
  saveAsBtn.addEventListener("click", () => {
4155
5081
  const content = sourceTextEl.value;
4156
5082
  if (!content.trim()) {
@@ -4233,6 +5159,24 @@ ${cssVarsBlock}
4233
5159
  }
4234
5160
  });
4235
5161
 
5162
+ if (getEditorBtn) {
5163
+ getEditorBtn.addEventListener("click", () => {
5164
+ const requestId = beginUiAction("get_from_editor");
5165
+ if (!requestId) return;
5166
+
5167
+ const sent = sendMessage({
5168
+ type: "get_from_editor_request",
5169
+ requestId,
5170
+ });
5171
+
5172
+ if (!sent) {
5173
+ pendingRequestId = null;
5174
+ pendingKind = null;
5175
+ setBusy(false);
5176
+ }
5177
+ });
5178
+ }
5179
+
4236
5180
  sendRunBtn.addEventListener("click", () => {
4237
5181
  const content = sourceTextEl.value;
4238
5182
  if (!content.trim()) {
@@ -4397,9 +5341,41 @@ export default function (pi: ExtensionAPI) {
4397
5341
  let lastCommandCtx: ExtensionCommandContext | null = null;
4398
5342
  let lastThemeVarsJson = "";
4399
5343
  let agentBusy = false;
5344
+ let terminalActivityPhase: TerminalActivityPhase = "idle";
5345
+ let terminalActivityToolName: string | null = null;
5346
+ let terminalActivityLabel: string | null = null;
5347
+ let lastSpecificToolActivityLabel: string | null = null;
5348
+ let currentModelLabel = "none";
5349
+ let terminalSessionLabel = buildTerminalSessionLabel(studioCwd);
4400
5350
 
4401
5351
  const isStudioBusy = () => agentBusy || activeRequest !== null;
4402
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
+
4403
5379
  const notifyStudio = (message: string, level: "info" | "warning" | "error" = "info") => {
4404
5380
  if (!lastCommandCtx) return;
4405
5381
  lastCommandCtx.ui.notify(message, level);
@@ -4427,18 +5403,98 @@ export default function (pi: ExtensionAPI) {
4427
5403
  }
4428
5404
  };
4429
5405
 
5406
+ const emitDebugEvent = (event: string, details?: Record<string, unknown>) => {
5407
+ broadcast({
5408
+ type: "debug_event",
5409
+ event,
5410
+ timestamp: Date.now(),
5411
+ details: details ?? null,
5412
+ });
5413
+ };
5414
+
5415
+ const setTerminalActivity = (phase: TerminalActivityPhase, toolName?: string | null, label?: string | null) => {
5416
+ const nextPhase: TerminalActivityPhase =
5417
+ phase === "running" || phase === "tool" || phase === "responding"
5418
+ ? phase
5419
+ : "idle";
5420
+ const nextToolName = nextPhase === "tool" ? (toolName?.trim() || null) : null;
5421
+ const baseLabel = nextPhase === "tool" ? normalizeActivityLabel(label || "") : null;
5422
+ let nextLabel: string | null = null;
5423
+
5424
+ if (nextPhase === "tool") {
5425
+ if (baseLabel && !isGenericToolActivityLabel(baseLabel)) {
5426
+ if (
5427
+ lastSpecificToolActivityLabel
5428
+ && lastSpecificToolActivityLabel !== baseLabel
5429
+ && !isGenericToolActivityLabel(lastSpecificToolActivityLabel)
5430
+ ) {
5431
+ nextLabel = normalizeActivityLabel(`${lastSpecificToolActivityLabel} → ${baseLabel}`);
5432
+ } else {
5433
+ nextLabel = baseLabel;
5434
+ }
5435
+ lastSpecificToolActivityLabel = baseLabel;
5436
+ } else {
5437
+ nextLabel = baseLabel;
5438
+ }
5439
+ } else {
5440
+ nextLabel = null;
5441
+ if (nextPhase === "idle") {
5442
+ lastSpecificToolActivityLabel = null;
5443
+ }
5444
+ }
5445
+
5446
+ if (
5447
+ terminalActivityPhase === nextPhase
5448
+ && terminalActivityToolName === nextToolName
5449
+ && terminalActivityLabel === nextLabel
5450
+ ) {
5451
+ return;
5452
+ }
5453
+ terminalActivityPhase = nextPhase;
5454
+ terminalActivityToolName = nextToolName;
5455
+ terminalActivityLabel = nextLabel;
5456
+ emitDebugEvent("terminal_activity", {
5457
+ phase: terminalActivityPhase,
5458
+ toolName: terminalActivityToolName,
5459
+ label: terminalActivityLabel,
5460
+ baseLabel,
5461
+ lastSpecificToolActivityLabel,
5462
+ activeRequestId: activeRequest?.id ?? null,
5463
+ activeRequestKind: activeRequest?.kind ?? null,
5464
+ agentBusy,
5465
+ });
5466
+ broadcastState();
5467
+ };
5468
+
4430
5469
  const broadcastState = () => {
5470
+ terminalSessionLabel = buildTerminalSessionLabel(studioCwd, getSessionNameSafe());
5471
+ currentModelLabel = formatModelLabelWithThinking(currentModelLabel, getThinkingLevelSafe());
4431
5472
  broadcast({
4432
5473
  type: "studio_state",
4433
5474
  busy: isStudioBusy(),
5475
+ agentBusy,
5476
+ terminalPhase: terminalActivityPhase,
5477
+ terminalToolName: terminalActivityToolName,
5478
+ terminalActivityLabel,
5479
+ modelLabel: currentModelLabel,
5480
+ terminalSessionLabel,
4434
5481
  activeRequestId: activeRequest?.id ?? null,
5482
+ activeRequestKind: activeRequest?.kind ?? null,
4435
5483
  });
4436
5484
  };
4437
5485
 
4438
5486
  const clearActiveRequest = (options?: { notify?: string; level?: "info" | "warning" | "error" }) => {
4439
5487
  if (!activeRequest) return;
5488
+ const completedRequestId = activeRequest.id;
5489
+ const completedKind = activeRequest.kind;
4440
5490
  clearTimeout(activeRequest.timer);
4441
5491
  activeRequest = null;
5492
+ emitDebugEvent("clear_active_request", {
5493
+ requestId: completedRequestId,
5494
+ kind: completedKind,
5495
+ notify: options?.notify ?? null,
5496
+ agentBusy,
5497
+ });
4442
5498
  broadcastState();
4443
5499
  if (options?.notify) {
4444
5500
  broadcast({ type: "info", message: options.notify, level: options.level ?? "info" });
@@ -4446,6 +5502,12 @@ export default function (pi: ExtensionAPI) {
4446
5502
  };
4447
5503
 
4448
5504
  const beginRequest = (requestId: string, kind: StudioRequestKind): boolean => {
5505
+ emitDebugEvent("begin_request_attempt", {
5506
+ requestId,
5507
+ kind,
5508
+ hasActiveRequest: Boolean(activeRequest),
5509
+ agentBusy,
5510
+ });
4449
5511
  if (activeRequest) {
4450
5512
  broadcast({ type: "busy", requestId, message: "A studio request is already in progress." });
4451
5513
  return false;
@@ -4457,6 +5519,7 @@ export default function (pi: ExtensionAPI) {
4457
5519
 
4458
5520
  const timer = setTimeout(() => {
4459
5521
  if (!activeRequest || activeRequest.id !== requestId) return;
5522
+ emitDebugEvent("request_timeout", { requestId, kind });
4460
5523
  broadcast({ type: "error", requestId, message: "Studio request timed out. Please try again." });
4461
5524
  clearActiveRequest();
4462
5525
  }, REQUEST_TIMEOUT_MS);
@@ -4468,6 +5531,7 @@ export default function (pi: ExtensionAPI) {
4468
5531
  timer,
4469
5532
  };
4470
5533
 
5534
+ emitDebugEvent("begin_request", { requestId, kind });
4471
5535
  broadcast({ type: "request_started", requestId, kind });
4472
5536
  broadcastState();
4473
5537
  return true;
@@ -4491,11 +5555,26 @@ export default function (pi: ExtensionAPI) {
4491
5555
  return;
4492
5556
  }
4493
5557
 
5558
+ emitDebugEvent("studio_message", {
5559
+ type: msg.type,
5560
+ requestId: "requestId" in msg ? msg.requestId : null,
5561
+ activeRequestId: activeRequest?.id ?? null,
5562
+ activeRequestKind: activeRequest?.kind ?? null,
5563
+ agentBusy,
5564
+ });
5565
+
4494
5566
  if (msg.type === "hello") {
4495
5567
  sendToClient(client, {
4496
5568
  type: "hello_ack",
4497
5569
  busy: isStudioBusy(),
5570
+ agentBusy,
5571
+ terminalPhase: terminalActivityPhase,
5572
+ terminalToolName: terminalActivityToolName,
5573
+ terminalActivityLabel,
5574
+ modelLabel: currentModelLabel,
5575
+ terminalSessionLabel,
4498
5576
  activeRequestId: activeRequest?.id ?? null,
5577
+ activeRequestKind: activeRequest?.kind ?? null,
4499
5578
  lastResponse: lastStudioResponse,
4500
5579
  initialDocument: initialStudioDocument,
4501
5580
  });
@@ -4727,6 +5806,41 @@ export default function (pi: ExtensionAPI) {
4727
5806
  }
4728
5807
  return;
4729
5808
  }
5809
+
5810
+ if (msg.type === "get_from_editor_request") {
5811
+ if (!isValidRequestId(msg.requestId)) {
5812
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
5813
+ return;
5814
+ }
5815
+ if (isStudioBusy()) {
5816
+ sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
5817
+ return;
5818
+ }
5819
+ if (!lastCommandCtx || !lastCommandCtx.hasUI) {
5820
+ sendToClient(client, {
5821
+ type: "error",
5822
+ requestId: msg.requestId,
5823
+ message: "No interactive pi editor context is available.",
5824
+ });
5825
+ return;
5826
+ }
5827
+
5828
+ try {
5829
+ const content = lastCommandCtx.ui.getEditorText();
5830
+ sendToClient(client, {
5831
+ type: "editor_snapshot",
5832
+ requestId: msg.requestId,
5833
+ content,
5834
+ });
5835
+ } catch (error) {
5836
+ sendToClient(client, {
5837
+ type: "error",
5838
+ requestId: msg.requestId,
5839
+ message: `Failed to read pi editor text: ${error instanceof Error ? error.message : String(error)}`,
5840
+ });
5841
+ }
5842
+ return;
5843
+ }
4730
5844
  };
4731
5845
 
4732
5846
  const handleRenderPreviewRequest = async (req: IncomingMessage, res: ServerResponse) => {
@@ -4785,6 +5899,84 @@ export default function (pi: ExtensionAPI) {
4785
5899
  }
4786
5900
  };
4787
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
+
4788
5980
  const handleHttpRequest = (req: IncomingMessage, res: ServerResponse) => {
4789
5981
  if (!serverState) {
4790
5982
  respondText(res, 503, "Studio server not ready");
@@ -4834,6 +6026,29 @@ export default function (pi: ExtensionAPI) {
4834
6026
  return;
4835
6027
  }
4836
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
+
4837
6052
  if (requestUrl.pathname !== "/") {
4838
6053
  respondText(res, 404, "Not found");
4839
6054
  return;
@@ -4853,7 +6068,7 @@ export default function (pi: ExtensionAPI) {
4853
6068
  "Cross-Origin-Opener-Policy": "same-origin",
4854
6069
  "Cross-Origin-Resource-Policy": "same-origin",
4855
6070
  });
4856
- res.end(buildStudioHtml(initialStudioDocument, lastCommandCtx?.ui.theme));
6071
+ res.end(buildStudioHtml(initialStudioDocument, lastCommandCtx?.ui.theme, currentModelLabel, terminalSessionLabel));
4857
6072
  };
4858
6073
 
4859
6074
  const ensureServer = async (): Promise<StudioServerState> => {
@@ -4945,9 +6160,22 @@ export default function (pi: ExtensionAPI) {
4945
6160
 
4946
6161
  serverState = state;
4947
6162
 
4948
- // Periodically check for theme changes and push to all clients
6163
+ // Periodically check for theme/model metadata changes and push to all clients
4949
6164
  const themeCheckInterval = setInterval(() => {
4950
- 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;
4951
6179
  try {
4952
6180
  const style = getStudioThemeStyle(lastCommandCtx.ui.theme);
4953
6181
  const vars = buildThemeCssVars(style);
@@ -5004,21 +6232,101 @@ export default function (pi: ExtensionAPI) {
5004
6232
 
5005
6233
  pi.on("session_start", async (_event, ctx) => {
5006
6234
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
6235
+ agentBusy = false;
6236
+ refreshRuntimeMetadata(ctx);
6237
+ emitDebugEvent("session_start", {
6238
+ entryCount: ctx.sessionManager.getBranch().length,
6239
+ modelLabel: currentModelLabel,
6240
+ terminalSessionLabel,
6241
+ });
6242
+ setTerminalActivity("idle");
5007
6243
  });
5008
6244
 
5009
6245
  pi.on("session_switch", async (_event, ctx) => {
5010
6246
  clearActiveRequest({ notify: "Session switched. Studio request state cleared.", level: "warning" });
5011
6247
  lastCommandCtx = null;
5012
6248
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
6249
+ agentBusy = false;
6250
+ refreshRuntimeMetadata(ctx);
6251
+ emitDebugEvent("session_switch", {
6252
+ entryCount: ctx.sessionManager.getBranch().length,
6253
+ modelLabel: currentModelLabel,
6254
+ terminalSessionLabel,
6255
+ });
6256
+ setTerminalActivity("idle");
6257
+ });
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();
5013
6267
  });
5014
6268
 
5015
6269
  pi.on("agent_start", async () => {
5016
6270
  agentBusy = true;
5017
- broadcastState();
6271
+ emitDebugEvent("agent_start", { activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
6272
+ setTerminalActivity("running");
6273
+ });
6274
+
6275
+ pi.on("tool_call", async (event) => {
6276
+ if (!agentBusy) return;
6277
+ const toolName = typeof event.toolName === "string" ? event.toolName : "";
6278
+ const input = (event as { input?: unknown }).input;
6279
+ const label = deriveToolActivityLabel(toolName, input);
6280
+ emitDebugEvent("tool_call", { toolName, label, activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
6281
+ setTerminalActivity("tool", toolName, label);
6282
+ });
6283
+
6284
+ pi.on("tool_execution_start", async (event) => {
6285
+ if (!agentBusy) return;
6286
+ const label = deriveToolActivityLabel(event.toolName, event.args);
6287
+ emitDebugEvent("tool_execution_start", { toolName: event.toolName, label, activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
6288
+ setTerminalActivity("tool", event.toolName, label);
6289
+ });
6290
+
6291
+ pi.on("tool_execution_end", async (event) => {
6292
+ if (!agentBusy) return;
6293
+ emitDebugEvent("tool_execution_end", { toolName: event.toolName, activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
6294
+ // Keep tool phase visible until the next tool call, assistant response phase,
6295
+ // or agent_end. This avoids tool labels flashing too quickly to read.
6296
+ });
6297
+
6298
+ pi.on("message_start", async (event) => {
6299
+ const role = (event.message as { role?: string } | undefined)?.role;
6300
+ emitDebugEvent("message_start", { role: role ?? "", activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
6301
+ if (agentBusy && role === "assistant") {
6302
+ setTerminalActivity("responding");
6303
+ }
5018
6304
  });
5019
6305
 
5020
6306
  pi.on("message_end", async (event) => {
6307
+ const message = event.message as { stopReason?: string; role?: string };
6308
+ const stopReason = typeof message.stopReason === "string" ? message.stopReason : "";
6309
+ const role = typeof message.role === "string" ? message.role : "";
5021
6310
  const markdown = extractAssistantText(event.message);
6311
+ emitDebugEvent("message_end", {
6312
+ role,
6313
+ stopReason,
6314
+ hasMarkdown: Boolean(markdown),
6315
+ markdownLength: markdown ? markdown.length : 0,
6316
+ activeRequestId: activeRequest?.id ?? null,
6317
+ activeRequestKind: activeRequest?.kind ?? null,
6318
+ });
6319
+
6320
+ // Assistant is handing off to tool calls; request is still in progress.
6321
+ if (stopReason === "toolUse") {
6322
+ emitDebugEvent("message_end_tool_use", {
6323
+ role,
6324
+ activeRequestId: activeRequest?.id ?? null,
6325
+ activeRequestKind: activeRequest?.kind ?? null,
6326
+ });
6327
+ return;
6328
+ }
6329
+
5022
6330
  if (!markdown) return;
5023
6331
 
5024
6332
  if (activeRequest) {
@@ -5029,6 +6337,12 @@ export default function (pi: ExtensionAPI) {
5029
6337
  timestamp: Date.now(),
5030
6338
  kind,
5031
6339
  };
6340
+ emitDebugEvent("broadcast_response", {
6341
+ requestId,
6342
+ kind,
6343
+ markdownLength: markdown.length,
6344
+ stopReason,
6345
+ });
5032
6346
  broadcast({
5033
6347
  type: "response",
5034
6348
  requestId,
@@ -5046,6 +6360,11 @@ export default function (pi: ExtensionAPI) {
5046
6360
  timestamp: Date.now(),
5047
6361
  kind: inferredKind,
5048
6362
  };
6363
+ emitDebugEvent("broadcast_latest_response", {
6364
+ kind: inferredKind,
6365
+ markdownLength: markdown.length,
6366
+ stopReason,
6367
+ });
5049
6368
  broadcast({
5050
6369
  type: "latest_response",
5051
6370
  kind: inferredKind,
@@ -5056,7 +6375,8 @@ export default function (pi: ExtensionAPI) {
5056
6375
 
5057
6376
  pi.on("agent_end", async () => {
5058
6377
  agentBusy = false;
5059
- broadcastState();
6378
+ emitDebugEvent("agent_end", { activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
6379
+ setTerminalActivity("idle");
5060
6380
  if (activeRequest) {
5061
6381
  const requestId = activeRequest.id;
5062
6382
  broadcast({
@@ -5070,6 +6390,8 @@ export default function (pi: ExtensionAPI) {
5070
6390
 
5071
6391
  pi.on("session_shutdown", async () => {
5072
6392
  lastCommandCtx = null;
6393
+ agentBusy = false;
6394
+ setTerminalActivity("idle");
5073
6395
  await stopServer();
5074
6396
  });
5075
6397
 
@@ -5112,7 +6434,8 @@ export default function (pi: ExtensionAPI) {
5112
6434
 
5113
6435
  await ctx.waitForIdle();
5114
6436
  lastCommandCtx = ctx;
5115
- studioCwd = ctx.cwd;
6437
+ refreshRuntimeMetadata(ctx);
6438
+ broadcastState();
5116
6439
  // Seed theme vars so first ping doesn't trigger a false update
5117
6440
  try {
5118
6441
  const currentStyle = getStudioThemeStyle(ctx.ui.theme);