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 +10 -0
- package/WORKFLOW.md +21 -15
- package/client/studio-client.js +50 -3
- package/client/studio.css +14 -0
- package/index.ts +282 -7
- package/package.json +1 -1
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
|
|
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
|
@@ -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
|
-
\\
|
|
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
|
-
<
|
|
2769
|
-
<
|
|
2770
|
-
|
|
2771
|
-
|
|
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?: {
|
|
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
|
|