pi-studio 0.4.2 → 0.5.0

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/index.ts CHANGED
@@ -2,14 +2,16 @@ import type { ExtensionAPI, ExtensionCommandContext, SessionEntry, Theme } from
2
2
  import { spawn } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { readFileSync, statSync, writeFileSync } from "node:fs";
5
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
6
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
7
+ import { tmpdir } from "node:os";
6
8
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
7
9
  import { URL } from "node:url";
8
10
  import { WebSocketServer, WebSocket, type RawData } from "ws";
9
11
 
10
12
  type Lens = "writing" | "code";
11
13
  type RequestedLens = Lens | "auto";
12
- type StudioRequestKind = "critique" | "annotation" | "direct";
14
+ type StudioRequestKind = "critique" | "annotation" | "direct" | "compact";
13
15
  type StudioSourceKind = "file" | "last-response" | "blank";
14
16
  type TerminalActivityPhase = "idle" | "running" | "tool" | "responding";
15
17
 
@@ -34,6 +36,20 @@ interface LastStudioResponse {
34
36
  kind: StudioRequestKind;
35
37
  }
36
38
 
39
+ interface StudioResponseHistoryItem {
40
+ id: string;
41
+ markdown: string;
42
+ timestamp: number;
43
+ kind: StudioRequestKind;
44
+ prompt: string | null;
45
+ }
46
+
47
+ interface StudioContextUsageSnapshot {
48
+ tokens: number | null;
49
+ contextWindow: number | null;
50
+ percent: number | null;
51
+ }
52
+
37
53
  interface InitialStudioDocument {
38
54
  text: string;
39
55
  label: string;
@@ -72,6 +88,12 @@ interface SendRunRequestMessage {
72
88
  text: string;
73
89
  }
74
90
 
91
+ interface CompactRequestMessage {
92
+ type: "compact_request";
93
+ requestId: string;
94
+ customInstructions?: string;
95
+ }
96
+
75
97
  interface SaveAsRequestMessage {
76
98
  type: "save_as_request";
77
99
  requestId: string;
@@ -103,6 +125,7 @@ type IncomingStudioMessage =
103
125
  | CritiqueRequestMessage
104
126
  | AnnotationRequestMessage
105
127
  | SendRunRequestMessage
128
+ | CompactRequestMessage
106
129
  | SaveAsRequestMessage
107
130
  | SaveOverRequestMessage
108
131
  | SendToEditorRequestMessage
@@ -110,7 +133,22 @@ type IncomingStudioMessage =
110
133
 
111
134
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
112
135
  const PREVIEW_RENDER_MAX_CHARS = 400_000;
136
+ const PDF_EXPORT_MAX_CHARS = 400_000;
113
137
  const REQUEST_BODY_MAX_BYTES = 1_000_000;
138
+ const RESPONSE_HISTORY_LIMIT = 30;
139
+ const UPDATE_CHECK_TIMEOUT_MS = 1800;
140
+
141
+ const PDF_PREAMBLE = `\\usepackage{titlesec}
142
+ \\titleformat{\\section}{\\Large\\bfseries\\sffamily}{}{0pt}{}[\\vspace{2pt}\\titlerule]
143
+ \\titleformat{\\subsection}{\\large\\bfseries\\sffamily}{}{0pt}{}
144
+ \\titleformat{\\subsubsection}{\\normalsize\\bfseries\\sffamily}{}{0pt}{}
145
+ \\titlespacing*{\\section}{0pt}{1.5ex plus 0.5ex minus 0.2ex}{1ex plus 0.2ex}
146
+ \\titlespacing*{\\subsection}{0pt}{1.2ex plus 0.4ex minus 0.2ex}{0.6ex plus 0.1ex}
147
+ \\usepackage{enumitem}
148
+ \\setlist[itemize]{nosep, leftmargin=1.5em}
149
+ \\setlist[enumerate]{nosep, leftmargin=1.5em}
150
+ \\usepackage{parskip}
151
+ `;
114
152
 
115
153
  type StudioThemeMode = "dark" | "light";
116
154
 
@@ -659,6 +697,93 @@ function writeStudioFile(pathArg: string, cwd: string, content: string):
659
697
  }
660
698
  }
661
699
 
700
+ function readLocalPackageMetadata(): { name: string; version: string } | null {
701
+ try {
702
+ const raw = readFileSync(new URL("./package.json", import.meta.url), "utf-8");
703
+ const parsed = JSON.parse(raw) as { name?: unknown; version?: unknown };
704
+ const name = typeof parsed.name === "string" ? parsed.name.trim() : "";
705
+ const version = typeof parsed.version === "string" ? parsed.version.trim() : "";
706
+ if (!name || !version) return null;
707
+ return { name, version };
708
+ } catch {
709
+ return null;
710
+ }
711
+ }
712
+
713
+ interface ParsedSemver {
714
+ major: number;
715
+ minor: number;
716
+ patch: number;
717
+ prerelease: string | null;
718
+ }
719
+
720
+ function parseSemverLoose(version: string): ParsedSemver | null {
721
+ const normalized = String(version || "").trim().replace(/^v/i, "");
722
+ const match = normalized.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?/);
723
+ if (!match) return null;
724
+ const major = Number.parseInt(match[1] ?? "", 10);
725
+ const minor = Number.parseInt(match[2] ?? "0", 10);
726
+ const patch = Number.parseInt(match[3] ?? "0", 10);
727
+ if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) return null;
728
+ const prerelease = typeof match[4] === "string" && match[4].trim() ? match[4].trim() : null;
729
+ return { major, minor, patch, prerelease };
730
+ }
731
+
732
+ function compareSemverLoose(a: string, b: string): number {
733
+ const pa = parseSemverLoose(a);
734
+ const pb = parseSemverLoose(b);
735
+ if (!pa || !pb) {
736
+ return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
737
+ }
738
+ if (pa.major !== pb.major) return pa.major - pb.major;
739
+ if (pa.minor !== pb.minor) return pa.minor - pb.minor;
740
+ if (pa.patch !== pb.patch) return pa.patch - pb.patch;
741
+ if (pa.prerelease && !pb.prerelease) return -1;
742
+ if (!pa.prerelease && pb.prerelease) return 1;
743
+ if (!pa.prerelease && !pb.prerelease) return 0;
744
+ return (pa.prerelease ?? "").localeCompare(pb.prerelease ?? "", undefined, {
745
+ numeric: true,
746
+ sensitivity: "base",
747
+ });
748
+ }
749
+
750
+ function isVersionBehind(installedVersion: string, latestVersion: string): boolean {
751
+ return compareSemverLoose(installedVersion, latestVersion) < 0;
752
+ }
753
+
754
+ async function fetchLatestNpmVersion(packageName: string, timeoutMs = UPDATE_CHECK_TIMEOUT_MS): Promise<string | null> {
755
+ const pkg = String(packageName || "").trim();
756
+ if (!pkg) return null;
757
+ const encodedPackage = encodeURIComponent(pkg).replace(/^%40/, "@");
758
+ const endpoint = `https://registry.npmjs.org/${encodedPackage}/latest`;
759
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
760
+ const timer = controller
761
+ ? setTimeout(() => {
762
+ try {
763
+ controller.abort();
764
+ } catch {
765
+ // ignore abort race
766
+ }
767
+ }, timeoutMs)
768
+ : null;
769
+
770
+ try {
771
+ const response = await fetch(endpoint, {
772
+ method: "GET",
773
+ headers: { Accept: "application/json" },
774
+ signal: controller?.signal,
775
+ });
776
+ if (!response.ok) return null;
777
+ const payload = await response.json() as { version?: unknown };
778
+ const version = typeof payload.version === "string" ? payload.version.trim() : "";
779
+ return version || null;
780
+ } catch {
781
+ return null;
782
+ } finally {
783
+ if (timer) clearTimeout(timer);
784
+ }
785
+ }
786
+
662
787
  function normalizeMathDelimitersInSegment(markdown: string): string {
663
788
  let normalized = markdown.replace(/\\\[\s*([\s\S]*?)\s*\\\]/g, (_match, expr: string) => {
664
789
  const content = expr.trim();
@@ -800,6 +925,85 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
800
925
  });
801
926
  }
802
927
 
928
+ async function renderStudioPdfWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string): Promise<Buffer> {
929
+ const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
930
+ const pdfEngine = process.env.PANDOC_PDF_ENGINE?.trim() || "xelatex";
931
+ const inputFormat = isLatex
932
+ ? "latex"
933
+ : "gfm+tex_math_dollars+autolink_bare_uris+superscript+subscript-raw_html";
934
+ const normalizedMarkdown = isLatex ? markdown : normalizeObsidianImages(normalizeMathDelimiters(markdown));
935
+
936
+ const tempDir = join(tmpdir(), `pi-studio-pdf-${Date.now()}-${randomUUID()}`);
937
+ const preamblePath = join(tempDir, "_pdf_preamble.tex");
938
+ const outputPath = join(tempDir, "studio-export.pdf");
939
+
940
+ await mkdir(tempDir, { recursive: true });
941
+ await writeFile(preamblePath, PDF_PREAMBLE, "utf-8");
942
+
943
+ const args = [
944
+ "-f", inputFormat,
945
+ "-o", outputPath,
946
+ `--pdf-engine=${pdfEngine}`,
947
+ "-V", "geometry:margin=2.2cm",
948
+ "-V", "fontsize=11pt",
949
+ "-V", "linestretch=1.25",
950
+ "-V", "urlcolor=blue",
951
+ "-V", "linkcolor=blue",
952
+ "--include-in-header", preamblePath,
953
+ ];
954
+ if (resourcePath) args.push(`--resource-path=${resourcePath}`);
955
+
956
+ try {
957
+ await new Promise<void>((resolve, reject) => {
958
+ const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"] });
959
+ const stderrChunks: Buffer[] = [];
960
+ let settled = false;
961
+
962
+ const fail = (error: Error) => {
963
+ if (settled) return;
964
+ settled = true;
965
+ reject(error);
966
+ };
967
+
968
+ child.stderr.on("data", (chunk: Buffer | string) => {
969
+ stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
970
+ });
971
+
972
+ child.once("error", (error) => {
973
+ const errno = error as NodeJS.ErrnoException;
974
+ if (errno.code === "ENOENT") {
975
+ const commandHint = pandocCommand === "pandoc"
976
+ ? "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."
977
+ : `${pandocCommand} was not found. Check PANDOC_PATH.`;
978
+ fail(new Error(commandHint));
979
+ return;
980
+ }
981
+ fail(error);
982
+ });
983
+
984
+ child.once("close", (code) => {
985
+ if (settled) return;
986
+ if (code === 0) {
987
+ settled = true;
988
+ resolve();
989
+ return;
990
+ }
991
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
992
+ const hint = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
993
+ ? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
994
+ : "";
995
+ fail(new Error(`pandoc PDF export failed with exit code ${code}${stderr ? `: ${stderr}` : ""}${hint}`));
996
+ });
997
+
998
+ child.stdin.end(normalizedMarkdown);
999
+ });
1000
+
1001
+ return await readFile(outputPath);
1002
+ } finally {
1003
+ await rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
1004
+ }
1005
+ }
1006
+
803
1007
  function readRequestBody(req: IncomingMessage, maxBytes: number): Promise<string> {
804
1008
  return new Promise((resolve, reject) => {
805
1009
  const chunks: Buffer[] = [];
@@ -1065,6 +1269,116 @@ function extractLatestAssistantFromEntries(entries: SessionEntry[]): string | nu
1065
1269
  return null;
1066
1270
  }
1067
1271
 
1272
+ function extractUserText(message: unknown): string | null {
1273
+ const msg = message as {
1274
+ role?: string;
1275
+ content?: Array<{ type?: string; text?: string | { value?: string } }> | string;
1276
+ };
1277
+ if (!msg || msg.role !== "user") return null;
1278
+
1279
+ if (typeof msg.content === "string") {
1280
+ const text = msg.content.trim();
1281
+ return text.length > 0 ? text : null;
1282
+ }
1283
+
1284
+ if (!Array.isArray(msg.content)) return null;
1285
+
1286
+ const blocks: string[] = [];
1287
+ for (const part of msg.content) {
1288
+ if (!part || typeof part !== "object") continue;
1289
+ const partType = typeof part.type === "string" ? part.type : "";
1290
+ if (typeof part.text === "string") {
1291
+ if (!partType || partType === "text" || partType === "input_text") {
1292
+ blocks.push(part.text);
1293
+ }
1294
+ continue;
1295
+ }
1296
+ if (part.text && typeof part.text === "object" && typeof part.text.value === "string") {
1297
+ if (!partType || partType === "text" || partType === "input_text") {
1298
+ blocks.push(part.text.value);
1299
+ }
1300
+ }
1301
+ }
1302
+
1303
+ const text = blocks.join("\n\n").trim();
1304
+ return text.length > 0 ? text : null;
1305
+ }
1306
+
1307
+ function parseEntryTimestamp(timestamp: unknown): number {
1308
+ if (typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0) {
1309
+ return timestamp;
1310
+ }
1311
+ if (typeof timestamp === "string" && timestamp.trim()) {
1312
+ const parsed = Date.parse(timestamp);
1313
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
1314
+ }
1315
+ return Date.now();
1316
+ }
1317
+
1318
+ function buildResponseHistoryFromEntries(entries: SessionEntry[], limit = RESPONSE_HISTORY_LIMIT): StudioResponseHistoryItem[] {
1319
+ const history: StudioResponseHistoryItem[] = [];
1320
+ let lastUserPrompt: string | null = null;
1321
+
1322
+ for (const entry of entries) {
1323
+ if (!entry || entry.type !== "message") continue;
1324
+ const message = (entry as { message?: unknown }).message;
1325
+ const role = (message as { role?: string } | undefined)?.role;
1326
+ if (role === "user") {
1327
+ lastUserPrompt = extractUserText(message);
1328
+ continue;
1329
+ }
1330
+ if (role !== "assistant") continue;
1331
+ const markdown = extractAssistantText(message);
1332
+ if (!markdown) continue;
1333
+ history.push({
1334
+ id: typeof (entry as { id?: unknown }).id === "string" ? (entry as { id: string }).id : randomUUID(),
1335
+ markdown,
1336
+ timestamp: parseEntryTimestamp((entry as { timestamp?: unknown }).timestamp),
1337
+ kind: inferStudioResponseKind(markdown),
1338
+ prompt: lastUserPrompt,
1339
+ });
1340
+ }
1341
+
1342
+ if (history.length <= limit) return history;
1343
+ return history.slice(-limit);
1344
+ }
1345
+
1346
+ function normalizeContextUsageSnapshot(usage: { tokens: number | null; contextWindow: number; percent: number | null } | undefined): StudioContextUsageSnapshot {
1347
+ if (!usage) {
1348
+ return {
1349
+ tokens: null,
1350
+ contextWindow: null,
1351
+ percent: null,
1352
+ };
1353
+ }
1354
+
1355
+ const contextWindow =
1356
+ typeof usage.contextWindow === "number" && Number.isFinite(usage.contextWindow) && usage.contextWindow > 0
1357
+ ? usage.contextWindow
1358
+ : null;
1359
+ const tokens = typeof usage.tokens === "number" && Number.isFinite(usage.tokens) && usage.tokens >= 0
1360
+ ? usage.tokens
1361
+ : null;
1362
+
1363
+ let percent = typeof usage.percent === "number" && Number.isFinite(usage.percent)
1364
+ ? usage.percent
1365
+ : null;
1366
+ if (percent === null && tokens !== null && contextWindow) {
1367
+ percent = (tokens / contextWindow) * 100;
1368
+ }
1369
+ if (typeof percent === "number" && Number.isFinite(percent)) {
1370
+ percent = Math.max(0, Math.min(100, percent));
1371
+ } else {
1372
+ percent = null;
1373
+ }
1374
+
1375
+ return {
1376
+ tokens,
1377
+ contextWindow,
1378
+ percent,
1379
+ };
1380
+ }
1381
+
1068
1382
  function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
1069
1383
  let parsed: unknown;
1070
1384
  try {
@@ -1110,6 +1424,18 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
1110
1424
  };
1111
1425
  }
1112
1426
 
1427
+ if (
1428
+ msg.type === "compact_request" &&
1429
+ typeof msg.requestId === "string" &&
1430
+ (msg.customInstructions === undefined || typeof msg.customInstructions === "string")
1431
+ ) {
1432
+ return {
1433
+ type: "compact_request",
1434
+ requestId: msg.requestId,
1435
+ customInstructions: typeof msg.customInstructions === "string" ? msg.customInstructions : undefined,
1436
+ };
1437
+ }
1438
+
1113
1439
  if (
1114
1440
  msg.type === "save_as_request" &&
1115
1441
  typeof msg.requestId === "string" &&
@@ -1292,6 +1618,50 @@ function buildStudioUrl(port: number, token: string): string {
1292
1618
  return `http://127.0.0.1:${port}/?token=${encoded}`;
1293
1619
  }
1294
1620
 
1621
+ function formatModelLabel(model: { provider?: string; id?: string } | undefined): string {
1622
+ const provider = typeof model?.provider === "string" ? model.provider.trim() : "";
1623
+ const id = typeof model?.id === "string" ? model.id.trim() : "";
1624
+ if (provider && id) return `${provider}/${id}`;
1625
+ if (id) return id;
1626
+ return "none";
1627
+ }
1628
+
1629
+ function formatModelLabelWithThinking(modelLabel: string, thinkingLevel?: string): string {
1630
+ const base = String(modelLabel || "").replace(/\s*\([^)]*\)\s*$/, "").trim() || "none";
1631
+ if (base === "none") return "none";
1632
+ const level = String(thinkingLevel ?? "").trim();
1633
+ if (!level) return base;
1634
+ return `${base} (${level})`;
1635
+ }
1636
+
1637
+ function buildTerminalSessionLabel(cwd: string, sessionName?: string): string {
1638
+ const cwdBase = basename(cwd || process.cwd() || "") || cwd || "~";
1639
+ const termProgram = String(process.env.TERM_PROGRAM ?? "").trim();
1640
+ const name = String(sessionName ?? "").trim();
1641
+ const parts: string[] = [];
1642
+ if (termProgram) parts.push(termProgram);
1643
+ if (name) parts.push(name);
1644
+ parts.push(cwdBase);
1645
+ return parts.join(" · ");
1646
+ }
1647
+
1648
+ function sanitizePdfFilename(input: string | undefined): string {
1649
+ const fallback = "studio-preview.pdf";
1650
+ const raw = String(input ?? "").trim();
1651
+ if (!raw) return fallback;
1652
+
1653
+ const noPath = raw.split(/[\\/]/).pop() ?? raw;
1654
+ const cleaned = noPath
1655
+ .replace(/[\x00-\x1f\x7f]+/g, "")
1656
+ .replace(/[<>:"|?*]+/g, "-")
1657
+ .trim();
1658
+ if (!cleaned) return fallback;
1659
+
1660
+ const ensuredExt = cleaned.toLowerCase().endsWith(".pdf") ? cleaned : `${cleaned}.pdf`;
1661
+ if (ensuredExt.length <= 160) return ensuredExt;
1662
+ return `${ensuredExt.slice(0, 156)}.pdf`;
1663
+ }
1664
+
1295
1665
  function buildThemeCssVars(style: StudioThemeStyle): Record<string, string> {
1296
1666
  const panelShadow =
1297
1667
  style.mode === "light"
@@ -1358,11 +1728,31 @@ function buildThemeCssVars(style: StudioThemeStyle): Record<string, string> {
1358
1728
  };
1359
1729
  }
1360
1730
 
1361
- function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?: Theme): string {
1731
+ function buildStudioHtml(
1732
+ initialDocument: InitialStudioDocument | null,
1733
+ theme?: Theme,
1734
+ initialModelLabel?: string,
1735
+ initialTerminalLabel?: string,
1736
+ initialContextUsage?: StudioContextUsageSnapshot,
1737
+ ): string {
1362
1738
  const initialText = escapeHtmlForInline(initialDocument?.text ?? "");
1363
1739
  const initialSource = initialDocument?.source ?? "blank";
1364
1740
  const initialLabel = escapeHtmlForInline(initialDocument?.label ?? "blank");
1365
1741
  const initialPath = escapeHtmlForInline(initialDocument?.path ?? "");
1742
+ const initialModel = escapeHtmlForInline(initialModelLabel ?? "none");
1743
+ const initialTerminal = escapeHtmlForInline(initialTerminalLabel ?? "unknown");
1744
+ const initialContextTokens =
1745
+ typeof initialContextUsage?.tokens === "number" && Number.isFinite(initialContextUsage.tokens)
1746
+ ? String(initialContextUsage.tokens)
1747
+ : "";
1748
+ const initialContextWindow =
1749
+ typeof initialContextUsage?.contextWindow === "number" && Number.isFinite(initialContextUsage.contextWindow)
1750
+ ? String(initialContextUsage.contextWindow)
1751
+ : "";
1752
+ const initialContextPercent =
1753
+ typeof initialContextUsage?.percent === "number" && Number.isFinite(initialContextUsage.percent)
1754
+ ? String(initialContextUsage.percent)
1755
+ : "";
1366
1756
  const style = getStudioThemeStyle(theme);
1367
1757
  const vars = buildThemeCssVars(style);
1368
1758
  const mermaidConfig = {
@@ -1399,7 +1789,7 @@ function buildStudioHtml(initialDocument: InitialStudioDocument | null, theme?:
1399
1789
  <head>
1400
1790
  <meta charset="utf-8" />
1401
1791
  <meta name="viewport" content="width=device-width,initial-scale=1" />
1402
- <title>Pi Studio: Feedback Workspace</title>
1792
+ <title>pi Studio</title>
1403
1793
  <style>
1404
1794
  :root {
1405
1795
  ${cssVarsBlock}
@@ -1593,6 +1983,7 @@ ${cssVarsBlock}
1593
1983
  padding: 10px;
1594
1984
  font-size: 13px;
1595
1985
  line-height: 1.45;
1986
+ tab-size: 2;
1596
1987
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
1597
1988
  resize: vertical;
1598
1989
  }
@@ -1731,6 +2122,7 @@ ${cssVarsBlock}
1731
2122
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
1732
2123
  font-size: 13px;
1733
2124
  line-height: 1.45;
2125
+ tab-size: 2;
1734
2126
  color: var(--text);
1735
2127
  background: transparent;
1736
2128
  }
@@ -1830,6 +2222,27 @@ ${cssVarsBlock}
1830
2222
  color: var(--md-link-url);
1831
2223
  }
1832
2224
 
2225
+ .hl-annotation {
2226
+ color: var(--accent);
2227
+ background: var(--accent-soft);
2228
+ border: 1px solid var(--marker-border);
2229
+ border-radius: 4px;
2230
+ padding: 0 3px;
2231
+ }
2232
+
2233
+ .hl-annotation-muted {
2234
+ color: var(--muted);
2235
+ opacity: 0.65;
2236
+ }
2237
+
2238
+ .annotation-preview-marker {
2239
+ color: var(--accent);
2240
+ background: var(--accent-soft);
2241
+ border: 1px solid var(--marker-border);
2242
+ border-radius: 4px;
2243
+ padding: 0 4px;
2244
+ }
2245
+
1833
2246
  #sourcePreview {
1834
2247
  flex: 1 1 auto;
1835
2248
  min-height: 0;
@@ -2186,28 +2599,133 @@ ${cssVarsBlock}
2186
2599
  font-size: 12px;
2187
2600
  min-height: 32px;
2188
2601
  background: var(--panel);
2189
- display: flex;
2602
+ display: grid;
2603
+ grid-template-columns: minmax(0, 1fr) auto;
2604
+ grid-template-areas:
2605
+ "status status"
2606
+ "meta hint";
2607
+ column-gap: 12px;
2608
+ row-gap: 3px;
2609
+ align-items: start;
2610
+ }
2611
+
2612
+ #statusLine {
2613
+ grid-area: status;
2614
+ display: inline-flex;
2190
2615
  align-items: center;
2191
- justify-content: space-between;
2192
- gap: 10px;
2193
- flex-wrap: wrap;
2616
+ gap: 0;
2617
+ min-width: 0;
2618
+ justify-self: start;
2619
+ text-align: left;
2620
+ }
2621
+
2622
+ #statusLine.with-spinner {
2623
+ gap: 6px;
2624
+ }
2625
+
2626
+ #statusSpinner {
2627
+ width: 0;
2628
+ max-width: 0;
2629
+ overflow: hidden;
2630
+ opacity: 0;
2631
+ text-align: center;
2632
+ color: var(--accent);
2633
+ font-family: var(--font-mono);
2634
+ flex: 0 0 auto;
2635
+ transition: opacity 120ms ease;
2636
+ }
2637
+
2638
+ #statusLine.with-spinner #statusSpinner {
2639
+ width: 1.1em;
2640
+ max-width: 1.1em;
2641
+ opacity: 1;
2194
2642
  }
2195
2643
 
2196
2644
  #status {
2197
- flex: 1 1 auto;
2198
- min-width: 240px;
2645
+ min-width: 0;
2646
+ white-space: nowrap;
2647
+ overflow: hidden;
2648
+ text-overflow: ellipsis;
2649
+ text-align: left;
2650
+ }
2651
+
2652
+ .footer-meta {
2653
+ grid-area: meta;
2654
+ justify-self: start;
2655
+ color: var(--muted);
2656
+ font-size: 11px;
2657
+ text-align: left;
2658
+ max-width: 100%;
2659
+ display: inline-flex;
2660
+ align-items: center;
2661
+ gap: 8px;
2662
+ min-width: 0;
2663
+ }
2664
+
2665
+ .footer-meta-text {
2666
+ min-width: 0;
2667
+ white-space: nowrap;
2668
+ overflow: hidden;
2669
+ text-overflow: ellipsis;
2199
2670
  }
2200
2671
 
2201
2672
  .shortcut-hint {
2673
+ grid-area: hint;
2674
+ justify-self: end;
2675
+ align-self: center;
2202
2676
  color: var(--muted);
2203
2677
  font-size: 11px;
2204
2678
  white-space: nowrap;
2679
+ text-align: right;
2205
2680
  font-style: normal;
2681
+ opacity: 0.9;
2682
+ display: inline-flex;
2683
+ align-items: center;
2684
+ gap: 8px;
2685
+ }
2686
+
2687
+ .footer-compact-btn {
2688
+ padding: 4px 8px;
2689
+ font-size: 11px;
2690
+ line-height: 1.2;
2691
+ border-radius: 999px;
2692
+ border: 1px solid var(--border-muted);
2693
+ background: var(--panel-2);
2694
+ color: var(--text);
2695
+ white-space: nowrap;
2696
+ flex: 0 0 auto;
2206
2697
  }
2207
2698
 
2208
- footer.error { color: var(--error); }
2209
- footer.warning { color: var(--warn); }
2210
- footer.success { color: var(--ok); }
2699
+ .footer-compact-btn:not(:disabled):hover {
2700
+ background: var(--panel);
2701
+ }
2702
+
2703
+ #status.error { color: var(--error); }
2704
+ #status.warning { color: var(--warn); }
2705
+ #status.success { color: var(--ok); }
2706
+
2707
+ @media (max-width: 980px) {
2708
+ footer {
2709
+ grid-template-columns: 1fr;
2710
+ grid-template-areas:
2711
+ "status"
2712
+ "meta"
2713
+ "hint";
2714
+ }
2715
+
2716
+ .footer-meta {
2717
+ justify-self: start;
2718
+ max-width: 100%;
2719
+ }
2720
+
2721
+ .shortcut-hint {
2722
+ justify-self: start;
2723
+ text-align: left;
2724
+ white-space: normal;
2725
+ flex-wrap: wrap;
2726
+ gap: 6px;
2727
+ }
2728
+ }
2211
2729
 
2212
2730
  @media (max-width: 1080px) {
2213
2731
  main {
@@ -2216,9 +2734,9 @@ ${cssVarsBlock}
2216
2734
  }
2217
2735
  </style>
2218
2736
  </head>
2219
- <body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}">
2737
+ <body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}" data-context-tokens="${initialContextTokens}" data-context-window="${initialContextWindow}" data-context-percent="${initialContextPercent}">
2220
2738
  <header>
2221
- <h1><span class="app-logo" aria-hidden="true">π</span> Pi Studio <span class="app-subtitle">Editor & Response Workspace</span></h1>
2739
+ <h1><span class="app-logo" aria-hidden="true">π</span> Studio <span class="app-subtitle">Editor & Response Workspace</span></h1>
2222
2740
  <div class="controls">
2223
2741
  <button id="saveAsBtn" type="button" title="Save editor content to a new file path.">Save editor as…</button>
2224
2742
  <button id="saveOverBtn" type="button" title="Overwrite current file with editor content." disabled>Save editor</button>
@@ -2248,7 +2766,11 @@ ${cssVarsBlock}
2248
2766
  </div>
2249
2767
  <div class="source-actions">
2250
2768
  <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>
2251
- <button id="insertHeaderBtn" type="button" title="Prepends/updates the annotated-reply header in the editor.">Insert annotation header</button>
2769
+ <button id="insertHeaderBtn" type="button" title="Insert annotated-reply protocol header (includes source metadata and [an: ...] syntax hint).">Insert annotated reply header</button>
2770
+ <select id="annotationModeSelect" aria-label="Annotation visibility mode" title="On: keep and send [an: ...] markers. Hidden: keep markers in editor, hide in preview, and strip before Run/Critique.">
2771
+ <option value="on" selected>Annotations: On</option>
2772
+ <option value="off">Annotations: Hidden</option>
2773
+ </select>
2252
2774
  <select id="lensSelect" aria-label="Critique focus">
2253
2775
  <option value="auto" selected>Critique focus: Auto</option>
2254
2776
  <option value="writing">Critique focus: Writing</option>
@@ -2258,6 +2780,8 @@ ${cssVarsBlock}
2258
2780
  <button id="sendEditorBtn" type="button">Send to pi editor</button>
2259
2781
  <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
2260
2782
  <button id="copyDraftBtn" type="button">Copy editor text</button>
2783
+ <button id="saveAnnotatedBtn" type="button" title="Save full editor content (including [an: ...] markers) as a .annotated.md file.">Save .annotated.md</button>
2784
+ <button id="stripAnnotationsBtn" type="button" title="Destructively remove all [an: ...] markers from editor text.">Strip annotations…</button>
2261
2785
  <select id="highlightSelect" aria-label="Editor syntax highlighting">
2262
2786
  <option value="off">Syntax highlight: Off</option>
2263
2787
  <option value="on" selected>Syntax highlight: On</option>
@@ -2323,17 +2847,23 @@ ${cssVarsBlock}
2323
2847
  <option value="on" selected>Syntax highlight: On</option>
2324
2848
  </select>
2325
2849
  <button id="pullLatestBtn" type="button" title="Fetch the latest assistant response when auto-update is off.">Get latest response</button>
2850
+ <button id="historyPrevBtn" type="button" title="Show previous response in history.">◀ Prev response</button>
2851
+ <span id="historyIndexBadge" class="source-badge">History: 0/0</span>
2852
+ <button id="historyNextBtn" type="button" title="Show next response in history.">Next response ▶</button>
2853
+ <button id="loadHistoryPromptBtn" type="button" title="Load the prompt that generated the selected response into the editor.">Load response prompt into editor</button>
2326
2854
  <button id="loadResponseBtn" type="button">Load response into editor</button>
2327
2855
  <button id="loadCritiqueNotesBtn" type="button" hidden>Load critique notes into editor</button>
2328
2856
  <button id="loadCritiqueFullBtn" type="button" hidden>Load full critique into editor</button>
2329
2857
  <button id="copyResponseBtn" type="button">Copy response text</button>
2858
+ <button id="exportPdfBtn" type="button" title="Export the current right-pane preview as PDF via pandoc + xelatex.">Export right preview as PDF</button>
2330
2859
  </div>
2331
2860
  </div>
2332
2861
  </section>
2333
2862
  </main>
2334
2863
 
2335
2864
  <footer>
2336
- <span id="status">Booting studio…</span>
2865
+ <span id="statusLine"><span id="statusSpinner" aria-hidden="true"> </span><span id="status">Booting studio…</span></span>
2866
+ <span id="footerMeta" class="footer-meta"><span id="footerMetaText" class="footer-meta-text">Model: ${initialModel} · Terminal: ${initialTerminal} · Context: unknown</span><button id="compactBtn" class="footer-compact-btn" type="button" title="Trigger pi context compaction now.">Compact context</button></span>
2337
2867
  <span class="shortcut-hint">Focus pane: Cmd/Ctrl+Esc (or F10), Esc to exit · Run editor text: Cmd/Ctrl+Enter</span>
2338
2868
  </footer>
2339
2869
 
@@ -2341,15 +2871,32 @@ ${cssVarsBlock}
2341
2871
  <script defer src="https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.min.js"></script>
2342
2872
  <script>
2343
2873
  (() => {
2874
+ const statusLineEl = document.getElementById("statusLine");
2344
2875
  const statusEl = document.getElementById("status");
2876
+ const statusSpinnerEl = document.getElementById("statusSpinner");
2877
+ const footerMetaEl = document.getElementById("footerMeta");
2878
+ const footerMetaTextEl = document.getElementById("footerMetaText");
2879
+ const BRAILLE_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2880
+ let spinnerTimer = null;
2881
+ let spinnerFrameIndex = 0;
2345
2882
  if (statusEl) {
2346
- statusEl.textContent = "WS: Connecting · Studio script starting…";
2883
+ statusEl.textContent = "Connecting · Studio script starting…";
2347
2884
  }
2348
2885
 
2349
2886
  function hardFail(prefix, error) {
2350
2887
  const details = error && error.message ? error.message : String(error || "unknown error");
2888
+ if (spinnerTimer) {
2889
+ window.clearInterval(spinnerTimer);
2890
+ spinnerTimer = null;
2891
+ }
2892
+ if (statusLineEl && statusLineEl.classList) {
2893
+ statusLineEl.classList.remove("with-spinner");
2894
+ }
2895
+ if (statusSpinnerEl) {
2896
+ statusSpinnerEl.textContent = "";
2897
+ }
2351
2898
  if (statusEl) {
2352
- statusEl.textContent = "WS: Disconnected · " + prefix + ": " + details;
2899
+ statusEl.textContent = "Disconnected · " + prefix + ": " + details;
2353
2900
  statusEl.className = "error";
2354
2901
  }
2355
2902
  }
@@ -2391,14 +2938,23 @@ ${cssVarsBlock}
2391
2938
  const loadCritiqueNotesBtn = document.getElementById("loadCritiqueNotesBtn");
2392
2939
  const loadCritiqueFullBtn = document.getElementById("loadCritiqueFullBtn");
2393
2940
  const copyResponseBtn = document.getElementById("copyResponseBtn");
2941
+ const exportPdfBtn = document.getElementById("exportPdfBtn");
2942
+ const historyPrevBtn = document.getElementById("historyPrevBtn");
2943
+ const historyNextBtn = document.getElementById("historyNextBtn");
2944
+ const historyIndexBadgeEl = document.getElementById("historyIndexBadge");
2945
+ const loadHistoryPromptBtn = document.getElementById("loadHistoryPromptBtn");
2394
2946
  const saveAsBtn = document.getElementById("saveAsBtn");
2395
2947
  const saveOverBtn = document.getElementById("saveOverBtn");
2396
2948
  const sendEditorBtn = document.getElementById("sendEditorBtn");
2397
2949
  const getEditorBtn = document.getElementById("getEditorBtn");
2398
2950
  const sendRunBtn = document.getElementById("sendRunBtn");
2399
2951
  const copyDraftBtn = document.getElementById("copyDraftBtn");
2952
+ const saveAnnotatedBtn = document.getElementById("saveAnnotatedBtn");
2953
+ const stripAnnotationsBtn = document.getElementById("stripAnnotationsBtn");
2400
2954
  const highlightSelect = document.getElementById("highlightSelect");
2401
2955
  const langSelect = document.getElementById("langSelect");
2956
+ const annotationModeSelect = document.getElementById("annotationModeSelect");
2957
+ const compactBtn = document.getElementById("compactBtn");
2402
2958
 
2403
2959
  const initialSourceState = {
2404
2960
  source: (document.body && document.body.dataset && document.body.dataset.initialSource) || "blank",
@@ -2408,7 +2964,7 @@ ${cssVarsBlock}
2408
2964
 
2409
2965
  let ws = null;
2410
2966
  let wsState = "Connecting";
2411
- let statusMessage = "Studio script starting…";
2967
+ let statusMessage = "Connecting · Studio script starting…";
2412
2968
  let statusLevel = "";
2413
2969
  let pendingRequestId = null;
2414
2970
  let pendingKind = null;
@@ -2426,12 +2982,32 @@ ${cssVarsBlock}
2426
2982
  let latestResponseNormalized = "";
2427
2983
  let latestCritiqueNotes = "";
2428
2984
  let latestCritiqueNotesNormalized = "";
2985
+ let responseHistory = [];
2986
+ let responseHistoryIndex = -1;
2429
2987
  let agentBusyFromServer = false;
2430
2988
  let terminalActivityPhase = "idle";
2431
2989
  let terminalActivityToolName = "";
2432
2990
  let terminalActivityLabel = "";
2433
2991
  let lastSpecificToolLabel = "";
2434
2992
  let uiBusy = false;
2993
+ let pdfExportInProgress = false;
2994
+ let compactInProgress = false;
2995
+ let modelLabel = (document.body && document.body.dataset && document.body.dataset.modelLabel) || "none";
2996
+ let terminalSessionLabel = (document.body && document.body.dataset && document.body.dataset.terminalLabel) || "unknown";
2997
+ let contextTokens = null;
2998
+ let contextWindow = null;
2999
+ let contextPercent = null;
3000
+
3001
+ function parseFiniteNumber(value) {
3002
+ if (value == null || value === "") return null;
3003
+ const parsed = Number(value);
3004
+ return Number.isFinite(parsed) ? parsed : null;
3005
+ }
3006
+
3007
+ contextTokens = parseFiniteNumber(document.body && document.body.dataset ? document.body.dataset.contextTokens : null);
3008
+ contextWindow = parseFiniteNumber(document.body && document.body.dataset ? document.body.dataset.contextWindow : null);
3009
+ contextPercent = parseFiniteNumber(document.body && document.body.dataset ? document.body.dataset.contextPercent : null);
3010
+
2435
3011
  let sourceState = {
2436
3012
  source: initialSourceState.source,
2437
3013
  label: initialSourceState.label,
@@ -2482,6 +3058,7 @@ ${cssVarsBlock}
2482
3058
  var SUPPORTED_LANGUAGES = Object.keys(LANG_EXT_MAP);
2483
3059
  const RESPONSE_HIGHLIGHT_MAX_CHARS = 120_000;
2484
3060
  const RESPONSE_HIGHLIGHT_STORAGE_KEY = "piStudio.responseHighlightEnabled";
3061
+ const ANNOTATION_MODE_STORAGE_KEY = "piStudio.annotationsEnabled";
2485
3062
  const PREVIEW_INPUT_DEBOUNCE_MS = 0;
2486
3063
  const PREVIEW_PENDING_BADGE_DELAY_MS = 220;
2487
3064
  const previewPendingTimers = new WeakMap();
@@ -2494,6 +3071,8 @@ ${cssVarsBlock}
2494
3071
  let editorLanguage = "markdown";
2495
3072
  let responseHighlightEnabled = false;
2496
3073
  let editorHighlightRenderRaf = null;
3074
+ let annotationsEnabled = true;
3075
+ const ANNOTATION_MARKER_REGEX = /\\[an:\\s*([^\\]\\n]+?)\\]/gi;
2497
3076
  const MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
2498
3077
  const MERMAID_CONFIG = ${JSON.stringify(mermaidConfig)};
2499
3078
  const MERMAID_UNAVAILABLE_MESSAGE = "Mermaid renderer unavailable. Showing mermaid blocks as code.";
@@ -2547,15 +3126,23 @@ ${cssVarsBlock}
2547
3126
  if (typeof message.terminalPhase === "string") summary.terminalPhase = message.terminalPhase;
2548
3127
  if (typeof message.terminalToolName === "string") summary.terminalToolName = message.terminalToolName;
2549
3128
  if (typeof message.terminalActivityLabel === "string") summary.terminalActivityLabel = message.terminalActivityLabel;
3129
+ if (typeof message.modelLabel === "string") summary.modelLabel = message.modelLabel;
3130
+ if (typeof message.terminalSessionLabel === "string") summary.terminalSessionLabel = message.terminalSessionLabel;
3131
+ if (typeof message.contextTokens === "number") summary.contextTokens = message.contextTokens;
3132
+ if (typeof message.contextWindow === "number") summary.contextWindow = message.contextWindow;
3133
+ if (typeof message.contextPercent === "number") summary.contextPercent = message.contextPercent;
3134
+ if (typeof message.compactInProgress === "boolean") summary.compactInProgress = message.compactInProgress;
2550
3135
  if (typeof message.stopReason === "string") summary.stopReason = message.stopReason;
2551
3136
  if (typeof message.markdown === "string") summary.markdownLength = message.markdown.length;
2552
3137
  if (typeof message.label === "string") summary.label = message.label;
3138
+ if (Array.isArray(message.responseHistory)) summary.responseHistoryCount = message.responseHistory.length;
3139
+ if (Array.isArray(message.items)) summary.itemsCount = message.items.length;
2553
3140
  if (typeof message.details === "object" && message.details !== null) summary.details = message.details;
2554
3141
  return summary;
2555
3142
  }
2556
3143
 
2557
3144
  function getIdleStatus() {
2558
- return "Ready. Edit, load, or annotate text, then run, save, send to pi editor, or critique.";
3145
+ return "Edit, load, or annotate text, then run, save, send to pi editor, or critique.";
2559
3146
  }
2560
3147
 
2561
3148
  function normalizeTerminalPhase(phase) {
@@ -2595,6 +3182,8 @@ ${cssVarsBlock}
2595
3182
  if (terminalActivityPhase === "idle") {
2596
3183
  lastSpecificToolLabel = "";
2597
3184
  }
3185
+
3186
+ syncFooterSpinnerState();
2598
3187
  }
2599
3188
 
2600
3189
  function getTerminalBusyStatus() {
@@ -2622,6 +3211,7 @@ ${cssVarsBlock}
2622
3211
  if (kind === "annotation") return "sending annotated reply";
2623
3212
  if (kind === "critique") return "running critique";
2624
3213
  if (kind === "direct") return "running editor text";
3214
+ if (kind === "compact") return "compacting context";
2625
3215
  if (kind === "send_to_editor") return "sending to pi editor";
2626
3216
  if (kind === "get_from_editor") return "loading from pi editor";
2627
3217
  if (kind === "save_as" || kind === "save_over") return "saving editor text";
@@ -2650,43 +3240,184 @@ ${cssVarsBlock}
2650
3240
  return "Studio: " + action + "…";
2651
3241
  }
2652
3242
 
2653
- function renderStatus() {
2654
- const prefix = "WS: " + wsState;
2655
- statusEl.textContent = prefix + " · " + statusMessage;
2656
- statusEl.className = statusLevel || "";
3243
+ function shouldAnimateFooterSpinner() {
3244
+ return wsState !== "Disconnected" && (uiBusy || agentBusyFromServer || terminalActivityPhase !== "idle");
2657
3245
  }
2658
3246
 
2659
- function setWsState(nextState) {
2660
- wsState = nextState || "Disconnected";
2661
- renderStatus();
3247
+ function formatNumber(value) {
3248
+ if (typeof value !== "number" || !Number.isFinite(value)) return "?";
3249
+ try {
3250
+ return new Intl.NumberFormat().format(Math.round(value));
3251
+ } catch {
3252
+ return String(Math.round(value));
3253
+ }
2662
3254
  }
2663
3255
 
2664
- function setStatus(message, level) {
2665
- statusMessage = message;
2666
- statusLevel = level || "";
2667
- renderStatus();
2668
- debugTrace("status", {
2669
- wsState,
2670
- message: statusMessage,
2671
- level: statusLevel,
2672
- pendingRequestId,
2673
- pendingKind,
2674
- uiBusy,
2675
- agentBusyFromServer,
2676
- terminalPhase: terminalActivityPhase,
2677
- terminalToolName: terminalActivityToolName,
2678
- terminalActivityLabel,
2679
- lastSpecificToolLabel,
2680
- });
2681
- }
3256
+ function formatContextUsageText() {
3257
+ const hasWindow = typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0;
3258
+ const hasTokens = typeof contextTokens === "number" && Number.isFinite(contextTokens) && contextTokens >= 0;
3259
+ let percentValue = typeof contextPercent === "number" && Number.isFinite(contextPercent)
3260
+ ? contextPercent
3261
+ : null;
2682
3262
 
2683
- renderStatus();
3263
+ if (percentValue == null && hasTokens && hasWindow) {
3264
+ percentValue = (contextTokens / contextWindow) * 100;
3265
+ }
2684
3266
 
2685
- function updateSourceBadge() {
2686
- const label = sourceState && sourceState.label ? sourceState.label : "blank";
2687
- sourceBadgeEl.textContent = "Editor origin: " + label;
2688
- // Show "Set working dir" button when not file-backed
2689
- var isFileBacked = sourceState.source === "file" && Boolean(sourceState.path);
3267
+ if (!hasTokens && !hasWindow) {
3268
+ return "Context: unknown";
3269
+ }
3270
+ if (!hasTokens && hasWindow) {
3271
+ return "Context: ? / " + formatNumber(contextWindow);
3272
+ }
3273
+
3274
+ let text = "Context: " + formatNumber(contextTokens);
3275
+ if (hasWindow) {
3276
+ text += " / " + formatNumber(contextWindow);
3277
+ }
3278
+ if (percentValue != null && Number.isFinite(percentValue)) {
3279
+ const bounded = Math.max(0, Math.min(100, percentValue));
3280
+ text += " (" + bounded.toFixed(1) + "%)";
3281
+ }
3282
+ return text;
3283
+ }
3284
+
3285
+ function applyContextUsageFromMessage(message) {
3286
+ if (!message || typeof message !== "object") return false;
3287
+
3288
+ let changed = false;
3289
+
3290
+ if (Object.prototype.hasOwnProperty.call(message, "contextTokens")) {
3291
+ const next = typeof message.contextTokens === "number" && Number.isFinite(message.contextTokens) && message.contextTokens >= 0
3292
+ ? message.contextTokens
3293
+ : null;
3294
+ if (next !== contextTokens) {
3295
+ contextTokens = next;
3296
+ changed = true;
3297
+ }
3298
+ }
3299
+
3300
+ if (Object.prototype.hasOwnProperty.call(message, "contextWindow")) {
3301
+ const next = typeof message.contextWindow === "number" && Number.isFinite(message.contextWindow) && message.contextWindow > 0
3302
+ ? message.contextWindow
3303
+ : null;
3304
+ if (next !== contextWindow) {
3305
+ contextWindow = next;
3306
+ changed = true;
3307
+ }
3308
+ }
3309
+
3310
+ if (Object.prototype.hasOwnProperty.call(message, "contextPercent")) {
3311
+ const next = typeof message.contextPercent === "number" && Number.isFinite(message.contextPercent)
3312
+ ? Math.max(0, Math.min(100, message.contextPercent))
3313
+ : null;
3314
+ if (next !== contextPercent) {
3315
+ contextPercent = next;
3316
+ changed = true;
3317
+ }
3318
+ }
3319
+
3320
+ return changed;
3321
+ }
3322
+
3323
+ function updateDocumentTitle() {
3324
+ const modelText = modelLabel && modelLabel.trim() ? modelLabel.trim() : "none";
3325
+ const terminalText = terminalSessionLabel && terminalSessionLabel.trim() ? terminalSessionLabel.trim() : "unknown";
3326
+ const titleParts = ["pi Studio"];
3327
+ if (terminalText && terminalText !== "unknown") titleParts.push(terminalText);
3328
+ if (modelText && modelText !== "none") titleParts.push(modelText);
3329
+ document.title = titleParts.join(" · ");
3330
+ }
3331
+
3332
+ function updateFooterMeta() {
3333
+ const modelText = modelLabel && modelLabel.trim() ? modelLabel.trim() : "none";
3334
+ const terminalText = terminalSessionLabel && terminalSessionLabel.trim() ? terminalSessionLabel.trim() : "unknown";
3335
+ const contextText = formatContextUsageText();
3336
+ const text = "Model: " + modelText + " · Terminal: " + terminalText + " · " + contextText;
3337
+ if (footerMetaTextEl) {
3338
+ footerMetaTextEl.textContent = text;
3339
+ footerMetaTextEl.title = text;
3340
+ } else if (footerMetaEl) {
3341
+ footerMetaEl.textContent = text;
3342
+ footerMetaEl.title = text;
3343
+ }
3344
+ updateDocumentTitle();
3345
+ }
3346
+
3347
+ function stopFooterSpinner() {
3348
+ if (spinnerTimer) {
3349
+ window.clearInterval(spinnerTimer);
3350
+ spinnerTimer = null;
3351
+ }
3352
+ }
3353
+
3354
+ function startFooterSpinner() {
3355
+ if (spinnerTimer) return;
3356
+ spinnerTimer = window.setInterval(() => {
3357
+ spinnerFrameIndex = (spinnerFrameIndex + 1) % BRAILLE_SPINNER_FRAMES.length;
3358
+ renderStatus();
3359
+ }, 80);
3360
+ }
3361
+
3362
+ function syncFooterSpinnerState() {
3363
+ if (shouldAnimateFooterSpinner()) {
3364
+ startFooterSpinner();
3365
+ } else {
3366
+ stopFooterSpinner();
3367
+ }
3368
+ }
3369
+
3370
+ function renderStatus() {
3371
+ statusEl.textContent = statusMessage;
3372
+ statusEl.className = statusLevel || "";
3373
+
3374
+ const spinnerActive = shouldAnimateFooterSpinner();
3375
+ if (statusLineEl && statusLineEl.classList) {
3376
+ statusLineEl.classList.toggle("with-spinner", spinnerActive);
3377
+ }
3378
+ if (statusSpinnerEl) {
3379
+ statusSpinnerEl.textContent = spinnerActive
3380
+ ? (BRAILLE_SPINNER_FRAMES[spinnerFrameIndex % BRAILLE_SPINNER_FRAMES.length] || "")
3381
+ : "";
3382
+ }
3383
+
3384
+ updateFooterMeta();
3385
+ }
3386
+
3387
+ function setWsState(nextState) {
3388
+ wsState = nextState || "Disconnected";
3389
+ syncFooterSpinnerState();
3390
+ renderStatus();
3391
+ syncActionButtons();
3392
+ }
3393
+
3394
+ function setStatus(message, level) {
3395
+ statusMessage = message;
3396
+ statusLevel = level || "";
3397
+ syncFooterSpinnerState();
3398
+ renderStatus();
3399
+ debugTrace("status", {
3400
+ wsState,
3401
+ message: statusMessage,
3402
+ level: statusLevel,
3403
+ pendingRequestId,
3404
+ pendingKind,
3405
+ uiBusy,
3406
+ agentBusyFromServer,
3407
+ terminalPhase: terminalActivityPhase,
3408
+ terminalToolName: terminalActivityToolName,
3409
+ terminalActivityLabel,
3410
+ lastSpecificToolLabel,
3411
+ });
3412
+ }
3413
+
3414
+ renderStatus();
3415
+
3416
+ function updateSourceBadge() {
3417
+ const label = sourceState && sourceState.label ? sourceState.label : "blank";
3418
+ sourceBadgeEl.textContent = "Editor origin: " + label;
3419
+ // Show "Set working dir" button when not file-backed
3420
+ var isFileBacked = sourceState.source === "file" && Boolean(sourceState.path);
2690
3421
  if (isFileBacked) {
2691
3422
  if (resourceDirInput) resourceDirInput.value = "";
2692
3423
  if (resourceDirLabel) resourceDirLabel.textContent = "";
@@ -2810,6 +3541,151 @@ ${cssVarsBlock}
2810
3541
  }
2811
3542
  }
2812
3543
 
3544
+ function normalizeHistoryKind(kind) {
3545
+ return kind === "critique" ? "critique" : "annotation";
3546
+ }
3547
+
3548
+ function normalizeHistoryItem(item, fallbackIndex) {
3549
+ if (!item || typeof item !== "object") return null;
3550
+ if (typeof item.markdown !== "string") return null;
3551
+ const markdown = item.markdown;
3552
+ if (!markdown.trim()) return null;
3553
+
3554
+ const id = typeof item.id === "string" && item.id.trim()
3555
+ ? item.id.trim()
3556
+ : ("history-" + fallbackIndex + "-" + Date.now());
3557
+ const timestamp = typeof item.timestamp === "number" && Number.isFinite(item.timestamp) && item.timestamp > 0
3558
+ ? item.timestamp
3559
+ : Date.now();
3560
+ const prompt = typeof item.prompt === "string"
3561
+ ? item.prompt
3562
+ : (item.prompt == null ? null : String(item.prompt));
3563
+
3564
+ return {
3565
+ id,
3566
+ markdown,
3567
+ timestamp,
3568
+ kind: normalizeHistoryKind(item.kind),
3569
+ prompt,
3570
+ };
3571
+ }
3572
+
3573
+ function getSelectedHistoryItem() {
3574
+ if (!Array.isArray(responseHistory) || responseHistory.length === 0) return null;
3575
+ if (responseHistoryIndex < 0 || responseHistoryIndex >= responseHistory.length) return null;
3576
+ return responseHistory[responseHistoryIndex] || null;
3577
+ }
3578
+
3579
+ function clearActiveResponseView() {
3580
+ latestResponseMarkdown = "";
3581
+ latestResponseKind = "annotation";
3582
+ latestResponseTimestamp = 0;
3583
+ latestResponseIsStructuredCritique = false;
3584
+ latestResponseHasContent = false;
3585
+ latestResponseNormalized = "";
3586
+ latestCritiqueNotes = "";
3587
+ latestCritiqueNotesNormalized = "";
3588
+ refreshResponseUi();
3589
+ }
3590
+
3591
+ function updateHistoryControls() {
3592
+ const total = Array.isArray(responseHistory) ? responseHistory.length : 0;
3593
+ const selected = total > 0 && responseHistoryIndex >= 0 && responseHistoryIndex < total
3594
+ ? responseHistoryIndex + 1
3595
+ : 0;
3596
+ if (historyIndexBadgeEl) {
3597
+ historyIndexBadgeEl.textContent = "History: " + selected + "/" + total;
3598
+ }
3599
+ if (historyPrevBtn) {
3600
+ historyPrevBtn.disabled = uiBusy || total <= 1 || responseHistoryIndex <= 0;
3601
+ }
3602
+ if (historyNextBtn) {
3603
+ historyNextBtn.disabled = uiBusy || total <= 1 || responseHistoryIndex < 0 || responseHistoryIndex >= total - 1;
3604
+ }
3605
+
3606
+ const selectedItem = getSelectedHistoryItem();
3607
+ const hasPrompt = Boolean(selectedItem && typeof selectedItem.prompt === "string" && selectedItem.prompt.trim());
3608
+ if (loadHistoryPromptBtn) {
3609
+ loadHistoryPromptBtn.disabled = uiBusy || !hasPrompt;
3610
+ loadHistoryPromptBtn.textContent = hasPrompt
3611
+ ? "Load response prompt into editor"
3612
+ : "Response prompt unavailable";
3613
+ }
3614
+ }
3615
+
3616
+ function applySelectedHistoryItem() {
3617
+ const item = getSelectedHistoryItem();
3618
+ if (!item) {
3619
+ clearActiveResponseView();
3620
+ return false;
3621
+ }
3622
+ handleIncomingResponse(item.markdown, item.kind, item.timestamp);
3623
+ return true;
3624
+ }
3625
+
3626
+ function selectHistoryIndex(index, options) {
3627
+ const total = Array.isArray(responseHistory) ? responseHistory.length : 0;
3628
+ if (total === 0) {
3629
+ responseHistoryIndex = -1;
3630
+ clearActiveResponseView();
3631
+ updateHistoryControls();
3632
+ return false;
3633
+ }
3634
+
3635
+ const nextIndex = Math.max(0, Math.min(total - 1, Number(index) || 0));
3636
+ responseHistoryIndex = nextIndex;
3637
+ const applied = applySelectedHistoryItem();
3638
+ updateHistoryControls();
3639
+
3640
+ if (applied && !(options && options.silent)) {
3641
+ const item = getSelectedHistoryItem();
3642
+ if (item) {
3643
+ const responseLabel = item.kind === "critique" ? "critique" : "response";
3644
+ setStatus("Viewing " + responseLabel + " history " + (nextIndex + 1) + "/" + total + ".");
3645
+ }
3646
+ }
3647
+ return applied;
3648
+ }
3649
+
3650
+ function setResponseHistory(items, options) {
3651
+ const normalized = Array.isArray(items)
3652
+ ? items
3653
+ .map((item, index) => normalizeHistoryItem(item, index))
3654
+ .filter((item) => item && typeof item === "object")
3655
+ : [];
3656
+
3657
+ const previousItem = getSelectedHistoryItem();
3658
+ const previousId = previousItem && typeof previousItem.id === "string" ? previousItem.id : null;
3659
+
3660
+ responseHistory = normalized;
3661
+
3662
+ if (!responseHistory.length) {
3663
+ responseHistoryIndex = -1;
3664
+ clearActiveResponseView();
3665
+ updateHistoryControls();
3666
+ return false;
3667
+ }
3668
+
3669
+ let targetIndex = responseHistory.length - 1;
3670
+ const preserveSelection = Boolean(options && options.preserveSelection);
3671
+ const autoSelectLatest = options && Object.prototype.hasOwnProperty.call(options, "autoSelectLatest")
3672
+ ? Boolean(options.autoSelectLatest)
3673
+ : true;
3674
+
3675
+ if (preserveSelection && previousId) {
3676
+ const preservedIndex = responseHistory.findIndex((item) => item.id === previousId);
3677
+ if (preservedIndex >= 0) {
3678
+ targetIndex = preservedIndex;
3679
+ } else if (!autoSelectLatest && responseHistoryIndex >= 0 && responseHistoryIndex < responseHistory.length) {
3680
+ targetIndex = responseHistoryIndex;
3681
+ }
3682
+ } else if (!autoSelectLatest && responseHistoryIndex >= 0 && responseHistoryIndex < responseHistory.length) {
3683
+ targetIndex = responseHistoryIndex;
3684
+ }
3685
+
3686
+ return selectHistoryIndex(targetIndex, { silent: Boolean(options && options.silent) });
3687
+ }
3688
+
2813
3689
  function updateReferenceBadge() {
2814
3690
  if (!referenceBadgeEl) return;
2815
3691
 
@@ -2833,9 +3709,14 @@ ${cssVarsBlock}
2833
3709
 
2834
3710
  const time = formatReferenceTime(latestResponseTimestamp);
2835
3711
  const responseLabel = latestResponseKind === "critique" ? "assistant critique" : "assistant response";
3712
+ const total = Array.isArray(responseHistory) ? responseHistory.length : 0;
3713
+ const selected = total > 0 && responseHistoryIndex >= 0 && responseHistoryIndex < total
3714
+ ? responseHistoryIndex + 1
3715
+ : 0;
3716
+ const historyPrefix = total > 0 ? "Response history " + selected + "/" + total + " · " : "";
2836
3717
  referenceBadgeEl.textContent = time
2837
- ? "Latest response: " + responseLabel + " · " + time
2838
- : "Latest response: " + responseLabel;
3718
+ ? historyPrefix + responseLabel + " · " + time
3719
+ : historyPrefix + responseLabel;
2839
3720
  }
2840
3721
 
2841
3722
  function normalizeForCompare(text) {
@@ -2846,6 +3727,28 @@ ${cssVarsBlock}
2846
3727
  return normalizeForCompare(a) === normalizeForCompare(b);
2847
3728
  }
2848
3729
 
3730
+ function hasAnnotationMarkers(text) {
3731
+ const source = String(text || "");
3732
+ ANNOTATION_MARKER_REGEX.lastIndex = 0;
3733
+ const hasMarker = ANNOTATION_MARKER_REGEX.test(source);
3734
+ ANNOTATION_MARKER_REGEX.lastIndex = 0;
3735
+ return hasMarker;
3736
+ }
3737
+
3738
+ function stripAnnotationMarkers(text) {
3739
+ return String(text || "").replace(ANNOTATION_MARKER_REGEX, "");
3740
+ }
3741
+
3742
+ function prepareEditorTextForSend(text) {
3743
+ const raw = String(text || "");
3744
+ return annotationsEnabled ? raw : stripAnnotationMarkers(raw);
3745
+ }
3746
+
3747
+ function prepareEditorTextForPreview(text) {
3748
+ const raw = String(text || "");
3749
+ return annotationsEnabled ? raw : stripAnnotationMarkers(raw);
3750
+ }
3751
+
2849
3752
  function updateSyncBadge(normalizedEditorText) {
2850
3753
  if (!syncBadgeEl) return;
2851
3754
 
@@ -2896,6 +3799,67 @@ ${cssVarsBlock}
2896
3799
  return buildPreviewErrorHtml("Preview sanitizer unavailable. Showing plain markdown.", markdown);
2897
3800
  }
2898
3801
 
3802
+ function applyAnnotationMarkersToElement(targetEl, mode) {
3803
+ if (!targetEl || mode === "none") return;
3804
+ if (typeof document.createTreeWalker !== "function") return;
3805
+
3806
+ const walker = document.createTreeWalker(targetEl, NodeFilter.SHOW_TEXT);
3807
+ const textNodes = [];
3808
+ let node = walker.nextNode();
3809
+ while (node) {
3810
+ const textNode = node;
3811
+ const value = typeof textNode.nodeValue === "string" ? textNode.nodeValue : "";
3812
+ if (value && value.toLowerCase().indexOf("[an:") !== -1) {
3813
+ const parent = textNode.parentElement;
3814
+ const tag = parent && parent.tagName ? parent.tagName.toUpperCase() : "";
3815
+ if (tag !== "CODE" && tag !== "PRE" && tag !== "SCRIPT" && tag !== "STYLE" && tag !== "TEXTAREA") {
3816
+ textNodes.push(textNode);
3817
+ }
3818
+ }
3819
+ node = walker.nextNode();
3820
+ }
3821
+
3822
+ for (const textNode of textNodes) {
3823
+ const text = typeof textNode.nodeValue === "string" ? textNode.nodeValue : "";
3824
+ if (!text) continue;
3825
+ ANNOTATION_MARKER_REGEX.lastIndex = 0;
3826
+ if (!ANNOTATION_MARKER_REGEX.test(text)) continue;
3827
+ ANNOTATION_MARKER_REGEX.lastIndex = 0;
3828
+
3829
+ const fragment = document.createDocumentFragment();
3830
+ let lastIndex = 0;
3831
+ let match;
3832
+ while ((match = ANNOTATION_MARKER_REGEX.exec(text)) !== null) {
3833
+ const token = match[0] || "";
3834
+ const start = typeof match.index === "number" ? match.index : 0;
3835
+ if (start > lastIndex) {
3836
+ fragment.appendChild(document.createTextNode(text.slice(lastIndex, start)));
3837
+ }
3838
+
3839
+ if (mode === "highlight") {
3840
+ const markerEl = document.createElement("span");
3841
+ markerEl.className = "annotation-preview-marker";
3842
+ markerEl.textContent = typeof match[1] === "string" ? match[1].trim() : token;
3843
+ markerEl.title = token;
3844
+ fragment.appendChild(markerEl);
3845
+ }
3846
+
3847
+ lastIndex = start + token.length;
3848
+ if (token.length === 0) {
3849
+ ANNOTATION_MARKER_REGEX.lastIndex += 1;
3850
+ }
3851
+ }
3852
+
3853
+ if (lastIndex < text.length) {
3854
+ fragment.appendChild(document.createTextNode(text.slice(lastIndex)));
3855
+ }
3856
+
3857
+ if (textNode.parentNode) {
3858
+ textNode.parentNode.replaceChild(fragment, textNode);
3859
+ }
3860
+ }
3861
+ }
3862
+
2899
3863
  function appendMermaidNotice(targetEl, message) {
2900
3864
  if (!targetEl || typeof targetEl.querySelector !== "function" || typeof targetEl.appendChild !== "function") {
2901
3865
  return;
@@ -3097,6 +4061,126 @@ ${cssVarsBlock}
3097
4061
  return payload.html;
3098
4062
  }
3099
4063
 
4064
+ function parseContentDispositionFilename(headerValue) {
4065
+ if (!headerValue || typeof headerValue !== "string") return "";
4066
+
4067
+ const utfMatch = headerValue.match(/filename\\*=UTF-8''([^;]+)/i);
4068
+ if (utfMatch && utfMatch[1]) {
4069
+ try {
4070
+ return decodeURIComponent(utfMatch[1].trim());
4071
+ } catch {
4072
+ return utfMatch[1].trim();
4073
+ }
4074
+ }
4075
+
4076
+ const quotedMatch = headerValue.match(/filename="([^"]+)"/i);
4077
+ if (quotedMatch && quotedMatch[1]) return quotedMatch[1].trim();
4078
+
4079
+ const plainMatch = headerValue.match(/filename=([^;]+)/i);
4080
+ if (plainMatch && plainMatch[1]) return plainMatch[1].trim();
4081
+
4082
+ return "";
4083
+ }
4084
+
4085
+ async function exportRightPanePdf() {
4086
+ if (uiBusy || pdfExportInProgress) {
4087
+ setStatus("Studio is busy.", "warning");
4088
+ return;
4089
+ }
4090
+
4091
+ const token = getToken();
4092
+ if (!token) {
4093
+ setStatus("Missing Studio token in URL. Re-run /studio.", "error");
4094
+ return;
4095
+ }
4096
+
4097
+ const rightPaneShowsPreview = rightView === "preview" || rightView === "editor-preview";
4098
+ if (!rightPaneShowsPreview) {
4099
+ setStatus("Switch right pane to Response (Preview) or Editor (Preview) to export PDF.", "warning");
4100
+ return;
4101
+ }
4102
+
4103
+ const markdown = rightView === "editor-preview" ? prepareEditorTextForPreview(sourceTextEl.value) : latestResponseMarkdown;
4104
+ if (!markdown || !markdown.trim()) {
4105
+ setStatus("Nothing to export yet.", "warning");
4106
+ return;
4107
+ }
4108
+
4109
+ const sourcePath = sourceState.path || "";
4110
+ const resourceDir = (!sourceState.path && resourceDirInput) ? resourceDirInput.value.trim() : "";
4111
+ const isLatex = /\\\\documentclass\\b|\\\\begin\\{document\\}/.test(markdown);
4112
+ let filenameHint = rightView === "editor-preview" ? "studio-editor-preview.pdf" : "studio-response-preview.pdf";
4113
+ if (sourceState.path) {
4114
+ const baseName = sourceState.path.split(/[\\\\/]/).pop() || "studio";
4115
+ const stem = baseName.replace(/\\.[^.]+$/, "") || "studio";
4116
+ filenameHint = stem + "-preview.pdf";
4117
+ }
4118
+
4119
+ pdfExportInProgress = true;
4120
+ updateResultActionButtons();
4121
+ setStatus("Exporting PDF…", "warning");
4122
+
4123
+ try {
4124
+ const response = await fetch("/export-pdf?token=" + encodeURIComponent(token), {
4125
+ method: "POST",
4126
+ headers: {
4127
+ "Content-Type": "application/json",
4128
+ },
4129
+ body: JSON.stringify({
4130
+ markdown: String(markdown || ""),
4131
+ sourcePath: sourcePath,
4132
+ resourceDir: resourceDir,
4133
+ isLatex: isLatex,
4134
+ filenameHint: filenameHint,
4135
+ }),
4136
+ });
4137
+
4138
+ if (!response.ok) {
4139
+ const contentType = String(response.headers.get("content-type") || "").toLowerCase();
4140
+ let message = "PDF export failed with HTTP " + response.status + ".";
4141
+ if (contentType.includes("application/json")) {
4142
+ const payload = await response.json().catch(() => null);
4143
+ if (payload && typeof payload.error === "string") {
4144
+ message = payload.error;
4145
+ }
4146
+ } else {
4147
+ const text = await response.text().catch(() => "");
4148
+ if (text && text.trim()) {
4149
+ message = text.trim();
4150
+ }
4151
+ }
4152
+ throw new Error(message);
4153
+ }
4154
+
4155
+ const blob = await response.blob();
4156
+ const headerFilename = parseContentDispositionFilename(response.headers.get("content-disposition"));
4157
+ let downloadName = headerFilename || filenameHint || "studio-preview.pdf";
4158
+ if (!/\\.pdf$/i.test(downloadName)) {
4159
+ downloadName += ".pdf";
4160
+ }
4161
+
4162
+ const blobUrl = URL.createObjectURL(blob);
4163
+ const link = document.createElement("a");
4164
+ link.href = blobUrl;
4165
+ link.download = downloadName;
4166
+ link.rel = "noopener";
4167
+ document.body.appendChild(link);
4168
+ link.click();
4169
+ link.remove();
4170
+ window.setTimeout(() => {
4171
+ URL.revokeObjectURL(blobUrl);
4172
+ }, 1800);
4173
+
4174
+ setStatus("Exported PDF: " + downloadName, "success");
4175
+ } catch (error) {
4176
+ const detail = error && error.message ? error.message : String(error || "unknown error");
4177
+ setStatus("PDF export failed: " + detail, "error");
4178
+ } finally {
4179
+ pdfExportInProgress = false;
4180
+ updateResultActionButtons();
4181
+ }
4182
+ }
4183
+
3100
4184
  async function applyRenderedMarkdown(targetEl, markdown, pane, nonce) {
3101
4185
  try {
3102
4186
  const renderedHtml = await renderMarkdownWithPandoc(markdown);
@@ -3109,6 +4193,10 @@ ${cssVarsBlock}
3109
4193
 
3110
4194
  finishPreviewRender(targetEl);
3111
4195
  targetEl.innerHTML = sanitizeRenderedHtml(renderedHtml, markdown);
4196
+ const annotationMode = (pane === "source" || pane === "response")
4197
+ ? (annotationsEnabled ? "highlight" : "hide")
4198
+ : "none";
4199
+ applyAnnotationMarkersToElement(targetEl, annotationMode);
3112
4200
  await renderMermaidInElement(targetEl);
3113
4201
 
3114
4202
  // Warn if relative images are present but unlikely to resolve (non-file-backed content)
@@ -3134,7 +4222,7 @@ ${cssVarsBlock}
3134
4222
 
3135
4223
  function renderSourcePreviewNow() {
3136
4224
  if (editorView !== "preview") return;
3137
- const text = sourceTextEl.value || "";
4225
+ const text = prepareEditorTextForPreview(sourceTextEl.value || "");
3138
4226
  if (editorLanguage && editorLanguage !== "markdown" && editorLanguage !== "latex") {
3139
4227
  finishPreviewRender(sourcePreviewEl);
3140
4228
  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>";
@@ -3194,7 +4282,7 @@ ${cssVarsBlock}
3194
4282
 
3195
4283
  function renderActiveResult() {
3196
4284
  if (rightView === "editor-preview") {
3197
- const editorText = sourceTextEl.value || "";
4285
+ const editorText = prepareEditorTextForPreview(sourceTextEl.value || "");
3198
4286
  if (!editorText.trim()) {
3199
4287
  finishPreviewRender(critiqueViewEl);
3200
4288
  critiqueViewEl.innerHTML = "<pre class='plain-markdown'>Editor is empty.</pre>";
@@ -3270,6 +4358,20 @@ ${cssVarsBlock}
3270
4358
 
3271
4359
  copyResponseBtn.disabled = uiBusy || !hasResponse;
3272
4360
 
4361
+ const rightPaneShowsPreview = rightView === "preview" || rightView === "editor-preview";
4362
+ const exportText = rightView === "editor-preview" ? prepareEditorTextForPreview(sourceTextEl.value) : latestResponseMarkdown;
4363
+ const canExportPdf = rightPaneShowsPreview && Boolean(String(exportText || "").trim());
4364
+ if (exportPdfBtn) {
4365
+ exportPdfBtn.disabled = uiBusy || pdfExportInProgress || !canExportPdf;
4366
+ if (rightView === "markdown") {
4367
+ exportPdfBtn.title = "Switch right pane to Response (Preview) or Editor (Preview) to export PDF.";
4368
+ } else if (!canExportPdf) {
4369
+ exportPdfBtn.title = "Nothing to export yet.";
4370
+ } else {
4371
+ exportPdfBtn.title = "Export the current right-pane preview as PDF via pandoc + xelatex.";
4372
+ }
4373
+ }
4374
+
3273
4375
  pullLatestBtn.disabled = uiBusy || followLatest;
3274
4376
  pullLatestBtn.textContent = queuedLatestResponse ? "Get latest response *" : "Get latest response";
3275
4377
 
@@ -3280,6 +4382,7 @@ ${cssVarsBlock}
3280
4382
  updateSourceBadge();
3281
4383
  updateReferenceBadge();
3282
4384
  renderActiveResult();
4385
+ updateHistoryControls();
3283
4386
  updateResultActionButtons();
3284
4387
  }
3285
4388
 
@@ -3294,6 +4397,24 @@ ${cssVarsBlock}
3294
4397
  return null;
3295
4398
  }
3296
4399
 
4400
+ function buildAnnotatedSaveSuggestion() {
4401
+ const effectivePath = getEffectiveSavePath() || sourceState.path || "";
4402
+ if (effectivePath) {
4403
+ const parts = String(effectivePath).split(/[/\\\\]/);
4404
+ const fileName = parts.pop() || "draft.md";
4405
+ const dir = parts.length > 0 ? parts.join("/") + "/" : "";
4406
+ const stem = fileName.replace(/\\.[^.]+$/, "") || "draft";
4407
+ return dir + stem + ".annotated.md";
4408
+ }
4409
+
4410
+ const rawLabel = sourceState.label ? sourceState.label.replace(/^upload:\\s*/i, "") : "draft.md";
4411
+ const stem = rawLabel.replace(/\\.[^.]+$/, "") || "draft";
4412
+ const suggestedDir = resourceDirInput && resourceDirInput.value.trim()
4413
+ ? resourceDirInput.value.trim().replace(/\\/$/, "") + "/"
4414
+ : "./";
4415
+ return suggestedDir + stem + ".annotated.md";
4416
+ }
4417
+
3297
4418
  function updateSaveFileTooltip() {
3298
4419
  if (!saveOverBtn) return;
3299
4420
 
@@ -3318,6 +4439,10 @@ ${cssVarsBlock}
3318
4439
  copyDraftBtn.disabled = uiBusy;
3319
4440
  if (highlightSelect) highlightSelect.disabled = uiBusy;
3320
4441
  if (langSelect) langSelect.disabled = uiBusy;
4442
+ if (annotationModeSelect) annotationModeSelect.disabled = uiBusy;
4443
+ if (saveAnnotatedBtn) saveAnnotatedBtn.disabled = uiBusy;
4444
+ if (stripAnnotationsBtn) stripAnnotationsBtn.disabled = uiBusy || !hasAnnotationMarkers(sourceTextEl.value);
4445
+ if (compactBtn) compactBtn.disabled = uiBusy || compactInProgress || wsState === "Disconnected";
3321
4446
  editorViewSelect.disabled = uiBusy;
3322
4447
  rightViewSelect.disabled = uiBusy;
3323
4448
  followSelect.disabled = uiBusy;
@@ -3326,11 +4451,14 @@ ${cssVarsBlock}
3326
4451
  critiqueBtn.disabled = uiBusy;
3327
4452
  lensSelect.disabled = uiBusy;
3328
4453
  updateSaveFileTooltip();
4454
+ updateHistoryControls();
3329
4455
  updateResultActionButtons();
3330
4456
  }
3331
4457
 
3332
4458
  function setBusy(busy) {
3333
4459
  uiBusy = Boolean(busy);
4460
+ syncFooterSpinnerState();
4461
+ renderStatus();
3334
4462
  syncActionButtons();
3335
4463
  }
3336
4464
 
@@ -3344,6 +4472,47 @@ ${cssVarsBlock}
3344
4472
  syncActionButtons();
3345
4473
  }
3346
4474
 
4475
+ function setEditorText(nextText, options) {
4476
+ const value = String(nextText || "");
4477
+ const preserveScroll = Boolean(options && options.preserveScroll);
4478
+ const preserveSelection = Boolean(options && options.preserveSelection);
4479
+ const previousScrollTop = sourceTextEl.scrollTop;
4480
+ const previousScrollLeft = sourceTextEl.scrollLeft;
4481
+ const previousSelectionStart = sourceTextEl.selectionStart;
4482
+ const previousSelectionEnd = sourceTextEl.selectionEnd;
4483
+
4484
+ sourceTextEl.value = value;
4485
+
4486
+ if (preserveSelection) {
4487
+ const maxIndex = value.length;
4488
+ const start = Math.max(0, Math.min(previousSelectionStart || 0, maxIndex));
4489
+ const end = Math.max(start, Math.min(previousSelectionEnd || start, maxIndex));
4490
+ sourceTextEl.setSelectionRange(start, end);
4491
+ }
4492
+
4493
+ if (preserveScroll) {
4494
+ sourceTextEl.scrollTop = previousScrollTop;
4495
+ sourceTextEl.scrollLeft = previousScrollLeft;
4496
+ }
4497
+
4498
+ syncEditorHighlightScroll();
4499
+ const schedule = typeof window.requestAnimationFrame === "function"
4500
+ ? window.requestAnimationFrame.bind(window)
4501
+ : (cb) => window.setTimeout(cb, 16);
4502
+ schedule(() => {
4503
+ syncEditorHighlightScroll();
4504
+ });
4505
+
4506
+ updateAnnotatedReplyHeaderButton();
4507
+
4508
+ if (!options || options.updatePreview !== false) {
4509
+ renderSourcePreview();
4510
+ }
4511
+ if (!options || options.updateMeta !== false) {
4512
+ scheduleEditorMetaUpdate();
4513
+ }
4514
+ }
4515
+
3347
4516
  function setEditorView(nextView) {
3348
4517
  editorView = nextView === "preview" ? "preview" : "markdown";
3349
4518
  editorViewSelect.value = editorView;
@@ -3412,7 +4581,7 @@ ${cssVarsBlock}
3412
4581
 
3413
4582
  function highlightInlineMarkdown(text) {
3414
4583
  const source = String(text || "");
3415
- const pattern = /(\\x60[^\\x60]*\\x60)|(\\[[^\\]]+\\]\\([^)]+\\))/g;
4584
+ const pattern = /(\\x60[^\\x60]*\\x60)|(\\[[^\\]]+\\]\\([^)]+\\))|(\\[an:\\s*[^\\]\\n]+\\])/gi;
3416
4585
  let lastIndex = 0;
3417
4586
  let out = "";
3418
4587
 
@@ -3435,6 +4604,8 @@ ${cssVarsBlock}
3435
4604
  } else {
3436
4605
  out += escapeHtml(token);
3437
4606
  }
4607
+ } else if (match[3]) {
4608
+ out += wrapHighlight(annotationsEnabled ? "hl-annotation" : "hl-annotation-muted", token);
3438
4609
  } else {
3439
4610
  out += escapeHtml(token);
3440
4611
  }
@@ -3805,6 +4976,10 @@ ${cssVarsBlock}
3805
4976
  function runEditorMetaUpdateNow() {
3806
4977
  const normalizedEditor = normalizeForCompare(sourceTextEl.value);
3807
4978
  updateResultActionButtons(normalizedEditor);
4979
+ updateAnnotatedReplyHeaderButton();
4980
+ if (stripAnnotationsBtn) {
4981
+ stripAnnotationsBtn.disabled = uiBusy || !hasAnnotationMarkers(sourceTextEl.value);
4982
+ }
3808
4983
  }
3809
4984
 
3810
4985
  function scheduleEditorMetaUpdate() {
@@ -3856,6 +5031,10 @@ ${cssVarsBlock}
3856
5031
  return readStoredToggle(RESPONSE_HIGHLIGHT_STORAGE_KEY);
3857
5032
  }
3858
5033
 
5034
+ function readStoredAnnotationsEnabled() {
5035
+ return readStoredToggle(ANNOTATION_MODE_STORAGE_KEY);
5036
+ }
5037
+
3859
5038
  function persistEditorHighlightEnabled(enabled) {
3860
5039
  persistStoredToggle(EDITOR_HIGHLIGHT_STORAGE_KEY, enabled);
3861
5040
  }
@@ -3864,6 +5043,10 @@ ${cssVarsBlock}
3864
5043
  persistStoredToggle(RESPONSE_HIGHLIGHT_STORAGE_KEY, enabled);
3865
5044
  }
3866
5045
 
5046
+ function persistAnnotationsEnabled(enabled) {
5047
+ persistStoredToggle(ANNOTATION_MODE_STORAGE_KEY, enabled);
5048
+ }
5049
+
3867
5050
  function updateEditorHighlightState() {
3868
5051
  const enabled = editorHighlightEnabled && editorView === "markdown";
3869
5052
 
@@ -3953,6 +5136,38 @@ ${cssVarsBlock}
3953
5136
  renderActiveResult();
3954
5137
  }
3955
5138
 
5139
+ function updateAnnotationModeUi() {
5140
+ if (annotationModeSelect) {
5141
+ annotationModeSelect.value = annotationsEnabled ? "on" : "off";
5142
+ annotationModeSelect.title = annotationsEnabled
5143
+ ? "Annotations On: keep and send [an: ...] markers."
5144
+ : "Annotations Hidden: keep markers in editor, hide in preview, and strip before Run/Critique.";
5145
+ }
5146
+
5147
+ if (sendRunBtn) {
5148
+ sendRunBtn.title = annotationsEnabled
5149
+ ? "Run editor text as-is (includes [an: ...] markers). Shortcut: Cmd/Ctrl+Enter."
5150
+ : "Run editor text with [an: ...] markers stripped. Shortcut: Cmd/Ctrl+Enter.";
5151
+ }
5152
+
5153
+ if (critiqueBtn) {
5154
+ critiqueBtn.title = annotationsEnabled
5155
+ ? "Critique editor text as-is (includes [an: ...] markers)."
5156
+ : "Critique editor text with [an: ...] markers stripped.";
5157
+ }
5158
+ }
5159
+
5160
+ function setAnnotationsEnabled(enabled, _options) {
5161
+ annotationsEnabled = Boolean(enabled);
5162
+ persistAnnotationsEnabled(annotationsEnabled);
5163
+ updateAnnotationModeUi();
5164
+
5165
+ if (editorHighlightEnabled && editorView === "markdown") {
5166
+ scheduleEditorHighlightRender();
5167
+ }
5168
+ renderSourcePreview();
5169
+ }
5170
+
3956
5171
  function extractSection(markdown, title) {
3957
5172
  if (!markdown || !title) return "";
3958
5173
 
@@ -4049,6 +5264,10 @@ ${cssVarsBlock}
4049
5264
 
4050
5265
  debugTrace("server_message", summarizeServerMessage(message));
4051
5266
 
5267
+ if (applyContextUsageFromMessage(message)) {
5268
+ updateFooterMeta();
5269
+ }
5270
+
4052
5271
  if (message.type === "debug_event") {
4053
5272
  debugTrace("server_debug_event", summarizeServerMessage(message));
4054
5273
  return;
@@ -4058,6 +5277,13 @@ ${cssVarsBlock}
4058
5277
  const busy = Boolean(message.busy);
4059
5278
  agentBusyFromServer = Boolean(message.agentBusy);
4060
5279
  updateTerminalActivityState(message.terminalPhase, message.terminalToolName, message.terminalActivityLabel);
5280
+ if (typeof message.modelLabel === "string") {
5281
+ modelLabel = message.modelLabel;
5282
+ }
5283
+ if (typeof message.terminalSessionLabel === "string") {
5284
+ terminalSessionLabel = message.terminalSessionLabel;
5285
+ }
5286
+ updateFooterMeta();
4061
5287
  setBusy(busy);
4062
5288
  setWsState(busy ? "Submitting" : "Ready");
4063
5289
  if (typeof message.activeRequestId === "string" && message.activeRequestId.length > 0) {
@@ -4073,13 +5299,21 @@ ${cssVarsBlock}
4073
5299
  pendingKind = null;
4074
5300
  }
4075
5301
 
5302
+ if (typeof message.compactInProgress === "boolean") {
5303
+ compactInProgress = message.compactInProgress;
5304
+ } else if (pendingKind === "compact") {
5305
+ compactInProgress = true;
5306
+ } else if (!busy) {
5307
+ compactInProgress = false;
5308
+ }
5309
+
4076
5310
  let loadedInitialDocument = false;
4077
5311
  if (
4078
5312
  !initialDocumentApplied &&
4079
5313
  message.initialDocument &&
4080
5314
  typeof message.initialDocument.text === "string"
4081
5315
  ) {
4082
- sourceTextEl.value = message.initialDocument.text;
5316
+ setEditorText(message.initialDocument.text, { preserveScroll: false, preserveSelection: false });
4083
5317
  initialDocumentApplied = true;
4084
5318
  loadedInitialDocument = true;
4085
5319
  setSourceState({
@@ -4088,13 +5322,21 @@ ${cssVarsBlock}
4088
5322
  path: message.initialDocument.path || null,
4089
5323
  });
4090
5324
  refreshResponseUi();
4091
- renderSourcePreview();
4092
5325
  if (typeof message.initialDocument.label === "string" && message.initialDocument.label.length > 0) {
4093
5326
  setStatus("Loaded " + message.initialDocument.label + ".", "success");
4094
5327
  }
4095
5328
  }
4096
5329
 
4097
- if (message.lastResponse && typeof message.lastResponse.markdown === "string") {
5330
+ let appliedHistory = false;
5331
+ if (Array.isArray(message.responseHistory)) {
5332
+ appliedHistory = setResponseHistory(message.responseHistory, {
5333
+ autoSelectLatest: true,
5334
+ preserveSelection: false,
5335
+ silent: true,
5336
+ });
5337
+ }
5338
+
5339
+ if (!appliedHistory && message.lastResponse && typeof message.lastResponse.markdown === "string") {
4098
5340
  const lastMarkdown = message.lastResponse.markdown;
4099
5341
  const lastResponseKind =
4100
5342
  message.lastResponse.kind === "critique"
@@ -4133,15 +5375,46 @@ ${cssVarsBlock}
4133
5375
  pendingRequestId = typeof message.requestId === "string" ? message.requestId : pendingRequestId;
4134
5376
  pendingKind = typeof message.kind === "string" ? message.kind : "unknown";
4135
5377
  stickyStudioKind = pendingKind;
5378
+ if (pendingKind === "compact") {
5379
+ compactInProgress = true;
5380
+ }
4136
5381
  setBusy(true);
4137
5382
  setWsState("Submitting");
4138
5383
  setStatus(getStudioBusyStatus(pendingKind), "warning");
4139
5384
  return;
4140
5385
  }
4141
5386
 
4142
- if (message.type === "response") {
4143
- if (pendingRequestId && typeof message.requestId === "string" && message.requestId !== pendingRequestId) {
4144
- return;
5387
+ if (message.type === "compaction_completed") {
5388
+ if (typeof message.requestId === "string" && pendingRequestId === message.requestId) {
5389
+ pendingRequestId = null;
5390
+ pendingKind = null;
5391
+ }
5392
+ compactInProgress = false;
5393
+ stickyStudioKind = null;
5394
+ const busy = Boolean(message.busy);
5395
+ setBusy(busy);
5396
+ setWsState(busy ? "Submitting" : "Ready");
5397
+ setStatus(typeof message.message === "string" ? message.message : "Compaction completed.", "success");
5398
+ return;
5399
+ }
5400
+
5401
+ if (message.type === "compaction_error") {
5402
+ if (typeof message.requestId === "string" && pendingRequestId === message.requestId) {
5403
+ pendingRequestId = null;
5404
+ pendingKind = null;
5405
+ }
5406
+ compactInProgress = false;
5407
+ stickyStudioKind = null;
5408
+ const busy = Boolean(message.busy);
5409
+ setBusy(busy);
5410
+ setWsState(busy ? "Submitting" : "Ready");
5411
+ setStatus(typeof message.message === "string" ? message.message : "Compaction failed.", "error");
5412
+ return;
5413
+ }
5414
+
5415
+ if (message.type === "response") {
5416
+ if (pendingRequestId && typeof message.requestId === "string" && message.requestId !== pendingRequestId) {
5417
+ return;
4145
5418
  }
4146
5419
 
4147
5420
  const responseKind =
@@ -4152,23 +5425,45 @@ ${cssVarsBlock}
4152
5425
  stickyStudioKind = responseKind;
4153
5426
  pendingRequestId = null;
4154
5427
  pendingKind = null;
5428
+ queuedLatestResponse = null;
4155
5429
  setBusy(false);
4156
5430
  setWsState("Ready");
4157
- if (typeof message.markdown === "string") {
5431
+
5432
+ let appliedFromHistory = false;
5433
+ if (Array.isArray(message.responseHistory)) {
5434
+ appliedFromHistory = setResponseHistory(message.responseHistory, {
5435
+ autoSelectLatest: true,
5436
+ preserveSelection: false,
5437
+ silent: true,
5438
+ });
5439
+ }
5440
+
5441
+ if (!appliedFromHistory && typeof message.markdown === "string") {
4158
5442
  handleIncomingResponse(message.markdown, responseKind, message.timestamp);
4159
- if (responseKind === "critique") {
4160
- setStatus("Critique ready.", "success");
4161
- } else if (responseKind === "direct") {
4162
- setStatus("Model response ready.", "success");
4163
- } else {
4164
- setStatus("Response ready.", "success");
4165
- }
5443
+ }
5444
+
5445
+ if (responseKind === "critique") {
5446
+ setStatus("Critique ready.", "success");
5447
+ } else if (responseKind === "direct") {
5448
+ setStatus("Model response ready.", "success");
5449
+ } else {
5450
+ setStatus("Response ready.", "success");
4166
5451
  }
4167
5452
  return;
4168
5453
  }
4169
5454
 
4170
5455
  if (message.type === "latest_response") {
4171
5456
  if (pendingRequestId) return;
5457
+
5458
+ const hasHistory = Array.isArray(message.responseHistory);
5459
+ if (hasHistory) {
5460
+ setResponseHistory(message.responseHistory, {
5461
+ autoSelectLatest: followLatest,
5462
+ preserveSelection: !followLatest,
5463
+ silent: true,
5464
+ });
5465
+ }
5466
+
4172
5467
  if (typeof message.markdown === "string") {
4173
5468
  const payload = {
4174
5469
  kind: message.kind === "critique" ? "critique" : "annotation",
@@ -4183,15 +5478,29 @@ ${cssVarsBlock}
4183
5478
  return;
4184
5479
  }
4185
5480
 
4186
- if (applyLatestPayload(payload)) {
5481
+ if (!hasHistory && applyLatestPayload(payload)) {
4187
5482
  queuedLatestResponse = null;
4188
5483
  updateResultActionButtons();
4189
5484
  setStatus("Updated from latest response.", "success");
5485
+ return;
4190
5486
  }
5487
+
5488
+ queuedLatestResponse = null;
5489
+ updateResultActionButtons();
5490
+ setStatus("Updated from latest response.", "success");
4191
5491
  }
4192
5492
  return;
4193
5493
  }
4194
5494
 
5495
+ if (message.type === "response_history") {
5496
+ setResponseHistory(message.items, {
5497
+ autoSelectLatest: followLatest,
5498
+ preserveSelection: !followLatest,
5499
+ silent: true,
5500
+ });
5501
+ return;
5502
+ }
5503
+
4195
5504
  if (message.type === "saved") {
4196
5505
  if (typeof message.requestId === "string" && pendingRequestId === message.requestId) {
4197
5506
  pendingRequestId = null;
@@ -4231,8 +5540,7 @@ ${cssVarsBlock}
4231
5540
  }
4232
5541
 
4233
5542
  const content = typeof message.content === "string" ? message.content : "";
4234
- sourceTextEl.value = content;
4235
- renderSourcePreview();
5543
+ setEditorText(content, { preserveScroll: false, preserveSelection: false });
4236
5544
  setSourceState({ source: "pi-editor", label: "pi editor draft", path: null });
4237
5545
  setBusy(false);
4238
5546
  setWsState("Ready");
@@ -4249,6 +5557,13 @@ ${cssVarsBlock}
4249
5557
  const busy = Boolean(message.busy);
4250
5558
  agentBusyFromServer = Boolean(message.agentBusy);
4251
5559
  updateTerminalActivityState(message.terminalPhase, message.terminalToolName, message.terminalActivityLabel);
5560
+ if (typeof message.modelLabel === "string") {
5561
+ modelLabel = message.modelLabel;
5562
+ }
5563
+ if (typeof message.terminalSessionLabel === "string") {
5564
+ terminalSessionLabel = message.terminalSessionLabel;
5565
+ }
5566
+ updateFooterMeta();
4252
5567
 
4253
5568
  if (typeof message.activeRequestId === "string" && message.activeRequestId.length > 0) {
4254
5569
  pendingRequestId = message.activeRequestId;
@@ -4263,6 +5578,14 @@ ${cssVarsBlock}
4263
5578
  pendingKind = null;
4264
5579
  }
4265
5580
 
5581
+ if (typeof message.compactInProgress === "boolean") {
5582
+ compactInProgress = message.compactInProgress;
5583
+ } else if (pendingKind === "compact") {
5584
+ compactInProgress = true;
5585
+ } else if (!busy) {
5586
+ compactInProgress = false;
5587
+ }
5588
+
4266
5589
  setBusy(busy);
4267
5590
  setWsState(busy ? "Submitting" : "Ready");
4268
5591
 
@@ -4291,6 +5614,9 @@ ${cssVarsBlock}
4291
5614
 
4292
5615
  if (message.type === "busy") {
4293
5616
  if (message.requestId && pendingRequestId === message.requestId) {
5617
+ if (pendingKind === "compact") {
5618
+ compactInProgress = false;
5619
+ }
4294
5620
  pendingRequestId = null;
4295
5621
  pendingKind = null;
4296
5622
  }
@@ -4303,6 +5629,9 @@ ${cssVarsBlock}
4303
5629
 
4304
5630
  if (message.type === "error") {
4305
5631
  if (message.requestId && pendingRequestId === message.requestId) {
5632
+ if (pendingKind === "compact") {
5633
+ compactInProgress = false;
5634
+ }
4306
5635
  pendingRequestId = null;
4307
5636
  pendingKind = null;
4308
5637
  }
@@ -4421,7 +5750,8 @@ ${cssVarsBlock}
4421
5750
  function buildAnnotationHeader() {
4422
5751
  const sourceDescriptor = describeSourceForAnnotation();
4423
5752
  let header = "annotated reply below:\\n";
4424
- header += "original source: " + sourceDescriptor + "\\n\\n---\\n\\n";
5753
+ header += "original source: " + sourceDescriptor + "\\n";
5754
+ header += "annotation syntax: [an: your note]\\n\\n---\\n\\n";
4425
5755
  return header;
4426
5756
  }
4427
5757
 
@@ -4447,19 +5777,38 @@ ${cssVarsBlock}
4447
5777
  };
4448
5778
  }
4449
5779
 
4450
- function insertOrUpdateAnnotationHeader() {
5780
+ function updateAnnotatedReplyHeaderButton() {
5781
+ if (!insertHeaderBtn) return;
5782
+ const hasHeader = stripAnnotationHeader(sourceTextEl.value).hadHeader;
5783
+ if (hasHeader) {
5784
+ insertHeaderBtn.textContent = "Remove annotated reply header";
5785
+ insertHeaderBtn.title = "Remove annotated-reply protocol header while keeping body text.";
5786
+ return;
5787
+ }
5788
+ insertHeaderBtn.textContent = "Insert annotated reply header";
5789
+ insertHeaderBtn.title = "Insert annotated-reply protocol header (includes source metadata and [an: ...] syntax hint).";
5790
+ }
5791
+
5792
+ function toggleAnnotatedReplyHeader() {
4451
5793
  const stripped = stripAnnotationHeader(sourceTextEl.value);
4452
- const updated = buildAnnotationHeader() + stripped.body;
4453
5794
 
5795
+ if (stripped.hadHeader) {
5796
+ const updated = stripped.body;
5797
+ setEditorText(updated, { preserveScroll: true, preserveSelection: true });
5798
+ updateResultActionButtons();
5799
+ setStatus("Removed annotated reply header.", "success");
5800
+ return;
5801
+ }
5802
+
5803
+ const updated = buildAnnotationHeader() + stripped.body;
4454
5804
  if (isTextEquivalent(sourceTextEl.value, updated)) {
4455
- setStatus("Annotation header already up to date.");
5805
+ setStatus("Annotated reply header already present.");
4456
5806
  return;
4457
5807
  }
4458
5808
 
4459
- sourceTextEl.value = updated;
4460
- renderSourcePreview();
5809
+ setEditorText(updated, { preserveScroll: true, preserveSelection: true });
4461
5810
  updateResultActionButtons();
4462
- setStatus(stripped.hadHeader ? "Updated annotation header source." : "Inserted annotation header.", "success");
5811
+ setStatus("Inserted annotated reply header.", "success");
4463
5812
  }
4464
5813
 
4465
5814
  function requestLatestResponse() {
@@ -4479,6 +5828,9 @@ ${cssVarsBlock}
4479
5828
  }
4480
5829
 
4481
5830
  window.addEventListener("keydown", handlePaneShortcut);
5831
+ window.addEventListener("beforeunload", () => {
5832
+ stopFooterSpinner();
5833
+ });
4482
5834
 
4483
5835
  editorViewSelect.addEventListener("change", () => {
4484
5836
  setEditorView(editorViewSelect.value);
@@ -4491,7 +5843,11 @@ ${cssVarsBlock}
4491
5843
  followSelect.addEventListener("change", () => {
4492
5844
  followLatest = followSelect.value !== "off";
4493
5845
  if (followLatest && queuedLatestResponse) {
4494
- if (applyLatestPayload(queuedLatestResponse)) {
5846
+ if (responseHistory.length > 0) {
5847
+ selectHistoryIndex(responseHistory.length - 1, { silent: true });
5848
+ queuedLatestResponse = null;
5849
+ setStatus("Applied queued response.", "success");
5850
+ } else if (applyLatestPayload(queuedLatestResponse)) {
4495
5851
  queuedLatestResponse = null;
4496
5852
  setStatus("Applied queued response.", "success");
4497
5853
  }
@@ -4519,9 +5875,90 @@ ${cssVarsBlock}
4519
5875
  });
4520
5876
  }
4521
5877
 
5878
+ if (annotationModeSelect) {
5879
+ annotationModeSelect.addEventListener("change", () => {
5880
+ setAnnotationsEnabled(annotationModeSelect.value !== "off");
5881
+ });
5882
+ }
5883
+
5884
+ if (compactBtn) {
5885
+ compactBtn.addEventListener("click", () => {
5886
+ if (compactInProgress) {
5887
+ setStatus("Compaction is already running.", "warning");
5888
+ return;
5889
+ }
5890
+ if (uiBusy) {
5891
+ setStatus("Studio is busy.", "warning");
5892
+ return;
5893
+ }
5894
+
5895
+ const requestId = makeRequestId();
5896
+ pendingRequestId = requestId;
5897
+ pendingKind = "compact";
5898
+ stickyStudioKind = "compact";
5899
+ compactInProgress = true;
5900
+ setBusy(true);
5901
+ setWsState("Submitting");
5902
+
5903
+ const sent = sendMessage({ type: "compact_request", requestId });
5904
+ if (!sent) {
5905
+ compactInProgress = false;
5906
+ if (pendingRequestId === requestId) {
5907
+ pendingRequestId = null;
5908
+ pendingKind = null;
5909
+ }
5910
+ stickyStudioKind = null;
5911
+ setBusy(false);
5912
+ return;
5913
+ }
5914
+
5915
+ setStatus("Studio: compacting context…", "warning");
5916
+ });
5917
+ }
5918
+
5919
+ if (historyPrevBtn) {
5920
+ historyPrevBtn.addEventListener("click", () => {
5921
+ if (!responseHistory.length) {
5922
+ setStatus("No response history available yet.", "warning");
5923
+ return;
5924
+ }
5925
+ selectHistoryIndex(responseHistoryIndex - 1);
5926
+ });
5927
+ }
5928
+
5929
+ if (historyNextBtn) {
5930
+ historyNextBtn.addEventListener("click", () => {
5931
+ if (!responseHistory.length) {
5932
+ setStatus("No response history available yet.", "warning");
5933
+ return;
5934
+ }
5935
+ selectHistoryIndex(responseHistoryIndex + 1);
5936
+ });
5937
+ }
5938
+
5939
+ if (loadHistoryPromptBtn) {
5940
+ loadHistoryPromptBtn.addEventListener("click", () => {
5941
+ const item = getSelectedHistoryItem();
5942
+ const prompt = item && typeof item.prompt === "string" ? item.prompt : "";
5943
+ if (!prompt.trim()) {
5944
+ setStatus("Prompt unavailable for the selected response.", "warning");
5945
+ return;
5946
+ }
5947
+
5948
+ setEditorText(prompt, { preserveScroll: false, preserveSelection: false });
5949
+ setSourceState({ source: "blank", label: "response prompt", path: null });
5950
+ setStatus("Loaded response prompt into editor.", "success");
5951
+ });
5952
+ }
5953
+
4522
5954
  pullLatestBtn.addEventListener("click", () => {
4523
5955
  if (queuedLatestResponse) {
4524
- if (applyLatestPayload(queuedLatestResponse)) {
5956
+ if (responseHistory.length > 0) {
5957
+ selectHistoryIndex(responseHistory.length - 1, { silent: true });
5958
+ queuedLatestResponse = null;
5959
+ setStatus("Pulled latest response from history.", "success");
5960
+ updateResultActionButtons();
5961
+ } else if (applyLatestPayload(queuedLatestResponse)) {
4525
5962
  queuedLatestResponse = null;
4526
5963
  setStatus("Pulled queued response.", "success");
4527
5964
  updateResultActionButtons();
@@ -4541,12 +5978,28 @@ ${cssVarsBlock}
4541
5978
  syncEditorHighlightScroll();
4542
5979
  });
4543
5980
 
5981
+ sourceTextEl.addEventListener("keyup", () => {
5982
+ if (!editorHighlightEnabled || editorView !== "markdown") return;
5983
+ syncEditorHighlightScroll();
5984
+ });
5985
+
5986
+ sourceTextEl.addEventListener("mouseup", () => {
5987
+ if (!editorHighlightEnabled || editorView !== "markdown") return;
5988
+ syncEditorHighlightScroll();
5989
+ });
5990
+
5991
+ window.addEventListener("resize", () => {
5992
+ if (!editorHighlightEnabled || editorView !== "markdown") return;
5993
+ syncEditorHighlightScroll();
5994
+ });
5995
+
4544
5996
  insertHeaderBtn.addEventListener("click", () => {
4545
- insertOrUpdateAnnotationHeader();
5997
+ toggleAnnotatedReplyHeader();
4546
5998
  });
4547
5999
 
4548
6000
  critiqueBtn.addEventListener("click", () => {
4549
- const documentText = sourceTextEl.value.trim();
6001
+ const preparedDocumentText = prepareEditorTextForSend(sourceTextEl.value);
6002
+ const documentText = preparedDocumentText.trim();
4550
6003
  if (!documentText) {
4551
6004
  setStatus("Add editor text before critique.", "warning");
4552
6005
  return;
@@ -4574,8 +6027,7 @@ ${cssVarsBlock}
4574
6027
  setStatus("No response available yet.", "warning");
4575
6028
  return;
4576
6029
  }
4577
- sourceTextEl.value = latestResponseMarkdown;
4578
- renderSourcePreview();
6030
+ setEditorText(latestResponseMarkdown, { preserveScroll: false, preserveSelection: false });
4579
6031
  setSourceState({ source: "last-response", label: "last model response", path: null });
4580
6032
  setStatus("Loaded response into editor.", "success");
4581
6033
  });
@@ -4592,8 +6044,7 @@ ${cssVarsBlock}
4592
6044
  return;
4593
6045
  }
4594
6046
 
4595
- sourceTextEl.value = notes;
4596
- renderSourcePreview();
6047
+ setEditorText(notes, { preserveScroll: false, preserveSelection: false });
4597
6048
  setSourceState({ source: "blank", label: "critique notes", path: null });
4598
6049
  setStatus("Loaded critique notes into editor.", "success");
4599
6050
  });
@@ -4604,8 +6055,7 @@ ${cssVarsBlock}
4604
6055
  return;
4605
6056
  }
4606
6057
 
4607
- sourceTextEl.value = latestResponseMarkdown;
4608
- renderSourcePreview();
6058
+ setEditorText(latestResponseMarkdown, { preserveScroll: false, preserveSelection: false });
4609
6059
  setSourceState({ source: "blank", label: "full critique", path: null });
4610
6060
  setStatus("Loaded full critique into editor.", "success");
4611
6061
  });
@@ -4624,6 +6074,12 @@ ${cssVarsBlock}
4624
6074
  }
4625
6075
  });
4626
6076
 
6077
+ if (exportPdfBtn) {
6078
+ exportPdfBtn.addEventListener("click", () => {
6079
+ void exportRightPanePdf();
6080
+ });
6081
+ }
6082
+
4627
6083
  saveAsBtn.addEventListener("click", () => {
4628
6084
  const content = sourceTextEl.value;
4629
6085
  if (!content.trim()) {
@@ -4725,8 +6181,8 @@ ${cssVarsBlock}
4725
6181
  }
4726
6182
 
4727
6183
  sendRunBtn.addEventListener("click", () => {
4728
- const content = sourceTextEl.value;
4729
- if (!content.trim()) {
6184
+ const prepared = prepareEditorTextForSend(sourceTextEl.value);
6185
+ if (!prepared.trim()) {
4730
6186
  setStatus("Editor is empty. Nothing to run.", "warning");
4731
6187
  return;
4732
6188
  }
@@ -4737,7 +6193,7 @@ ${cssVarsBlock}
4737
6193
  const sent = sendMessage({
4738
6194
  type: "send_run_request",
4739
6195
  requestId,
4740
- text: content,
6196
+ text: prepared,
4741
6197
  });
4742
6198
 
4743
6199
  if (!sent) {
@@ -4762,6 +6218,53 @@ ${cssVarsBlock}
4762
6218
  }
4763
6219
  });
4764
6220
 
6221
+ if (saveAnnotatedBtn) {
6222
+ saveAnnotatedBtn.addEventListener("click", () => {
6223
+ const content = sourceTextEl.value;
6224
+ if (!content.trim()) {
6225
+ setStatus("Editor is empty. Nothing to save.", "warning");
6226
+ return;
6227
+ }
6228
+
6229
+ const suggested = buildAnnotatedSaveSuggestion();
6230
+ const path = window.prompt("Save annotated editor content as:", suggested);
6231
+ if (!path) return;
6232
+
6233
+ const requestId = beginUiAction("save_as");
6234
+ if (!requestId) return;
6235
+
6236
+ const sent = sendMessage({
6237
+ type: "save_as_request",
6238
+ requestId,
6239
+ path,
6240
+ content,
6241
+ });
6242
+
6243
+ if (!sent) {
6244
+ pendingRequestId = null;
6245
+ pendingKind = null;
6246
+ setBusy(false);
6247
+ }
6248
+ });
6249
+ }
6250
+
6251
+ if (stripAnnotationsBtn) {
6252
+ stripAnnotationsBtn.addEventListener("click", () => {
6253
+ const content = sourceTextEl.value;
6254
+ if (!hasAnnotationMarkers(content)) {
6255
+ setStatus("No [an: ...] markers found in editor.", "warning");
6256
+ return;
6257
+ }
6258
+
6259
+ const confirmed = window.confirm("Remove all [an: ...] markers from editor text? This cannot be undone.");
6260
+ if (!confirmed) return;
6261
+
6262
+ const strippedContent = stripAnnotationMarkers(content);
6263
+ setEditorText(strippedContent, { preserveScroll: true, preserveSelection: false });
6264
+ setStatus("Removed annotation markers from editor text.", "success");
6265
+ });
6266
+ }
6267
+
4765
6268
  // Working directory controls — three states: button | input | label
4766
6269
  function showResourceDirState(state) {
4767
6270
  // state: "button" | "input" | "label"
@@ -4830,8 +6333,7 @@ ${cssVarsBlock}
4830
6333
  const reader = new FileReader();
4831
6334
  reader.onload = () => {
4832
6335
  const text = typeof reader.result === "string" ? reader.result : "";
4833
- sourceTextEl.value = text;
4834
- renderSourcePreview();
6336
+ setEditorText(text, { preserveScroll: false, preserveSelection: false });
4835
6337
  setSourceState({
4836
6338
  source: "blank",
4837
6339
  label: "upload: " + file.name,
@@ -4852,6 +6354,7 @@ ${cssVarsBlock}
4852
6354
 
4853
6355
  setSourceState(initialSourceState);
4854
6356
  refreshResponseUi();
6357
+ updateAnnotatedReplyHeaderButton();
4855
6358
  setActivePane("left");
4856
6359
 
4857
6360
  const storedEditorHighlightEnabled = readStoredEditorHighlightEnabled();
@@ -4866,6 +6369,10 @@ ${cssVarsBlock}
4866
6369
  const initialResponseHighlightEnabled = storedResponseHighlightEnabled ?? Boolean(responseHighlightSelect && responseHighlightSelect.value === "on");
4867
6370
  setResponseHighlightEnabled(initialResponseHighlightEnabled);
4868
6371
 
6372
+ const storedAnnotationsEnabled = readStoredAnnotationsEnabled();
6373
+ const initialAnnotationsEnabled = storedAnnotationsEnabled ?? Boolean(annotationModeSelect ? annotationModeSelect.value !== "off" : true);
6374
+ setAnnotationsEnabled(initialAnnotationsEnabled, { silent: true });
6375
+
4869
6376
  setEditorView(editorView);
4870
6377
  setRightView(rightView);
4871
6378
  renderSourcePreview();
@@ -4892,14 +6399,121 @@ export default function (pi: ExtensionAPI) {
4892
6399
  let terminalActivityToolName: string | null = null;
4893
6400
  let terminalActivityLabel: string | null = null;
4894
6401
  let lastSpecificToolActivityLabel: string | null = null;
6402
+ let currentModel: { provider?: string; id?: string } | undefined;
6403
+ let currentModelLabel = "none";
6404
+ let terminalSessionLabel = buildTerminalSessionLabel(studioCwd);
6405
+ let studioResponseHistory: StudioResponseHistoryItem[] = [];
6406
+ let contextUsageSnapshot: StudioContextUsageSnapshot = {
6407
+ tokens: null,
6408
+ contextWindow: null,
6409
+ percent: null,
6410
+ };
6411
+ let compactInProgress = false;
6412
+ let compactRequestId: string | null = null;
6413
+ let updateCheckStarted = false;
6414
+ let updateCheckCompleted = false;
6415
+ const packageMetadata = readLocalPackageMetadata();
6416
+
6417
+ const isStudioBusy = () => agentBusy || activeRequest !== null || compactInProgress;
6418
+
6419
+ const getSessionNameSafe = (): string | undefined => {
6420
+ try {
6421
+ return pi.getSessionName();
6422
+ } catch {
6423
+ return undefined;
6424
+ }
6425
+ };
6426
+
6427
+ const getThinkingLevelSafe = (): string | undefined => {
6428
+ try {
6429
+ return pi.getThinkingLevel();
6430
+ } catch {
6431
+ return undefined;
6432
+ }
6433
+ };
4895
6434
 
4896
- const isStudioBusy = () => agentBusy || activeRequest !== null;
6435
+ const refreshRuntimeMetadata = (ctx?: { cwd?: string; model?: { provider?: string; id?: string } | undefined }) => {
6436
+ if (ctx?.cwd) {
6437
+ studioCwd = ctx.cwd;
6438
+ }
6439
+ if (ctx && Object.prototype.hasOwnProperty.call(ctx, "model")) {
6440
+ if (ctx.model) {
6441
+ currentModel = {
6442
+ provider: ctx.model.provider,
6443
+ id: ctx.model.id,
6444
+ };
6445
+ } else {
6446
+ currentModel = undefined;
6447
+ }
6448
+ } else if (!currentModel && lastCommandCtx?.model) {
6449
+ currentModel = {
6450
+ provider: lastCommandCtx.model.provider,
6451
+ id: lastCommandCtx.model.id,
6452
+ };
6453
+ }
6454
+ const baseModelLabel = formatModelLabel(currentModel);
6455
+ currentModelLabel = formatModelLabelWithThinking(baseModelLabel, getThinkingLevelSafe());
6456
+ terminalSessionLabel = buildTerminalSessionLabel(studioCwd, getSessionNameSafe());
6457
+ };
4897
6458
 
4898
6459
  const notifyStudio = (message: string, level: "info" | "warning" | "error" = "info") => {
4899
6460
  if (!lastCommandCtx) return;
4900
6461
  lastCommandCtx.ui.notify(message, level);
4901
6462
  };
4902
6463
 
6464
+ const refreshContextUsage = (
6465
+ ctx?: { getContextUsage(): { tokens: number | null; contextWindow: number; percent: number | null } | undefined },
6466
+ ): StudioContextUsageSnapshot => {
6467
+ const usage = ctx?.getContextUsage?.() ?? lastCommandCtx?.getContextUsage?.();
6468
+ if (usage === undefined) return contextUsageSnapshot;
6469
+ contextUsageSnapshot = normalizeContextUsageSnapshot(usage);
6470
+ return contextUsageSnapshot;
6471
+ };
6472
+
6473
+ const clearCompactionState = () => {
6474
+ compactInProgress = false;
6475
+ compactRequestId = null;
6476
+ };
6477
+
6478
+ const syncStudioResponseHistory = (entries: SessionEntry[]) => {
6479
+ studioResponseHistory = buildResponseHistoryFromEntries(entries, RESPONSE_HISTORY_LIMIT);
6480
+ const latest = studioResponseHistory[studioResponseHistory.length - 1];
6481
+ if (!latest) {
6482
+ lastStudioResponse = null;
6483
+ return;
6484
+ }
6485
+ lastStudioResponse = {
6486
+ markdown: latest.markdown,
6487
+ timestamp: latest.timestamp,
6488
+ kind: latest.kind,
6489
+ };
6490
+ };
6491
+
6492
+ const broadcastResponseHistory = () => {
6493
+ broadcast({
6494
+ type: "response_history",
6495
+ items: studioResponseHistory,
6496
+ });
6497
+ };
6498
+
6499
+ const maybeNotifyUpdateAvailable = async (ctx: ExtensionCommandContext) => {
6500
+ if (updateCheckStarted || updateCheckCompleted) return;
6501
+ updateCheckStarted = true;
6502
+ try {
6503
+ const metadata = packageMetadata;
6504
+ if (!metadata) return;
6505
+ const latest = await fetchLatestNpmVersion(metadata.name, UPDATE_CHECK_TIMEOUT_MS);
6506
+ if (!latest) return;
6507
+ if (!isVersionBehind(metadata.version, latest)) return;
6508
+ ctx.ui.notify(
6509
+ `Update available for ${metadata.name}: ${metadata.version} → ${latest}. Run: pi install npm:${metadata.name}`,
6510
+ "info",
6511
+ );
6512
+ } finally {
6513
+ updateCheckCompleted = true;
6514
+ }
6515
+ };
6516
+
4903
6517
  const sendToClient = (client: WebSocket, payload: unknown) => {
4904
6518
  if (client.readyState !== WebSocket.OPEN) return;
4905
6519
  try {
@@ -4978,14 +6592,17 @@ export default function (pi: ExtensionAPI) {
4978
6592
  label: terminalActivityLabel,
4979
6593
  baseLabel,
4980
6594
  lastSpecificToolActivityLabel,
4981
- activeRequestId: activeRequest?.id ?? null,
4982
- activeRequestKind: activeRequest?.kind ?? null,
6595
+ activeRequestId: activeRequest?.id ?? compactRequestId ?? null,
6596
+ activeRequestKind: activeRequest?.kind ?? (compactInProgress ? "compact" : null),
4983
6597
  agentBusy,
4984
6598
  });
4985
6599
  broadcastState();
4986
6600
  };
4987
6601
 
4988
6602
  const broadcastState = () => {
6603
+ terminalSessionLabel = buildTerminalSessionLabel(studioCwd, getSessionNameSafe());
6604
+ currentModelLabel = formatModelLabelWithThinking(formatModelLabel(currentModel), getThinkingLevelSafe());
6605
+ refreshContextUsage();
4989
6606
  broadcast({
4990
6607
  type: "studio_state",
4991
6608
  busy: isStudioBusy(),
@@ -4993,8 +6610,14 @@ export default function (pi: ExtensionAPI) {
4993
6610
  terminalPhase: terminalActivityPhase,
4994
6611
  terminalToolName: terminalActivityToolName,
4995
6612
  terminalActivityLabel,
4996
- activeRequestId: activeRequest?.id ?? null,
4997
- activeRequestKind: activeRequest?.kind ?? null,
6613
+ modelLabel: currentModelLabel,
6614
+ terminalSessionLabel,
6615
+ contextTokens: contextUsageSnapshot.tokens,
6616
+ contextWindow: contextUsageSnapshot.contextWindow,
6617
+ contextPercent: contextUsageSnapshot.percent,
6618
+ compactInProgress,
6619
+ activeRequestId: activeRequest?.id ?? compactRequestId ?? null,
6620
+ activeRequestKind: activeRequest?.kind ?? (compactInProgress ? "compact" : null),
4998
6621
  });
4999
6622
  };
5000
6623
 
@@ -5027,6 +6650,10 @@ export default function (pi: ExtensionAPI) {
5027
6650
  broadcast({ type: "busy", requestId, message: "A studio request is already in progress." });
5028
6651
  return false;
5029
6652
  }
6653
+ if (compactInProgress) {
6654
+ broadcast({ type: "busy", requestId, message: "Context compaction is currently running." });
6655
+ return false;
6656
+ }
5030
6657
  if (agentBusy) {
5031
6658
  broadcast({ type: "busy", requestId, message: "pi is currently busy. Wait for the current turn to finish." });
5032
6659
  return false;
@@ -5079,6 +6706,7 @@ export default function (pi: ExtensionAPI) {
5079
6706
  });
5080
6707
 
5081
6708
  if (msg.type === "hello") {
6709
+ refreshContextUsage();
5082
6710
  sendToClient(client, {
5083
6711
  type: "hello_ack",
5084
6712
  busy: isStudioBusy(),
@@ -5086,9 +6714,16 @@ export default function (pi: ExtensionAPI) {
5086
6714
  terminalPhase: terminalActivityPhase,
5087
6715
  terminalToolName: terminalActivityToolName,
5088
6716
  terminalActivityLabel,
5089
- activeRequestId: activeRequest?.id ?? null,
5090
- activeRequestKind: activeRequest?.kind ?? null,
6717
+ modelLabel: currentModelLabel,
6718
+ terminalSessionLabel,
6719
+ contextTokens: contextUsageSnapshot.tokens,
6720
+ contextWindow: contextUsageSnapshot.contextWindow,
6721
+ contextPercent: contextUsageSnapshot.percent,
6722
+ compactInProgress,
6723
+ activeRequestId: activeRequest?.id ?? compactRequestId ?? null,
6724
+ activeRequestKind: activeRequest?.kind ?? (compactInProgress ? "compact" : null),
5091
6725
  lastResponse: lastStudioResponse,
6726
+ responseHistory: studioResponseHistory,
5092
6727
  initialDocument: initialStudioDocument,
5093
6728
  });
5094
6729
  return;
@@ -5104,6 +6739,7 @@ export default function (pi: ExtensionAPI) {
5104
6739
  kind: lastStudioResponse.kind,
5105
6740
  markdown: lastStudioResponse.markdown,
5106
6741
  timestamp: lastStudioResponse.timestamp,
6742
+ responseHistory: studioResponseHistory,
5107
6743
  });
5108
6744
  return;
5109
6745
  }
@@ -5201,6 +6837,90 @@ export default function (pi: ExtensionAPI) {
5201
6837
  return;
5202
6838
  }
5203
6839
 
6840
+ if (msg.type === "compact_request") {
6841
+ if (!isValidRequestId(msg.requestId)) {
6842
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
6843
+ return;
6844
+ }
6845
+ if (isStudioBusy()) {
6846
+ sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
6847
+ return;
6848
+ }
6849
+
6850
+ const compactCtx = lastCommandCtx;
6851
+ if (!compactCtx) {
6852
+ sendToClient(client, {
6853
+ type: "error",
6854
+ requestId: msg.requestId,
6855
+ message: "No interactive pi context is available to run compaction.",
6856
+ });
6857
+ return;
6858
+ }
6859
+
6860
+ const customInstructions = typeof msg.customInstructions === "string" && msg.customInstructions.trim()
6861
+ ? msg.customInstructions.trim()
6862
+ : undefined;
6863
+ if (customInstructions && customInstructions.length > 2000) {
6864
+ sendToClient(client, {
6865
+ type: "error",
6866
+ requestId: msg.requestId,
6867
+ message: "Compaction instructions are too long (max 2000 characters).",
6868
+ });
6869
+ return;
6870
+ }
6871
+
6872
+ compactInProgress = true;
6873
+ compactRequestId = msg.requestId;
6874
+ refreshContextUsage(compactCtx);
6875
+ emitDebugEvent("compact_start", {
6876
+ requestId: msg.requestId,
6877
+ hasCustomInstructions: Boolean(customInstructions),
6878
+ });
6879
+ broadcast({ type: "request_started", requestId: msg.requestId, kind: "compact" });
6880
+ broadcastState();
6881
+
6882
+ const finishCompaction = (result: { type: "compaction_completed" | "compaction_error"; message: string }) => {
6883
+ if (!compactInProgress || compactRequestId !== msg.requestId) return;
6884
+ clearCompactionState();
6885
+ refreshContextUsage(compactCtx);
6886
+ emitDebugEvent(result.type, { requestId: msg.requestId, message: result.message });
6887
+ broadcast({
6888
+ type: result.type,
6889
+ requestId: msg.requestId,
6890
+ message: result.message,
6891
+ busy: isStudioBusy(),
6892
+ contextTokens: contextUsageSnapshot.tokens,
6893
+ contextWindow: contextUsageSnapshot.contextWindow,
6894
+ contextPercent: contextUsageSnapshot.percent,
6895
+ });
6896
+ broadcastState();
6897
+ };
6898
+
6899
+ try {
6900
+ compactCtx.compact({
6901
+ customInstructions,
6902
+ onComplete: () => {
6903
+ finishCompaction({
6904
+ type: "compaction_completed",
6905
+ message: "Compaction completed.",
6906
+ });
6907
+ },
6908
+ onError: (error) => {
6909
+ finishCompaction({
6910
+ type: "compaction_error",
6911
+ message: `Compaction failed: ${error instanceof Error ? error.message : String(error)}`,
6912
+ });
6913
+ },
6914
+ });
6915
+ } catch (error) {
6916
+ finishCompaction({
6917
+ type: "compaction_error",
6918
+ message: `Failed to start compaction: ${error instanceof Error ? error.message : String(error)}`,
6919
+ });
6920
+ }
6921
+ return;
6922
+ }
6923
+
5204
6924
  if (msg.type === "save_as_request") {
5205
6925
  if (!isValidRequestId(msg.requestId)) {
5206
6926
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
@@ -5412,6 +7132,84 @@ export default function (pi: ExtensionAPI) {
5412
7132
  }
5413
7133
  };
5414
7134
 
7135
+ const handleExportPdfRequest = async (req: IncomingMessage, res: ServerResponse) => {
7136
+ let rawBody = "";
7137
+ try {
7138
+ rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
7139
+ } catch (error) {
7140
+ const message = error instanceof Error ? error.message : String(error);
7141
+ const status = message.includes("exceeds") ? 413 : 400;
7142
+ respondJson(res, status, { ok: false, error: message });
7143
+ return;
7144
+ }
7145
+
7146
+ let parsedBody: unknown;
7147
+ try {
7148
+ parsedBody = rawBody ? JSON.parse(rawBody) : {};
7149
+ } catch {
7150
+ respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
7151
+ return;
7152
+ }
7153
+
7154
+ const markdown =
7155
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { markdown?: unknown }).markdown === "string"
7156
+ ? (parsedBody as { markdown: string }).markdown
7157
+ : null;
7158
+ if (markdown === null) {
7159
+ respondJson(res, 400, { ok: false, error: "Missing markdown string in request body." });
7160
+ return;
7161
+ }
7162
+
7163
+ if (markdown.length > PDF_EXPORT_MAX_CHARS) {
7164
+ respondJson(res, 413, {
7165
+ ok: false,
7166
+ error: `PDF export text exceeds ${PDF_EXPORT_MAX_CHARS} characters.`,
7167
+ });
7168
+ return;
7169
+ }
7170
+
7171
+ const sourcePath =
7172
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { sourcePath?: unknown }).sourcePath === "string"
7173
+ ? (parsedBody as { sourcePath: string }).sourcePath
7174
+ : "";
7175
+ const userResourceDir =
7176
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { resourceDir?: unknown }).resourceDir === "string"
7177
+ ? (parsedBody as { resourceDir: string }).resourceDir
7178
+ : "";
7179
+ const resourcePath = sourcePath ? dirname(sourcePath) : (userResourceDir || studioCwd || undefined);
7180
+ const requestedIsLatex =
7181
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { isLatex?: unknown }).isLatex === "boolean"
7182
+ ? (parsedBody as { isLatex: boolean }).isLatex
7183
+ : null;
7184
+ const isLatex = requestedIsLatex ?? /\\documentclass\b|\\begin\{document\}/.test(markdown);
7185
+ const requestedFilename =
7186
+ parsedBody && typeof parsedBody === "object" && typeof (parsedBody as { filenameHint?: unknown }).filenameHint === "string"
7187
+ ? (parsedBody as { filenameHint: string }).filenameHint
7188
+ : "";
7189
+ const filename = sanitizePdfFilename(requestedFilename || (isLatex ? "studio-latex-preview.pdf" : "studio-preview.pdf"));
7190
+
7191
+ try {
7192
+ const pdf = await renderStudioPdfWithPandoc(markdown, isLatex, resourcePath);
7193
+ const safeAsciiName = filename
7194
+ .replace(/[\x00-\x1f\x7f]/g, "")
7195
+ .replace(/[;"\\]/g, "_")
7196
+ .replace(/\s+/g, " ")
7197
+ .trim() || "studio-preview.pdf";
7198
+
7199
+ res.writeHead(200, {
7200
+ "Content-Type": "application/pdf",
7201
+ "Cache-Control": "no-store",
7202
+ "X-Content-Type-Options": "nosniff",
7203
+ "Content-Disposition": `attachment; filename="${safeAsciiName}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
7204
+ "Content-Length": String(pdf.length),
7205
+ });
7206
+ res.end(pdf);
7207
+ } catch (error) {
7208
+ const message = error instanceof Error ? error.message : String(error);
7209
+ respondJson(res, 500, { ok: false, error: `PDF export failed: ${message}` });
7210
+ }
7211
+ };
7212
+
5415
7213
  const handleHttpRequest = (req: IncomingMessage, res: ServerResponse) => {
5416
7214
  if (!serverState) {
5417
7215
  respondText(res, 503, "Studio server not ready");
@@ -5461,6 +7259,29 @@ export default function (pi: ExtensionAPI) {
5461
7259
  return;
5462
7260
  }
5463
7261
 
7262
+ if (requestUrl.pathname === "/export-pdf") {
7263
+ const token = requestUrl.searchParams.get("token") ?? "";
7264
+ if (token !== serverState.token) {
7265
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
7266
+ return;
7267
+ }
7268
+
7269
+ const method = (req.method ?? "GET").toUpperCase();
7270
+ if (method !== "POST") {
7271
+ res.setHeader("Allow", "POST");
7272
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
7273
+ return;
7274
+ }
7275
+
7276
+ void handleExportPdfRequest(req, res).catch((error) => {
7277
+ respondJson(res, 500, {
7278
+ ok: false,
7279
+ error: `PDF export failed: ${error instanceof Error ? error.message : String(error)}`,
7280
+ });
7281
+ });
7282
+ return;
7283
+ }
7284
+
5464
7285
  if (requestUrl.pathname !== "/") {
5465
7286
  respondText(res, 404, "Not found");
5466
7287
  return;
@@ -5480,7 +7301,8 @@ export default function (pi: ExtensionAPI) {
5480
7301
  "Cross-Origin-Opener-Policy": "same-origin",
5481
7302
  "Cross-Origin-Resource-Policy": "same-origin",
5482
7303
  });
5483
- res.end(buildStudioHtml(initialStudioDocument, lastCommandCtx?.ui.theme));
7304
+ refreshContextUsage();
7305
+ res.end(buildStudioHtml(initialStudioDocument, lastCommandCtx?.ui.theme, currentModelLabel, terminalSessionLabel, contextUsageSnapshot));
5484
7306
  };
5485
7307
 
5486
7308
  const ensureServer = async (): Promise<StudioServerState> => {
@@ -5572,9 +7394,22 @@ export default function (pi: ExtensionAPI) {
5572
7394
 
5573
7395
  serverState = state;
5574
7396
 
5575
- // Periodically check for theme changes and push to all clients
7397
+ // Periodically check for theme/model metadata changes and push to all clients
5576
7398
  const themeCheckInterval = setInterval(() => {
5577
- if (!lastCommandCtx?.ui?.theme || !serverState || serverState.clients.size === 0) return;
7399
+ if (!serverState || serverState.clients.size === 0) return;
7400
+
7401
+ try {
7402
+ const previousModelLabel = currentModelLabel;
7403
+ const previousTerminalLabel = terminalSessionLabel;
7404
+ refreshRuntimeMetadata();
7405
+ if (currentModelLabel !== previousModelLabel || terminalSessionLabel !== previousTerminalLabel) {
7406
+ broadcastState();
7407
+ }
7408
+ } catch {
7409
+ // Ignore metadata read errors
7410
+ }
7411
+
7412
+ if (!lastCommandCtx?.ui?.theme) return;
5578
7413
  try {
5579
7414
  const style = getStudioThemeStyle(lastCommandCtx.ui.theme);
5580
7415
  const vars = buildThemeCssVars(style);
@@ -5598,6 +7433,7 @@ export default function (pi: ExtensionAPI) {
5598
7433
  const stopServer = async () => {
5599
7434
  if (!serverState) return;
5600
7435
  clearActiveRequest();
7436
+ clearCompactionState();
5601
7437
  closeAllClients(1001, "Server shutting down");
5602
7438
 
5603
7439
  const state = serverState;
@@ -5620,29 +7456,58 @@ export default function (pi: ExtensionAPI) {
5620
7456
  };
5621
7457
 
5622
7458
  const hydrateLatestAssistant = (entries: SessionEntry[]) => {
5623
- const latest = extractLatestAssistantFromEntries(entries);
5624
- if (!latest) return;
5625
- lastStudioResponse = {
5626
- markdown: latest,
5627
- timestamp: Date.now(),
5628
- kind: inferStudioResponseKind(latest),
5629
- };
7459
+ syncStudioResponseHistory(entries);
5630
7460
  };
5631
7461
 
5632
7462
  pi.on("session_start", async (_event, ctx) => {
5633
7463
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
7464
+ clearCompactionState();
5634
7465
  agentBusy = false;
5635
- emitDebugEvent("session_start", { entryCount: ctx.sessionManager.getBranch().length });
7466
+ refreshRuntimeMetadata({ cwd: ctx.cwd, model: ctx.model });
7467
+ refreshContextUsage(ctx);
7468
+ emitDebugEvent("session_start", {
7469
+ entryCount: ctx.sessionManager.getBranch().length,
7470
+ modelLabel: currentModelLabel,
7471
+ terminalSessionLabel,
7472
+ });
5636
7473
  setTerminalActivity("idle");
7474
+ broadcastResponseHistory();
5637
7475
  });
5638
7476
 
5639
7477
  pi.on("session_switch", async (_event, ctx) => {
5640
7478
  clearActiveRequest({ notify: "Session switched. Studio request state cleared.", level: "warning" });
7479
+ clearCompactionState();
5641
7480
  lastCommandCtx = null;
5642
7481
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
5643
7482
  agentBusy = false;
5644
- emitDebugEvent("session_switch", { entryCount: ctx.sessionManager.getBranch().length });
7483
+ refreshRuntimeMetadata({ cwd: ctx.cwd, model: ctx.model });
7484
+ refreshContextUsage(ctx);
7485
+ emitDebugEvent("session_switch", {
7486
+ entryCount: ctx.sessionManager.getBranch().length,
7487
+ modelLabel: currentModelLabel,
7488
+ terminalSessionLabel,
7489
+ });
5645
7490
  setTerminalActivity("idle");
7491
+ broadcastResponseHistory();
7492
+ });
7493
+
7494
+ pi.on("session_tree", async (_event, ctx) => {
7495
+ hydrateLatestAssistant(ctx.sessionManager.getBranch());
7496
+ refreshRuntimeMetadata({ cwd: ctx.cwd, model: ctx.model });
7497
+ refreshContextUsage(ctx);
7498
+ broadcastResponseHistory();
7499
+ broadcastState();
7500
+ });
7501
+
7502
+ pi.on("model_select", async (event, ctx) => {
7503
+ refreshRuntimeMetadata({ cwd: ctx.cwd, model: event.model });
7504
+ refreshContextUsage(ctx);
7505
+ emitDebugEvent("model_select", {
7506
+ modelLabel: currentModelLabel,
7507
+ source: event.source,
7508
+ previousModel: formatModelLabel(event.previousModel),
7509
+ });
7510
+ broadcastState();
5646
7511
  });
5647
7512
 
5648
7513
  pi.on("agent_start", async () => {
@@ -5682,7 +7547,7 @@ export default function (pi: ExtensionAPI) {
5682
7547
  }
5683
7548
  });
5684
7549
 
5685
- pi.on("message_end", async (event) => {
7550
+ pi.on("message_end", async (event, ctx) => {
5686
7551
  const message = event.message as { stopReason?: string; role?: string };
5687
7552
  const stopReason = typeof message.stopReason === "string" ? message.stopReason : "";
5688
7553
  const role = typeof message.role === "string" ? message.role : "";
@@ -5708,12 +7573,33 @@ export default function (pi: ExtensionAPI) {
5708
7573
 
5709
7574
  if (!markdown) return;
5710
7575
 
7576
+ syncStudioResponseHistory(ctx.sessionManager.getBranch());
7577
+ refreshContextUsage(ctx);
7578
+ const latestHistoryItem = studioResponseHistory[studioResponseHistory.length - 1];
7579
+ if (!latestHistoryItem || latestHistoryItem.markdown !== markdown) {
7580
+ const fallbackPrompt = studioResponseHistory.length > 0
7581
+ ? studioResponseHistory[studioResponseHistory.length - 1]?.prompt ?? null
7582
+ : null;
7583
+ const fallbackHistoryItem: StudioResponseHistoryItem = {
7584
+ id: randomUUID(),
7585
+ markdown,
7586
+ timestamp: Date.now(),
7587
+ kind: inferStudioResponseKind(markdown),
7588
+ prompt: fallbackPrompt,
7589
+ };
7590
+ const nextHistory = [...studioResponseHistory, fallbackHistoryItem];
7591
+ studioResponseHistory = nextHistory.slice(-RESPONSE_HISTORY_LIMIT);
7592
+ }
7593
+
7594
+ const latestItem = studioResponseHistory[studioResponseHistory.length - 1];
7595
+ const responseTimestamp = latestItem?.timestamp ?? Date.now();
7596
+
5711
7597
  if (activeRequest) {
5712
7598
  const requestId = activeRequest.id;
5713
7599
  const kind = activeRequest.kind;
5714
7600
  lastStudioResponse = {
5715
7601
  markdown,
5716
- timestamp: Date.now(),
7602
+ timestamp: responseTimestamp,
5717
7603
  kind,
5718
7604
  };
5719
7605
  emitDebugEvent("broadcast_response", {
@@ -5728,7 +7614,9 @@ export default function (pi: ExtensionAPI) {
5728
7614
  kind,
5729
7615
  markdown,
5730
7616
  timestamp: lastStudioResponse.timestamp,
7617
+ responseHistory: studioResponseHistory,
5731
7618
  });
7619
+ broadcastResponseHistory();
5732
7620
  clearActiveRequest();
5733
7621
  return;
5734
7622
  }
@@ -5736,7 +7624,7 @@ export default function (pi: ExtensionAPI) {
5736
7624
  const inferredKind = inferStudioResponseKind(markdown);
5737
7625
  lastStudioResponse = {
5738
7626
  markdown,
5739
- timestamp: Date.now(),
7627
+ timestamp: responseTimestamp,
5740
7628
  kind: inferredKind,
5741
7629
  };
5742
7630
  emitDebugEvent("broadcast_latest_response", {
@@ -5749,11 +7637,14 @@ export default function (pi: ExtensionAPI) {
5749
7637
  kind: inferredKind,
5750
7638
  markdown,
5751
7639
  timestamp: lastStudioResponse.timestamp,
7640
+ responseHistory: studioResponseHistory,
5752
7641
  });
7642
+ broadcastResponseHistory();
5753
7643
  });
5754
7644
 
5755
7645
  pi.on("agent_end", async () => {
5756
7646
  agentBusy = false;
7647
+ refreshContextUsage();
5757
7648
  emitDebugEvent("agent_end", { activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
5758
7649
  setTerminalActivity("idle");
5759
7650
  if (activeRequest) {
@@ -5770,12 +7661,13 @@ export default function (pi: ExtensionAPI) {
5770
7661
  pi.on("session_shutdown", async () => {
5771
7662
  lastCommandCtx = null;
5772
7663
  agentBusy = false;
7664
+ clearCompactionState();
5773
7665
  setTerminalActivity("idle");
5774
7666
  await stopServer();
5775
7667
  });
5776
7668
 
5777
7669
  pi.registerCommand("studio", {
5778
- description: "Open Pi Studio browser UI (/studio, /studio <file>, /studio --blank, /studio --last)",
7670
+ description: "Open pi Studio browser UI (/studio, /studio <file>, /studio --blank, /studio --last)",
5779
7671
  handler: async (args: string, ctx: ExtensionCommandContext) => {
5780
7672
  const trimmed = args.trim();
5781
7673
 
@@ -5813,7 +7705,12 @@ export default function (pi: ExtensionAPI) {
5813
7705
 
5814
7706
  await ctx.waitForIdle();
5815
7707
  lastCommandCtx = ctx;
5816
- studioCwd = ctx.cwd;
7708
+ refreshRuntimeMetadata({ cwd: ctx.cwd, model: ctx.model });
7709
+ refreshContextUsage(ctx);
7710
+ syncStudioResponseHistory(ctx.sessionManager.getBranch());
7711
+ broadcastState();
7712
+ broadcastResponseHistory();
7713
+ void maybeNotifyUpdateAvailable(ctx);
5817
7714
  // Seed theme vars so first ping doesn't trigger a false update
5818
7715
  try {
5819
7716
  const currentStyle = getStudioThemeStyle(ctx.ui.theme);
@@ -5901,14 +7798,14 @@ export default function (pi: ExtensionAPI) {
5901
7798
  try {
5902
7799
  await openUrlInDefaultBrowser(url);
5903
7800
  if (initialStudioDocument?.source === "file") {
5904
- ctx.ui.notify(`Opened Pi Studio with file loaded: ${initialStudioDocument.label}`, "info");
7801
+ ctx.ui.notify(`Opened pi Studio with file loaded: ${initialStudioDocument.label}`, "info");
5905
7802
  } else if (initialStudioDocument?.source === "last-response") {
5906
7803
  ctx.ui.notify(
5907
- `Opened Pi Studio with last model response (${initialStudioDocument.text.length} chars).`,
7804
+ `Opened pi Studio with last model response (${initialStudioDocument.text.length} chars).`,
5908
7805
  "info",
5909
7806
  );
5910
7807
  } else {
5911
- ctx.ui.notify("Opened Pi Studio with blank editor.", "info");
7808
+ ctx.ui.notify("Opened pi Studio with blank editor.", "info");
5912
7809
  }
5913
7810
  ctx.ui.notify(`Studio URL: ${url}`, "info");
5914
7811
  } catch (error) {