pi-studio 0.9.27 → 0.9.29
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 +14 -0
- package/README.md +3 -1
- package/client/studio-client.js +154 -3
- package/client/studio.css +89 -3
- package/index.ts +180 -28
- package/package.json +1 -1
- package/shared/studio-ssh-hint.js +15 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ All notable changes to `pi-studio` are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.9.29] — 2026-06-09
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Added `/studio --no-browser` and `/studio --port <port>` launch flags for explicit remote/forwarded Studio sessions when SSH auto-detection is not enough.
|
|
11
|
+
|
|
12
|
+
## [0.9.28] — 2026-06-08
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- Added a footer **Pi model & thinking** menu for switching the active Pi model and thinking level from Studio while keeping Studio Suggest model choice separate.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Clarified the browser-import tooltip to explain that **Save editor as…** can make an imported copy file-backed.
|
|
19
|
+
- Regularized Source & context menu notes so explanatory text uses a quieter, consistent menu-note style.
|
|
20
|
+
|
|
7
21
|
## [0.9.27] — 2026-06-08
|
|
8
22
|
|
|
9
23
|
### Added
|
package/README.md
CHANGED
|
@@ -56,6 +56,8 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
|
|
|
56
56
|
| `/studio <path>` | Open with file preloaded |
|
|
57
57
|
| `/studio --last` | Force last response |
|
|
58
58
|
| `/studio --blank` | Force blank editor |
|
|
59
|
+
| `/studio --no-browser` | Start/print the Studio URL without opening a browser, useful for forwarded or phone/browser sessions |
|
|
60
|
+
| `/studio --port <port>` | Bind Studio to a fixed localhost port instead of a random free port |
|
|
59
61
|
| `/studio --status` | Show studio server status |
|
|
60
62
|
| `/studio --stop` | Stop studio server |
|
|
61
63
|
| `/studio --help` | Show help |
|
|
@@ -113,7 +115,7 @@ caption: Optional caption
|
|
|
113
115
|
## Notes
|
|
114
116
|
|
|
115
117
|
- Local-only server (`127.0.0.1`) with tokenized Studio URLs.
|
|
116
|
-
- For remote SSH sessions, keep Studio bound to localhost and use SSH local port forwarding; `/studio` and `/studio --status` print the full tokenized localhost URL. The SSH hint repeats the full URL so it is visible even if your terminal only shows the latest notification. Open that URL through the tunnel, preserving the `?token=...` parameter.
|
|
118
|
+
- For remote SSH sessions, keep Studio bound to localhost and use SSH local port forwarding; `/studio` and `/studio --status` print the full tokenized localhost URL. The SSH hint repeats the full URL so it is visible even if your terminal only shows the latest notification. Open that URL through the tunnel, preserving the `?token=...` parameter. If SSH is not auto-detected, use `/studio --no-browser`; for stable forwarding, use `/studio --port <port>` or combine them, e.g. `/studio --no-browser --port 3417`.
|
|
117
119
|
- Full Studio is a singleton per Pi session: use `/studio` to open it, `/studio-replace` to explicitly replace it, and `/studio-editor-only` for extra editing/preview tabs that do not take over the full Studio session view.
|
|
118
120
|
- Studio is designed as a complement to terminal pi, not a replacement.
|
|
119
121
|
- Installing pi-studio makes the optional `pi-studio-dark` and `pi-studio-light` themes available in pi's theme selector; it does not change your active theme.
|
package/client/studio-client.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
const footerMetaModelEl = document.getElementById("footerMetaModel");
|
|
8
8
|
const footerMetaTerminalEl = document.getElementById("footerMetaTerminal");
|
|
9
9
|
const footerMetaContextEl = document.getElementById("footerMetaContext");
|
|
10
|
+
const footerModelMenuEl = document.getElementById("footerModelMenu");
|
|
10
11
|
let faviconLinkEl = document.querySelector('link[rel="icon"], link[rel="shortcut icon"]');
|
|
11
12
|
if (!faviconLinkEl) {
|
|
12
13
|
faviconLinkEl = document.createElement("link");
|
|
@@ -522,6 +523,10 @@
|
|
|
522
523
|
let previewExportInProgress = false;
|
|
523
524
|
let compactInProgress = false;
|
|
524
525
|
let modelLabel = (document.body && document.body.dataset && document.body.dataset.modelLabel) || "none";
|
|
526
|
+
let piModelOptions = [];
|
|
527
|
+
let piCurrentModel = null;
|
|
528
|
+
let piThinkingLevel = "";
|
|
529
|
+
let footerModelMenuOpen = false;
|
|
525
530
|
let terminalSessionLabel = (document.body && document.body.dataset && document.body.dataset.terminalLabel) || "unknown";
|
|
526
531
|
let terminalSessionDetail = (document.body && document.body.dataset && document.body.dataset.terminalDetail) || terminalSessionLabel;
|
|
527
532
|
let contextTokens = null;
|
|
@@ -2510,13 +2515,13 @@
|
|
|
2510
2515
|
completionModelSelect.hidden = false;
|
|
2511
2516
|
suggestionItems.push(completionModelSelect);
|
|
2512
2517
|
}
|
|
2513
|
-
const completionThinkingNoteEl = makeStudioUiRefreshElement("div", "
|
|
2518
|
+
const completionThinkingNoteEl = makeStudioUiRefreshElement("div", "studio-refresh-menu-note completion-thinking-note", "Suggest: thinking off; model choice only affects suggestions.");
|
|
2514
2519
|
completionThinkingNoteEl.setAttribute("aria-label", "Suggestion model note");
|
|
2515
2520
|
suggestionItems.push(completionThinkingNoteEl);
|
|
2516
2521
|
appendStudioUiRefreshMenuSection(contextMenu.menu, "Suggestions", suggestionItems);
|
|
2517
2522
|
const statusItems = [];
|
|
2518
2523
|
if (!isEditorOnlyMode) {
|
|
2519
|
-
sourceSessionSummaryEl = makeStudioUiRefreshElement("div", "
|
|
2524
|
+
sourceSessionSummaryEl = makeStudioUiRefreshElement("div", "studio-refresh-menu-note source-session-summary", "Session tree: branch history follows the current Pi branch; editor text stays independent.");
|
|
2520
2525
|
sourceSessionSummaryEl.setAttribute("aria-label", "Pi session tree and editor sync behaviour");
|
|
2521
2526
|
sourceSessionSummaryEl.title = "Use /tree in the Pi terminal to navigate branches. Studio updates branch history to match the active branch and leaves editor text unchanged.";
|
|
2522
2527
|
statusItems.push(sourceSessionSummaryEl);
|
|
@@ -3179,6 +3184,118 @@
|
|
|
3179
3184
|
}
|
|
3180
3185
|
}
|
|
3181
3186
|
|
|
3187
|
+
function encodePiModelValue(provider, id) {
|
|
3188
|
+
return JSON.stringify([String(provider || ""), String(id || "")]);
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
function decodePiModelValue(value) {
|
|
3192
|
+
try {
|
|
3193
|
+
const parsed = JSON.parse(String(value || ""));
|
|
3194
|
+
if (!Array.isArray(parsed) || parsed.length < 2) return null;
|
|
3195
|
+
const provider = String(parsed[0] || "").trim();
|
|
3196
|
+
const id = String(parsed[1] || "").trim();
|
|
3197
|
+
return provider && id ? { provider, id } : null;
|
|
3198
|
+
} catch {
|
|
3199
|
+
return null;
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
function normalizePiModelOptions(options) {
|
|
3204
|
+
return Array.isArray(options)
|
|
3205
|
+
? options.map((option) => ({
|
|
3206
|
+
provider: String(option && option.provider || "").trim(),
|
|
3207
|
+
id: String(option && option.id || "").trim(),
|
|
3208
|
+
label: String(option && option.label || "").trim(),
|
|
3209
|
+
reasoning: Boolean(option && option.reasoning),
|
|
3210
|
+
})).filter((option) => option.provider && option.id)
|
|
3211
|
+
: [];
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
function updatePiSessionModelState(message) {
|
|
3215
|
+
if (!message || typeof message !== "object") return;
|
|
3216
|
+
if (Array.isArray(message.piModels)) {
|
|
3217
|
+
piModelOptions = normalizePiModelOptions(message.piModels);
|
|
3218
|
+
} else if (Array.isArray(message.suggestionModels) && !piModelOptions.length) {
|
|
3219
|
+
piModelOptions = normalizePiModelOptions(message.suggestionModels);
|
|
3220
|
+
}
|
|
3221
|
+
if (message.currentModel && typeof message.currentModel === "object") {
|
|
3222
|
+
const model = message.currentModel;
|
|
3223
|
+
const provider = String(model.provider || "").trim();
|
|
3224
|
+
const id = String(model.id || "").trim();
|
|
3225
|
+
piCurrentModel = provider && id ? {
|
|
3226
|
+
provider,
|
|
3227
|
+
id,
|
|
3228
|
+
label: String(model.label || "").trim(),
|
|
3229
|
+
reasoning: Boolean(model.reasoning),
|
|
3230
|
+
} : null;
|
|
3231
|
+
}
|
|
3232
|
+
if (typeof message.thinkingLevel === "string") {
|
|
3233
|
+
piThinkingLevel = message.thinkingLevel.trim();
|
|
3234
|
+
}
|
|
3235
|
+
renderFooterModelMenu();
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
function getPiCurrentModelValue() {
|
|
3239
|
+
return piCurrentModel && piCurrentModel.provider && piCurrentModel.id
|
|
3240
|
+
? encodePiModelValue(piCurrentModel.provider, piCurrentModel.id)
|
|
3241
|
+
: "";
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
function getPiThinkingLevels() {
|
|
3245
|
+
return ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
function renderFooterModelMenu() {
|
|
3249
|
+
if (!footerModelMenuEl) return;
|
|
3250
|
+
const currentValue = getPiCurrentModelValue();
|
|
3251
|
+
const optionValues = new Set(piModelOptions.map((option) => encodePiModelValue(option.provider, option.id)));
|
|
3252
|
+
const modelOptionsHtml = piModelOptions.map((option) => {
|
|
3253
|
+
const value = encodePiModelValue(option.provider, option.id);
|
|
3254
|
+
const label = option.label || (option.provider + "/" + option.id);
|
|
3255
|
+
return "<option value='" + escapeHtml(value) + "'" + (value === currentValue ? " selected" : "") + ">" + escapeHtml(label) + "</option>";
|
|
3256
|
+
});
|
|
3257
|
+
if (currentValue && !optionValues.has(currentValue)) {
|
|
3258
|
+
const label = piCurrentModel && piCurrentModel.label ? piCurrentModel.label : modelLabel;
|
|
3259
|
+
modelOptionsHtml.unshift("<option value='" + escapeHtml(currentValue) + "' selected>" + escapeHtml(label || "current model") + "</option>");
|
|
3260
|
+
}
|
|
3261
|
+
const thinking = piThinkingLevel || "off";
|
|
3262
|
+
const thinkingOptionsHtml = getPiThinkingLevels().map((level) => {
|
|
3263
|
+
return "<option value='" + escapeHtml(level) + "'" + (level === thinking ? " selected" : "") + ">Thinking: " + escapeHtml(level) + "</option>";
|
|
3264
|
+
});
|
|
3265
|
+
footerModelMenuEl.innerHTML = ""
|
|
3266
|
+
+ "<div class='footer-model-menu-heading'>Pi model & thinking</div>"
|
|
3267
|
+
+ "<label class='footer-model-menu-field'><span>Pi model</span><select id='footerPiModelSelect'>" + modelOptionsHtml.join("") + "</select></label>"
|
|
3268
|
+
+ "<label class='footer-model-menu-field'><span>Thinking</span><select id='footerPiThinkingSelect'>" + thinkingOptionsHtml.join("") + "</select></label>"
|
|
3269
|
+
+ "<div class='footer-model-menu-note'>Affects future Pi turns. Studio Suggest has its own model setting.</div>";
|
|
3270
|
+
}
|
|
3271
|
+
|
|
3272
|
+
function setFooterModelMenuOpen(open) {
|
|
3273
|
+
footerModelMenuOpen = Boolean(open);
|
|
3274
|
+
if (footerModelMenuEl) footerModelMenuEl.hidden = !footerModelMenuOpen;
|
|
3275
|
+
if (footerMetaModelEl) {
|
|
3276
|
+
footerMetaModelEl.classList.toggle("is-open", footerModelMenuOpen);
|
|
3277
|
+
footerMetaModelEl.setAttribute("aria-expanded", footerModelMenuOpen ? "true" : "false");
|
|
3278
|
+
}
|
|
3279
|
+
if (footerModelMenuOpen) renderFooterModelMenu();
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
function requestPiModelSelection(value) {
|
|
3283
|
+
const model = decodePiModelValue(value);
|
|
3284
|
+
if (!model) {
|
|
3285
|
+
setStatus("No Pi model selected.", "warning");
|
|
3286
|
+
return;
|
|
3287
|
+
}
|
|
3288
|
+
const sent = sendMessage({ type: "pi_model_select_request", provider: model.provider, id: model.id });
|
|
3289
|
+
if (sent) setStatus("Switching Pi model…", "warning");
|
|
3290
|
+
}
|
|
3291
|
+
|
|
3292
|
+
function requestPiThinkingLevel(level) {
|
|
3293
|
+
const normalized = String(level || "").trim();
|
|
3294
|
+
if (!normalized) return;
|
|
3295
|
+
const sent = sendMessage({ type: "pi_thinking_level_request", level: normalized });
|
|
3296
|
+
if (sent) setStatus("Setting Pi thinking level…", "warning");
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3182
3299
|
function updateFooterMeta() {
|
|
3183
3300
|
const modelText = modelLabel && modelLabel.trim() ? modelLabel.trim() : "none";
|
|
3184
3301
|
const terminalText = terminalSessionLabel && terminalSessionLabel.trim() ? terminalSessionLabel.trim() : "unknown";
|
|
@@ -3192,7 +3309,9 @@
|
|
|
3192
3309
|
footerMetaModelEl.textContent = modelText;
|
|
3193
3310
|
footerMetaTerminalEl.textContent = terminalText;
|
|
3194
3311
|
footerMetaContextEl.textContent = contextDisplayText;
|
|
3195
|
-
footerMetaModelEl.title = "
|
|
3312
|
+
footerMetaModelEl.title = "Pi model and thinking: " + modelText;
|
|
3313
|
+
footerMetaModelEl.setAttribute("aria-haspopup", "menu");
|
|
3314
|
+
footerMetaModelEl.setAttribute("aria-expanded", footerModelMenuOpen ? "true" : "false");
|
|
3196
3315
|
footerMetaTerminalEl.title = terminalDetailText;
|
|
3197
3316
|
footerMetaContextEl.title = contextTitleText;
|
|
3198
3317
|
if (footerMetaTextEl) footerMetaTextEl.title = titleText;
|
|
@@ -18649,6 +18768,7 @@
|
|
|
18649
18768
|
if (Array.isArray(message.suggestionModels)) {
|
|
18650
18769
|
updateCompletionSuggestionModelOptions(message.suggestionModels);
|
|
18651
18770
|
}
|
|
18771
|
+
updatePiSessionModelState(message);
|
|
18652
18772
|
if (typeof message.terminalSessionLabel === "string") {
|
|
18653
18773
|
terminalSessionLabel = message.terminalSessionLabel;
|
|
18654
18774
|
}
|
|
@@ -19224,6 +19344,7 @@
|
|
|
19224
19344
|
if (Array.isArray(message.suggestionModels)) {
|
|
19225
19345
|
updateCompletionSuggestionModelOptions(message.suggestionModels);
|
|
19226
19346
|
}
|
|
19347
|
+
updatePiSessionModelState(message);
|
|
19227
19348
|
if (typeof message.terminalSessionLabel === "string") {
|
|
19228
19349
|
terminalSessionLabel = message.terminalSessionLabel;
|
|
19229
19350
|
}
|
|
@@ -20103,9 +20224,39 @@
|
|
|
20103
20224
|
if (event.key === "Escape") {
|
|
20104
20225
|
closeExportPreviewMenu();
|
|
20105
20226
|
closePreviewLinkMenu();
|
|
20227
|
+
setFooterModelMenuOpen(false);
|
|
20106
20228
|
}
|
|
20107
20229
|
});
|
|
20108
20230
|
|
|
20231
|
+
if (footerMetaModelEl) {
|
|
20232
|
+
footerMetaModelEl.addEventListener("click", (event) => {
|
|
20233
|
+
event.preventDefault();
|
|
20234
|
+
event.stopPropagation();
|
|
20235
|
+
setFooterModelMenuOpen(!footerModelMenuOpen);
|
|
20236
|
+
});
|
|
20237
|
+
}
|
|
20238
|
+
if (footerModelMenuEl) {
|
|
20239
|
+
footerModelMenuEl.addEventListener("click", (event) => {
|
|
20240
|
+
event.stopPropagation();
|
|
20241
|
+
});
|
|
20242
|
+
footerModelMenuEl.addEventListener("change", (event) => {
|
|
20243
|
+
const target = event.target;
|
|
20244
|
+
if (!(target instanceof HTMLSelectElement)) return;
|
|
20245
|
+
if (target.id === "footerPiModelSelect") {
|
|
20246
|
+
requestPiModelSelection(target.value);
|
|
20247
|
+
setFooterModelMenuOpen(false);
|
|
20248
|
+
} else if (target.id === "footerPiThinkingSelect") {
|
|
20249
|
+
requestPiThinkingLevel(target.value);
|
|
20250
|
+
setFooterModelMenuOpen(false);
|
|
20251
|
+
}
|
|
20252
|
+
});
|
|
20253
|
+
}
|
|
20254
|
+
document.addEventListener("click", (event) => {
|
|
20255
|
+
const target = event.target;
|
|
20256
|
+
if (target instanceof Element && (target.closest("#footerModelMenu") || target.closest("#footerMetaModel"))) return;
|
|
20257
|
+
setFooterModelMenuOpen(false);
|
|
20258
|
+
});
|
|
20259
|
+
|
|
20109
20260
|
saveAsBtn.addEventListener("click", () => {
|
|
20110
20261
|
const content = sourceTextEl.value;
|
|
20111
20262
|
if (!content.trim()) {
|
package/client/studio.css
CHANGED
|
@@ -4241,6 +4241,82 @@
|
|
|
4241
4241
|
max-width: 34ch;
|
|
4242
4242
|
}
|
|
4243
4243
|
|
|
4244
|
+
.footer-model-btn {
|
|
4245
|
+
border: 1px solid transparent;
|
|
4246
|
+
border-radius: 7px;
|
|
4247
|
+
background: transparent;
|
|
4248
|
+
color: inherit;
|
|
4249
|
+
font: inherit;
|
|
4250
|
+
text-align: left;
|
|
4251
|
+
padding: 2px 4px;
|
|
4252
|
+
cursor: pointer;
|
|
4253
|
+
}
|
|
4254
|
+
|
|
4255
|
+
.footer-model-btn:hover,
|
|
4256
|
+
.footer-model-btn:focus-visible,
|
|
4257
|
+
.footer-model-btn.is-open {
|
|
4258
|
+
background: var(--panel-2);
|
|
4259
|
+
border-color: var(--control-border);
|
|
4260
|
+
color: var(--text);
|
|
4261
|
+
outline: none;
|
|
4262
|
+
}
|
|
4263
|
+
|
|
4264
|
+
.footer-model-menu {
|
|
4265
|
+
position: fixed;
|
|
4266
|
+
left: 12px;
|
|
4267
|
+
bottom: 38px;
|
|
4268
|
+
width: min(440px, calc(100vw - 24px));
|
|
4269
|
+
padding: 10px;
|
|
4270
|
+
border: 1px solid var(--panel-border);
|
|
4271
|
+
border-radius: 12px;
|
|
4272
|
+
background: var(--panel);
|
|
4273
|
+
color: var(--text);
|
|
4274
|
+
box-shadow: 0 18px 46px var(--shadow-color);
|
|
4275
|
+
z-index: 110;
|
|
4276
|
+
}
|
|
4277
|
+
|
|
4278
|
+
.footer-model-menu[hidden] {
|
|
4279
|
+
display: none !important;
|
|
4280
|
+
}
|
|
4281
|
+
|
|
4282
|
+
.footer-model-menu-heading {
|
|
4283
|
+
margin: 0 2px 8px;
|
|
4284
|
+
color: var(--muted);
|
|
4285
|
+
font-size: 11px;
|
|
4286
|
+
font-weight: 650;
|
|
4287
|
+
text-transform: uppercase;
|
|
4288
|
+
letter-spacing: 0.06em;
|
|
4289
|
+
}
|
|
4290
|
+
|
|
4291
|
+
.footer-model-menu-field {
|
|
4292
|
+
display: grid;
|
|
4293
|
+
gap: 4px;
|
|
4294
|
+
margin: 8px 0;
|
|
4295
|
+
min-width: 0;
|
|
4296
|
+
}
|
|
4297
|
+
|
|
4298
|
+
.footer-model-menu-field span,
|
|
4299
|
+
.footer-model-menu-note {
|
|
4300
|
+
color: var(--muted);
|
|
4301
|
+
font-size: 12px;
|
|
4302
|
+
line-height: 1.35;
|
|
4303
|
+
}
|
|
4304
|
+
|
|
4305
|
+
.footer-model-menu-field select {
|
|
4306
|
+
width: 100%;
|
|
4307
|
+
min-width: 0;
|
|
4308
|
+
background: var(--panel-2);
|
|
4309
|
+
color: var(--text);
|
|
4310
|
+
border: 1px solid var(--control-border);
|
|
4311
|
+
border-radius: 8px;
|
|
4312
|
+
padding: 5px 7px;
|
|
4313
|
+
font: inherit;
|
|
4314
|
+
}
|
|
4315
|
+
|
|
4316
|
+
.footer-model-menu-note {
|
|
4317
|
+
margin-top: 8px;
|
|
4318
|
+
}
|
|
4319
|
+
|
|
4244
4320
|
.footer-meta-terminal {
|
|
4245
4321
|
flex: 0 4 auto;
|
|
4246
4322
|
max-width: 34ch;
|
|
@@ -5629,9 +5705,7 @@
|
|
|
5629
5705
|
font-weight: 450;
|
|
5630
5706
|
}
|
|
5631
5707
|
|
|
5632
|
-
body.studio-ui-refresh .studio-refresh-menu-item > .source-origin-summary
|
|
5633
|
-
body.studio-ui-refresh .studio-refresh-menu-item > .source-session-summary,
|
|
5634
|
-
body.studio-ui-refresh .studio-refresh-menu-item > .completion-thinking-note {
|
|
5708
|
+
body.studio-ui-refresh .studio-refresh-menu-item > .source-origin-summary {
|
|
5635
5709
|
width: 100%;
|
|
5636
5710
|
min-width: 0;
|
|
5637
5711
|
border-color: var(--border-subtle);
|
|
@@ -5640,6 +5714,18 @@
|
|
|
5640
5714
|
white-space: normal;
|
|
5641
5715
|
overflow-wrap: anywhere;
|
|
5642
5716
|
line-height: 1.35;
|
|
5717
|
+
font-weight: 500;
|
|
5718
|
+
}
|
|
5719
|
+
|
|
5720
|
+
body.studio-ui-refresh .studio-refresh-menu-note {
|
|
5721
|
+
width: 100%;
|
|
5722
|
+
min-width: 0;
|
|
5723
|
+
padding: 4px 6px;
|
|
5724
|
+
color: var(--muted);
|
|
5725
|
+
font-size: 12px;
|
|
5726
|
+
line-height: 1.35;
|
|
5727
|
+
white-space: normal;
|
|
5728
|
+
overflow-wrap: anywhere;
|
|
5643
5729
|
}
|
|
5644
5730
|
|
|
5645
5731
|
body.studio-ui-refresh .studio-refresh-menu #critiqueBtn {
|
package/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { completeSimple, type ThinkingLevel } from "@earendil-works/pi-ai";
|
|
3
|
+
import { completeSimple, type ModelThinkingLevel, type ThinkingLevel } from "@earendil-works/pi-ai";
|
|
4
4
|
import { Type } from "@sinclair/typebox";
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
} from "./shared/studio-markdown-latex-literals.js";
|
|
30
30
|
import { escapeStudioPdfLatexTextFragment } from "./shared/studio-pdf-escape.js";
|
|
31
31
|
import { resolveStudioPdfResourceFile } from "./shared/studio-pdf-resource.js";
|
|
32
|
-
import { buildStudioSshTunnelHint, isStudioSshSession as isSshSession } from "./shared/studio-ssh-hint.js";
|
|
32
|
+
import { buildStudioForwardingHint, buildStudioSshTunnelHint, isStudioSshSession as isSshSession } from "./shared/studio-ssh-hint.js";
|
|
33
33
|
|
|
34
34
|
type Lens = "writing" | "code";
|
|
35
35
|
type RequestedLens = Lens | "auto";
|
|
@@ -349,6 +349,17 @@ interface CompletionSuggestionCancelRequestMessage {
|
|
|
349
349
|
requestId: string;
|
|
350
350
|
}
|
|
351
351
|
|
|
352
|
+
interface PiModelSelectRequestMessage {
|
|
353
|
+
type: "pi_model_select_request";
|
|
354
|
+
provider: string;
|
|
355
|
+
id: string;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
interface PiThinkingLevelRequestMessage {
|
|
359
|
+
type: "pi_thinking_level_request";
|
|
360
|
+
level: ModelThinkingLevel;
|
|
361
|
+
}
|
|
362
|
+
|
|
352
363
|
interface QuizGenerateRequestMessage {
|
|
353
364
|
type: "quiz_generate_request";
|
|
354
365
|
requestId: string;
|
|
@@ -492,6 +503,8 @@ type IncomingStudioMessage =
|
|
|
492
503
|
| SendRunRequestMessage
|
|
493
504
|
| CompletionSuggestionRequestMessage
|
|
494
505
|
| CompletionSuggestionCancelRequestMessage
|
|
506
|
+
| PiModelSelectRequestMessage
|
|
507
|
+
| PiThinkingLevelRequestMessage
|
|
495
508
|
| QuizGenerateRequestMessage
|
|
496
509
|
| QuizAnswerRequestMessage
|
|
497
510
|
| QuizDiscussRequestMessage
|
|
@@ -8324,6 +8337,24 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
|
|
|
8324
8337
|
};
|
|
8325
8338
|
}
|
|
8326
8339
|
|
|
8340
|
+
if (msg.type === "pi_model_select_request" && typeof msg.provider === "string" && typeof msg.id === "string") {
|
|
8341
|
+
return {
|
|
8342
|
+
type: "pi_model_select_request",
|
|
8343
|
+
provider: msg.provider,
|
|
8344
|
+
id: msg.id,
|
|
8345
|
+
};
|
|
8346
|
+
}
|
|
8347
|
+
|
|
8348
|
+
if (msg.type === "pi_thinking_level_request" && typeof msg.level === "string") {
|
|
8349
|
+
const level = msg.level.trim().toLowerCase();
|
|
8350
|
+
if (level === "off" || level === "minimal" || level === "low" || level === "medium" || level === "high" || level === "xhigh") {
|
|
8351
|
+
return {
|
|
8352
|
+
type: "pi_thinking_level_request",
|
|
8353
|
+
level,
|
|
8354
|
+
};
|
|
8355
|
+
}
|
|
8356
|
+
}
|
|
8357
|
+
|
|
8327
8358
|
if (msg.type === "completion_suggestion_request" && typeof msg.requestId === "string" && typeof msg.text === "string") {
|
|
8328
8359
|
const textLength = msg.text.length;
|
|
8329
8360
|
const rawStart = typeof msg.selectionStart === "number" && Number.isFinite(msg.selectionStart) ? msg.selectionStart : textLength;
|
|
@@ -9812,22 +9843,53 @@ function buildStudioUrl(
|
|
|
9812
9843
|
return `http://127.0.0.1:${port}/?${params.toString()}`;
|
|
9813
9844
|
}
|
|
9814
9845
|
|
|
9815
|
-
|
|
9846
|
+
interface StudioLaunchFlags {
|
|
9847
|
+
args: string;
|
|
9848
|
+
openRemoteBrowser: boolean;
|
|
9849
|
+
noBrowser: boolean;
|
|
9850
|
+
port?: number;
|
|
9851
|
+
error?: string;
|
|
9852
|
+
}
|
|
9853
|
+
|
|
9854
|
+
function parseStudioLaunchOpenFlags(rawArgs: string): StudioLaunchFlags {
|
|
9816
9855
|
const parsed = tokenizeStudioCommandArgs(rawArgs);
|
|
9817
|
-
if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, error: parsed.error };
|
|
9856
|
+
if (parsed.error) return { args: rawArgs, openRemoteBrowser: false, noBrowser: false, error: parsed.error };
|
|
9818
9857
|
const remaining: string[] = [];
|
|
9819
9858
|
let openRemoteBrowser = false;
|
|
9820
|
-
|
|
9821
|
-
|
|
9859
|
+
let noBrowser = false;
|
|
9860
|
+
let port: number | undefined;
|
|
9861
|
+
for (let i = 0; i < parsed.tokens.length; i += 1) {
|
|
9862
|
+
const token = parsed.tokens[i]!;
|
|
9863
|
+
if (token === "--open-remote" || token === "--open-remote-browser" || token === "--open-browser") {
|
|
9822
9864
|
openRemoteBrowser = true;
|
|
9823
9865
|
continue;
|
|
9824
9866
|
}
|
|
9867
|
+
if (token === "--no-browser" || token === "--no-open" || token === "--no-open-browser") {
|
|
9868
|
+
noBrowser = true;
|
|
9869
|
+
continue;
|
|
9870
|
+
}
|
|
9871
|
+
if (token === "--port" || token.startsWith("--port=")) {
|
|
9872
|
+
const rawPort = token.startsWith("--port=") ? token.slice("--port=".length) : parsed.tokens[++i];
|
|
9873
|
+
if (!rawPort) {
|
|
9874
|
+
return { args: rawArgs, openRemoteBrowser, noBrowser, error: "Missing value for --port." };
|
|
9875
|
+
}
|
|
9876
|
+
const requestedPort = Number(rawPort);
|
|
9877
|
+
if (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65535) {
|
|
9878
|
+
return { args: rawArgs, openRemoteBrowser, noBrowser, error: `Invalid --port value: ${rawPort}. Use an integer from 1 to 65535.` };
|
|
9879
|
+
}
|
|
9880
|
+
port = requestedPort;
|
|
9881
|
+
continue;
|
|
9882
|
+
}
|
|
9825
9883
|
remaining.push(token);
|
|
9826
9884
|
}
|
|
9827
|
-
|
|
9885
|
+
if (openRemoteBrowser && noBrowser) {
|
|
9886
|
+
return { args: rawArgs, openRemoteBrowser, noBrowser, port, error: "Use either --no-browser or --open-browser, not both." };
|
|
9887
|
+
}
|
|
9888
|
+
return { args: remaining.join(" "), openRemoteBrowser, noBrowser, port };
|
|
9828
9889
|
}
|
|
9829
9890
|
|
|
9830
|
-
function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean }): boolean {
|
|
9891
|
+
function shouldAutoOpenStudioBrowser(options?: { openRemoteBrowser?: boolean; noBrowser?: boolean }): boolean {
|
|
9892
|
+
if (options?.noBrowser) return false;
|
|
9831
9893
|
return !isSshSession() || Boolean(options?.openRemoteBrowser);
|
|
9832
9894
|
}
|
|
9833
9895
|
|
|
@@ -10220,7 +10282,7 @@ ${cssVarsBlock}
|
|
|
10220
10282
|
<button id="saveOverBtn" type="button" title="Overwrite current file with editor content. Shortcut: Cmd/Ctrl+S.">Save editor</button>
|
|
10221
10283
|
<button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
|
|
10222
10284
|
<button id="clearWorkspaceBtn" type="button" title="Clear editor text and reset this tab to a fresh blank draft. Saved files and responses are not changed.">Reset editor</button>
|
|
10223
|
-
<label class="file-label" title="Browser import: load a selected text file as a detached
|
|
10285
|
+
<label class="file-label" title="Browser import: load a selected text file as a detached copy. Use Save editor as… to attach this copy to a file path and make it file-backed, or use the Files view to open a refreshable file-backed document directly.">Import file copy…<input id="fileInput" type="file" accept=".md,.markdown,.mdx,.qmd,.js,.mjs,.cjs,.jsx,.ts,.mts,.cts,.tsx,.py,.pyw,.sh,.bash,.zsh,.json,.jsonc,.json5,.rs,.c,.h,.cpp,.cxx,.cc,.hpp,.hxx,.jl,.f90,.f95,.f03,.f,.for,.r,.R,.m,.tex,.latex,.diff,.patch,.java,.go,.rb,.swift,.html,.htm,.css,.xml,.yaml,.yml,.toml,.lua,.txt,.rst,.adoc" /></label>
|
|
10224
10286
|
<button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
|
|
10225
10287
|
<button id="zenModeBtn" class="zen-mode-btn" type="button" title="Hide secondary Studio controls. Shortcut: F9.">Zen</button>
|
|
10226
10288
|
</div>
|
|
@@ -10503,7 +10565,8 @@ ${cssVarsBlock}
|
|
|
10503
10565
|
|
|
10504
10566
|
<footer>
|
|
10505
10567
|
<span id="statusLine"><span id="statusSpinner" aria-hidden="true"> </span><span id="status">Booting studio…</span></span>
|
|
10506
|
-
<span id="footerMeta" class="footer-meta"><span id="footerMetaText" class="footer-meta-text"><
|
|
10568
|
+
<span id="footerMeta" class="footer-meta"><span id="footerMetaText" class="footer-meta-text"><button id="footerMetaModel" class="footer-meta-part footer-meta-model footer-model-btn" type="button" aria-haspopup="menu" aria-expanded="false">${initialModel}</button><span class="footer-meta-sep">·</span><span id="footerMetaTerminal" class="footer-meta-part footer-meta-terminal">${initialTerminal}</span><span class="footer-meta-sep">·</span><span id="footerMetaContext" class="footer-meta-part footer-meta-context">unknown</span></span><button id="compactBtn" class="footer-compact-btn" type="button" title="Trigger pi context compaction now.">Compact</button></span>
|
|
10569
|
+
<div id="footerModelMenu" class="footer-model-menu" hidden></div>
|
|
10507
10570
|
<button id="shortcutsBtn" class="shortcut-hint" type="button" title="Show Studio keyboard shortcuts. Press ? when not editing text.">Shortcuts (?)</button>
|
|
10508
10571
|
</footer>
|
|
10509
10572
|
|
|
@@ -10624,7 +10687,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
10624
10687
|
let terminalActivityToolName: string | null = null;
|
|
10625
10688
|
let terminalActivityLabel: string | null = null;
|
|
10626
10689
|
let lastSpecificToolActivityLabel: string | null = null;
|
|
10627
|
-
let currentModel: { provider?: string; id?: string } | undefined;
|
|
10690
|
+
let currentModel: { provider?: string; id?: string; name?: string; reasoning?: boolean } | undefined;
|
|
10628
10691
|
let currentModelLabel = "none";
|
|
10629
10692
|
let terminalSessionLabel = buildTerminalSessionLabel(studioCwd);
|
|
10630
10693
|
let terminalSessionDetail = buildTerminalSessionDetail(studioCwd);
|
|
@@ -10897,15 +10960,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
10897
10960
|
}
|
|
10898
10961
|
};
|
|
10899
10962
|
|
|
10900
|
-
const getThinkingLevelSafe = ():
|
|
10963
|
+
const getThinkingLevelSafe = (): ModelThinkingLevel | undefined => {
|
|
10901
10964
|
try {
|
|
10902
|
-
return pi.getThinkingLevel();
|
|
10965
|
+
return pi.getThinkingLevel() as ModelThinkingLevel;
|
|
10903
10966
|
} catch {
|
|
10904
10967
|
return undefined;
|
|
10905
10968
|
}
|
|
10906
10969
|
};
|
|
10907
10970
|
|
|
10908
|
-
const
|
|
10971
|
+
const setThinkingLevelSafe = (level: ModelThinkingLevel) => {
|
|
10972
|
+
// Pi's CLI/model config support "off" as a thinking level; some extension API typings still expose the narrower reasoning-only type.
|
|
10973
|
+
(pi.setThinkingLevel as (nextLevel: ModelThinkingLevel) => void)(level);
|
|
10974
|
+
};
|
|
10975
|
+
|
|
10976
|
+
const refreshRuntimeMetadata = (ctx?: { cwd?: string; model?: { provider?: string; id?: string; name?: string; reasoning?: boolean } | undefined }) => {
|
|
10909
10977
|
if (ctx?.cwd) {
|
|
10910
10978
|
studioCwd = ctx.cwd;
|
|
10911
10979
|
}
|
|
@@ -10914,6 +10982,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
10914
10982
|
currentModel = {
|
|
10915
10983
|
provider: ctx.model.provider,
|
|
10916
10984
|
id: ctx.model.id,
|
|
10985
|
+
name: ctx.model.name,
|
|
10986
|
+
reasoning: Boolean(ctx.model.reasoning),
|
|
10917
10987
|
};
|
|
10918
10988
|
} else {
|
|
10919
10989
|
currentModel = undefined;
|
|
@@ -10922,6 +10992,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
10922
10992
|
currentModel = {
|
|
10923
10993
|
provider: lastCommandCtx.model.provider,
|
|
10924
10994
|
id: lastCommandCtx.model.id,
|
|
10995
|
+
name: lastCommandCtx.model.name,
|
|
10996
|
+
reasoning: Boolean(lastCommandCtx.model.reasoning),
|
|
10925
10997
|
};
|
|
10926
10998
|
}
|
|
10927
10999
|
const baseModelLabel = formatModelLabel(currentModel);
|
|
@@ -11606,7 +11678,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
11606
11678
|
broadcastState();
|
|
11607
11679
|
};
|
|
11608
11680
|
|
|
11609
|
-
const
|
|
11681
|
+
const getStudioModelOptions = () => {
|
|
11610
11682
|
const registry = lastCommandCtx?.modelRegistry ?? latestModelRequestCtx?.modelRegistry;
|
|
11611
11683
|
if (!registry || typeof registry.getAvailable !== "function") return [];
|
|
11612
11684
|
return registry.getAvailable().map((model) => ({
|
|
@@ -11617,11 +11689,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
11617
11689
|
}));
|
|
11618
11690
|
};
|
|
11619
11691
|
|
|
11692
|
+
const getCurrentStudioModelDescriptor = () => currentModel
|
|
11693
|
+
? {
|
|
11694
|
+
provider: currentModel.provider,
|
|
11695
|
+
id: currentModel.id,
|
|
11696
|
+
label: formatStudioModelOptionLabel(currentModel),
|
|
11697
|
+
reasoning: Boolean(currentModel.reasoning),
|
|
11698
|
+
}
|
|
11699
|
+
: null;
|
|
11700
|
+
|
|
11620
11701
|
const broadcastState = () => {
|
|
11621
11702
|
terminalSessionLabel = buildTerminalSessionLabel(studioCwd, getSessionNameSafe());
|
|
11622
11703
|
terminalSessionDetail = buildTerminalSessionDetail(studioCwd, getSessionNameSafe());
|
|
11623
11704
|
currentModelLabel = formatModelLabelWithThinking(formatModelLabel(currentModel), getThinkingLevelSafe());
|
|
11624
11705
|
refreshContextUsage();
|
|
11706
|
+
const modelOptions = getStudioModelOptions();
|
|
11625
11707
|
broadcast({
|
|
11626
11708
|
type: "studio_state",
|
|
11627
11709
|
busy: isStudioBusy(),
|
|
@@ -11630,7 +11712,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
11630
11712
|
terminalToolName: terminalActivityToolName,
|
|
11631
11713
|
terminalActivityLabel,
|
|
11632
11714
|
modelLabel: currentModelLabel,
|
|
11633
|
-
|
|
11715
|
+
currentModel: getCurrentStudioModelDescriptor(),
|
|
11716
|
+
thinkingLevel: getThinkingLevelSafe() ?? "off",
|
|
11717
|
+
piModels: modelOptions,
|
|
11718
|
+
suggestionModels: modelOptions,
|
|
11634
11719
|
terminalSessionLabel,
|
|
11635
11720
|
terminalSessionDetail,
|
|
11636
11721
|
contextTokens: contextUsageSnapshot.tokens,
|
|
@@ -11926,7 +12011,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
11926
12011
|
terminalToolName: terminalActivityToolName,
|
|
11927
12012
|
terminalActivityLabel,
|
|
11928
12013
|
modelLabel: currentModelLabel,
|
|
11929
|
-
|
|
12014
|
+
currentModel: getCurrentStudioModelDescriptor(),
|
|
12015
|
+
thinkingLevel: getThinkingLevelSafe() ?? "off",
|
|
12016
|
+
piModels: getStudioModelOptions(),
|
|
12017
|
+
suggestionModels: getStudioModelOptions(),
|
|
11930
12018
|
terminalSessionLabel,
|
|
11931
12019
|
terminalSessionDetail,
|
|
11932
12020
|
contextTokens: contextUsageSnapshot.tokens,
|
|
@@ -11945,6 +12033,47 @@ export default function (pi: ExtensionAPI) {
|
|
|
11945
12033
|
return;
|
|
11946
12034
|
}
|
|
11947
12035
|
|
|
12036
|
+
if (msg.type === "pi_model_select_request") {
|
|
12037
|
+
void (async () => {
|
|
12038
|
+
const registry = lastCommandCtx?.modelRegistry ?? latestModelRequestCtx?.modelRegistry;
|
|
12039
|
+
if (!registry || typeof registry.find !== "function") {
|
|
12040
|
+
sendToClient(client, { type: "info", level: "warning", message: "Pi model registry is not available yet." });
|
|
12041
|
+
return;
|
|
12042
|
+
}
|
|
12043
|
+
const model = registry.find(msg.provider, msg.id);
|
|
12044
|
+
if (!model) {
|
|
12045
|
+
sendToClient(client, { type: "info", level: "warning", message: `Pi model not found: ${msg.provider}/${msg.id}` });
|
|
12046
|
+
return;
|
|
12047
|
+
}
|
|
12048
|
+
try {
|
|
12049
|
+
const ok = await pi.setModel(model);
|
|
12050
|
+
if (!ok) {
|
|
12051
|
+
sendToClient(client, { type: "info", level: "warning", message: `Could not switch to ${formatStudioModelOptionLabel(model)}; credentials may be unavailable.` });
|
|
12052
|
+
return;
|
|
12053
|
+
}
|
|
12054
|
+
latestModelRequestCtx = { model, modelRegistry: registry };
|
|
12055
|
+
refreshRuntimeMetadata({ model });
|
|
12056
|
+
broadcastState();
|
|
12057
|
+
sendToClient(client, { type: "info", level: "info", message: `Pi model switched to ${formatStudioModelOptionLabel(model)}.` });
|
|
12058
|
+
} catch (error) {
|
|
12059
|
+
sendToClient(client, { type: "info", level: "error", message: `Model switch failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
12060
|
+
}
|
|
12061
|
+
})();
|
|
12062
|
+
return;
|
|
12063
|
+
}
|
|
12064
|
+
|
|
12065
|
+
if (msg.type === "pi_thinking_level_request") {
|
|
12066
|
+
try {
|
|
12067
|
+
setThinkingLevelSafe(msg.level);
|
|
12068
|
+
refreshRuntimeMetadata({ model: lastCommandCtx?.model ?? latestModelRequestCtx?.model });
|
|
12069
|
+
broadcastState();
|
|
12070
|
+
sendToClient(client, { type: "info", level: "info", message: `Pi thinking level set to ${getThinkingLevelSafe() ?? msg.level}.` });
|
|
12071
|
+
} catch (error) {
|
|
12072
|
+
sendToClient(client, { type: "info", level: "error", message: `Thinking level change failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
12073
|
+
}
|
|
12074
|
+
return;
|
|
12075
|
+
}
|
|
12076
|
+
|
|
11948
12077
|
if (msg.type === "get_latest_response") {
|
|
11949
12078
|
if (!lastStudioResponse) {
|
|
11950
12079
|
sendToClient(client, { type: "info", message: "No latest assistant response is available yet." });
|
|
@@ -13998,7 +14127,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
13998
14127
|
res.end(buildStudioHtml(requestInitialDocument, serverState.token, lastCommandCtx?.ui.theme, currentModelLabel, terminalSessionLabel, terminalSessionDetail, contextUsageSnapshot, studioMode));
|
|
13999
14128
|
};
|
|
14000
14129
|
|
|
14001
|
-
const ensureServer = async (): Promise<StudioServerState> => {
|
|
14130
|
+
const ensureServer = async (requestedPort?: number): Promise<StudioServerState> => {
|
|
14002
14131
|
if (serverState) return serverState;
|
|
14003
14132
|
|
|
14004
14133
|
const server = createServer(handleHttpRequest);
|
|
@@ -14086,6 +14215,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
14086
14215
|
});
|
|
14087
14216
|
});
|
|
14088
14217
|
|
|
14218
|
+
const listenPort = typeof requestedPort === "number" && Number.isInteger(requestedPort) && requestedPort > 0 ? requestedPort : 0;
|
|
14219
|
+
|
|
14089
14220
|
await new Promise<void>((resolve, reject) => {
|
|
14090
14221
|
const onError = (error: Error) => {
|
|
14091
14222
|
server.off("listening", onListening);
|
|
@@ -14097,7 +14228,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
14097
14228
|
};
|
|
14098
14229
|
server.once("error", onError);
|
|
14099
14230
|
server.once("listening", onListening);
|
|
14100
|
-
server.listen(
|
|
14231
|
+
server.listen(listenPort, "127.0.0.1");
|
|
14101
14232
|
});
|
|
14102
14233
|
|
|
14103
14234
|
const address = server.address();
|
|
@@ -14883,6 +15014,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
14883
15014
|
return;
|
|
14884
15015
|
}
|
|
14885
15016
|
const launchArgs = launchOpenFlags.args;
|
|
15017
|
+
if (serverState && launchOpenFlags.port && serverState.port !== launchOpenFlags.port) {
|
|
15018
|
+
ctx.ui.notify(`Studio server is already running on port ${serverState.port}; requested port ${launchOpenFlags.port}. Use /studio --stop, then restart Studio with --port ${launchOpenFlags.port} to change it.`, "warning");
|
|
15019
|
+
}
|
|
14886
15020
|
if (mode === "full" && hasConnectedFullStudioView()) {
|
|
14887
15021
|
if (options?.replaceExistingFull) {
|
|
14888
15022
|
closeStudioClientsByMode("full", 4001, "Full Studio replaced");
|
|
@@ -14891,8 +15025,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
14891
15025
|
if (serverState) {
|
|
14892
15026
|
const url = buildStudioUrl(serverState.port, serverState.token, "full");
|
|
14893
15027
|
ctx.ui.notify(`Studio URL: ${url}`, "info");
|
|
14894
|
-
const
|
|
14895
|
-
|
|
15028
|
+
const tunnelHint = buildStudioSshTunnelHint(serverState.port, url)
|
|
15029
|
+
?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(serverState.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
|
|
15030
|
+
if (tunnelHint) ctx.ui.notify(tunnelHint, "info");
|
|
14896
15031
|
}
|
|
14897
15032
|
return;
|
|
14898
15033
|
}
|
|
@@ -14917,17 +15052,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
14917
15052
|
if (!selected) return;
|
|
14918
15053
|
initialStudioDocument = selected;
|
|
14919
15054
|
|
|
14920
|
-
|
|
15055
|
+
let state: StudioServerState;
|
|
15056
|
+
try {
|
|
15057
|
+
state = await ensureServer(launchOpenFlags.port);
|
|
15058
|
+
} catch (error) {
|
|
15059
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
15060
|
+
const portText = launchOpenFlags.port ? ` on port ${launchOpenFlags.port}` : "";
|
|
15061
|
+
ctx.ui.notify(`Failed to start Studio server${portText}: ${message}`, "error");
|
|
15062
|
+
return;
|
|
15063
|
+
}
|
|
14921
15064
|
const url = buildStudioUrl(state.port, state.token, mode, selected);
|
|
14922
|
-
const
|
|
15065
|
+
const tunnelHint = buildStudioSshTunnelHint(state.port, url)
|
|
15066
|
+
?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
|
|
14923
15067
|
const openedLabel = mode === "editor-only" ? "pi Studio editor-only view" : "pi Studio";
|
|
14924
15068
|
|
|
14925
15069
|
const shouldOpenBrowser = shouldAutoOpenStudioBrowser({
|
|
14926
15070
|
openRemoteBrowser: launchOpenFlags.openRemoteBrowser,
|
|
15071
|
+
noBrowser: launchOpenFlags.noBrowser,
|
|
14927
15072
|
});
|
|
14928
15073
|
try {
|
|
14929
15074
|
if (!shouldOpenBrowser) {
|
|
14930
|
-
|
|
15075
|
+
const skipReason = launchOpenFlags.noBrowser ? "--no-browser was used" : "SSH was detected";
|
|
15076
|
+
ctx.ui.notify(`${openedLabel} is ready. Browser auto-open was skipped because ${skipReason}.`, "info");
|
|
14931
15077
|
} else {
|
|
14932
15078
|
await openUrlInDefaultBrowser(url);
|
|
14933
15079
|
if (selected.source === "file") {
|
|
@@ -14947,12 +15093,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
14947
15093
|
}
|
|
14948
15094
|
} finally {
|
|
14949
15095
|
ctx.ui.notify(`Studio URL: ${url}`, "info");
|
|
14950
|
-
if (
|
|
15096
|
+
if (tunnelHint) ctx.ui.notify(tunnelHint, "info");
|
|
14951
15097
|
}
|
|
14952
15098
|
};
|
|
14953
15099
|
|
|
14954
15100
|
pi.registerCommand("studio", {
|
|
14955
|
-
description: "Open pi Studio browser UI (/studio, /studio <file>, /studio --blank, /studio --last)",
|
|
15101
|
+
description: "Open pi Studio browser UI (/studio, /studio <file>, /studio --blank, /studio --last, /studio --no-browser, /studio --port <port>)",
|
|
14956
15102
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
14957
15103
|
const trimmed = args.trim();
|
|
14958
15104
|
|
|
@@ -14985,6 +15131,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
14985
15131
|
+ " /studio <path> Open studio with file preloaded\n"
|
|
14986
15132
|
+ " /studio --blank Open with blank editor\n"
|
|
14987
15133
|
+ " /studio --last Open with last model response\n"
|
|
15134
|
+
+ " /studio --no-browser Print the Studio URL without opening a browser\n"
|
|
15135
|
+
+ " /studio --port <port> Bind Studio to a fixed localhost port when starting\n"
|
|
14988
15136
|
+ " /studio --open-remote Over SSH, open the remote browser anyway\n"
|
|
14989
15137
|
+ " /studio --status Show studio status\n"
|
|
14990
15138
|
+ " /studio --stop Stop studio server\n"
|
|
@@ -15004,7 +15152,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
15004
15152
|
});
|
|
15005
15153
|
|
|
15006
15154
|
pi.registerCommand("studio-replace", {
|
|
15007
|
-
description: "Replace the current full pi Studio view (/studio-replace, /studio-replace <file
|
|
15155
|
+
description: "Replace the current full pi Studio view (/studio-replace, /studio-replace <file>, /studio-replace --no-browser)",
|
|
15008
15156
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
15009
15157
|
const trimmed = args.trim();
|
|
15010
15158
|
if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
|
|
@@ -15014,6 +15162,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
15014
15162
|
+ " /studio-replace <path> Replace the current full Studio view with file preloaded\n"
|
|
15015
15163
|
+ " /studio-replace --blank Replace with blank editor\n"
|
|
15016
15164
|
+ " /studio-replace --last Replace with last model response\n"
|
|
15165
|
+
+ " /studio-replace --no-browser Print URL without opening a browser\n"
|
|
15166
|
+
+ " /studio-replace --port <port> Bind Studio to a fixed localhost port when starting\n"
|
|
15017
15167
|
+ "Editor-only Studio views stay open.",
|
|
15018
15168
|
"info",
|
|
15019
15169
|
);
|
|
@@ -15029,7 +15179,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
15029
15179
|
});
|
|
15030
15180
|
|
|
15031
15181
|
pi.registerCommand("studio-editor-only", {
|
|
15032
|
-
description: "Open pi Studio in editor-only mode (/studio-editor-only, /studio-editor-only <file
|
|
15182
|
+
description: "Open pi Studio in editor-only mode (/studio-editor-only, /studio-editor-only <file>, /studio-editor-only --no-browser)",
|
|
15033
15183
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
15034
15184
|
const trimmed = args.trim();
|
|
15035
15185
|
if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
|
|
@@ -15039,6 +15189,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
15039
15189
|
+ " /studio-editor-only <path> Open an editor-only Studio view with file preloaded\n"
|
|
15040
15190
|
+ " /studio-editor-only --blank Open with blank editor\n"
|
|
15041
15191
|
+ " /studio-editor-only --last Open with last model response loaded into the editor\n"
|
|
15192
|
+
+ " /studio-editor-only --no-browser Print URL without opening a browser\n"
|
|
15193
|
+
+ " /studio-editor-only --port <port> Bind Studio to a fixed localhost port when starting\n"
|
|
15042
15194
|
+ "Multiple editor-only views are allowed in the same Pi session.",
|
|
15043
15195
|
"info",
|
|
15044
15196
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-studio",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.29",
|
|
4
4
|
"description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -4,16 +4,25 @@ export function isStudioSshSession(env = process.env) {
|
|
|
4
4
|
);
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
export function
|
|
8
|
-
if (!isStudioSshSession(env)) return null;
|
|
7
|
+
export function buildStudioForwardingHint(port, studioUrl, options = {}) {
|
|
9
8
|
const normalizedPort = Number(port);
|
|
10
9
|
const remotePort = Number.isInteger(normalizedPort) && normalizedPort > 0 ? normalizedPort : port;
|
|
11
10
|
const url = String(studioUrl || "").trim();
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
const prefix = String(options.prefix || "").trim();
|
|
12
|
+
const lines = [];
|
|
13
|
+
if (prefix) lines.push(prefix);
|
|
14
|
+
lines.push(
|
|
15
|
+
"To open Studio locally through SSH, run this on your local machine:",
|
|
15
16
|
` ssh -L ${remotePort}:127.0.0.1:${remotePort} <remote-host>`,
|
|
16
17
|
"Then open this Studio URL in your local browser:",
|
|
17
18
|
` ${url}`,
|
|
18
|
-
|
|
19
|
+
);
|
|
20
|
+
return lines.join("\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildStudioSshTunnelHint(port, studioUrl, env = process.env) {
|
|
24
|
+
if (!isStudioSshSession(env)) return null;
|
|
25
|
+
return buildStudioForwardingHint(port, studioUrl, {
|
|
26
|
+
prefix: "SSH detected. Studio was not opened in the remote browser.",
|
|
27
|
+
});
|
|
19
28
|
}
|