pi-studio 0.5.5 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -88,6 +88,12 @@ All notable changes to `pi-studio` are documented here.
88
88
 
89
89
  ## [Unreleased]
90
90
 
91
+ ## [0.5.6] — 2026-03-10
92
+
93
+ ### Changed
94
+ - Studio monospace surfaces now use a shared `--font-mono` stack, with best-effort terminal-font detection (Ghostty/WezTerm/Kitty/Alacritty config when available) and `PI_STUDIO_FONT_MONO` as a manual override.
95
+ - In-flight **Run editor text** / **Critique editor text** requests now swap the triggering button into an in-place theme-aware **Stop** state while disabling the other action.
96
+
91
97
  ## [0.5.5] — 2026-03-09
92
98
 
93
99
  ### Fixed
package/README.md CHANGED
@@ -62,6 +62,7 @@ pi -e https://github.com/omaclaren/pi-studio
62
62
 
63
63
  - Local-only server (`127.0.0.1`) with rotating tokenized URLs.
64
64
  - Studio is designed as a complement to terminal pi, not a replacement.
65
+ - Editor/code font uses a best-effort terminal-monospace match when the current terminal config exposes it; set `PI_STUDIO_FONT_MONO` to force a specific CSS `font-family` stack.
65
66
  - Full preview/PDF quality depends on `pandoc` (and `xelatex` for PDF):
66
67
  - `brew install pandoc`
67
68
  - install TeX Live/MacTeX for PDF export
package/index.ts CHANGED
@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
4
4
  import { readFileSync, statSync, writeFileSync } from "node:fs";
5
5
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
6
6
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
7
- import { tmpdir } from "node:os";
7
+ import { homedir, tmpdir } from "node:os";
8
8
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
9
9
  import { URL } from "node:url";
10
10
  import { WebSocketServer, WebSocket, type RawData } from "ws";
@@ -120,6 +120,11 @@ interface GetFromEditorRequestMessage {
120
120
  requestId: string;
121
121
  }
122
122
 
123
+ interface CancelRequestMessage {
124
+ type: "cancel_request";
125
+ requestId: string;
126
+ }
127
+
123
128
  type IncomingStudioMessage =
124
129
  | HelloMessage
125
130
  | PingMessage
@@ -131,7 +136,8 @@ type IncomingStudioMessage =
131
136
  | SaveAsRequestMessage
132
137
  | SaveOverRequestMessage
133
138
  | SendToEditorRequestMessage
134
- | GetFromEditorRequestMessage;
139
+ | GetFromEditorRequestMessage
140
+ | CancelRequestMessage;
135
141
 
136
142
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
137
143
  const PREVIEW_RENDER_MAX_CHARS = 400_000;
@@ -453,6 +459,205 @@ interface ThemeExportPalette {
453
459
 
454
460
  const themeExportPaletteCache = new Map<string, ThemeExportPalette | null>();
455
461
 
462
+ const DEFAULT_MONO_FONT_FAMILIES = [
463
+ "ui-monospace",
464
+ "SFMono-Regular",
465
+ "Menlo",
466
+ "Monaco",
467
+ "Consolas",
468
+ "Liberation Mono",
469
+ "Courier New",
470
+ "monospace",
471
+ ] as const;
472
+
473
+ const CSS_GENERIC_FONT_FAMILIES = new Set([
474
+ "serif",
475
+ "sans-serif",
476
+ "monospace",
477
+ "cursive",
478
+ "fantasy",
479
+ "system-ui",
480
+ "emoji",
481
+ "math",
482
+ "fangsong",
483
+ "ui-serif",
484
+ "ui-sans-serif",
485
+ "ui-monospace",
486
+ "ui-rounded",
487
+ ]);
488
+
489
+ let cachedStudioMonoFontStack: string | null = null;
490
+
491
+ function getHomeDirectory(): string {
492
+ return process.env.HOME ?? homedir();
493
+ }
494
+
495
+ function getXdgConfigDirectory(): string {
496
+ const configured = process.env.XDG_CONFIG_HOME?.trim();
497
+ if (configured) return configured;
498
+ return join(getHomeDirectory(), ".config");
499
+ }
500
+
501
+ function sanitizeCssValue(value: string): string {
502
+ return value.replace(/[\r\n;]+/g, " ").trim();
503
+ }
504
+
505
+ function stripSimpleInlineComment(value: string): string {
506
+ let quote: '"' | "'" | null = null;
507
+ for (let i = 0; i < value.length; i += 1) {
508
+ const char = value[i];
509
+ if (quote) {
510
+ if (char === quote && value[i - 1] !== "\\") quote = null;
511
+ continue;
512
+ }
513
+ if (char === '"' || char === "'") {
514
+ quote = char;
515
+ continue;
516
+ }
517
+ if (char === "#") {
518
+ return value.slice(0, i).trim();
519
+ }
520
+ }
521
+ return value.trim();
522
+ }
523
+
524
+ function normalizeConfiguredFontFamily(value: string | undefined): string | undefined {
525
+ if (!value) return undefined;
526
+ const sanitized = sanitizeCssValue(stripSimpleInlineComment(value));
527
+ if (!sanitized) return undefined;
528
+ const unquoted =
529
+ (sanitized.startsWith('"') && sanitized.endsWith('"'))
530
+ || (sanitized.startsWith("'") && sanitized.endsWith("'"))
531
+ ? sanitized.slice(1, -1).trim()
532
+ : sanitized;
533
+ return unquoted || undefined;
534
+ }
535
+
536
+ function formatCssFontFamilyToken(value: string): string {
537
+ const trimmed = sanitizeCssValue(value);
538
+ if (!trimmed) return "";
539
+ if (CSS_GENERIC_FONT_FAMILIES.has(trimmed.toLowerCase())) return trimmed;
540
+ if (
541
+ (trimmed.startsWith('"') && trimmed.endsWith('"'))
542
+ || (trimmed.startsWith("'") && trimmed.endsWith("'"))
543
+ ) {
544
+ return trimmed;
545
+ }
546
+ return `"${trimmed.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
547
+ }
548
+
549
+ function readFirstExistingTextFile(paths: string[]): string | undefined {
550
+ for (const path of paths) {
551
+ try {
552
+ const text = readFileSync(path, "utf-8");
553
+ if (text.trim()) return text;
554
+ } catch {
555
+ // Ignore missing/unreadable files
556
+ }
557
+ }
558
+ return undefined;
559
+ }
560
+
561
+ function detectGhosttyFontFamily(): string | undefined {
562
+ const home = getHomeDirectory();
563
+ const content = readFirstExistingTextFile([
564
+ join(getXdgConfigDirectory(), "ghostty", "config"),
565
+ join(home, "Library", "Application Support", "com.mitchellh.ghostty", "config"),
566
+ ]);
567
+ if (!content) return undefined;
568
+ const match = content.match(/^\s*font-family\s*=\s*(.+?)\s*$/m);
569
+ return normalizeConfiguredFontFamily(match?.[1]);
570
+ }
571
+
572
+ function detectKittyFontFamily(): string | undefined {
573
+ const content = readFirstExistingTextFile([
574
+ join(getXdgConfigDirectory(), "kitty", "kitty.conf"),
575
+ ]);
576
+ if (!content) return undefined;
577
+ const match = content.match(/^\s*font_family\s+(.+?)\s*$/m);
578
+ return normalizeConfiguredFontFamily(match?.[1]);
579
+ }
580
+
581
+ function detectWezTermFontFamily(): string | undefined {
582
+ const home = getHomeDirectory();
583
+ const content = readFirstExistingTextFile([
584
+ join(getXdgConfigDirectory(), "wezterm", "wezterm.lua"),
585
+ join(home, ".wezterm.lua"),
586
+ ]);
587
+ if (!content) return undefined;
588
+ const patterns = [
589
+ /font_with_fallback\s*\(\s*\{[\s\S]*?["']([^"']+)["']/m,
590
+ /font\s*\(\s*["']([^"']+)["']/m,
591
+ /font\s*=\s*["']([^"']+)["']/m,
592
+ /family\s*=\s*["']([^"']+)["']/m,
593
+ ];
594
+ for (const pattern of patterns) {
595
+ const family = normalizeConfiguredFontFamily(content.match(pattern)?.[1]);
596
+ if (family) return family;
597
+ }
598
+ return undefined;
599
+ }
600
+
601
+ function detectAlacrittyFontFamily(): string | undefined {
602
+ const content = readFirstExistingTextFile([
603
+ join(getXdgConfigDirectory(), "alacritty", "alacritty.toml"),
604
+ join(getXdgConfigDirectory(), "alacritty.toml"),
605
+ join(getXdgConfigDirectory(), "alacritty", "alacritty.yml"),
606
+ join(getXdgConfigDirectory(), "alacritty", "alacritty.yaml"),
607
+ ]);
608
+ if (!content) return undefined;
609
+ const patterns = [
610
+ /^\s*family\s*=\s*["']([^"']+)["']\s*$/m,
611
+ /^\s*family\s*:\s*["']?([^"'#\n]+)["']?\s*$/m,
612
+ ];
613
+ for (const pattern of patterns) {
614
+ const family = normalizeConfiguredFontFamily(content.match(pattern)?.[1]);
615
+ if (family) return family;
616
+ }
617
+ return undefined;
618
+ }
619
+
620
+ function detectTerminalMonospaceFontFamily(): string | undefined {
621
+ const termProgram = (process.env.TERM_PROGRAM ?? "").trim().toLowerCase();
622
+ const term = (process.env.TERM ?? "").trim().toLowerCase();
623
+
624
+ if (termProgram === "ghostty" || term.includes("ghostty")) return detectGhosttyFontFamily();
625
+ if (termProgram === "wezterm") return detectWezTermFontFamily();
626
+ if (termProgram === "kitty" || term.includes("kitty")) return detectKittyFontFamily();
627
+ if (termProgram === "alacritty") return detectAlacrittyFontFamily();
628
+ return undefined;
629
+ }
630
+
631
+ function buildMonoFontStack(primaryFamily?: string): string {
632
+ const entries: string[] = [];
633
+ const seen = new Set<string>();
634
+ const push = (family: string) => {
635
+ const trimmed = family.trim();
636
+ if (!trimmed) return;
637
+ const key = trimmed.replace(/^['"]|['"]$/g, "").toLowerCase();
638
+ if (seen.has(key)) return;
639
+ seen.add(key);
640
+ entries.push(formatCssFontFamilyToken(trimmed));
641
+ };
642
+
643
+ if (primaryFamily) push(primaryFamily);
644
+ for (const family of DEFAULT_MONO_FONT_FAMILIES) push(family);
645
+ return entries.join(", ");
646
+ }
647
+
648
+ function getStudioMonoFontStack(): string {
649
+ if (cachedStudioMonoFontStack) return cachedStudioMonoFontStack;
650
+
651
+ const override = sanitizeCssValue(process.env.PI_STUDIO_FONT_MONO ?? "");
652
+ if (override) {
653
+ cachedStudioMonoFontStack = override;
654
+ return cachedStudioMonoFontStack;
655
+ }
656
+
657
+ cachedStudioMonoFontStack = buildMonoFontStack(detectTerminalMonospaceFontFamily());
658
+ return cachedStudioMonoFontStack;
659
+ }
660
+
456
661
  function resolveThemeExportValue(
457
662
  value: string | number | undefined,
458
663
  vars: Record<string, string | number>,
@@ -1498,6 +1703,13 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
1498
1703
  };
1499
1704
  }
1500
1705
 
1706
+ if (msg.type === "cancel_request" && typeof msg.requestId === "string") {
1707
+ return {
1708
+ type: "cancel_request",
1709
+ requestId: msg.requestId,
1710
+ };
1711
+ }
1712
+
1501
1713
  return null;
1502
1714
  }
1503
1715
 
@@ -1693,6 +1905,7 @@ function buildThemeCssVars(style: StudioThemeStyle): Record<string, string> {
1693
1905
  ? "0 1px 2px rgba(15, 23, 42, 0.03), 0 4px 14px rgba(15, 23, 42, 0.04)"
1694
1906
  : "0 1px 2px rgba(0, 0, 0, 0.36), 0 6px 18px rgba(0, 0, 0, 0.22)";
1695
1907
  const accentContrast = style.mode === "light" ? "#ffffff" : "#0e1616";
1908
+ const errorContrast = style.mode === "light" ? "#ffffff" : "#0e1616";
1696
1909
  const blockquoteBg = withAlpha(
1697
1910
  style.palette.mdQuoteBorder,
1698
1911
  style.mode === "light" ? 0.10 : 0.16,
@@ -1706,6 +1919,7 @@ function buildThemeCssVars(style: StudioThemeStyle): Record<string, string> {
1706
1919
  const editorBg = style.mode === "light"
1707
1920
  ? blendColors(style.palette.panel, "#ffffff", 0.5)
1708
1921
  : style.palette.panel;
1922
+ const monoFontStack = getStudioMonoFontStack();
1709
1923
 
1710
1924
  return {
1711
1925
  "color-scheme": style.mode,
@@ -1747,9 +1961,11 @@ function buildThemeCssVars(style: StudioThemeStyle): Record<string, string> {
1747
1961
  "--syntax-punctuation": style.palette.syntaxPunctuation,
1748
1962
  "--panel-shadow": panelShadow,
1749
1963
  "--accent-contrast": accentContrast,
1964
+ "--error-contrast": errorContrast,
1750
1965
  "--blockquote-bg": blockquoteBg,
1751
1966
  "--table-alt-bg": tableAltBg,
1752
1967
  "--editor-bg": editorBg,
1968
+ "--font-mono": monoFontStack,
1753
1969
  };
1754
1970
  }
1755
1971
 
@@ -1780,10 +1996,11 @@ function buildStudioHtml(
1780
1996
  : "";
1781
1997
  const style = getStudioThemeStyle(theme);
1782
1998
  const vars = buildThemeCssVars(style);
1999
+ const monoFontStack = vars["--font-mono"] ?? buildMonoFontStack();
1783
2000
  const mermaidConfig = {
1784
2001
  startOnLoad: false,
1785
2002
  theme: "base",
1786
- fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
2003
+ fontFamily: monoFontStack,
1787
2004
  flowchart: {
1788
2005
  curve: "basis",
1789
2006
  },
@@ -1909,6 +2126,14 @@ ${cssVarsBlock}
1909
2126
  }
1910
2127
 
1911
2128
  #sendRunBtn,
2129
+ #critiqueBtn {
2130
+ min-width: 10rem;
2131
+ display: inline-flex;
2132
+ justify-content: center;
2133
+ align-items: center;
2134
+ }
2135
+
2136
+ #sendRunBtn:not(:disabled):not(.request-stop-active),
1912
2137
  #loadResponseBtn:not(:disabled):not([hidden]) {
1913
2138
  background: var(--accent);
1914
2139
  border-color: var(--accent);
@@ -1916,11 +2141,24 @@ ${cssVarsBlock}
1916
2141
  font-weight: 600;
1917
2142
  }
1918
2143
 
1919
- #sendRunBtn:not(:disabled):hover,
2144
+ #sendRunBtn:not(:disabled):not(.request-stop-active):hover,
1920
2145
  #loadResponseBtn:not(:disabled):not([hidden]):hover {
1921
2146
  filter: brightness(0.95);
1922
2147
  }
1923
2148
 
2149
+ #sendRunBtn.request-stop-active,
2150
+ #critiqueBtn.request-stop-active {
2151
+ background: var(--error);
2152
+ border-color: var(--error);
2153
+ color: var(--error-contrast);
2154
+ font-weight: 600;
2155
+ }
2156
+
2157
+ #sendRunBtn.request-stop-active:not(:disabled):hover,
2158
+ #critiqueBtn.request-stop-active:not(:disabled):hover {
2159
+ filter: brightness(0.95);
2160
+ }
2161
+
1924
2162
  .file-label {
1925
2163
  cursor: pointer;
1926
2164
  display: inline-flex;
@@ -2034,7 +2272,7 @@ ${cssVarsBlock}
2034
2272
  font-size: 13px;
2035
2273
  line-height: 1.45;
2036
2274
  tab-size: 2;
2037
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
2275
+ font-family: var(--font-mono);
2038
2276
  resize: vertical;
2039
2277
  }
2040
2278
 
@@ -2181,7 +2419,7 @@ ${cssVarsBlock}
2181
2419
  word-break: normal;
2182
2420
  overflow-wrap: break-word;
2183
2421
  overscroll-behavior: none;
2184
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
2422
+ font-family: var(--font-mono);
2185
2423
  font-size: 13px;
2186
2424
  line-height: 1.45;
2187
2425
  tab-size: 2;
@@ -2424,7 +2662,7 @@ ${cssVarsBlock}
2424
2662
  }
2425
2663
 
2426
2664
  .rendered-markdown code {
2427
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
2665
+ font-family: var(--font-mono);
2428
2666
  font-size: 0.9em;
2429
2667
  color: var(--md-code);
2430
2668
  }
@@ -2571,7 +2809,7 @@ ${cssVarsBlock}
2571
2809
  margin: 0;
2572
2810
  white-space: pre-wrap;
2573
2811
  word-break: break-word;
2574
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
2812
+ font-family: var(--font-mono);
2575
2813
  font-size: 13px;
2576
2814
  line-height: 1.5;
2577
2815
  }
@@ -2580,7 +2818,7 @@ ${cssVarsBlock}
2580
2818
  margin: 0;
2581
2819
  white-space: pre-wrap;
2582
2820
  word-break: break-word;
2583
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
2821
+ font-family: var(--font-mono);
2584
2822
  font-size: 13px;
2585
2823
  line-height: 1.5;
2586
2824
  }
@@ -4642,7 +4880,7 @@ ${cssVarsBlock}
4642
4880
  saveOverBtn.disabled = uiBusy || !canSaveOver;
4643
4881
  sendEditorBtn.disabled = uiBusy;
4644
4882
  if (getEditorBtn) getEditorBtn.disabled = uiBusy;
4645
- sendRunBtn.disabled = uiBusy;
4883
+ syncRunAndCritiqueButtons();
4646
4884
  copyDraftBtn.disabled = uiBusy;
4647
4885
  if (highlightSelect) highlightSelect.disabled = uiBusy;
4648
4886
  if (langSelect) langSelect.disabled = uiBusy;
@@ -4655,7 +4893,6 @@ ${cssVarsBlock}
4655
4893
  followSelect.disabled = uiBusy;
4656
4894
  if (responseHighlightSelect) responseHighlightSelect.disabled = uiBusy || rightView !== "markdown";
4657
4895
  insertHeaderBtn.disabled = uiBusy;
4658
- critiqueBtn.disabled = uiBusy;
4659
4896
  lensSelect.disabled = uiBusy;
4660
4897
  updateSaveFileTooltip();
4661
4898
  updateHistoryControls();
@@ -5352,6 +5589,51 @@ ${cssVarsBlock}
5352
5589
  renderActiveResult();
5353
5590
  }
5354
5591
 
5592
+ function getAbortablePendingKind() {
5593
+ if (!pendingRequestId) return null;
5594
+ return pendingKind === "direct" || pendingKind === "critique" ? pendingKind : null;
5595
+ }
5596
+
5597
+ function requestCancelForPendingRequest(expectedKind) {
5598
+ const activeKind = getAbortablePendingKind();
5599
+ if (!activeKind || activeKind !== expectedKind || !pendingRequestId) {
5600
+ setStatus("No matching Studio request is running.", "warning");
5601
+ return false;
5602
+ }
5603
+ const sent = sendMessage({ type: "cancel_request", requestId: pendingRequestId });
5604
+ if (!sent) return false;
5605
+ setStatus("Stopping request…", "warning");
5606
+ return true;
5607
+ }
5608
+
5609
+ function syncRunAndCritiqueButtons() {
5610
+ const activeKind = getAbortablePendingKind();
5611
+ const sendRunIsStop = activeKind === "direct";
5612
+ const critiqueIsStop = activeKind === "critique";
5613
+
5614
+ if (sendRunBtn) {
5615
+ sendRunBtn.textContent = sendRunIsStop ? "Stop" : "Run editor text";
5616
+ sendRunBtn.classList.toggle("request-stop-active", sendRunIsStop);
5617
+ sendRunBtn.disabled = sendRunIsStop ? wsState === "Disconnected" : (uiBusy || critiqueIsStop);
5618
+ sendRunBtn.title = sendRunIsStop
5619
+ ? "Stop the running editor-text request."
5620
+ : (annotationsEnabled
5621
+ ? "Run editor text as-is (includes [an: ...] markers). Shortcut: Cmd/Ctrl+Enter."
5622
+ : "Run editor text with [an: ...] markers stripped. Shortcut: Cmd/Ctrl+Enter.");
5623
+ }
5624
+
5625
+ if (critiqueBtn) {
5626
+ critiqueBtn.textContent = critiqueIsStop ? "Stop" : "Critique editor text";
5627
+ critiqueBtn.classList.toggle("request-stop-active", critiqueIsStop);
5628
+ critiqueBtn.disabled = critiqueIsStop ? wsState === "Disconnected" : (uiBusy || sendRunIsStop);
5629
+ critiqueBtn.title = critiqueIsStop
5630
+ ? "Stop the running critique request."
5631
+ : (annotationsEnabled
5632
+ ? "Critique editor text as-is (includes [an: ...] markers)."
5633
+ : "Critique editor text with [an: ...] markers stripped.");
5634
+ }
5635
+ }
5636
+
5355
5637
  function updateAnnotationModeUi() {
5356
5638
  if (annotationModeSelect) {
5357
5639
  annotationModeSelect.value = annotationsEnabled ? "on" : "off";
@@ -5360,17 +5642,7 @@ ${cssVarsBlock}
5360
5642
  : "Annotations Hidden: keep markers in editor, hide in preview, and strip before Run/Critique.";
5361
5643
  }
5362
5644
 
5363
- if (sendRunBtn) {
5364
- sendRunBtn.title = annotationsEnabled
5365
- ? "Run editor text as-is (includes [an: ...] markers). Shortcut: Cmd/Ctrl+Enter."
5366
- : "Run editor text with [an: ...] markers stripped. Shortcut: Cmd/Ctrl+Enter.";
5367
- }
5368
-
5369
- if (critiqueBtn) {
5370
- critiqueBtn.title = annotationsEnabled
5371
- ? "Critique editor text as-is (includes [an: ...] markers)."
5372
- : "Critique editor text with [an: ...] markers stripped.";
5373
- }
5645
+ syncRunAndCritiqueButtons();
5374
5646
  }
5375
5647
 
5376
5648
  function setAnnotationsEnabled(enabled, _options) {
@@ -6254,6 +6526,11 @@ ${cssVarsBlock}
6254
6526
  });
6255
6527
 
6256
6528
  critiqueBtn.addEventListener("click", () => {
6529
+ if (getAbortablePendingKind() === "critique") {
6530
+ requestCancelForPendingRequest("critique");
6531
+ return;
6532
+ }
6533
+
6257
6534
  const preparedDocumentText = prepareEditorTextForSend(sourceTextEl.value);
6258
6535
  const documentText = preparedDocumentText.trim();
6259
6536
  if (!documentText) {
@@ -6449,6 +6726,11 @@ ${cssVarsBlock}
6449
6726
  }
6450
6727
 
6451
6728
  sendRunBtn.addEventListener("click", () => {
6729
+ if (getAbortablePendingKind() === "direct") {
6730
+ requestCancelForPendingRequest("direct");
6731
+ return;
6732
+ }
6733
+
6452
6734
  const prepared = prepareEditorTextForSend(sourceTextEl.value);
6453
6735
  if (!prepared.trim()) {
6454
6736
  setStatus("Editor is empty. Nothing to run.", "warning");
@@ -6662,6 +6944,7 @@ export default function (pi: ExtensionAPI) {
6662
6944
  let studioCwd = process.cwd();
6663
6945
  let lastCommandCtx: ExtensionCommandContext | null = null;
6664
6946
  let lastThemeVarsJson = "";
6947
+ let suppressedStudioResponse: { requestId: string; kind: StudioRequestKind } | null = null;
6665
6948
  let agentBusy = false;
6666
6949
  let terminalActivityPhase: TerminalActivityPhase = "idle";
6667
6950
  let terminalActivityToolName: string | null = null;
@@ -6916,7 +7199,35 @@ export default function (pi: ExtensionAPI) {
6916
7199
  }
6917
7200
  };
6918
7201
 
7202
+ const cancelActiveRequest = (requestId: string): { ok: true; kind: StudioRequestKind } | { ok: false; message: string } => {
7203
+ if (!activeRequest) {
7204
+ return { ok: false, message: "No studio request is currently running." };
7205
+ }
7206
+ if (activeRequest.id !== requestId) {
7207
+ return { ok: false, message: "That studio request is no longer active." };
7208
+ }
7209
+ if (!lastCommandCtx) {
7210
+ return { ok: false, message: "No interactive pi context is available to stop the request." };
7211
+ }
7212
+
7213
+ const kind = activeRequest.kind;
7214
+ try {
7215
+ lastCommandCtx.abort();
7216
+ } catch (error) {
7217
+ return {
7218
+ ok: false,
7219
+ message: `Failed to stop request: ${error instanceof Error ? error.message : String(error)}`,
7220
+ };
7221
+ }
7222
+
7223
+ suppressedStudioResponse = { requestId, kind };
7224
+ emitDebugEvent("cancel_active_request", { requestId, kind });
7225
+ clearActiveRequest({ notify: "Cancelled request.", level: "warning" });
7226
+ return { ok: true, kind };
7227
+ };
7228
+
6919
7229
  const beginRequest = (requestId: string, kind: StudioRequestKind): boolean => {
7230
+ suppressedStudioResponse = null;
6920
7231
  emitDebugEvent("begin_request_attempt", {
6921
7232
  requestId,
6922
7233
  kind,
@@ -7024,6 +7335,19 @@ export default function (pi: ExtensionAPI) {
7024
7335
  return;
7025
7336
  }
7026
7337
 
7338
+ if (msg.type === "cancel_request") {
7339
+ if (!isValidRequestId(msg.requestId)) {
7340
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
7341
+ return;
7342
+ }
7343
+
7344
+ const result = cancelActiveRequest(msg.requestId);
7345
+ if (!result.ok) {
7346
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: result.message });
7347
+ }
7348
+ return;
7349
+ }
7350
+
7027
7351
  if (msg.type === "critique_request") {
7028
7352
  if (!isValidRequestId(msg.requestId)) {
7029
7353
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
@@ -7856,6 +8180,16 @@ export default function (pi: ExtensionAPI) {
7856
8180
 
7857
8181
  if (!markdown) return;
7858
8182
 
8183
+ if (suppressedStudioResponse) {
8184
+ emitDebugEvent("suppressed_cancelled_response", {
8185
+ requestId: suppressedStudioResponse.requestId,
8186
+ kind: suppressedStudioResponse.kind,
8187
+ markdownLength: markdown.length,
8188
+ thinkingLength: thinking ? thinking.length : 0,
8189
+ });
8190
+ return;
8191
+ }
8192
+
7859
8193
  syncStudioResponseHistory(ctx.sessionManager.getBranch());
7860
8194
  refreshContextUsage(ctx);
7861
8195
  const latestHistoryItem = studioResponseHistory[studioResponseHistory.length - 1];
@@ -7936,7 +8270,12 @@ export default function (pi: ExtensionAPI) {
7936
8270
  pi.on("agent_end", async () => {
7937
8271
  agentBusy = false;
7938
8272
  refreshContextUsage();
7939
- emitDebugEvent("agent_end", { activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
8273
+ emitDebugEvent("agent_end", {
8274
+ activeRequestId: activeRequest?.id ?? null,
8275
+ activeRequestKind: activeRequest?.kind ?? null,
8276
+ suppressedRequestId: suppressedStudioResponse?.requestId ?? null,
8277
+ suppressedRequestKind: suppressedStudioResponse?.kind ?? null,
8278
+ });
7940
8279
  setTerminalActivity("idle");
7941
8280
  if (activeRequest) {
7942
8281
  const requestId = activeRequest.id;
@@ -7947,6 +8286,7 @@ export default function (pi: ExtensionAPI) {
7947
8286
  });
7948
8287
  clearActiveRequest();
7949
8288
  }
8289
+ suppressedStudioResponse = null;
7950
8290
  });
7951
8291
 
7952
8292
  pi.on("session_shutdown", async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "Browser GUI for structured critique workflows in pi",
5
5
  "type": "module",
6
6
  "license": "MIT",