pi-studio 0.5.5 → 0.5.7

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
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to `pi-studio` are documented here.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [0.5.7] — 2026-03-12
8
+
9
+ ### Changed
10
+ - Preview rendering now passes `--wrap=none` to pandoc and preview-side annotation matching now tolerates embedded newlines, fixing missed `[an: ...]` highlights in preview for longer annotations.
11
+ - Editor sync indicator is now intentionally quiet: Studio only shows the badge when the editor exactly matches the current response/thinking, and hides it while drafting/out-of-sync.
12
+ - Response history navigation now includes **Last response ▶|** for jumping straight back to the newest loaded history item.
13
+ - Renamed **Get latest response** to **Fetch latest response** for clearer distinction from history navigation, and moved **Load response into editor** ahead of **Load response prompt into editor** in the action row.
14
+
5
15
  ## [0.4.3] — 2026-03-04
6
16
 
7
17
  ### Added
@@ -86,7 +96,11 @@ All notable changes to `pi-studio` are documented here.
86
96
  - Active pane indicator simplified to subtle border color change (removed thick top accent bar).
87
97
  - Panel shadows, button hierarchy (filled accent for primary actions), heading scale, blockquote/table styling improvements.
88
98
 
89
- ## [Unreleased]
99
+ ## [0.5.6] — 2026-03-10
100
+
101
+ ### Changed
102
+ - 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.
103
+ - 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.
90
104
 
91
105
  ## [0.5.5] — 2026-03-09
92
106
 
package/README.md CHANGED
@@ -16,7 +16,7 @@ Experimental extension for [pi](https://github.com/badlogic/pi-mono) that opens
16
16
 
17
17
  - Opens a two-pane browser workspace: **Editor** (left) + **Response/Thinking/Editor Preview** (right)
18
18
  - Runs editor text directly, or asks for structured critique (auto/writing/code focus)
19
- - Browses response history (`Prev/Next`) and loads either:
19
+ - Browses response history (`Prev/Next/Last`) and loads either:
20
20
  - response text
21
21
  - critique notes/full critique
22
22
  - assistant thinking (when available)
@@ -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/WORKFLOW.md CHANGED
@@ -79,10 +79,10 @@ Rules:
79
79
  - Header view toggles: `Left: Editor (Raw|Preview)`, `Right: Response (Raw|Preview) | Editor (Preview)`
80
80
  - Preview mode uses server-side `pandoc` rendering (math-aware) with plain-markdown fallback when renderer is unavailable.
81
81
  - Editor actions: **Insert/Remove annotated reply header**, **Annotations: On|Hidden**, **Strip annotations…**, **Run editor text**, **Critique editor text** (+ critique focus), **Send to pi editor**, **Copy editor text**, **Save .annotated.md**
82
- - Response actions include `Auto-update response: On|Off`, **Get latest response**, response-history browse (`Prev/Next`), and **Load response prompt into editor**
82
+ - Response actions include `Auto-update response: On|Off`, **Fetch latest response**, response-history browse (`Prev/Next/Last`), **Load response into editor**, and **Load response prompt into editor**
83
83
  - Source badge: `blank | last model response | file <path> | upload`
84
84
  - Response badge: `none | assistant response | assistant critique` (+ timestamp)
85
- - Sync badge: `No response loaded | In sync with response | Edited since response`
85
+ - Sync badge: shown only when the editor exactly matches the currently viewed response/thinking (`In sync with response | In sync with thinking`)
86
86
  - Footer WS/status phases: `Connecting`, `Ready`, `Submitting`, `Disconnected`
87
87
 
88
88
  ---
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>,
@@ -865,7 +1070,7 @@ function normalizeObsidianImages(markdown: string): string {
865
1070
  async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string): Promise<string> {
866
1071
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
867
1072
  const inputFormat = isLatex ? "latex" : "gfm+tex_math_dollars-raw_html";
868
- const args = ["-f", inputFormat, "-t", "html5", "--mathml"];
1073
+ const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none"];
869
1074
  if (resourcePath) {
870
1075
  args.push(`--resource-path=${resourcePath}`);
871
1076
  // Embed images as data URIs so they render in the browser preview
@@ -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
 
@@ -2074,13 +2312,9 @@ ${cssVarsBlock}
2074
2312
  }
2075
2313
 
2076
2314
  .sync-badge.sync {
2077
- border-color: var(--ok-border);
2078
- color: var(--ok);
2079
- }
2080
-
2081
- .sync-badge.edited {
2082
- border-color: var(--warn-border);
2083
- color: var(--warn);
2315
+ border-color: var(--border-muted);
2316
+ color: var(--muted);
2317
+ opacity: 0.88;
2084
2318
  }
2085
2319
 
2086
2320
  .source-actions {
@@ -2181,7 +2415,7 @@ ${cssVarsBlock}
2181
2415
  word-break: normal;
2182
2416
  overflow-wrap: break-word;
2183
2417
  overscroll-behavior: none;
2184
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
2418
+ font-family: var(--font-mono);
2185
2419
  font-size: 13px;
2186
2420
  line-height: 1.45;
2187
2421
  tab-size: 2;
@@ -2424,7 +2658,7 @@ ${cssVarsBlock}
2424
2658
  }
2425
2659
 
2426
2660
  .rendered-markdown code {
2427
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
2661
+ font-family: var(--font-mono);
2428
2662
  font-size: 0.9em;
2429
2663
  color: var(--md-code);
2430
2664
  }
@@ -2571,7 +2805,7 @@ ${cssVarsBlock}
2571
2805
  margin: 0;
2572
2806
  white-space: pre-wrap;
2573
2807
  word-break: break-word;
2574
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
2808
+ font-family: var(--font-mono);
2575
2809
  font-size: 13px;
2576
2810
  line-height: 1.5;
2577
2811
  }
@@ -2580,7 +2814,7 @@ ${cssVarsBlock}
2580
2814
  margin: 0;
2581
2815
  white-space: pre-wrap;
2582
2816
  word-break: break-word;
2583
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
2817
+ font-family: var(--font-mono);
2584
2818
  font-size: 13px;
2585
2819
  line-height: 1.5;
2586
2820
  }
@@ -2844,7 +3078,7 @@ ${cssVarsBlock}
2844
3078
  <input id="resourceDirInput" type="text" placeholder="/path/to/working/directory" title="Absolute path to working directory" />
2845
3079
  <button id="resourceDirClearBtn" type="button" title="Clear working directory">✕</button>
2846
3080
  </span>
2847
- <span id="syncBadge" class="source-badge sync-badge">No response loaded</span>
3081
+ <span id="syncBadge" class="source-badge sync-badge" hidden>In sync with response</span>
2848
3082
  </div>
2849
3083
  <div class="source-actions">
2850
3084
  <div class="source-actions-row">
@@ -2943,16 +3177,17 @@ ${cssVarsBlock}
2943
3177
  </select>
2944
3178
  </div>
2945
3179
  <div class="response-actions-row history-row">
2946
- <button id="pullLatestBtn" type="button" title="Fetch the latest assistant response when auto-update is off.">Get latest response</button>
3180
+ <button id="pullLatestBtn" type="button" title="Fetch the latest assistant response when auto-update is off.">Fetch latest response</button>
2947
3181
  <button id="historyPrevBtn" type="button" title="Show previous response in history.">◀ Prev response</button>
2948
3182
  <span id="historyIndexBadge" class="source-badge">History: 0/0</span>
2949
3183
  <button id="historyNextBtn" type="button" title="Show next response in history.">Next response ▶</button>
3184
+ <button id="historyLastBtn" type="button" title="Jump to the latest loaded response in history.">Last response ▶|</button>
2950
3185
  </div>
2951
3186
  <div class="response-actions-row">
2952
- <button id="loadHistoryPromptBtn" type="button" title="Load the prompt that generated the selected response into the editor.">Load response prompt into editor</button>
2953
3187
  <button id="loadResponseBtn" type="button">Load response into editor</button>
2954
3188
  <button id="loadCritiqueNotesBtn" type="button" hidden>Load critique notes into editor</button>
2955
3189
  <button id="loadCritiqueFullBtn" type="button" hidden>Load full critique into editor</button>
3190
+ <button id="loadHistoryPromptBtn" type="button" title="Load the prompt that generated the selected response into the editor.">Load response prompt into editor</button>
2956
3191
  <button id="copyResponseBtn" type="button">Copy response text</button>
2957
3192
  </div>
2958
3193
  </div>
@@ -3040,6 +3275,7 @@ ${cssVarsBlock}
3040
3275
  const exportPdfBtn = document.getElementById("exportPdfBtn");
3041
3276
  const historyPrevBtn = document.getElementById("historyPrevBtn");
3042
3277
  const historyNextBtn = document.getElementById("historyNextBtn");
3278
+ const historyLastBtn = document.getElementById("historyLastBtn");
3043
3279
  const historyIndexBadgeEl = document.getElementById("historyIndexBadge");
3044
3280
  const loadHistoryPromptBtn = document.getElementById("loadHistoryPromptBtn");
3045
3281
  const saveAsBtn = document.getElementById("saveAsBtn");
@@ -3181,7 +3417,7 @@ ${cssVarsBlock}
3181
3417
  let responseHighlightEnabled = false;
3182
3418
  let editorHighlightRenderRaf = null;
3183
3419
  let annotationsEnabled = true;
3184
- const ANNOTATION_MARKER_REGEX = /\\[an:\\s*([^\\]\\n]+?)\\]/gi;
3420
+ const ANNOTATION_MARKER_REGEX = /\\[an:\\s*([^\\]]+?)\\]/gi;
3185
3421
  const EMPTY_OVERLAY_LINE = "\\u200b";
3186
3422
  const MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
3187
3423
  const MERMAID_CONFIG = ${JSON.stringify(mermaidConfig)};
@@ -3754,6 +3990,9 @@ ${cssVarsBlock}
3754
3990
  if (historyNextBtn) {
3755
3991
  historyNextBtn.disabled = uiBusy || total <= 1 || responseHistoryIndex < 0 || responseHistoryIndex >= total - 1;
3756
3992
  }
3993
+ if (historyLastBtn) {
3994
+ historyLastBtn.disabled = uiBusy || total <= 1 || responseHistoryIndex < 0 || responseHistoryIndex >= total - 1;
3995
+ }
3757
3996
 
3758
3997
  const selectedItem = getSelectedHistoryItem();
3759
3998
  const hasPrompt = Boolean(selectedItem && typeof selectedItem.prompt === "string" && selectedItem.prompt.trim());
@@ -3930,8 +4169,9 @@ ${cssVarsBlock}
3930
4169
  : latestResponseHasContent;
3931
4170
 
3932
4171
  if (!hasComparableContent) {
3933
- syncBadgeEl.textContent = showingThinking ? "No thinking loaded" : "No response loaded";
3934
- syncBadgeEl.classList.remove("sync", "edited");
4172
+ syncBadgeEl.hidden = true;
4173
+ syncBadgeEl.textContent = showingThinking ? "In sync with thinking" : "In sync with response";
4174
+ syncBadgeEl.classList.remove("sync");
3935
4175
  return;
3936
4176
  }
3937
4177
 
@@ -3940,15 +4180,15 @@ ${cssVarsBlock}
3940
4180
  : normalizeForCompare(sourceTextEl.value);
3941
4181
  const targetNormalized = showingThinking ? latestResponseThinkingNormalized : latestResponseNormalized;
3942
4182
  const inSync = normalizedEditor === targetNormalized;
4183
+ syncBadgeEl.hidden = !inSync;
4184
+ syncBadgeEl.textContent = showingThinking ? "In sync with thinking" : "In sync with response";
4185
+
3943
4186
  if (inSync) {
3944
- syncBadgeEl.textContent = showingThinking ? "In sync with thinking" : "In sync with response";
3945
4187
  syncBadgeEl.classList.add("sync");
3946
- syncBadgeEl.classList.remove("edited");
3947
- } else {
3948
- syncBadgeEl.textContent = showingThinking ? "Out of sync with thinking" : "Out of sync with response";
3949
- syncBadgeEl.classList.add("edited");
3950
- syncBadgeEl.classList.remove("sync");
4188
+ return;
3951
4189
  }
4190
+
4191
+ syncBadgeEl.classList.remove("sync");
3952
4192
  }
3953
4193
 
3954
4194
  function buildPlainMarkdownHtml(markdown) {
@@ -4580,7 +4820,7 @@ ${cssVarsBlock}
4580
4820
  }
4581
4821
 
4582
4822
  pullLatestBtn.disabled = uiBusy || followLatest;
4583
- pullLatestBtn.textContent = queuedLatestResponse ? "Get latest response *" : "Get latest response";
4823
+ pullLatestBtn.textContent = queuedLatestResponse ? "Fetch latest response *" : "Fetch latest response";
4584
4824
 
4585
4825
  updateSyncBadge(normalizedEditor);
4586
4826
  }
@@ -4642,7 +4882,7 @@ ${cssVarsBlock}
4642
4882
  saveOverBtn.disabled = uiBusy || !canSaveOver;
4643
4883
  sendEditorBtn.disabled = uiBusy;
4644
4884
  if (getEditorBtn) getEditorBtn.disabled = uiBusy;
4645
- sendRunBtn.disabled = uiBusy;
4885
+ syncRunAndCritiqueButtons();
4646
4886
  copyDraftBtn.disabled = uiBusy;
4647
4887
  if (highlightSelect) highlightSelect.disabled = uiBusy;
4648
4888
  if (langSelect) langSelect.disabled = uiBusy;
@@ -4655,7 +4895,6 @@ ${cssVarsBlock}
4655
4895
  followSelect.disabled = uiBusy;
4656
4896
  if (responseHighlightSelect) responseHighlightSelect.disabled = uiBusy || rightView !== "markdown";
4657
4897
  insertHeaderBtn.disabled = uiBusy;
4658
- critiqueBtn.disabled = uiBusy;
4659
4898
  lensSelect.disabled = uiBusy;
4660
4899
  updateSaveFileTooltip();
4661
4900
  updateHistoryControls();
@@ -4792,7 +5031,7 @@ ${cssVarsBlock}
4792
5031
 
4793
5032
  function highlightInlineMarkdown(text) {
4794
5033
  const source = String(text || "");
4795
- const pattern = /(\\x60[^\\x60]*\\x60)|(\\[[^\\]]+\\]\\([^)]+\\))|(\\[an:\\s*[^\\]\\n]+\\])/gi;
5034
+ const pattern = /(\\x60[^\\x60]*\\x60)|(\\[[^\\]]+\\]\\([^)]+\\))|(\\[an:\\s*[^\\]]+\\])/gi;
4796
5035
  let lastIndex = 0;
4797
5036
  let out = "";
4798
5037
 
@@ -5352,6 +5591,51 @@ ${cssVarsBlock}
5352
5591
  renderActiveResult();
5353
5592
  }
5354
5593
 
5594
+ function getAbortablePendingKind() {
5595
+ if (!pendingRequestId) return null;
5596
+ return pendingKind === "direct" || pendingKind === "critique" ? pendingKind : null;
5597
+ }
5598
+
5599
+ function requestCancelForPendingRequest(expectedKind) {
5600
+ const activeKind = getAbortablePendingKind();
5601
+ if (!activeKind || activeKind !== expectedKind || !pendingRequestId) {
5602
+ setStatus("No matching Studio request is running.", "warning");
5603
+ return false;
5604
+ }
5605
+ const sent = sendMessage({ type: "cancel_request", requestId: pendingRequestId });
5606
+ if (!sent) return false;
5607
+ setStatus("Stopping request…", "warning");
5608
+ return true;
5609
+ }
5610
+
5611
+ function syncRunAndCritiqueButtons() {
5612
+ const activeKind = getAbortablePendingKind();
5613
+ const sendRunIsStop = activeKind === "direct";
5614
+ const critiqueIsStop = activeKind === "critique";
5615
+
5616
+ if (sendRunBtn) {
5617
+ sendRunBtn.textContent = sendRunIsStop ? "Stop" : "Run editor text";
5618
+ sendRunBtn.classList.toggle("request-stop-active", sendRunIsStop);
5619
+ sendRunBtn.disabled = sendRunIsStop ? wsState === "Disconnected" : (uiBusy || critiqueIsStop);
5620
+ sendRunBtn.title = sendRunIsStop
5621
+ ? "Stop the running editor-text request."
5622
+ : (annotationsEnabled
5623
+ ? "Run editor text as-is (includes [an: ...] markers). Shortcut: Cmd/Ctrl+Enter."
5624
+ : "Run editor text with [an: ...] markers stripped. Shortcut: Cmd/Ctrl+Enter.");
5625
+ }
5626
+
5627
+ if (critiqueBtn) {
5628
+ critiqueBtn.textContent = critiqueIsStop ? "Stop" : "Critique editor text";
5629
+ critiqueBtn.classList.toggle("request-stop-active", critiqueIsStop);
5630
+ critiqueBtn.disabled = critiqueIsStop ? wsState === "Disconnected" : (uiBusy || sendRunIsStop);
5631
+ critiqueBtn.title = critiqueIsStop
5632
+ ? "Stop the running critique request."
5633
+ : (annotationsEnabled
5634
+ ? "Critique editor text as-is (includes [an: ...] markers)."
5635
+ : "Critique editor text with [an: ...] markers stripped.");
5636
+ }
5637
+ }
5638
+
5355
5639
  function updateAnnotationModeUi() {
5356
5640
  if (annotationModeSelect) {
5357
5641
  annotationModeSelect.value = annotationsEnabled ? "on" : "off";
@@ -5360,17 +5644,7 @@ ${cssVarsBlock}
5360
5644
  : "Annotations Hidden: keep markers in editor, hide in preview, and strip before Run/Critique.";
5361
5645
  }
5362
5646
 
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
- }
5647
+ syncRunAndCritiqueButtons();
5374
5648
  }
5375
5649
 
5376
5650
  function setAnnotationsEnabled(enabled, _options) {
@@ -5695,7 +5969,7 @@ ${cssVarsBlock}
5695
5969
  if (!followLatest) {
5696
5970
  queuedLatestResponse = payload;
5697
5971
  updateResultActionButtons();
5698
- setStatus("New response available — click Get latest response.", "warning");
5972
+ setStatus("New response available — click Fetch latest response.", "warning");
5699
5973
  return;
5700
5974
  }
5701
5975
 
@@ -6070,7 +6344,7 @@ ${cssVarsBlock}
6070
6344
  function requestLatestResponse() {
6071
6345
  const sent = sendMessage({ type: "get_latest_response" });
6072
6346
  if (!sent) return;
6073
- setStatus("Requested latest response.");
6347
+ setStatus("Fetching latest response");
6074
6348
  }
6075
6349
 
6076
6350
  if (leftPaneEl) {
@@ -6108,7 +6382,7 @@ ${cssVarsBlock}
6108
6382
  setStatus("Applied queued response.", "success");
6109
6383
  }
6110
6384
  } else if (!followLatest) {
6111
- setStatus("Auto-update is off. Use Get latest response.");
6385
+ setStatus("Auto-update is off. Use Fetch latest response.");
6112
6386
  }
6113
6387
  updateResultActionButtons();
6114
6388
  });
@@ -6192,6 +6466,16 @@ ${cssVarsBlock}
6192
6466
  });
6193
6467
  }
6194
6468
 
6469
+ if (historyLastBtn) {
6470
+ historyLastBtn.addEventListener("click", () => {
6471
+ if (!responseHistory.length) {
6472
+ setStatus("No response history available yet.", "warning");
6473
+ return;
6474
+ }
6475
+ selectHistoryIndex(responseHistory.length - 1);
6476
+ });
6477
+ }
6478
+
6195
6479
  if (loadHistoryPromptBtn) {
6196
6480
  loadHistoryPromptBtn.addEventListener("click", () => {
6197
6481
  const item = getSelectedHistoryItem();
@@ -6254,6 +6538,11 @@ ${cssVarsBlock}
6254
6538
  });
6255
6539
 
6256
6540
  critiqueBtn.addEventListener("click", () => {
6541
+ if (getAbortablePendingKind() === "critique") {
6542
+ requestCancelForPendingRequest("critique");
6543
+ return;
6544
+ }
6545
+
6257
6546
  const preparedDocumentText = prepareEditorTextForSend(sourceTextEl.value);
6258
6547
  const documentText = preparedDocumentText.trim();
6259
6548
  if (!documentText) {
@@ -6449,6 +6738,11 @@ ${cssVarsBlock}
6449
6738
  }
6450
6739
 
6451
6740
  sendRunBtn.addEventListener("click", () => {
6741
+ if (getAbortablePendingKind() === "direct") {
6742
+ requestCancelForPendingRequest("direct");
6743
+ return;
6744
+ }
6745
+
6452
6746
  const prepared = prepareEditorTextForSend(sourceTextEl.value);
6453
6747
  if (!prepared.trim()) {
6454
6748
  setStatus("Editor is empty. Nothing to run.", "warning");
@@ -6662,6 +6956,7 @@ export default function (pi: ExtensionAPI) {
6662
6956
  let studioCwd = process.cwd();
6663
6957
  let lastCommandCtx: ExtensionCommandContext | null = null;
6664
6958
  let lastThemeVarsJson = "";
6959
+ let suppressedStudioResponse: { requestId: string; kind: StudioRequestKind } | null = null;
6665
6960
  let agentBusy = false;
6666
6961
  let terminalActivityPhase: TerminalActivityPhase = "idle";
6667
6962
  let terminalActivityToolName: string | null = null;
@@ -6916,7 +7211,35 @@ export default function (pi: ExtensionAPI) {
6916
7211
  }
6917
7212
  };
6918
7213
 
7214
+ const cancelActiveRequest = (requestId: string): { ok: true; kind: StudioRequestKind } | { ok: false; message: string } => {
7215
+ if (!activeRequest) {
7216
+ return { ok: false, message: "No studio request is currently running." };
7217
+ }
7218
+ if (activeRequest.id !== requestId) {
7219
+ return { ok: false, message: "That studio request is no longer active." };
7220
+ }
7221
+ if (!lastCommandCtx) {
7222
+ return { ok: false, message: "No interactive pi context is available to stop the request." };
7223
+ }
7224
+
7225
+ const kind = activeRequest.kind;
7226
+ try {
7227
+ lastCommandCtx.abort();
7228
+ } catch (error) {
7229
+ return {
7230
+ ok: false,
7231
+ message: `Failed to stop request: ${error instanceof Error ? error.message : String(error)}`,
7232
+ };
7233
+ }
7234
+
7235
+ suppressedStudioResponse = { requestId, kind };
7236
+ emitDebugEvent("cancel_active_request", { requestId, kind });
7237
+ clearActiveRequest({ notify: "Cancelled request.", level: "warning" });
7238
+ return { ok: true, kind };
7239
+ };
7240
+
6919
7241
  const beginRequest = (requestId: string, kind: StudioRequestKind): boolean => {
7242
+ suppressedStudioResponse = null;
6920
7243
  emitDebugEvent("begin_request_attempt", {
6921
7244
  requestId,
6922
7245
  kind,
@@ -7024,6 +7347,19 @@ export default function (pi: ExtensionAPI) {
7024
7347
  return;
7025
7348
  }
7026
7349
 
7350
+ if (msg.type === "cancel_request") {
7351
+ if (!isValidRequestId(msg.requestId)) {
7352
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
7353
+ return;
7354
+ }
7355
+
7356
+ const result = cancelActiveRequest(msg.requestId);
7357
+ if (!result.ok) {
7358
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: result.message });
7359
+ }
7360
+ return;
7361
+ }
7362
+
7027
7363
  if (msg.type === "critique_request") {
7028
7364
  if (!isValidRequestId(msg.requestId)) {
7029
7365
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
@@ -7856,6 +8192,16 @@ export default function (pi: ExtensionAPI) {
7856
8192
 
7857
8193
  if (!markdown) return;
7858
8194
 
8195
+ if (suppressedStudioResponse) {
8196
+ emitDebugEvent("suppressed_cancelled_response", {
8197
+ requestId: suppressedStudioResponse.requestId,
8198
+ kind: suppressedStudioResponse.kind,
8199
+ markdownLength: markdown.length,
8200
+ thinkingLength: thinking ? thinking.length : 0,
8201
+ });
8202
+ return;
8203
+ }
8204
+
7859
8205
  syncStudioResponseHistory(ctx.sessionManager.getBranch());
7860
8206
  refreshContextUsage(ctx);
7861
8207
  const latestHistoryItem = studioResponseHistory[studioResponseHistory.length - 1];
@@ -7936,7 +8282,12 @@ export default function (pi: ExtensionAPI) {
7936
8282
  pi.on("agent_end", async () => {
7937
8283
  agentBusy = false;
7938
8284
  refreshContextUsage();
7939
- emitDebugEvent("agent_end", { activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
8285
+ emitDebugEvent("agent_end", {
8286
+ activeRequestId: activeRequest?.id ?? null,
8287
+ activeRequestKind: activeRequest?.kind ?? null,
8288
+ suppressedRequestId: suppressedStudioResponse?.requestId ?? null,
8289
+ suppressedRequestKind: suppressedStudioResponse?.kind ?? null,
8290
+ });
7940
8291
  setTerminalActivity("idle");
7941
8292
  if (activeRequest) {
7942
8293
  const requestId = activeRequest.id;
@@ -7947,6 +8298,7 @@ export default function (pi: ExtensionAPI) {
7947
8298
  });
7948
8299
  clearActiveRequest();
7949
8300
  }
8301
+ suppressedStudioResponse = null;
7950
8302
  });
7951
8303
 
7952
8304
  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.7",
4
4
  "description": "Browser GUI for structured critique workflows in pi",
5
5
  "type": "module",
6
6
  "license": "MIT",