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.
- package/CHANGELOG.md +28 -0
- package/README.md +9 -4
- package/index.ts +1371 -48
- 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 {
|
|
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(
|
|
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:
|
|
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
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
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
|
-
|
|
2040
|
-
|
|
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
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
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="
|
|
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 = "
|
|
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 = "
|
|
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 "
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
3674
|
-
|
|
3675
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
3818
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|