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 +15 -0
- package/WORKFLOW.md +21 -15
- package/client/studio-client.js +50 -3
- package/client/studio.css +14 -0
- package/index.ts +319 -19
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
- **
|
|
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
|
|
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**,
|
|
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
|
-
|
|
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
|
-
-
|
|
96
|
-
-
|
|
97
|
-
-
|
|
98
|
-
- After touching
|
|
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
|
|
package/client/studio-client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
\\
|
|
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
|
-
|
|
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
|
-
|
|
2225
|
-
|
|
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
|
-
<
|
|
2769
|
-
<
|
|
2770
|
-
|
|
2771
|
-
|
|
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?: {
|
|
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 =
|
|
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,
|