pi-studio 0.5.4 → 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,21 @@ 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
+
97
+ ## [0.5.5] — 2026-03-09
98
+
99
+ ### Fixed
100
+ - Improved raw-editor caret/overlay alignment in Syntax highlight mode:
101
+ - width-neutral annotation highlight styling
102
+ - more textarea-like wrap behavior in the highlight overlay
103
+ - preserved empty trailing lines in highlighted output so end-of-file blank lines stay aligned
104
+ - reduced raw overlay metric drift for comment/quote styling
105
+
91
106
  ## [0.5.4] — 2026-03-09
92
107
 
93
108
  ### Added
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
 
@@ -2165,6 +2403,7 @@ ${cssVarsBlock}
2165
2403
  border-radius: 8px;
2166
2404
  background: var(--editor-bg);
2167
2405
  overflow: hidden;
2406
+ overscroll-behavior: none;
2168
2407
  }
2169
2408
 
2170
2409
  .editor-highlight {
@@ -2177,8 +2416,10 @@ ${cssVarsBlock}
2177
2416
  overflow: auto;
2178
2417
  pointer-events: none;
2179
2418
  white-space: pre-wrap;
2180
- word-break: break-word;
2181
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
2419
+ word-break: normal;
2420
+ overflow-wrap: break-word;
2421
+ overscroll-behavior: none;
2422
+ font-family: var(--font-mono);
2182
2423
  font-size: 13px;
2183
2424
  line-height: 1.45;
2184
2425
  tab-size: 2;
@@ -2197,6 +2438,7 @@ ${cssVarsBlock}
2197
2438
  background: transparent;
2198
2439
  resize: none;
2199
2440
  outline: none;
2441
+ overscroll-behavior: none;
2200
2442
  }
2201
2443
 
2202
2444
  #sourceText.highlight-active {
@@ -2240,7 +2482,7 @@ ${cssVarsBlock}
2240
2482
 
2241
2483
  .hl-code-com {
2242
2484
  color: var(--syntax-comment);
2243
- font-style: italic;
2485
+ font-style: normal;
2244
2486
  }
2245
2487
 
2246
2488
  .hl-code-var,
@@ -2269,7 +2511,7 @@ ${cssVarsBlock}
2269
2511
 
2270
2512
  .hl-quote {
2271
2513
  color: var(--md-quote);
2272
- font-style: italic;
2514
+ font-style: normal;
2273
2515
  }
2274
2516
 
2275
2517
  .hl-link {
@@ -2284,9 +2526,10 @@ ${cssVarsBlock}
2284
2526
  .hl-annotation {
2285
2527
  color: var(--accent);
2286
2528
  background: var(--accent-soft);
2287
- border: 1px solid var(--marker-border);
2529
+ border: 0;
2288
2530
  border-radius: 4px;
2289
- padding: 0 3px;
2531
+ padding: 0;
2532
+ box-shadow: inset 0 0 0 1px var(--marker-border);
2290
2533
  }
2291
2534
 
2292
2535
  .hl-annotation-muted {
@@ -2419,7 +2662,7 @@ ${cssVarsBlock}
2419
2662
  }
2420
2663
 
2421
2664
  .rendered-markdown code {
2422
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
2665
+ font-family: var(--font-mono);
2423
2666
  font-size: 0.9em;
2424
2667
  color: var(--md-code);
2425
2668
  }
@@ -2566,7 +2809,7 @@ ${cssVarsBlock}
2566
2809
  margin: 0;
2567
2810
  white-space: pre-wrap;
2568
2811
  word-break: break-word;
2569
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
2812
+ font-family: var(--font-mono);
2570
2813
  font-size: 13px;
2571
2814
  line-height: 1.5;
2572
2815
  }
@@ -2575,7 +2818,7 @@ ${cssVarsBlock}
2575
2818
  margin: 0;
2576
2819
  white-space: pre-wrap;
2577
2820
  word-break: break-word;
2578
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
2821
+ font-family: var(--font-mono);
2579
2822
  font-size: 13px;
2580
2823
  line-height: 1.5;
2581
2824
  }
@@ -3177,6 +3420,7 @@ ${cssVarsBlock}
3177
3420
  let editorHighlightRenderRaf = null;
3178
3421
  let annotationsEnabled = true;
3179
3422
  const ANNOTATION_MARKER_REGEX = /\\[an:\\s*([^\\]\\n]+?)\\]/gi;
3423
+ const EMPTY_OVERLAY_LINE = "\\u200b";
3180
3424
  const MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
3181
3425
  const MERMAID_CONFIG = ${JSON.stringify(mermaidConfig)};
3182
3426
  const MERMAID_UNAVAILABLE_MESSAGE = "Mermaid renderer unavailable. Showing mermaid blocks as code.";
@@ -4636,7 +4880,7 @@ ${cssVarsBlock}
4636
4880
  saveOverBtn.disabled = uiBusy || !canSaveOver;
4637
4881
  sendEditorBtn.disabled = uiBusy;
4638
4882
  if (getEditorBtn) getEditorBtn.disabled = uiBusy;
4639
- sendRunBtn.disabled = uiBusy;
4883
+ syncRunAndCritiqueButtons();
4640
4884
  copyDraftBtn.disabled = uiBusy;
4641
4885
  if (highlightSelect) highlightSelect.disabled = uiBusy;
4642
4886
  if (langSelect) langSelect.disabled = uiBusy;
@@ -4649,7 +4893,6 @@ ${cssVarsBlock}
4649
4893
  followSelect.disabled = uiBusy;
4650
4894
  if (responseHighlightSelect) responseHighlightSelect.disabled = uiBusy || rightView !== "markdown";
4651
4895
  insertHeaderBtn.disabled = uiBusy;
4652
- critiqueBtn.disabled = uiBusy;
4653
4896
  lensSelect.disabled = uiBusy;
4654
4897
  updateSaveFileTooltip();
4655
4898
  updateHistoryControls();
@@ -5073,7 +5316,12 @@ ${cssVarsBlock}
5073
5316
  }
5074
5317
 
5075
5318
  if (inFence) {
5076
- out.push(line.length > 0 ? highlightCodeLine(line, fenceLanguage) : "");
5319
+ out.push(line.length > 0 ? highlightCodeLine(line, fenceLanguage) : EMPTY_OVERLAY_LINE);
5320
+ continue;
5321
+ }
5322
+
5323
+ if (line.length === 0) {
5324
+ out.push(EMPTY_OVERLAY_LINE);
5077
5325
  continue;
5078
5326
  }
5079
5327
 
@@ -5112,7 +5360,7 @@ ${cssVarsBlock}
5112
5360
  const out = [];
5113
5361
  for (const line of lines) {
5114
5362
  if (line.length === 0) {
5115
- out.push("");
5363
+ out.push(EMPTY_OVERLAY_LINE);
5116
5364
  } else if (lang) {
5117
5365
  out.push(highlightCodeLine(line, lang));
5118
5366
  } else {
@@ -5341,6 +5589,51 @@ ${cssVarsBlock}
5341
5589
  renderActiveResult();
5342
5590
  }
5343
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
+
5344
5637
  function updateAnnotationModeUi() {
5345
5638
  if (annotationModeSelect) {
5346
5639
  annotationModeSelect.value = annotationsEnabled ? "on" : "off";
@@ -5349,17 +5642,7 @@ ${cssVarsBlock}
5349
5642
  : "Annotations Hidden: keep markers in editor, hide in preview, and strip before Run/Critique.";
5350
5643
  }
5351
5644
 
5352
- if (sendRunBtn) {
5353
- sendRunBtn.title = annotationsEnabled
5354
- ? "Run editor text as-is (includes [an: ...] markers). Shortcut: Cmd/Ctrl+Enter."
5355
- : "Run editor text with [an: ...] markers stripped. Shortcut: Cmd/Ctrl+Enter.";
5356
- }
5357
-
5358
- if (critiqueBtn) {
5359
- critiqueBtn.title = annotationsEnabled
5360
- ? "Critique editor text as-is (includes [an: ...] markers)."
5361
- : "Critique editor text with [an: ...] markers stripped.";
5362
- }
5645
+ syncRunAndCritiqueButtons();
5363
5646
  }
5364
5647
 
5365
5648
  function setAnnotationsEnabled(enabled, _options) {
@@ -6243,6 +6526,11 @@ ${cssVarsBlock}
6243
6526
  });
6244
6527
 
6245
6528
  critiqueBtn.addEventListener("click", () => {
6529
+ if (getAbortablePendingKind() === "critique") {
6530
+ requestCancelForPendingRequest("critique");
6531
+ return;
6532
+ }
6533
+
6246
6534
  const preparedDocumentText = prepareEditorTextForSend(sourceTextEl.value);
6247
6535
  const documentText = preparedDocumentText.trim();
6248
6536
  if (!documentText) {
@@ -6438,6 +6726,11 @@ ${cssVarsBlock}
6438
6726
  }
6439
6727
 
6440
6728
  sendRunBtn.addEventListener("click", () => {
6729
+ if (getAbortablePendingKind() === "direct") {
6730
+ requestCancelForPendingRequest("direct");
6731
+ return;
6732
+ }
6733
+
6441
6734
  const prepared = prepareEditorTextForSend(sourceTextEl.value);
6442
6735
  if (!prepared.trim()) {
6443
6736
  setStatus("Editor is empty. Nothing to run.", "warning");
@@ -6651,6 +6944,7 @@ export default function (pi: ExtensionAPI) {
6651
6944
  let studioCwd = process.cwd();
6652
6945
  let lastCommandCtx: ExtensionCommandContext | null = null;
6653
6946
  let lastThemeVarsJson = "";
6947
+ let suppressedStudioResponse: { requestId: string; kind: StudioRequestKind } | null = null;
6654
6948
  let agentBusy = false;
6655
6949
  let terminalActivityPhase: TerminalActivityPhase = "idle";
6656
6950
  let terminalActivityToolName: string | null = null;
@@ -6905,7 +7199,35 @@ export default function (pi: ExtensionAPI) {
6905
7199
  }
6906
7200
  };
6907
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
+
6908
7229
  const beginRequest = (requestId: string, kind: StudioRequestKind): boolean => {
7230
+ suppressedStudioResponse = null;
6909
7231
  emitDebugEvent("begin_request_attempt", {
6910
7232
  requestId,
6911
7233
  kind,
@@ -7013,6 +7335,19 @@ export default function (pi: ExtensionAPI) {
7013
7335
  return;
7014
7336
  }
7015
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
+
7016
7351
  if (msg.type === "critique_request") {
7017
7352
  if (!isValidRequestId(msg.requestId)) {
7018
7353
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
@@ -7845,6 +8180,16 @@ export default function (pi: ExtensionAPI) {
7845
8180
 
7846
8181
  if (!markdown) return;
7847
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
+
7848
8193
  syncStudioResponseHistory(ctx.sessionManager.getBranch());
7849
8194
  refreshContextUsage(ctx);
7850
8195
  const latestHistoryItem = studioResponseHistory[studioResponseHistory.length - 1];
@@ -7925,7 +8270,12 @@ export default function (pi: ExtensionAPI) {
7925
8270
  pi.on("agent_end", async () => {
7926
8271
  agentBusy = false;
7927
8272
  refreshContextUsage();
7928
- 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
+ });
7929
8279
  setTerminalActivity("idle");
7930
8280
  if (activeRequest) {
7931
8281
  const requestId = activeRequest.id;
@@ -7936,6 +8286,7 @@ export default function (pi: ExtensionAPI) {
7936
8286
  });
7937
8287
  clearActiveRequest();
7938
8288
  }
8289
+ suppressedStudioResponse = null;
7939
8290
  });
7940
8291
 
7941
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.4",
3
+ "version": "0.5.6",
4
4
  "description": "Browser GUI for structured critique workflows in pi",
5
5
  "type": "module",
6
6
  "license": "MIT",