pi-studio 0.5.14 → 0.5.15

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
@@ -4,6 +4,16 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.15] — 2026-03-16
8
+
9
+ ### Added
10
+ - Per-pane **Focus pane** controls for both the editor and response panes, matching the current Ghostty/cmux split-browser workflow more directly.
11
+ - cmux-aware Studio completion notifications with safer workspace-level targeting, a running/compacting sidebar status pill, stale-notification clearing when a new Studio request starts, and suppression when the Studio browser surface is already focused.
12
+
13
+ ### Fixed
14
+ - Active **Focus pane** buttons now keep their accent-coloured hover state instead of switching to a dark hover style.
15
+ - PDF export now defines the LaTeX `Highlighting` environment when Pandoc has not already created it, fixing exports that previously failed with `Environment Highlighting undefined`.
16
+
7
17
  ## [0.5.14] — 2026-03-15
8
18
 
9
19
  ### Fixed
package/WORKFLOW.md CHANGED
@@ -1,4 +1,4 @@
1
- # pi-studio workflow spec (v0.2 draft)
1
+ # pi-studio workflow/spec note
2
2
 
3
3
  ## Goal
4
4
 
@@ -7,9 +7,9 @@ Keep Studio simple while supporting both loops:
7
7
  1. **User → model feedback** (annotated reply)
8
8
  2. **Model → user critique** (structured critique package)
9
9
 
10
- Studio uses a **single workspace** (no tab/mode switching):
10
+ Studio uses a **single workspace**:
11
11
  - left pane: **Editor**
12
- - right pane: **Response**
12
+ - right pane: **Response / Thinking / Editor Preview**
13
13
 
14
14
  ---
15
15
 
@@ -46,17 +46,23 @@ Critiques current editor text and expects/handles structured output:
46
46
 
47
47
  ## Response handling
48
48
 
49
- Right pane always shows the **latest assistant response** (reply or critique).
49
+ By default, the right pane follows the latest assistant response, but Studio can also:
50
+ - browse older assistant responses via response history
51
+ - show **Thinking (Raw)** for the currently selected response when available
52
+ - show **Editor (Preview)** for the current editor text
50
53
 
51
- When response is structured critique, Studio enables additional helpers:
54
+ When the selected response is structured critique, Studio enables additional helpers:
52
55
  - **Load critique (notes)** (`## Assessment` + `## Critiques`)
53
56
  - **Load critique (full)** (`## Assessment` + `## Critiques` + `## Document`)
54
57
 
55
58
  For non-critique responses:
56
59
  - **Load response into editor**
57
60
 
58
- Always available:
59
- - **Copy response**
61
+ In Thinking view (when available):
62
+ - **Load thinking into editor**
63
+ - **Copy thinking text**
64
+
65
+ Otherwise, Studio supports copying the currently viewed response text.
60
66
 
61
67
  ---
62
68
 
@@ -75,11 +81,11 @@ Rules:
75
81
 
76
82
  ## Required UI elements
77
83
 
78
- - Header actions: **Save As…**, **Save file** (file-backed), **Load file in editor**
79
- - Header view toggles: `Left: Editor (Raw|Preview)`, `Right: Response (Raw|Preview) | Editor (Preview)`
84
+ - Header actions: **Save As…**, **Save file** (file-backed), **Load file content**
85
+ - Header view toggles: `Left: Editor (Raw|Preview)`, `Right: Response (Raw|Preview) | Thinking (Raw) | Editor (Preview)`
80
86
  - Preview mode uses server-side `pandoc` rendering (math-aware) with plain-markdown fallback when renderer is unavailable.
81
87
  - 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`, **Fetch latest response**, response-history browse (`Prev/Next/Last`), **Load response into editor**, and **Load response prompt into editor**
88
+ - Response actions include `Auto-update response: On|Off`, **Fetch latest response**, response-history browse (`Prev/Next/Last`), **Load response into editor**, **Load response prompt into editor**, and thinking-aware load/copy actions when Thinking view is active
83
89
  - Source badge: `blank | last model response | file <path> | upload`
84
90
  - Response badge: `none | assistant response | assistant critique` (+ timestamp)
85
91
  - Sync badge: shown only when the editor exactly matches the currently viewed response/thinking (`In sync with response | In sync with thinking`)
@@ -89,13 +95,13 @@ Rules:
89
95
 
90
96
  ## Escaping pitfalls (implementation note)
91
97
 
92
- `index.ts` builds browser HTML as a TypeScript template string and embeds inline browser JavaScript. This creates multiple parse layers (TS string HTML JS), so incorrect escaping can break Studio boot (e.g. stuck at `Booting studio…`).
98
+ Studio is less fragile than before because browser JS/CSS now live in extracted client files, but `index.ts` still builds the HTML shell and injects boot/theme/source values. Incorrect escaping can still break Studio boot.
93
99
 
94
100
  Rules of thumb:
95
- - In embedded JS string literals authored from TS template context, use `\\n` (not `\n`) for runtime newlines.
96
- - Escape regex backslashes for the embedding layer (`\\s`, `\\n`, `\\[`), so browser JS receives the intended regex.
97
- - Prefer `JSON.stringify(value)` when injecting arbitrary text into inline script.
98
- - After touching inline `<script>` sections in `index.ts`, do a `/studio` boot smoke test immediately.
101
+ - Prefer `JSON.stringify(value)` when injecting arbitrary text into boot data or script-adjacent HTML.
102
+ - Be careful with HTML attribute escaping for injected values.
103
+ - After touching the HTML shell / boot-data wiring in `index.ts`, do a `/studio` boot smoke test immediately.
104
+ - After touching `client/studio-client.js` or `client/studio.css`, smoke test the main workflows: boot, websocket connect/reconnect, file load, run/critique, preview, and response history.
99
105
 
100
106
  ## Acceptance criteria
101
107
 
@@ -85,6 +85,8 @@
85
85
  const langSelect = document.getElementById("langSelect");
86
86
  const annotationModeSelect = document.getElementById("annotationModeSelect");
87
87
  const compactBtn = document.getElementById("compactBtn");
88
+ const leftFocusBtn = document.getElementById("leftFocusBtn");
89
+ const rightFocusBtn = document.getElementById("rightFocusBtn");
88
90
 
89
91
  const initialSourceState = {
90
92
  source: (document.body && document.body.dataset && document.body.dataset.initialSource) || "blank",
@@ -697,6 +699,23 @@
697
699
  }
698
700
  }
699
701
 
702
+ function updatePaneFocusButtons() {
703
+ [
704
+ [leftFocusBtn, "left"],
705
+ [rightFocusBtn, "right"],
706
+ ].forEach(([btn, pane]) => {
707
+ if (!btn) return;
708
+ const isFocusedPane = paneFocusTarget === pane;
709
+ const paneName = pane === "right" ? "response" : "editor";
710
+ btn.classList.toggle("is-active", isFocusedPane);
711
+ btn.setAttribute("aria-pressed", isFocusedPane ? "true" : "false");
712
+ btn.textContent = isFocusedPane ? "Exit focus" : "Focus pane";
713
+ btn.title = isFocusedPane
714
+ ? "Exit focus mode for the " + paneName + " pane."
715
+ : "Show only the " + paneName + " pane. Shortcut: Cmd/Ctrl+Esc or F10.";
716
+ });
717
+ }
718
+
700
719
  function applyPaneFocusClasses() {
701
720
  document.body.classList.remove("pane-focus-left", "pane-focus-right");
702
721
  if (paneFocusTarget === "left") {
@@ -704,6 +723,7 @@
704
723
  } else if (paneFocusTarget === "right") {
705
724
  document.body.classList.add("pane-focus-right");
706
725
  }
726
+ updatePaneFocusButtons();
707
727
  }
708
728
 
709
729
  function setActivePane(nextPane) {
@@ -725,6 +745,14 @@
725
745
  return "Editor";
726
746
  }
727
747
 
748
+ function enterPaneFocus(nextPane) {
749
+ const pane = nextPane === "right" ? "right" : "left";
750
+ setActivePane(pane);
751
+ paneFocusTarget = pane;
752
+ applyPaneFocusClasses();
753
+ setStatus("Focus mode: " + paneLabel(pane) + " pane (Esc to exit).");
754
+ }
755
+
728
756
  function togglePaneFocus() {
729
757
  if (paneFocusTarget === activePane) {
730
758
  paneFocusTarget = "off";
@@ -733,9 +761,7 @@
733
761
  return;
734
762
  }
735
763
 
736
- paneFocusTarget = activePane;
737
- applyPaneFocusClasses();
738
- setStatus("Focus mode: " + paneLabel(activePane) + " pane (Esc to exit).");
764
+ enterPaneFocus(activePane);
739
765
  }
740
766
 
741
767
  function exitPaneFocus() {
@@ -3420,6 +3446,27 @@
3420
3446
  rightPaneEl.addEventListener("focusin", () => setActivePane("right"));
3421
3447
  }
3422
3448
 
3449
+ if (leftFocusBtn) {
3450
+ leftFocusBtn.addEventListener("click", () => {
3451
+ if (paneFocusTarget === "left") {
3452
+ exitPaneFocus();
3453
+ return;
3454
+ }
3455
+ enterPaneFocus("left");
3456
+ });
3457
+ }
3458
+
3459
+ if (rightFocusBtn) {
3460
+ rightFocusBtn.addEventListener("click", () => {
3461
+ if (paneFocusTarget === "right") {
3462
+ exitPaneFocus();
3463
+ return;
3464
+ }
3465
+ enterPaneFocus("right");
3466
+ });
3467
+ }
3468
+
3469
+ updatePaneFocusButtons();
3423
3470
  window.addEventListener("keydown", handlePaneShortcut);
3424
3471
  window.addEventListener("beforeunload", () => {
3425
3472
  stopFooterSpinner();
package/client/studio.css CHANGED
@@ -203,6 +203,20 @@
203
203
  border-radius: 7px;
204
204
  }
205
205
 
206
+ .pane-focus-btn.is-active {
207
+ background: var(--accent);
208
+ border-color: var(--accent);
209
+ color: var(--accent-contrast);
210
+ font-weight: 600;
211
+ }
212
+
213
+ button.pane-focus-btn.is-active:not(:disabled):hover {
214
+ background: var(--accent);
215
+ border-color: var(--accent);
216
+ color: var(--accent-contrast);
217
+ filter: brightness(0.95);
218
+ }
219
+
206
220
  .section-header select {
207
221
  font-weight: 600;
208
222
  font-size: 14px;
package/index.ts CHANGED
@@ -156,6 +156,9 @@ const PDF_EXPORT_MAX_CHARS = 400_000;
156
156
  const REQUEST_BODY_MAX_BYTES = 1_000_000;
157
157
  const RESPONSE_HISTORY_LIMIT = 30;
158
158
  const UPDATE_CHECK_TIMEOUT_MS = 1800;
159
+ const CMUX_NOTIFY_TIMEOUT_MS = 1200;
160
+ const STUDIO_TERMINAL_NOTIFY_TITLE = "pi Studio";
161
+ const CMUX_STUDIO_STATUS_KEY = "pi_studio";
159
162
 
160
163
  const PDF_PREAMBLE = `\\usepackage{titlesec}
161
164
  \\titleformat{\\section}{\\Large\\bfseries\\sffamily}{}{0pt}{}[\\vspace{2pt}\\titlerule]
@@ -168,7 +171,13 @@ const PDF_PREAMBLE = `\\usepackage{titlesec}
168
171
  \\setlist[enumerate]{nosep, leftmargin=1.5em}
169
172
  \\usepackage{parskip}
170
173
  \\usepackage{fvextra}
171
- \\RecustomVerbatimEnvironment{Highlighting}{Verbatim}{commandchars=\\\\\\{\\},breaklines,breakanywhere}
174
+ \\makeatletter
175
+ \\@ifundefined{Highlighting}{%
176
+ \\DefineVerbatimEnvironment{Highlighting}{Verbatim}{commandchars=\\\\\\{\\},breaklines,breakanywhere}%
177
+ }{%
178
+ \\RecustomVerbatimEnvironment{Highlighting}{Verbatim}{commandchars=\\\\\\{\\},breaklines,breakanywhere}%
179
+ }
180
+ \\makeatother
172
181
  `;
173
182
 
174
183
  type StudioThemeMode = "dark" | "light";
@@ -2765,10 +2774,15 @@ ${cssVarsBlock}
2765
2774
  <main>
2766
2775
  <section id="leftPane">
2767
2776
  <div id="leftSectionHeader" class="section-header">
2768
- <select id="editorViewSelect" aria-label="Editor view mode">
2769
- <option value="markdown" selected>Editor (Raw)</option>
2770
- <option value="preview">Editor (Preview)</option>
2771
- </select>
2777
+ <div class="section-header-main">
2778
+ <select id="editorViewSelect" aria-label="Editor view mode">
2779
+ <option value="markdown" selected>Editor (Raw)</option>
2780
+ <option value="preview">Editor (Preview)</option>
2781
+ </select>
2782
+ </div>
2783
+ <div class="section-header-actions">
2784
+ <button id="leftFocusBtn" class="pane-focus-btn" type="button" title="Show only the editor pane. Shortcut: Cmd/Ctrl+Esc or F10.">Focus pane</button>
2785
+ </div>
2772
2786
  </div>
2773
2787
  <div class="source-wrap">
2774
2788
  <div class="source-meta">
@@ -2858,6 +2872,7 @@ ${cssVarsBlock}
2858
2872
  </select>
2859
2873
  </div>
2860
2874
  <div class="section-header-actions">
2875
+ <button id="rightFocusBtn" class="pane-focus-btn" type="button" title="Show only the response pane. Shortcut: Cmd/Ctrl+Esc or F10.">Focus pane</button>
2861
2876
  <button id="exportPdfBtn" type="button" title="Export the current right-pane preview as PDF via pandoc + xelatex.">Export right preview as PDF</button>
2862
2877
  </div>
2863
2878
  </div>
@@ -2990,6 +3005,246 @@ export default function (pi: ExtensionAPI) {
2990
3005
  lastCommandCtx.ui.notify(message, level);
2991
3006
  };
2992
3007
 
3008
+ const getStudioTerminalNotifyMode = (): "auto" | "off" | "bell" | "cmux" | "text" => {
3009
+ const raw = String(process.env.PI_STUDIO_TERMINAL_NOTIFY ?? "").trim().toLowerCase();
3010
+ if (raw === "off" || raw === "none") return "off";
3011
+ if (raw === "bell") return "bell";
3012
+ if (raw === "cmux") return "cmux";
3013
+ if (raw === "text" || raw === "line") return "text";
3014
+ return "auto";
3015
+ };
3016
+
3017
+ const getInteractiveTerminalStream = (): NodeJS.WriteStream | null => {
3018
+ if (process.stderr?.isTTY) return process.stderr;
3019
+ if (process.stdout?.isTTY) return process.stdout;
3020
+ return null;
3021
+ };
3022
+
3023
+ const isProbablyCmuxSession = (): boolean => {
3024
+ const workspaceId = String(process.env.CMUX_WORKSPACE_ID ?? "").trim();
3025
+ if (workspaceId) return true;
3026
+ const termProgram = String(process.env.TERM_PROGRAM ?? "").trim().toLowerCase();
3027
+ if (termProgram === "cmux") return true;
3028
+ const term = String(process.env.TERM ?? "").trim().toLowerCase();
3029
+ return term.includes("cmux");
3030
+ };
3031
+
3032
+ const sanitizeTerminalNotificationText = (value: string, maxLength = 240): string => {
3033
+ const sanitized = String(value)
3034
+ .replace(/[\u0000-\u0008\u000b-\u001a\u001c-\u001f\u007f]+/g, " ")
3035
+ .replace(/\u001b/g, "")
3036
+ .replace(/[;|\r\n]+/g, " ")
3037
+ .replace(/\s+/g, " ")
3038
+ .trim();
3039
+ return sanitized.slice(0, maxLength);
3040
+ };
3041
+
3042
+ const shouldUseCmuxTerminalIntegration = (): boolean => {
3043
+ const mode = getStudioTerminalNotifyMode();
3044
+ return isProbablyCmuxSession() && (mode === "auto" || mode === "cmux");
3045
+ };
3046
+
3047
+ const getCmuxWorkspaceArgs = (): string[] => {
3048
+ const workspaceId = String(process.env.CMUX_WORKSPACE_ID ?? "").trim();
3049
+ return workspaceId ? ["--workspace", workspaceId] : [];
3050
+ };
3051
+
3052
+ const runCmuxCommand = (args: string[], options?: { captureOutput?: boolean }): { ok: boolean; stdout: string } => {
3053
+ try {
3054
+ const env = { ...process.env };
3055
+ delete env.CMUX_SURFACE_ID;
3056
+ const result = spawnSync("cmux", args, {
3057
+ stdio: options?.captureOutput ? ["ignore", "pipe", "ignore"] : "ignore",
3058
+ encoding: options?.captureOutput ? "utf8" : undefined,
3059
+ timeout: CMUX_NOTIFY_TIMEOUT_MS,
3060
+ env,
3061
+ });
3062
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
3063
+ return {
3064
+ ok: !result.error && result.status === 0,
3065
+ stdout,
3066
+ };
3067
+ } catch {
3068
+ return { ok: false, stdout: "" };
3069
+ }
3070
+ };
3071
+
3072
+ const isCmuxBrowserFocusedInCallerWorkspace = (): boolean => {
3073
+ if (!shouldUseCmuxTerminalIntegration()) return false;
3074
+ const result = runCmuxCommand(["identify"], { captureOutput: true });
3075
+ if (!result.ok) return false;
3076
+ try {
3077
+ const parsed = JSON.parse(result.stdout) as {
3078
+ caller?: { workspace_ref?: string | null };
3079
+ focused?: { workspace_ref?: string | null; surface_type?: string | null; is_browser_surface?: boolean | null };
3080
+ };
3081
+ const callerWorkspaceRef = typeof parsed.caller?.workspace_ref === "string"
3082
+ ? parsed.caller.workspace_ref.trim()
3083
+ : "";
3084
+ const focusedWorkspaceRef = typeof parsed.focused?.workspace_ref === "string"
3085
+ ? parsed.focused.workspace_ref.trim()
3086
+ : "";
3087
+ const focusedSurfaceType = typeof parsed.focused?.surface_type === "string"
3088
+ ? parsed.focused.surface_type.trim().toLowerCase()
3089
+ : "";
3090
+ const focusedIsBrowser = parsed.focused?.is_browser_surface === true || focusedSurfaceType === "browser";
3091
+ return Boolean(callerWorkspaceRef && focusedWorkspaceRef && callerWorkspaceRef === focusedWorkspaceRef && focusedIsBrowser);
3092
+ } catch {
3093
+ return false;
3094
+ }
3095
+ };
3096
+
3097
+ const maybeClearStaleCmuxStudioNotifications = () => {
3098
+ if (!shouldUseCmuxTerminalIntegration()) return;
3099
+ const result = runCmuxCommand(["list-notifications"], { captureOutput: true });
3100
+ if (!result.ok) return;
3101
+ const output = result.stdout.trim();
3102
+ if (!output) return;
3103
+ const notifications = output
3104
+ .split(/\r?\n/)
3105
+ .map((line) => {
3106
+ const trimmed = line.trim();
3107
+ if (!trimmed) return null;
3108
+ const colonIndex = trimmed.indexOf(":");
3109
+ if (colonIndex === -1) return null;
3110
+ const fields = trimmed.slice(colonIndex + 1).split("|");
3111
+ if (fields.length !== 7) return null;
3112
+ const [, , , state, title] = fields;
3113
+ return {
3114
+ state,
3115
+ title,
3116
+ };
3117
+ });
3118
+ if (notifications.some((item) => item === null)) return;
3119
+ const clearable = notifications.every(
3120
+ (item) => item && item.state === "read" && item.title === STUDIO_TERMINAL_NOTIFY_TITLE,
3121
+ );
3122
+ if (!clearable) return;
3123
+ runCmuxCommand(["clear-notifications"]);
3124
+ };
3125
+
3126
+ const syncCmuxStudioStatus = () => {
3127
+ if (!shouldUseCmuxTerminalIntegration()) return;
3128
+ const workspaceArgs = getCmuxWorkspaceArgs();
3129
+ if (activeRequest) {
3130
+ runCmuxCommand([
3131
+ "set-status",
3132
+ CMUX_STUDIO_STATUS_KEY,
3133
+ "running…",
3134
+ "--color",
3135
+ "#5ea1ff",
3136
+ ...workspaceArgs,
3137
+ ]);
3138
+ return;
3139
+ }
3140
+ if (compactInProgress) {
3141
+ runCmuxCommand([
3142
+ "set-status",
3143
+ CMUX_STUDIO_STATUS_KEY,
3144
+ "compacting…",
3145
+ "--color",
3146
+ "#5ea1ff",
3147
+ ...workspaceArgs,
3148
+ ]);
3149
+ return;
3150
+ }
3151
+ runCmuxCommand(["clear-status", CMUX_STUDIO_STATUS_KEY, ...workspaceArgs]);
3152
+ };
3153
+
3154
+ const emitTerminalBell = (): boolean => {
3155
+ const stream = getInteractiveTerminalStream();
3156
+ if (!stream) return false;
3157
+ try {
3158
+ stream.write("\u0007");
3159
+ return true;
3160
+ } catch {
3161
+ return false;
3162
+ }
3163
+ };
3164
+
3165
+ const emitTerminalTextNotification = (message: string): boolean => {
3166
+ const stream = getInteractiveTerminalStream();
3167
+ if (!stream) return false;
3168
+ const line = sanitizeTerminalNotificationText(message, 400);
3169
+ if (!line) return false;
3170
+ try {
3171
+ stream.write(`\n[pi Studio] ${line}\n`);
3172
+ return true;
3173
+ } catch {
3174
+ return false;
3175
+ }
3176
+ };
3177
+
3178
+ const emitCmuxOscNotification = (message: string): boolean => {
3179
+ const stream = getInteractiveTerminalStream();
3180
+ if (!stream) return false;
3181
+ const title = sanitizeTerminalNotificationText(STUDIO_TERMINAL_NOTIFY_TITLE, 80);
3182
+ const body = sanitizeTerminalNotificationText(message, 240);
3183
+ if (!body) return false;
3184
+ try {
3185
+ stream.write(`\u001b]777;notify;${title};${body}\u0007`);
3186
+ return true;
3187
+ } catch {
3188
+ return false;
3189
+ }
3190
+ };
3191
+
3192
+ const emitCmuxCliNotification = (message: string): boolean => {
3193
+ const body = sanitizeTerminalNotificationText(message, 240);
3194
+ if (!body) return false;
3195
+ return runCmuxCommand([
3196
+ "notify",
3197
+ "--title",
3198
+ STUDIO_TERMINAL_NOTIFY_TITLE,
3199
+ "--body",
3200
+ body,
3201
+ ...getCmuxWorkspaceArgs(),
3202
+ ]).ok;
3203
+ };
3204
+
3205
+ const notifyStudioTerminal = (message: string, level: "info" | "warning" | "error" = "info") => {
3206
+ const mode = getStudioTerminalNotifyMode();
3207
+ const hasInteractiveTerminal = Boolean(getInteractiveTerminalStream());
3208
+ const inCmux = isProbablyCmuxSession();
3209
+ const useCmuxIntegration = shouldUseCmuxTerminalIntegration();
3210
+ const suppressCmuxCompletionNotification = useCmuxIntegration && isCmuxBrowserFocusedInCallerWorkspace();
3211
+ let deliveredBy: "cmux-cli" | "cmux-osc777" | "bell" | "text" | null = null;
3212
+
3213
+ if (useCmuxIntegration && !suppressCmuxCompletionNotification) {
3214
+ if (emitCmuxCliNotification(message)) {
3215
+ deliveredBy = "cmux-cli";
3216
+ } else if (emitCmuxOscNotification(message)) {
3217
+ deliveredBy = "cmux-osc777";
3218
+ }
3219
+ }
3220
+
3221
+ if (!deliveredBy && !suppressCmuxCompletionNotification) {
3222
+ if (mode === "text") {
3223
+ if (emitTerminalTextNotification(message)) deliveredBy = "text";
3224
+ } else if (mode === "bell") {
3225
+ if (emitTerminalBell()) deliveredBy = "bell";
3226
+ } else if (mode === "auto") {
3227
+ if (emitTerminalBell()) deliveredBy = "bell";
3228
+ }
3229
+ }
3230
+
3231
+ emitDebugEvent("terminal_notification", {
3232
+ message,
3233
+ level,
3234
+ mode,
3235
+ inCmux,
3236
+ hasInteractiveTerminal,
3237
+ suppressCmuxCompletionNotification,
3238
+ delivered: Boolean(deliveredBy),
3239
+ deliveredBy,
3240
+ });
3241
+ };
3242
+
3243
+ const getStudioRequestCompletionNotification = (kind: StudioRequestKind): string => {
3244
+ if (kind === "critique") return "Studio: critique ready.";
3245
+ return "Studio: response ready.";
3246
+ };
3247
+
2993
3248
  const refreshContextUsage = (
2994
3249
  ctx?: { getContextUsage(): { tokens: number | null; contextWindow: number; percent: number | null } | undefined },
2995
3250
  ): StudioContextUsageSnapshot => {
@@ -3002,6 +3257,7 @@ export default function (pi: ExtensionAPI) {
3002
3257
  const clearCompactionState = () => {
3003
3258
  compactInProgress = false;
3004
3259
  compactRequestId = null;
3260
+ syncCmuxStudioStatus();
3005
3261
  };
3006
3262
 
3007
3263
  const syncStudioResponseHistory = (entries: SessionEntry[]) => {
@@ -3157,22 +3413,34 @@ export default function (pi: ExtensionAPI) {
3157
3413
  });
3158
3414
  };
3159
3415
 
3160
- const clearActiveRequest = (options?: { notify?: string; level?: "info" | "warning" | "error" }) => {
3416
+ const clearActiveRequest = (options?: {
3417
+ notify?: string;
3418
+ level?: "info" | "warning" | "error";
3419
+ terminalNotify?: string;
3420
+ terminalNotifyLevel?: "info" | "warning" | "error";
3421
+ }) => {
3161
3422
  if (!activeRequest) return;
3162
3423
  const completedRequestId = activeRequest.id;
3163
3424
  const completedKind = activeRequest.kind;
3164
3425
  clearTimeout(activeRequest.timer);
3165
3426
  activeRequest = null;
3427
+ syncCmuxStudioStatus();
3166
3428
  emitDebugEvent("clear_active_request", {
3167
3429
  requestId: completedRequestId,
3168
3430
  kind: completedKind,
3169
3431
  notify: options?.notify ?? null,
3432
+ terminalNotify: options?.terminalNotify ?? null,
3170
3433
  agentBusy,
3171
3434
  });
3172
3435
  broadcastState();
3173
3436
  if (options?.notify) {
3174
3437
  broadcast({ type: "info", message: options.notify, level: options.level ?? "info" });
3175
3438
  }
3439
+ if (options?.terminalNotify) {
3440
+ const terminalLevel = options.terminalNotifyLevel ?? options.level ?? "info";
3441
+ notifyStudio(options.terminalNotify, terminalLevel);
3442
+ notifyStudioTerminal(options.terminalNotify, terminalLevel);
3443
+ }
3176
3444
  };
3177
3445
 
3178
3446
  const cancelActiveRequest = (requestId: string): { ok: true; kind: StudioRequestKind } | { ok: false; message: string } => {
@@ -3236,6 +3504,8 @@ export default function (pi: ExtensionAPI) {
3236
3504
  startedAt: Date.now(),
3237
3505
  timer,
3238
3506
  };
3507
+ maybeClearStaleCmuxStudioNotifications();
3508
+ syncCmuxStudioStatus();
3239
3509
 
3240
3510
  emitDebugEvent("begin_request", { requestId, kind });
3241
3511
  broadcast({ type: "request_started", requestId, kind });
@@ -3488,6 +3758,8 @@ export default function (pi: ExtensionAPI) {
3488
3758
 
3489
3759
  compactInProgress = true;
3490
3760
  compactRequestId = msg.requestId;
3761
+ maybeClearStaleCmuxStudioNotifications();
3762
+ syncCmuxStudioStatus();
3491
3763
  refreshContextUsage(compactCtx);
3492
3764
  emitDebugEvent("compact_start", {
3493
3765
  requestId: msg.requestId,
@@ -4322,7 +4594,10 @@ export default function (pi: ExtensionAPI) {
4322
4594
  responseHistory: studioResponseHistory,
4323
4595
  });
4324
4596
  broadcastResponseHistory();
4325
- clearActiveRequest();
4597
+ clearActiveRequest({
4598
+ terminalNotify: getStudioRequestCompletionNotification(kind),
4599
+ terminalNotifyLevel: "info",
4600
+ });
4326
4601
  return;
4327
4602
  }
4328
4603
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.5.14",
3
+ "version": "0.5.15",
4
4
  "description": "Browser GUI for structured critique workflows in pi",
5
5
  "type": "module",
6
6
  "license": "MIT",