pi-studio 0.5.14 → 0.5.16

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,21 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.16] — 2026-03-17
8
+
9
+ ### Fixed
10
+ - Response-history prompt loading now keeps the correct generating prompt for both Studio editor-sent requests and prompts entered directly in the terminal, instead of sometimes reusing stale editor text.
11
+
12
+ ## [0.5.15] — 2026-03-16
13
+
14
+ ### Added
15
+ - Per-pane **Focus pane** controls for both the editor and response panes, matching the current Ghostty/cmux split-browser workflow more directly.
16
+ - 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.
17
+
18
+ ### Fixed
19
+ - Active **Focus pane** buttons now keep their accent-coloured hover state instead of switching to a dark hover style.
20
+ - PDF export now defines the LaTeX `Highlighting` environment when Pandoc has not already created it, fixing exports that previously failed with `Environment Highlighting undefined`.
21
+
7
22
  ## [0.5.14] — 2026-03-15
8
23
 
9
24
  ### 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
@@ -29,6 +29,7 @@ interface StudioServerState {
29
29
  interface ActiveStudioRequest {
30
30
  id: string;
31
31
  kind: StudioRequestKind;
32
+ prompt: string | null;
32
33
  timer: NodeJS.Timeout;
33
34
  startedAt: number;
34
35
  }
@@ -156,6 +157,9 @@ const PDF_EXPORT_MAX_CHARS = 400_000;
156
157
  const REQUEST_BODY_MAX_BYTES = 1_000_000;
157
158
  const RESPONSE_HISTORY_LIMIT = 30;
158
159
  const UPDATE_CHECK_TIMEOUT_MS = 1800;
160
+ const CMUX_NOTIFY_TIMEOUT_MS = 1200;
161
+ const STUDIO_TERMINAL_NOTIFY_TITLE = "pi Studio";
162
+ const CMUX_STUDIO_STATUS_KEY = "pi_studio";
159
163
 
160
164
  const PDF_PREAMBLE = `\\usepackage{titlesec}
161
165
  \\titleformat{\\section}{\\Large\\bfseries\\sffamily}{}{0pt}{}[\\vspace{2pt}\\titlerule]
@@ -168,7 +172,13 @@ const PDF_PREAMBLE = `\\usepackage{titlesec}
168
172
  \\setlist[enumerate]{nosep, leftmargin=1.5em}
169
173
  \\usepackage{parskip}
170
174
  \\usepackage{fvextra}
171
- \\RecustomVerbatimEnvironment{Highlighting}{Verbatim}{commandchars=\\\\\\{\\},breaklines,breakanywhere}
175
+ \\makeatletter
176
+ \\@ifundefined{Highlighting}{%
177
+ \\DefineVerbatimEnvironment{Highlighting}{Verbatim}{commandchars=\\\\\\{\\},breaklines,breakanywhere}%
178
+ }{%
179
+ \\RecustomVerbatimEnvironment{Highlighting}{Verbatim}{commandchars=\\\\\\{\\},breaklines,breakanywhere}%
180
+ }
181
+ \\makeatother
172
182
  `;
173
183
 
174
184
  type StudioThemeMode = "dark" | "light";
@@ -2190,6 +2200,12 @@ function extractLatestAssistantFromEntries(entries: SessionEntry[]): string | nu
2190
2200
  return null;
2191
2201
  }
2192
2202
 
2203
+ function normalizePromptText(text: string | null | undefined): string | null {
2204
+ if (typeof text !== "string") return null;
2205
+ const trimmed = text.trim();
2206
+ return trimmed.length > 0 ? trimmed : null;
2207
+ }
2208
+
2193
2209
  function extractUserText(message: unknown): string | null {
2194
2210
  const msg = message as {
2195
2211
  role?: string;
@@ -2198,8 +2214,7 @@ function extractUserText(message: unknown): string | null {
2198
2214
  if (!msg || msg.role !== "user") return null;
2199
2215
 
2200
2216
  if (typeof msg.content === "string") {
2201
- const text = msg.content.trim();
2202
- return text.length > 0 ? text : null;
2217
+ return normalizePromptText(msg.content);
2203
2218
  }
2204
2219
 
2205
2220
  if (!Array.isArray(msg.content)) return null;
@@ -2221,8 +2236,16 @@ function extractUserText(message: unknown): string | null {
2221
2236
  }
2222
2237
  }
2223
2238
 
2224
- const text = blocks.join("\n\n").trim();
2225
- return text.length > 0 ? text : null;
2239
+ return normalizePromptText(blocks.join("\n\n"));
2240
+ }
2241
+
2242
+ function findLatestUserPrompt(entries: SessionEntry[]): string | null {
2243
+ let latestPrompt: string | null = null;
2244
+ for (const entry of entries) {
2245
+ if (!entry || entry.type !== "message") continue;
2246
+ latestPrompt = extractUserText((entry as { message?: unknown }).message) ?? latestPrompt;
2247
+ }
2248
+ return latestPrompt;
2226
2249
  }
2227
2250
 
2228
2251
  function parseEntryTimestamp(timestamp: unknown): number {
@@ -2765,10 +2788,15 @@ ${cssVarsBlock}
2765
2788
  <main>
2766
2789
  <section id="leftPane">
2767
2790
  <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>
2791
+ <div class="section-header-main">
2792
+ <select id="editorViewSelect" aria-label="Editor view mode">
2793
+ <option value="markdown" selected>Editor (Raw)</option>
2794
+ <option value="preview">Editor (Preview)</option>
2795
+ </select>
2796
+ </div>
2797
+ <div class="section-header-actions">
2798
+ <button id="leftFocusBtn" class="pane-focus-btn" type="button" title="Show only the editor pane. Shortcut: Cmd/Ctrl+Esc or F10.">Focus pane</button>
2799
+ </div>
2772
2800
  </div>
2773
2801
  <div class="source-wrap">
2774
2802
  <div class="source-meta">
@@ -2858,6 +2886,7 @@ ${cssVarsBlock}
2858
2886
  </select>
2859
2887
  </div>
2860
2888
  <div class="section-header-actions">
2889
+ <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
2890
  <button id="exportPdfBtn" type="button" title="Export the current right-pane preview as PDF via pandoc + xelatex.">Export right preview as PDF</button>
2862
2891
  </div>
2863
2892
  </div>
@@ -2930,6 +2959,8 @@ export default function (pi: ExtensionAPI) {
2930
2959
  let currentModelLabel = "none";
2931
2960
  let terminalSessionLabel = buildTerminalSessionLabel(studioCwd);
2932
2961
  let studioResponseHistory: StudioResponseHistoryItem[] = [];
2962
+ let latestSessionUserPrompt: string | null = null;
2963
+ let pendingTurnPrompt: string | null = null;
2933
2964
  let contextUsageSnapshot: StudioContextUsageSnapshot = {
2934
2965
  tokens: null,
2935
2966
  contextWindow: null,
@@ -2990,6 +3021,246 @@ export default function (pi: ExtensionAPI) {
2990
3021
  lastCommandCtx.ui.notify(message, level);
2991
3022
  };
2992
3023
 
3024
+ const getStudioTerminalNotifyMode = (): "auto" | "off" | "bell" | "cmux" | "text" => {
3025
+ const raw = String(process.env.PI_STUDIO_TERMINAL_NOTIFY ?? "").trim().toLowerCase();
3026
+ if (raw === "off" || raw === "none") return "off";
3027
+ if (raw === "bell") return "bell";
3028
+ if (raw === "cmux") return "cmux";
3029
+ if (raw === "text" || raw === "line") return "text";
3030
+ return "auto";
3031
+ };
3032
+
3033
+ const getInteractiveTerminalStream = (): NodeJS.WriteStream | null => {
3034
+ if (process.stderr?.isTTY) return process.stderr;
3035
+ if (process.stdout?.isTTY) return process.stdout;
3036
+ return null;
3037
+ };
3038
+
3039
+ const isProbablyCmuxSession = (): boolean => {
3040
+ const workspaceId = String(process.env.CMUX_WORKSPACE_ID ?? "").trim();
3041
+ if (workspaceId) return true;
3042
+ const termProgram = String(process.env.TERM_PROGRAM ?? "").trim().toLowerCase();
3043
+ if (termProgram === "cmux") return true;
3044
+ const term = String(process.env.TERM ?? "").trim().toLowerCase();
3045
+ return term.includes("cmux");
3046
+ };
3047
+
3048
+ const sanitizeTerminalNotificationText = (value: string, maxLength = 240): string => {
3049
+ const sanitized = String(value)
3050
+ .replace(/[\u0000-\u0008\u000b-\u001a\u001c-\u001f\u007f]+/g, " ")
3051
+ .replace(/\u001b/g, "")
3052
+ .replace(/[;|\r\n]+/g, " ")
3053
+ .replace(/\s+/g, " ")
3054
+ .trim();
3055
+ return sanitized.slice(0, maxLength);
3056
+ };
3057
+
3058
+ const shouldUseCmuxTerminalIntegration = (): boolean => {
3059
+ const mode = getStudioTerminalNotifyMode();
3060
+ return isProbablyCmuxSession() && (mode === "auto" || mode === "cmux");
3061
+ };
3062
+
3063
+ const getCmuxWorkspaceArgs = (): string[] => {
3064
+ const workspaceId = String(process.env.CMUX_WORKSPACE_ID ?? "").trim();
3065
+ return workspaceId ? ["--workspace", workspaceId] : [];
3066
+ };
3067
+
3068
+ const runCmuxCommand = (args: string[], options?: { captureOutput?: boolean }): { ok: boolean; stdout: string } => {
3069
+ try {
3070
+ const env = { ...process.env };
3071
+ delete env.CMUX_SURFACE_ID;
3072
+ const result = spawnSync("cmux", args, {
3073
+ stdio: options?.captureOutput ? ["ignore", "pipe", "ignore"] : "ignore",
3074
+ encoding: options?.captureOutput ? "utf8" : undefined,
3075
+ timeout: CMUX_NOTIFY_TIMEOUT_MS,
3076
+ env,
3077
+ });
3078
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
3079
+ return {
3080
+ ok: !result.error && result.status === 0,
3081
+ stdout,
3082
+ };
3083
+ } catch {
3084
+ return { ok: false, stdout: "" };
3085
+ }
3086
+ };
3087
+
3088
+ const isCmuxBrowserFocusedInCallerWorkspace = (): boolean => {
3089
+ if (!shouldUseCmuxTerminalIntegration()) return false;
3090
+ const result = runCmuxCommand(["identify"], { captureOutput: true });
3091
+ if (!result.ok) return false;
3092
+ try {
3093
+ const parsed = JSON.parse(result.stdout) as {
3094
+ caller?: { workspace_ref?: string | null };
3095
+ focused?: { workspace_ref?: string | null; surface_type?: string | null; is_browser_surface?: boolean | null };
3096
+ };
3097
+ const callerWorkspaceRef = typeof parsed.caller?.workspace_ref === "string"
3098
+ ? parsed.caller.workspace_ref.trim()
3099
+ : "";
3100
+ const focusedWorkspaceRef = typeof parsed.focused?.workspace_ref === "string"
3101
+ ? parsed.focused.workspace_ref.trim()
3102
+ : "";
3103
+ const focusedSurfaceType = typeof parsed.focused?.surface_type === "string"
3104
+ ? parsed.focused.surface_type.trim().toLowerCase()
3105
+ : "";
3106
+ const focusedIsBrowser = parsed.focused?.is_browser_surface === true || focusedSurfaceType === "browser";
3107
+ return Boolean(callerWorkspaceRef && focusedWorkspaceRef && callerWorkspaceRef === focusedWorkspaceRef && focusedIsBrowser);
3108
+ } catch {
3109
+ return false;
3110
+ }
3111
+ };
3112
+
3113
+ const maybeClearStaleCmuxStudioNotifications = () => {
3114
+ if (!shouldUseCmuxTerminalIntegration()) return;
3115
+ const result = runCmuxCommand(["list-notifications"], { captureOutput: true });
3116
+ if (!result.ok) return;
3117
+ const output = result.stdout.trim();
3118
+ if (!output) return;
3119
+ const notifications = output
3120
+ .split(/\r?\n/)
3121
+ .map((line) => {
3122
+ const trimmed = line.trim();
3123
+ if (!trimmed) return null;
3124
+ const colonIndex = trimmed.indexOf(":");
3125
+ if (colonIndex === -1) return null;
3126
+ const fields = trimmed.slice(colonIndex + 1).split("|");
3127
+ if (fields.length !== 7) return null;
3128
+ const [, , , state, title] = fields;
3129
+ return {
3130
+ state,
3131
+ title,
3132
+ };
3133
+ });
3134
+ if (notifications.some((item) => item === null)) return;
3135
+ const clearable = notifications.every(
3136
+ (item) => item && item.state === "read" && item.title === STUDIO_TERMINAL_NOTIFY_TITLE,
3137
+ );
3138
+ if (!clearable) return;
3139
+ runCmuxCommand(["clear-notifications"]);
3140
+ };
3141
+
3142
+ const syncCmuxStudioStatus = () => {
3143
+ if (!shouldUseCmuxTerminalIntegration()) return;
3144
+ const workspaceArgs = getCmuxWorkspaceArgs();
3145
+ if (activeRequest) {
3146
+ runCmuxCommand([
3147
+ "set-status",
3148
+ CMUX_STUDIO_STATUS_KEY,
3149
+ "running…",
3150
+ "--color",
3151
+ "#5ea1ff",
3152
+ ...workspaceArgs,
3153
+ ]);
3154
+ return;
3155
+ }
3156
+ if (compactInProgress) {
3157
+ runCmuxCommand([
3158
+ "set-status",
3159
+ CMUX_STUDIO_STATUS_KEY,
3160
+ "compacting…",
3161
+ "--color",
3162
+ "#5ea1ff",
3163
+ ...workspaceArgs,
3164
+ ]);
3165
+ return;
3166
+ }
3167
+ runCmuxCommand(["clear-status", CMUX_STUDIO_STATUS_KEY, ...workspaceArgs]);
3168
+ };
3169
+
3170
+ const emitTerminalBell = (): boolean => {
3171
+ const stream = getInteractiveTerminalStream();
3172
+ if (!stream) return false;
3173
+ try {
3174
+ stream.write("\u0007");
3175
+ return true;
3176
+ } catch {
3177
+ return false;
3178
+ }
3179
+ };
3180
+
3181
+ const emitTerminalTextNotification = (message: string): boolean => {
3182
+ const stream = getInteractiveTerminalStream();
3183
+ if (!stream) return false;
3184
+ const line = sanitizeTerminalNotificationText(message, 400);
3185
+ if (!line) return false;
3186
+ try {
3187
+ stream.write(`\n[pi Studio] ${line}\n`);
3188
+ return true;
3189
+ } catch {
3190
+ return false;
3191
+ }
3192
+ };
3193
+
3194
+ const emitCmuxOscNotification = (message: string): boolean => {
3195
+ const stream = getInteractiveTerminalStream();
3196
+ if (!stream) return false;
3197
+ const title = sanitizeTerminalNotificationText(STUDIO_TERMINAL_NOTIFY_TITLE, 80);
3198
+ const body = sanitizeTerminalNotificationText(message, 240);
3199
+ if (!body) return false;
3200
+ try {
3201
+ stream.write(`\u001b]777;notify;${title};${body}\u0007`);
3202
+ return true;
3203
+ } catch {
3204
+ return false;
3205
+ }
3206
+ };
3207
+
3208
+ const emitCmuxCliNotification = (message: string): boolean => {
3209
+ const body = sanitizeTerminalNotificationText(message, 240);
3210
+ if (!body) return false;
3211
+ return runCmuxCommand([
3212
+ "notify",
3213
+ "--title",
3214
+ STUDIO_TERMINAL_NOTIFY_TITLE,
3215
+ "--body",
3216
+ body,
3217
+ ...getCmuxWorkspaceArgs(),
3218
+ ]).ok;
3219
+ };
3220
+
3221
+ const notifyStudioTerminal = (message: string, level: "info" | "warning" | "error" = "info") => {
3222
+ const mode = getStudioTerminalNotifyMode();
3223
+ const hasInteractiveTerminal = Boolean(getInteractiveTerminalStream());
3224
+ const inCmux = isProbablyCmuxSession();
3225
+ const useCmuxIntegration = shouldUseCmuxTerminalIntegration();
3226
+ const suppressCmuxCompletionNotification = useCmuxIntegration && isCmuxBrowserFocusedInCallerWorkspace();
3227
+ let deliveredBy: "cmux-cli" | "cmux-osc777" | "bell" | "text" | null = null;
3228
+
3229
+ if (useCmuxIntegration && !suppressCmuxCompletionNotification) {
3230
+ if (emitCmuxCliNotification(message)) {
3231
+ deliveredBy = "cmux-cli";
3232
+ } else if (emitCmuxOscNotification(message)) {
3233
+ deliveredBy = "cmux-osc777";
3234
+ }
3235
+ }
3236
+
3237
+ if (!deliveredBy && !suppressCmuxCompletionNotification) {
3238
+ if (mode === "text") {
3239
+ if (emitTerminalTextNotification(message)) deliveredBy = "text";
3240
+ } else if (mode === "bell") {
3241
+ if (emitTerminalBell()) deliveredBy = "bell";
3242
+ } else if (mode === "auto") {
3243
+ if (emitTerminalBell()) deliveredBy = "bell";
3244
+ }
3245
+ }
3246
+
3247
+ emitDebugEvent("terminal_notification", {
3248
+ message,
3249
+ level,
3250
+ mode,
3251
+ inCmux,
3252
+ hasInteractiveTerminal,
3253
+ suppressCmuxCompletionNotification,
3254
+ delivered: Boolean(deliveredBy),
3255
+ deliveredBy,
3256
+ });
3257
+ };
3258
+
3259
+ const getStudioRequestCompletionNotification = (kind: StudioRequestKind): string => {
3260
+ if (kind === "critique") return "Studio: critique ready.";
3261
+ return "Studio: response ready.";
3262
+ };
3263
+
2993
3264
  const refreshContextUsage = (
2994
3265
  ctx?: { getContextUsage(): { tokens: number | null; contextWindow: number; percent: number | null } | undefined },
2995
3266
  ): StudioContextUsageSnapshot => {
@@ -3002,9 +3273,11 @@ export default function (pi: ExtensionAPI) {
3002
3273
  const clearCompactionState = () => {
3003
3274
  compactInProgress = false;
3004
3275
  compactRequestId = null;
3276
+ syncCmuxStudioStatus();
3005
3277
  };
3006
3278
 
3007
3279
  const syncStudioResponseHistory = (entries: SessionEntry[]) => {
3280
+ latestSessionUserPrompt = findLatestUserPrompt(entries);
3008
3281
  studioResponseHistory = buildResponseHistoryFromEntries(entries, RESPONSE_HISTORY_LIMIT);
3009
3282
  const latest = studioResponseHistory[studioResponseHistory.length - 1];
3010
3283
  if (!latest) {
@@ -3157,22 +3430,34 @@ export default function (pi: ExtensionAPI) {
3157
3430
  });
3158
3431
  };
3159
3432
 
3160
- const clearActiveRequest = (options?: { notify?: string; level?: "info" | "warning" | "error" }) => {
3433
+ const clearActiveRequest = (options?: {
3434
+ notify?: string;
3435
+ level?: "info" | "warning" | "error";
3436
+ terminalNotify?: string;
3437
+ terminalNotifyLevel?: "info" | "warning" | "error";
3438
+ }) => {
3161
3439
  if (!activeRequest) return;
3162
3440
  const completedRequestId = activeRequest.id;
3163
3441
  const completedKind = activeRequest.kind;
3164
3442
  clearTimeout(activeRequest.timer);
3165
3443
  activeRequest = null;
3444
+ syncCmuxStudioStatus();
3166
3445
  emitDebugEvent("clear_active_request", {
3167
3446
  requestId: completedRequestId,
3168
3447
  kind: completedKind,
3169
3448
  notify: options?.notify ?? null,
3449
+ terminalNotify: options?.terminalNotify ?? null,
3170
3450
  agentBusy,
3171
3451
  });
3172
3452
  broadcastState();
3173
3453
  if (options?.notify) {
3174
3454
  broadcast({ type: "info", message: options.notify, level: options.level ?? "info" });
3175
3455
  }
3456
+ if (options?.terminalNotify) {
3457
+ const terminalLevel = options.terminalNotifyLevel ?? options.level ?? "info";
3458
+ notifyStudio(options.terminalNotify, terminalLevel);
3459
+ notifyStudioTerminal(options.terminalNotify, terminalLevel);
3460
+ }
3176
3461
  };
3177
3462
 
3178
3463
  const cancelActiveRequest = (requestId: string): { ok: true; kind: StudioRequestKind } | { ok: false; message: string } => {
@@ -3202,7 +3487,7 @@ export default function (pi: ExtensionAPI) {
3202
3487
  return { ok: true, kind };
3203
3488
  };
3204
3489
 
3205
- const beginRequest = (requestId: string, kind: StudioRequestKind): boolean => {
3490
+ const beginRequest = (requestId: string, kind: StudioRequestKind, prompt?: string | null): boolean => {
3206
3491
  suppressedStudioResponse = null;
3207
3492
  emitDebugEvent("begin_request_attempt", {
3208
3493
  requestId,
@@ -3233,9 +3518,12 @@ export default function (pi: ExtensionAPI) {
3233
3518
  activeRequest = {
3234
3519
  id: requestId,
3235
3520
  kind,
3521
+ prompt: normalizePromptText(prompt),
3236
3522
  startedAt: Date.now(),
3237
3523
  timer,
3238
3524
  };
3525
+ maybeClearStaleCmuxStudioNotifications();
3526
+ syncCmuxStudioStatus();
3239
3527
 
3240
3528
  emitDebugEvent("begin_request", { requestId, kind });
3241
3529
  broadcast({ type: "request_started", requestId, kind });
@@ -3382,10 +3670,9 @@ export default function (pi: ExtensionAPI) {
3382
3670
  return;
3383
3671
  }
3384
3672
 
3385
- if (!beginRequest(msg.requestId, "critique")) return;
3386
-
3387
3673
  const lens = resolveLens(msg.lens, document);
3388
3674
  const prompt = buildCritiquePrompt(document, lens);
3675
+ if (!beginRequest(msg.requestId, "critique", prompt)) return;
3389
3676
 
3390
3677
  try {
3391
3678
  pi.sendUserMessage(prompt);
@@ -3412,7 +3699,7 @@ export default function (pi: ExtensionAPI) {
3412
3699
  return;
3413
3700
  }
3414
3701
 
3415
- if (!beginRequest(msg.requestId, "annotation")) return;
3702
+ if (!beginRequest(msg.requestId, "annotation", text)) return;
3416
3703
 
3417
3704
  try {
3418
3705
  pi.sendUserMessage(text);
@@ -3439,7 +3726,7 @@ export default function (pi: ExtensionAPI) {
3439
3726
  return;
3440
3727
  }
3441
3728
 
3442
- if (!beginRequest(msg.requestId, "direct")) return;
3729
+ if (!beginRequest(msg.requestId, "direct", msg.text)) return;
3443
3730
 
3444
3731
  try {
3445
3732
  pi.sendUserMessage(msg.text);
@@ -3488,6 +3775,8 @@ export default function (pi: ExtensionAPI) {
3488
3775
 
3489
3776
  compactInProgress = true;
3490
3777
  compactRequestId = msg.requestId;
3778
+ maybeClearStaleCmuxStudioNotifications();
3779
+ syncCmuxStudioStatus();
3491
3780
  refreshContextUsage(compactCtx);
3492
3781
  emitDebugEvent("compact_start", {
3493
3782
  requestId: msg.requestId,
@@ -4147,6 +4436,7 @@ export default function (pi: ExtensionAPI) {
4147
4436
  };
4148
4437
 
4149
4438
  pi.on("session_start", async (_event, ctx) => {
4439
+ pendingTurnPrompt = null;
4150
4440
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
4151
4441
  clearCompactionState();
4152
4442
  agentBusy = false;
@@ -4164,6 +4454,7 @@ export default function (pi: ExtensionAPI) {
4164
4454
  pi.on("session_switch", async (_event, ctx) => {
4165
4455
  clearActiveRequest({ notify: "Session switched. Studio request state cleared.", level: "warning" });
4166
4456
  clearCompactionState();
4457
+ pendingTurnPrompt = null;
4167
4458
  lastCommandCtx = null;
4168
4459
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
4169
4460
  agentBusy = false;
@@ -4251,6 +4542,11 @@ export default function (pi: ExtensionAPI) {
4251
4542
  activeRequestKind: activeRequest?.kind ?? null,
4252
4543
  });
4253
4544
 
4545
+ if (role === "user") {
4546
+ pendingTurnPrompt = extractUserText(event.message);
4547
+ return;
4548
+ }
4549
+
4254
4550
  // Assistant is handing off to tool calls; request is still in progress.
4255
4551
  if (stopReason === "toolUse") {
4256
4552
  emitDebugEvent("message_end_tool_use", {
@@ -4264,6 +4560,7 @@ export default function (pi: ExtensionAPI) {
4264
4560
  if (!markdown) return;
4265
4561
 
4266
4562
  if (suppressedStudioResponse) {
4563
+ pendingTurnPrompt = null;
4267
4564
  emitDebugEvent("suppressed_cancelled_response", {
4268
4565
  requestId: suppressedStudioResponse.requestId,
4269
4566
  kind: suppressedStudioResponse.kind,
@@ -4277,9 +4574,7 @@ export default function (pi: ExtensionAPI) {
4277
4574
  refreshContextUsage(ctx);
4278
4575
  const latestHistoryItem = studioResponseHistory[studioResponseHistory.length - 1];
4279
4576
  if (!latestHistoryItem || latestHistoryItem.markdown !== markdown) {
4280
- const fallbackPrompt = studioResponseHistory.length > 0
4281
- ? studioResponseHistory[studioResponseHistory.length - 1]?.prompt ?? null
4282
- : null;
4577
+ const fallbackPrompt = activeRequest?.prompt ?? pendingTurnPrompt ?? latestSessionUserPrompt ?? null;
4283
4578
  const fallbackHistoryItem: StudioResponseHistoryItem = {
4284
4579
  id: randomUUID(),
4285
4580
  markdown,
@@ -4295,6 +4590,7 @@ export default function (pi: ExtensionAPI) {
4295
4590
  const latestItem = studioResponseHistory[studioResponseHistory.length - 1];
4296
4591
  const responseTimestamp = latestItem?.timestamp ?? Date.now();
4297
4592
  const responseThinking = latestItem?.thinking ?? thinking ?? null;
4593
+ pendingTurnPrompt = null;
4298
4594
 
4299
4595
  if (activeRequest) {
4300
4596
  const requestId = activeRequest.id;
@@ -4322,7 +4618,10 @@ export default function (pi: ExtensionAPI) {
4322
4618
  responseHistory: studioResponseHistory,
4323
4619
  });
4324
4620
  broadcastResponseHistory();
4325
- clearActiveRequest();
4621
+ clearActiveRequest({
4622
+ terminalNotify: getStudioRequestCompletionNotification(kind),
4623
+ terminalNotifyLevel: "info",
4624
+ });
4326
4625
  return;
4327
4626
  }
4328
4627
 
@@ -4352,6 +4651,7 @@ export default function (pi: ExtensionAPI) {
4352
4651
 
4353
4652
  pi.on("agent_end", async () => {
4354
4653
  agentBusy = false;
4654
+ pendingTurnPrompt = null;
4355
4655
  refreshContextUsage();
4356
4656
  emitDebugEvent("agent_end", {
4357
4657
  activeRequestId: activeRequest?.id ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.5.14",
3
+ "version": "0.5.16",
4
4
  "description": "Browser GUI for structured critique workflows in pi",
5
5
  "type": "module",
6
6
  "license": "MIT",