pi-studio 0.9.26 → 0.9.27
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 +18 -0
- package/README.md +1 -1
- package/client/studio-client.js +185 -14
- package/client/studio.css +10 -1
- package/index.ts +224 -35
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `pi-studio` are documented here.
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.9.27] — 2026-06-08
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Added an **Open root** action to the Files view for opening the Files root folder in Finder or the system file manager.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- Clarified browser-import and Files-view file-backed open action tooltips.
|
|
14
|
+
- Added **Try another** regeneration for completion suggestions and a persistent Suggestion model picker that keeps suggestions separate from the main Pi model and uses thinking off, with clearer cursor-marker prompting and separate prose/code completion instructions.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Avoided showing the missing-LaTeX-engine hint for PDF exports when XeLaTeX/PDFLaTeX ran but the document itself had a LaTeX error.
|
|
18
|
+
- Restored Pandoc-rendered LaTeX title/author/abstract metadata in Studio previews that use embedded resources.
|
|
19
|
+
- Preserved optional LaTeX `\footnotemark[n]` markers as linked superscript affiliation markers in Studio previews.
|
|
20
|
+
- Nudged inline completion suggestions to return a non-empty continuation when explicitly requested at the end of a sentence or paragraph.
|
|
21
|
+
- Fixed Markdown blockquotes containing multiline `\[ ... \]` display math so quote markers are not folded into the math content.
|
|
22
|
+
|
|
5
23
|
## [0.9.26] — 2026-06-04
|
|
6
24
|
|
|
7
25
|
### Fixed
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
|
|
|
24
24
|
- Runs editor text directly, asks for structured critique (auto/writing/code focus), offers a manual **Suggest completion** action for short cursor-aware continuations (`Option/Alt+Tab` where available or `Cmd/Ctrl+Shift+Space` from the editor, `Tab` to insert a visible suggestion) with an optional editor-plus-latest-response context mode, or opens **Quiz me** for a Studio-native active-recall loop over the current editor text, selection, current file, folder, or repo, with optional focus guidance for shaping question selection
|
|
25
25
|
- Includes a live **Working** view for following current model/tool activity, with `All` / `Thinking` / `Tools` filters, image previews for image-producing tool outputs, plus **Load visible into editor** and **Copy visible** actions; when cycling response history, Working follows saved working details for the selected response when available
|
|
26
26
|
- Includes a right-pane **Changes** view for browsing the current git diff by file, previewing per-file diffs, opening changed files, loading the full diff into the editor, and copying the diff
|
|
27
|
-
- Includes a right-pane **Files** view for browsing the current Pi session/resource directory, opening folders, loading text/code/CSV/TSV documents into the editor, previewing PDFs/images, opening PDF/image previews in a new Studio tab, converting DOCX/ODT documents to editable Markdown when Pandoc is available after confirmation, copying paths, setting the current folder as the Studio working directory, and revealing files in the file manager
|
|
27
|
+
- Includes a right-pane **Files** view for browsing the current Pi session/resource directory, opening folders, opening the Files root in Finder/the system file manager, loading text/code/CSV/TSV documents into the editor, previewing PDFs/images, opening PDF/image previews in a new Studio tab, converting DOCX/ODT documents to editable Markdown when Pandoc is available after confirmation, copying paths, setting the current folder as the Studio working directory, and revealing files in the file manager
|
|
28
28
|
- Includes an optional tmux-backed **REPL** view for Shell, Python, IPython, Julia, R, GHCi, and Clojure sessions, with Raw/Literate send modes, `Cmd/Ctrl+Shift+Enter` **Send to REPL**, session start/stop/interrupt controls, a compact refresh-persistent **Studio REPL Record** of user and Pi-sent code, a secondary raw tmux mirror, agent-facing `studio_repl_status` / `studio_repl_send` tools, and Markdown/PDF/HTML export
|
|
29
29
|
- Includes a local persistent scratchpad for quick notes you want to keep out of the main editor until you're ready to copy or insert them, with a **Recent…** picker for recovering scratchpads saved under earlier file/draft identities
|
|
30
30
|
- Includes a docked **Outline** rail for navigating document structure in the current editor text, with clickable entries that jump in the raw editor and reveal matching preview locations when available
|
package/client/studio-client.js
CHANGED
|
@@ -118,8 +118,11 @@
|
|
|
118
118
|
const suggestCompletionBtn = document.getElementById("suggestCompletionBtn");
|
|
119
119
|
const suggestCompletionOptionsBtn = document.getElementById("suggestCompletionOptionsBtn");
|
|
120
120
|
const completionContextSelect = document.getElementById("completionContextSelect");
|
|
121
|
+
const completionModelSelect = document.getElementById("completionModelSelect");
|
|
121
122
|
const completionSuggestionPanelEl = document.getElementById("completionSuggestionPanel");
|
|
122
123
|
const completionSuggestionTextEl = document.getElementById("completionSuggestionText");
|
|
124
|
+
const completionSuggestionMetaEl = document.getElementById("completionSuggestionMeta");
|
|
125
|
+
const completionSuggestionRegenerateBtn = document.getElementById("completionSuggestionRegenerateBtn");
|
|
123
126
|
const completionSuggestionInsertBtn = document.getElementById("completionSuggestionInsertBtn");
|
|
124
127
|
const completionSuggestionDismissBtn = document.getElementById("completionSuggestionDismissBtn");
|
|
125
128
|
const saveAnnotatedBtn = document.getElementById("saveAnnotatedBtn");
|
|
@@ -325,6 +328,7 @@
|
|
|
325
328
|
const EDITOR_TAB_TEXT = " ";
|
|
326
329
|
const QUIZ_DEFAULT_COUNT = 5;
|
|
327
330
|
const COMPLETION_CONTEXT_STORAGE_KEY = "piStudio.completionContextMode";
|
|
331
|
+
const COMPLETION_MODEL_STORAGE_KEY = "piStudio.completionModel";
|
|
328
332
|
const COMPLETION_CONTEXT_MAX_CHARS = 12000;
|
|
329
333
|
const QUIZ_SCOPES = ["editor", "selection", "file", "folder", "repo"];
|
|
330
334
|
const QUIZ_ANGLES = ["general", "scientist", "mathematician", "statistician", "developer", "reviewer"];
|
|
@@ -2027,6 +2031,8 @@
|
|
|
2027
2031
|
let responseHighlightEnabled = false;
|
|
2028
2032
|
let completionSuggestionState = null;
|
|
2029
2033
|
let completionSuggestionContextMode = readCompletionSuggestionContextMode();
|
|
2034
|
+
let completionSuggestionModelValue = readCompletionSuggestionModelValue();
|
|
2035
|
+
let completionSuggestionModelOptions = [];
|
|
2030
2036
|
let completionSuggestionInFlight = false;
|
|
2031
2037
|
let completionSuggestionRequestId = null;
|
|
2032
2038
|
let completionSuggestionPendingSnapshot = null;
|
|
@@ -2499,7 +2505,15 @@
|
|
|
2499
2505
|
syncActionButtons();
|
|
2500
2506
|
});
|
|
2501
2507
|
});
|
|
2502
|
-
|
|
2508
|
+
const suggestionItems = [cursorContextBtn, sessionContextBtn];
|
|
2509
|
+
if (completionModelSelect) {
|
|
2510
|
+
completionModelSelect.hidden = false;
|
|
2511
|
+
suggestionItems.push(completionModelSelect);
|
|
2512
|
+
}
|
|
2513
|
+
const completionThinkingNoteEl = makeStudioUiRefreshElement("div", "source-badge completion-thinking-note", "Suggest uses thinking off and does not change the main Pi model.");
|
|
2514
|
+
completionThinkingNoteEl.setAttribute("aria-label", "Suggestion model note");
|
|
2515
|
+
suggestionItems.push(completionThinkingNoteEl);
|
|
2516
|
+
appendStudioUiRefreshMenuSection(contextMenu.menu, "Suggestions", suggestionItems);
|
|
2503
2517
|
const statusItems = [];
|
|
2504
2518
|
if (!isEditorOnlyMode) {
|
|
2505
2519
|
sourceSessionSummaryEl = makeStudioUiRefreshElement("div", "source-badge source-session-summary", "Session tree: branch history follows the current Pi branch. Editor text is independent.");
|
|
@@ -9661,14 +9675,17 @@
|
|
|
9661
9675
|
? "open-new"
|
|
9662
9676
|
: ((kind === "pdf" || kind === "image") ? "open-preview-new" : "");
|
|
9663
9677
|
const newTabLabel = kind === "text"
|
|
9664
|
-
? "Open file tab"
|
|
9678
|
+
? "Open file-backed tab"
|
|
9665
9679
|
: (kind === "office" ? "Convert tab" : ((kind === "pdf" || kind === "image") ? "Preview tab" : "New tab"));
|
|
9680
|
+
const newTabTitle = kind === "text"
|
|
9681
|
+
? "Open this file-backed document in a new refreshable editor tab. Save editor and Refresh from disk will use this file."
|
|
9682
|
+
: (kind === "office" ? "Convert this document to Markdown in a new editor tab." : ((kind === "pdf" || kind === "image") ? "Open this preview in a new Studio tab." : "Open in a new Studio tab."));
|
|
9666
9683
|
const textActions = newTabAction
|
|
9667
|
-
? "<button type='button' data-files-action='" + escapeHtml(newTabAction) + "' data-files-path='" + escapeHtml(entry.path) + "'>" + escapeHtml(newTabLabel) + "</button>"
|
|
9684
|
+
? "<button type='button' data-files-action='" + escapeHtml(newTabAction) + "' data-files-path='" + escapeHtml(entry.path) + "' title='" + escapeHtml(newTabTitle) + "'>" + escapeHtml(newTabLabel) + "</button>"
|
|
9668
9685
|
: "";
|
|
9669
9686
|
const openTitle = type === "directory"
|
|
9670
9687
|
? "Open folder"
|
|
9671
|
-
: (kind === "text" ? "Open in editor" : (kind === "office" ? "Convert to Markdown in editor" : (kind === "pdf" ? "Open PDF preview" : (kind === "image" ? "Open image preview" : "Copy or reveal this file"))));
|
|
9688
|
+
: (kind === "text" ? "Open file-backed document in the current editor. Save editor and Refresh from disk will use this file." : (kind === "office" ? "Convert to Markdown in the current editor" : (kind === "pdf" ? "Open PDF preview" : (kind === "image" ? "Open image preview" : "Copy or reveal this file"))));
|
|
9672
9689
|
return "<div class='files-row files-row-" + escapeHtml(type) + " files-kind-" + escapeHtml(kind) + "'>"
|
|
9673
9690
|
+ "<button type='button' class='files-open-btn' data-files-action='" + (type === "directory" ? "open-dir" : "open") + "' data-files-path='" + escapeHtml(entry.path) + "' data-files-kind='" + escapeHtml(kind) + "' title='" + escapeHtml(openTitle) + "'>"
|
|
9674
9691
|
+ "<span class='files-icon' aria-hidden='true'>" + icon + "</span>"
|
|
@@ -9695,6 +9712,7 @@
|
|
|
9695
9712
|
+ "<button type='button' data-files-action='refresh'>Refresh</button>"
|
|
9696
9713
|
+ (currentDir ? "<button type='button' data-files-action='copy-current' data-files-path='" + escapeHtml(currentDir) + "'>Copy path</button>" : "")
|
|
9697
9714
|
+ (currentDir ? "<button type='button' data-files-action='use-working-dir' data-files-path='" + escapeHtml(currentDir) + "'>Use as working dir</button>" : "")
|
|
9715
|
+
+ (rootDir ? "<button type='button' data-files-action='open-root' data-files-path='" + escapeHtml(rootDir) + "' title='Open the Files root folder in Finder or the system file manager.'>Open root</button>" : "")
|
|
9698
9716
|
+ (rootDir ? "<button type='button' data-files-action='copy-root' data-files-path='" + escapeHtml(rootDir) + "'>Copy root</button>" : "")
|
|
9699
9717
|
+ "</div>"
|
|
9700
9718
|
+ "</div>"
|
|
@@ -9845,6 +9863,23 @@
|
|
|
9845
9863
|
setStatus("Working dir set to current folder.", "success");
|
|
9846
9864
|
}
|
|
9847
9865
|
|
|
9866
|
+
async function openFileBrowserDirectoryInFileViewer(path) {
|
|
9867
|
+
const targetDir = normalizeStudioResourceDirValue(path || fileBrowserState.rootDir || fileBrowserState.currentDir || "");
|
|
9868
|
+
if (!targetDir) {
|
|
9869
|
+
setStatus("No folder to open.", "warning");
|
|
9870
|
+
return;
|
|
9871
|
+
}
|
|
9872
|
+
const context = getHtmlPreviewResourceContextOptions();
|
|
9873
|
+
const body = { dir: targetDir };
|
|
9874
|
+
if (context.sourcePath) body.sourcePath = context.sourcePath;
|
|
9875
|
+
if (context.resourceDir) body.resourceDir = context.resourceDir;
|
|
9876
|
+
const payload = await fetchStudioJson("/file-browser-open", {
|
|
9877
|
+
method: "POST",
|
|
9878
|
+
body: JSON.stringify(body),
|
|
9879
|
+
});
|
|
9880
|
+
setStatus(payload && payload.message ? payload.message : "Opened folder in file manager.", "success");
|
|
9881
|
+
}
|
|
9882
|
+
|
|
9848
9883
|
async function handleFilesPaneClick(event) {
|
|
9849
9884
|
if (rightView !== "files") return;
|
|
9850
9885
|
const target = event.target;
|
|
@@ -9888,6 +9923,10 @@
|
|
|
9888
9923
|
setFileBrowserCurrentDirectoryAsWorkingDir(path);
|
|
9889
9924
|
return;
|
|
9890
9925
|
}
|
|
9926
|
+
if (action === "open-root") {
|
|
9927
|
+
await openFileBrowserDirectoryInFileViewer(path || fileBrowserState.rootDir || "");
|
|
9928
|
+
return;
|
|
9929
|
+
}
|
|
9891
9930
|
if (action === "reveal") {
|
|
9892
9931
|
await revealPreviewLocalLink(path, getFileBrowserLocalLinkContext());
|
|
9893
9932
|
}
|
|
@@ -10437,13 +10476,18 @@
|
|
|
10437
10476
|
syncRunAndCritiqueButtons();
|
|
10438
10477
|
copyDraftBtn.disabled = uiBusy;
|
|
10439
10478
|
if (suggestCompletionBtn) {
|
|
10479
|
+
const hasSuggestionForCurrentText = Boolean(completionSuggestionState && sourceTextEl && sourceTextEl.value === completionSuggestionState.baseText);
|
|
10440
10480
|
suggestCompletionBtn.disabled = wsState !== "Ready" || (!completionSuggestionInFlight && (uiBusy || !String(sourceTextEl.value || "").trim()));
|
|
10441
|
-
suggestCompletionBtn.textContent = completionSuggestionInFlight ? "Stop" : "Suggest";
|
|
10481
|
+
suggestCompletionBtn.textContent = completionSuggestionInFlight ? "Stop" : (hasSuggestionForCurrentText ? "Try another" : "Suggest");
|
|
10442
10482
|
suggestCompletionBtn.title = completionSuggestionInFlight
|
|
10443
10483
|
? "Stop the current suggestion request."
|
|
10444
|
-
:
|
|
10484
|
+
: (hasSuggestionForCurrentText
|
|
10485
|
+
? "Ask for a different suggestion at the same cursor position."
|
|
10486
|
+
: "Ask for a short completion at the editor cursor. Shortcut: Option/Alt+Tab where available, or Cmd/Ctrl+Shift+Space from the editor.");
|
|
10445
10487
|
}
|
|
10446
10488
|
if (suggestCompletionOptionsBtn) suggestCompletionOptionsBtn.disabled = uiBusy || completionSuggestionInFlight;
|
|
10489
|
+
if (completionModelSelect) completionModelSelect.disabled = uiBusy || completionSuggestionInFlight;
|
|
10490
|
+
if (completionSuggestionRegenerateBtn) completionSuggestionRegenerateBtn.disabled = completionSuggestionInFlight || !completionSuggestionState;
|
|
10447
10491
|
syncCompletionSuggestionContextUi();
|
|
10448
10492
|
if (openCompanionBtn) openCompanionBtn.disabled = uiBusy || wsState !== "Ready";
|
|
10449
10493
|
if (highlightSelect) highlightSelect.disabled = uiBusy;
|
|
@@ -10784,9 +10828,37 @@
|
|
|
10784
10828
|
}
|
|
10785
10829
|
}
|
|
10786
10830
|
|
|
10831
|
+
function readCompletionSuggestionModelValue() {
|
|
10832
|
+
try {
|
|
10833
|
+
const stored = window.localStorage ? String(window.localStorage.getItem(COMPLETION_MODEL_STORAGE_KEY) || "") : "";
|
|
10834
|
+
return stored && stored !== "undefined" && stored !== "null" ? stored : "current";
|
|
10835
|
+
} catch {
|
|
10836
|
+
return "current";
|
|
10837
|
+
}
|
|
10838
|
+
}
|
|
10839
|
+
|
|
10840
|
+
function encodeCompletionModelValue(provider, id) {
|
|
10841
|
+
return JSON.stringify([String(provider || ""), String(id || "")]);
|
|
10842
|
+
}
|
|
10843
|
+
|
|
10844
|
+
function decodeCompletionModelValue(value) {
|
|
10845
|
+
const raw = String(value || "");
|
|
10846
|
+
if (!raw || raw === "current") return null;
|
|
10847
|
+
try {
|
|
10848
|
+
const parsed = JSON.parse(raw);
|
|
10849
|
+
if (!Array.isArray(parsed) || parsed.length < 2) return null;
|
|
10850
|
+
const provider = String(parsed[0] || "").trim();
|
|
10851
|
+
const id = String(parsed[1] || "").trim();
|
|
10852
|
+
return provider && id ? { provider, id } : null;
|
|
10853
|
+
} catch {
|
|
10854
|
+
return null;
|
|
10855
|
+
}
|
|
10856
|
+
}
|
|
10857
|
+
|
|
10787
10858
|
function setCompletionSuggestionContextMode(mode) {
|
|
10788
10859
|
completionSuggestionContextMode = mode === "session" ? "session" : "cursor";
|
|
10789
10860
|
if (completionContextSelect) completionContextSelect.value = completionSuggestionContextMode;
|
|
10861
|
+
hideCompletionSuggestion();
|
|
10790
10862
|
try {
|
|
10791
10863
|
if (window.localStorage) window.localStorage.setItem(COMPLETION_CONTEXT_STORAGE_KEY, completionSuggestionContextMode);
|
|
10792
10864
|
} catch {}
|
|
@@ -10795,8 +10867,65 @@
|
|
|
10795
10867
|
: "Suggestions will use cursor-local editor context only.");
|
|
10796
10868
|
}
|
|
10797
10869
|
|
|
10870
|
+
function setCompletionSuggestionModelValue(value) {
|
|
10871
|
+
const normalized = String(value || "current") || "current";
|
|
10872
|
+
completionSuggestionModelValue = normalized;
|
|
10873
|
+
if (completionModelSelect) completionModelSelect.value = normalized;
|
|
10874
|
+
hideCompletionSuggestion();
|
|
10875
|
+
try {
|
|
10876
|
+
if (window.localStorage) window.localStorage.setItem(COMPLETION_MODEL_STORAGE_KEY, normalized);
|
|
10877
|
+
} catch {}
|
|
10878
|
+
const selectedLabel = getCompletionSuggestionModelLabel();
|
|
10879
|
+
setStatus(normalized === "current"
|
|
10880
|
+
? "Suggestions will use the current Pi model with thinking off."
|
|
10881
|
+
: "Suggestions will use " + selectedLabel + " with thinking off.");
|
|
10882
|
+
}
|
|
10883
|
+
|
|
10884
|
+
function getCompletionSuggestionModelSelection() {
|
|
10885
|
+
return decodeCompletionModelValue(completionSuggestionModelValue);
|
|
10886
|
+
}
|
|
10887
|
+
|
|
10888
|
+
function getCompletionSuggestionModelLabel() {
|
|
10889
|
+
const selected = getCompletionSuggestionModelSelection();
|
|
10890
|
+
if (!selected) return "current Pi model";
|
|
10891
|
+
const match = completionSuggestionModelOptions.find((option) => option.provider === selected.provider && option.id === selected.id);
|
|
10892
|
+
return match && match.label ? match.label : (selected.provider + "/" + selected.id);
|
|
10893
|
+
}
|
|
10894
|
+
|
|
10895
|
+
function updateCompletionSuggestionModelOptions(options) {
|
|
10896
|
+
completionSuggestionModelOptions = Array.isArray(options)
|
|
10897
|
+
? options.map((option) => ({
|
|
10898
|
+
provider: String(option && option.provider || "").trim(),
|
|
10899
|
+
id: String(option && option.id || "").trim(),
|
|
10900
|
+
label: String(option && option.label || "").trim(),
|
|
10901
|
+
reasoning: Boolean(option && option.reasoning),
|
|
10902
|
+
})).filter((option) => option.provider && option.id)
|
|
10903
|
+
: [];
|
|
10904
|
+
syncCompletionSuggestionModelUi();
|
|
10905
|
+
}
|
|
10906
|
+
|
|
10907
|
+
function syncCompletionSuggestionModelUi() {
|
|
10908
|
+
if (!completionModelSelect) return;
|
|
10909
|
+
const currentValue = completionSuggestionModelValue || "current";
|
|
10910
|
+
const modelOptionsHtml = completionSuggestionModelOptions.map((option) => {
|
|
10911
|
+
const value = encodeCompletionModelValue(option.provider, option.id);
|
|
10912
|
+
const label = option.label || (option.provider + "/" + option.id);
|
|
10913
|
+
return "<option value='" + escapeHtml(value) + "'>Suggestion model: " + escapeHtml(label) + "</option>";
|
|
10914
|
+
});
|
|
10915
|
+
const validValues = new Set(["current", ...completionSuggestionModelOptions.map((option) => encodeCompletionModelValue(option.provider, option.id))]);
|
|
10916
|
+
if (!validValues.has(currentValue) && completionSuggestionModelOptions.length === 0 && currentValue !== "current") {
|
|
10917
|
+
modelOptionsHtml.push("<option value='" + escapeHtml(currentValue) + "'>Suggestion model: saved selection</option>");
|
|
10918
|
+
validValues.add(currentValue);
|
|
10919
|
+
}
|
|
10920
|
+
completionModelSelect.innerHTML = ["<option value='current'>Suggestion model: current Pi model</option>", ...modelOptionsHtml].join("");
|
|
10921
|
+
completionModelSelect.value = validValues.has(currentValue) ? currentValue : "current";
|
|
10922
|
+
if (completionModelSelect.value !== currentValue) completionSuggestionModelValue = "current";
|
|
10923
|
+
completionModelSelect.title = "Choose the model used for Suggest. Suggestions use direct completion with thinking off and do not change the main Pi model.";
|
|
10924
|
+
}
|
|
10925
|
+
|
|
10798
10926
|
function syncCompletionSuggestionContextUi() {
|
|
10799
10927
|
if (completionContextSelect) completionContextSelect.value = completionSuggestionContextMode;
|
|
10928
|
+
syncCompletionSuggestionModelUi();
|
|
10800
10929
|
if (suggestCompletionOptionsBtn) {
|
|
10801
10930
|
suggestCompletionOptionsBtn.textContent = "Source & context";
|
|
10802
10931
|
suggestCompletionOptionsBtn.title = completionSuggestionContextMode === "session"
|
|
@@ -10841,13 +10970,20 @@
|
|
|
10841
10970
|
function hideCompletionSuggestion() {
|
|
10842
10971
|
completionSuggestionState = null;
|
|
10843
10972
|
if (completionSuggestionTextEl) completionSuggestionTextEl.textContent = "";
|
|
10973
|
+
if (completionSuggestionMetaEl) completionSuggestionMetaEl.textContent = "";
|
|
10844
10974
|
if (completionSuggestionPanelEl) completionSuggestionPanelEl.hidden = true;
|
|
10975
|
+
syncActionButtons();
|
|
10845
10976
|
}
|
|
10846
10977
|
|
|
10847
10978
|
function showCompletionSuggestion(state) {
|
|
10848
10979
|
completionSuggestionState = state;
|
|
10849
10980
|
if (completionSuggestionTextEl) completionSuggestionTextEl.textContent = state && state.suggestion ? state.suggestion : "";
|
|
10981
|
+
if (completionSuggestionMetaEl) {
|
|
10982
|
+
const modelLabelText = state && state.modelLabel ? String(state.modelLabel) : getCompletionSuggestionModelLabel();
|
|
10983
|
+
completionSuggestionMetaEl.textContent = modelLabelText ? " · " + modelLabelText + " · thinking off" : " · thinking off";
|
|
10984
|
+
}
|
|
10850
10985
|
if (completionSuggestionPanelEl) completionSuggestionPanelEl.hidden = false;
|
|
10986
|
+
syncActionButtons();
|
|
10851
10987
|
}
|
|
10852
10988
|
|
|
10853
10989
|
function focusSourceTextNoScroll() {
|
|
@@ -10927,29 +11063,38 @@
|
|
|
10927
11063
|
}
|
|
10928
11064
|
}
|
|
10929
11065
|
|
|
10930
|
-
function requestCompletionSuggestion() {
|
|
11066
|
+
function requestCompletionSuggestion(options) {
|
|
10931
11067
|
if (isEditorOnlyMode && !sourceTextEl) return;
|
|
10932
11068
|
if (completionSuggestionInFlight) {
|
|
10933
11069
|
cancelCompletionSuggestion();
|
|
10934
11070
|
return;
|
|
10935
11071
|
}
|
|
10936
|
-
const
|
|
11072
|
+
const regenerateRequested = Boolean((options && options.regenerate) || (completionSuggestionState && sourceTextEl.value === completionSuggestionState.baseText));
|
|
11073
|
+
const existingSuggestion = regenerateRequested ? completionSuggestionState : null;
|
|
11074
|
+
const text = existingSuggestion ? String(existingSuggestion.baseText || "") : String(sourceTextEl.value || "");
|
|
10937
11075
|
if (!text.trim()) {
|
|
10938
11076
|
setStatus("Editor is empty.", "warning");
|
|
10939
11077
|
return;
|
|
10940
11078
|
}
|
|
10941
|
-
|
|
10942
|
-
|
|
11079
|
+
if (existingSuggestion && String(sourceTextEl.value || "") !== text) {
|
|
11080
|
+
setStatus("Editor changed. Request a fresh suggestion from the current cursor.", "warning");
|
|
11081
|
+
hideCompletionSuggestion();
|
|
11082
|
+
return;
|
|
11083
|
+
}
|
|
11084
|
+
const selectionStart = existingSuggestion ? existingSuggestion.selectionStart : (typeof sourceTextEl.selectionStart === "number" ? sourceTextEl.selectionStart : text.length);
|
|
11085
|
+
const selectionEnd = existingSuggestion ? existingSuggestion.selectionEnd : (typeof sourceTextEl.selectionEnd === "number" ? sourceTextEl.selectionEnd : selectionStart);
|
|
10943
11086
|
const contextText = getCompletionSuggestionContextText();
|
|
11087
|
+
const selectedModel = getCompletionSuggestionModelSelection();
|
|
10944
11088
|
const requestId = makeRequestId();
|
|
11089
|
+
const previousSuggestion = existingSuggestion && existingSuggestion.suggestion ? String(existingSuggestion.suggestion) : "";
|
|
10945
11090
|
completionSuggestionInFlight = true;
|
|
10946
11091
|
completionSuggestionRequestId = requestId;
|
|
10947
|
-
completionSuggestionPendingSnapshot = { text, selectionStart, selectionEnd };
|
|
11092
|
+
completionSuggestionPendingSnapshot = { text, selectionStart, selectionEnd, previousSuggestion };
|
|
10948
11093
|
completionSuggestionRefocusEditorOnResult = shouldRefocusEditorForCompletionRequest();
|
|
10949
11094
|
hideCompletionSuggestion();
|
|
10950
11095
|
syncActionButtons();
|
|
10951
|
-
setStatus("Generating completion suggestion…", "warning");
|
|
10952
|
-
const
|
|
11096
|
+
setStatus(existingSuggestion ? "Generating another suggestion…" : "Generating completion suggestion…", "warning");
|
|
11097
|
+
const message = {
|
|
10953
11098
|
type: "completion_suggestion_request",
|
|
10954
11099
|
requestId,
|
|
10955
11100
|
text,
|
|
@@ -10960,7 +11105,13 @@
|
|
|
10960
11105
|
path: sourceState && sourceState.path ? sourceState.path : undefined,
|
|
10961
11106
|
contextMode: completionSuggestionContextMode,
|
|
10962
11107
|
contextText: contextText || undefined,
|
|
10963
|
-
|
|
11108
|
+
previousSuggestion: previousSuggestion || undefined,
|
|
11109
|
+
};
|
|
11110
|
+
if (selectedModel) {
|
|
11111
|
+
message.suggestionModelProvider = selectedModel.provider;
|
|
11112
|
+
message.suggestionModelId = selectedModel.id;
|
|
11113
|
+
}
|
|
11114
|
+
const sent = sendMessage(message);
|
|
10964
11115
|
if (!sent) {
|
|
10965
11116
|
completionSuggestionInFlight = false;
|
|
10966
11117
|
completionSuggestionRequestId = null;
|
|
@@ -11035,6 +11186,8 @@
|
|
|
11035
11186
|
baseText,
|
|
11036
11187
|
selectionStart: start,
|
|
11037
11188
|
selectionEnd: end,
|
|
11189
|
+
previousSuggestion: pendingSnapshot && pendingSnapshot.previousSuggestion ? pendingSnapshot.previousSuggestion : "",
|
|
11190
|
+
modelLabel: typeof message.modelLabel === "string" ? message.modelLabel : getCompletionSuggestionModelLabel(),
|
|
11038
11191
|
});
|
|
11039
11192
|
const activeEl = document.activeElement;
|
|
11040
11193
|
if (
|
|
@@ -18493,6 +18646,9 @@
|
|
|
18493
18646
|
if (typeof message.modelLabel === "string") {
|
|
18494
18647
|
modelLabel = message.modelLabel;
|
|
18495
18648
|
}
|
|
18649
|
+
if (Array.isArray(message.suggestionModels)) {
|
|
18650
|
+
updateCompletionSuggestionModelOptions(message.suggestionModels);
|
|
18651
|
+
}
|
|
18496
18652
|
if (typeof message.terminalSessionLabel === "string") {
|
|
18497
18653
|
terminalSessionLabel = message.terminalSessionLabel;
|
|
18498
18654
|
}
|
|
@@ -19065,6 +19221,9 @@
|
|
|
19065
19221
|
if (typeof message.modelLabel === "string") {
|
|
19066
19222
|
modelLabel = message.modelLabel;
|
|
19067
19223
|
}
|
|
19224
|
+
if (Array.isArray(message.suggestionModels)) {
|
|
19225
|
+
updateCompletionSuggestionModelOptions(message.suggestionModels);
|
|
19226
|
+
}
|
|
19068
19227
|
if (typeof message.terminalSessionLabel === "string") {
|
|
19069
19228
|
terminalSessionLabel = message.terminalSessionLabel;
|
|
19070
19229
|
}
|
|
@@ -20190,6 +20349,18 @@
|
|
|
20190
20349
|
syncActionButtons();
|
|
20191
20350
|
});
|
|
20192
20351
|
}
|
|
20352
|
+
if (completionModelSelect) {
|
|
20353
|
+
completionModelSelect.value = completionSuggestionModelValue;
|
|
20354
|
+
completionModelSelect.addEventListener("change", () => {
|
|
20355
|
+
setCompletionSuggestionModelValue(completionModelSelect.value || "current");
|
|
20356
|
+
syncActionButtons();
|
|
20357
|
+
});
|
|
20358
|
+
}
|
|
20359
|
+
if (completionSuggestionRegenerateBtn) {
|
|
20360
|
+
completionSuggestionRegenerateBtn.addEventListener("click", () => {
|
|
20361
|
+
requestCompletionSuggestion({ regenerate: true });
|
|
20362
|
+
});
|
|
20363
|
+
}
|
|
20193
20364
|
if (completionSuggestionInsertBtn) {
|
|
20194
20365
|
completionSuggestionInsertBtn.addEventListener("click", () => {
|
|
20195
20366
|
insertCompletionSuggestion();
|
package/client/studio.css
CHANGED
|
@@ -883,6 +883,12 @@
|
|
|
883
883
|
font-weight: 650;
|
|
884
884
|
}
|
|
885
885
|
|
|
886
|
+
.completion-suggestion-meta {
|
|
887
|
+
color: var(--studio-info-text, var(--muted));
|
|
888
|
+
font-size: 11px;
|
|
889
|
+
font-weight: 500;
|
|
890
|
+
}
|
|
891
|
+
|
|
886
892
|
.completion-suggestion-text {
|
|
887
893
|
margin: 0;
|
|
888
894
|
max-height: 180px;
|
|
@@ -5624,12 +5630,15 @@
|
|
|
5624
5630
|
}
|
|
5625
5631
|
|
|
5626
5632
|
body.studio-ui-refresh .studio-refresh-menu-item > .source-origin-summary,
|
|
5627
|
-
body.studio-ui-refresh .studio-refresh-menu-item > .source-session-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 {
|
|
5628
5635
|
width: 100%;
|
|
5636
|
+
min-width: 0;
|
|
5629
5637
|
border-color: var(--border-subtle);
|
|
5630
5638
|
background: var(--panel-2);
|
|
5631
5639
|
color: var(--studio-info-text, var(--muted));
|
|
5632
5640
|
white-space: normal;
|
|
5641
|
+
overflow-wrap: anywhere;
|
|
5633
5642
|
line-height: 1.35;
|
|
5634
5643
|
}
|
|
5635
5644
|
|
package/index.ts
CHANGED
|
@@ -339,6 +339,9 @@ interface CompletionSuggestionRequestMessage {
|
|
|
339
339
|
path?: string;
|
|
340
340
|
contextMode?: "cursor" | "session";
|
|
341
341
|
contextText?: string;
|
|
342
|
+
previousSuggestion?: string;
|
|
343
|
+
suggestionModelProvider?: string;
|
|
344
|
+
suggestionModelId?: string;
|
|
342
345
|
}
|
|
343
346
|
|
|
344
347
|
interface CompletionSuggestionCancelRequestMessage {
|
|
@@ -754,6 +757,17 @@ function buildStudioPandocPdfEngineOptArgs(pdfEngine: string): string[] {
|
|
|
754
757
|
];
|
|
755
758
|
}
|
|
756
759
|
|
|
760
|
+
function getStudioMissingLatexEngineHint(stderr: string, pdfEngine: string): string {
|
|
761
|
+
const text = String(stderr || "");
|
|
762
|
+
const lower = text.toLowerCase();
|
|
763
|
+
const engine = basename(String(pdfEngine || "")).toLowerCase();
|
|
764
|
+
const engineMentioned = [engine, "xelatex", "pdflatex", "lualatex", "tectonic"].filter(Boolean).some((name) => lower.includes(name));
|
|
765
|
+
const missingEnginePattern = /(?:command not found|not found|no such file|could not find|cannot find|is not installed|not installed)/i;
|
|
766
|
+
return engineMentioned && missingEnginePattern.test(text)
|
|
767
|
+
? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
|
|
768
|
+
: "";
|
|
769
|
+
}
|
|
770
|
+
|
|
757
771
|
const STUDIO_PANDOC_HTML_FRAGMENT_TEMPLATE = `<!doctype html>
|
|
758
772
|
<html>
|
|
759
773
|
<head>
|
|
@@ -761,6 +775,26 @@ const STUDIO_PANDOC_HTML_FRAGMENT_TEMPLATE = `<!doctype html>
|
|
|
761
775
|
<title>pi Studio preview</title>
|
|
762
776
|
</head>
|
|
763
777
|
<body>
|
|
778
|
+
$if(title)$
|
|
779
|
+
<header id="title-block-header">
|
|
780
|
+
<h1 class="title">$title$</h1>
|
|
781
|
+
$if(subtitle)$
|
|
782
|
+
<p class="subtitle">$subtitle$</p>
|
|
783
|
+
$endif$
|
|
784
|
+
$for(author)$
|
|
785
|
+
<p class="author">$author$</p>
|
|
786
|
+
$endfor$
|
|
787
|
+
$if(date)$
|
|
788
|
+
<p class="date">$date$</p>
|
|
789
|
+
$endif$
|
|
790
|
+
$if(abstract)$
|
|
791
|
+
<div class="abstract">
|
|
792
|
+
<div class="abstract-title">Abstract</div>
|
|
793
|
+
$abstract$
|
|
794
|
+
</div>
|
|
795
|
+
$endif$
|
|
796
|
+
</header>
|
|
797
|
+
$endif$
|
|
764
798
|
$body$
|
|
765
799
|
</body>
|
|
766
800
|
</html>
|
|
@@ -4279,12 +4313,14 @@ function normalizeMathDelimitersInSegment(markdown: string): string {
|
|
|
4279
4313
|
});
|
|
4280
4314
|
|
|
4281
4315
|
normalized = normalized.replace(/\$\s*\\\[\s*([\s\S]*?)\s*\\\]\s*\$/g, (match, expr: string) => {
|
|
4316
|
+
if (/\n\s{0,3}>/.test(match)) return match;
|
|
4282
4317
|
if (!isLikelyMathExpression(expr)) return match;
|
|
4283
4318
|
const content = collapseDisplayMathContent(expr);
|
|
4284
4319
|
return content.length > 0 ? `\\[${content}\\]` : "\\[\\]";
|
|
4285
4320
|
});
|
|
4286
4321
|
|
|
4287
4322
|
normalized = normalized.replace(/\\\[\s*([\s\S]*?)\s*\\\]/g, (match, expr: string) => {
|
|
4323
|
+
if (/\n\s{0,3}>/.test(match)) return match;
|
|
4288
4324
|
if (!isLikelyMathExpression(expr)) return `[${expr.trim()}]`;
|
|
4289
4325
|
const content = collapseDisplayMathContent(expr);
|
|
4290
4326
|
return content.length > 0 ? `\\[${content}\\]` : "\\[\\]";
|
|
@@ -5920,9 +5956,17 @@ async function getStudioPandocHtmlResourceFlag(pandocCommand: string): Promise<"
|
|
|
5920
5956
|
return cached;
|
|
5921
5957
|
}
|
|
5922
5958
|
|
|
5959
|
+
function preprocessStudioLatexFootnotemarksForPreview(latex: string): string {
|
|
5960
|
+
return String(latex ?? "").replace(/\\footnotemark\s*\[\s*([^\]\r\n]+?)\s*\]/g, (_match, marker: string) => {
|
|
5961
|
+
const value = String(marker || "").trim();
|
|
5962
|
+
return /^\d+$/.test(value) ? `\\href{#fn${value}}{\\textsuperscript{${value}}}` : (value ? `\\textsuperscript{${value}}` : "");
|
|
5963
|
+
});
|
|
5964
|
+
}
|
|
5965
|
+
|
|
5923
5966
|
async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string, sourcePath?: string): Promise<string> {
|
|
5924
5967
|
const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
|
|
5925
|
-
const
|
|
5968
|
+
const latexPreviewSource = isLatex ? preprocessStudioLatexFootnotemarksForPreview(markdown) : markdown;
|
|
5969
|
+
const markdownWithNormalizedFences = isLatex ? latexPreviewSource : normalizeStudioMarkdownSmartFences(markdown);
|
|
5926
5970
|
const markdownWithoutHtmlComments = isLatex ? markdownWithNormalizedFences : stripStudioMarkdownHtmlCommentsPreservingYamlFrontMatter(markdownWithNormalizedFences);
|
|
5927
5971
|
const markdownWithPreviewPageBreaks = isLatex ? markdownWithoutHtmlComments : replaceStudioPreviewPageBreakCommands(markdownWithoutHtmlComments);
|
|
5928
5972
|
const latexSubfigurePreviewTransform = isLatex
|
|
@@ -5938,16 +5982,20 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
|
|
|
5938
5982
|
const bibliographyArgs = buildStudioPandocBibliographyArgs(markdown, isLatex, resourcePath);
|
|
5939
5983
|
const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none", ...bibliographyArgs];
|
|
5940
5984
|
let htmlTemplateDir: string | null = null;
|
|
5985
|
+
const useStudioHtmlTemplate = Boolean(resourcePath || isLatex);
|
|
5941
5986
|
if (resourcePath) {
|
|
5942
5987
|
args.push(`--resource-path=${resourcePath}`);
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
//
|
|
5988
|
+
}
|
|
5989
|
+
if (useStudioHtmlTemplate) {
|
|
5990
|
+
// Use standalone mode for embedded resources and LaTeX metadata. A minimal
|
|
5991
|
+
// Studio template keeps Pandoc's default standalone CSS out of the pane while
|
|
5992
|
+
// still rendering LaTeX title/author/abstract metadata.
|
|
5946
5993
|
htmlTemplateDir = join(tmpdir(), `pi-studio-pandoc-html-${randomUUID()}`);
|
|
5947
5994
|
await mkdir(htmlTemplateDir, { recursive: true });
|
|
5948
5995
|
const htmlTemplatePath = join(htmlTemplateDir, "template.html");
|
|
5949
5996
|
await writeFile(htmlTemplatePath, STUDIO_PANDOC_HTML_FRAGMENT_TEMPLATE, "utf-8");
|
|
5950
|
-
args.push(await getStudioPandocHtmlResourceFlag(pandocCommand)
|
|
5997
|
+
if (resourcePath) args.push(await getStudioPandocHtmlResourceFlag(pandocCommand));
|
|
5998
|
+
args.push("--standalone", `--template=${htmlTemplatePath}`);
|
|
5951
5999
|
}
|
|
5952
6000
|
const normalizedMarkdown = isLatex
|
|
5953
6001
|
? sourceWithResolvedRefs
|
|
@@ -5977,8 +6025,8 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
|
|
|
5977
6025
|
}
|
|
5978
6026
|
|
|
5979
6027
|
let renderedHtml = pandocResult.stdout;
|
|
5980
|
-
// When --standalone is used for embedded resources, extract only the <body> content.
|
|
5981
|
-
if (
|
|
6028
|
+
// When --standalone is used for embedded resources or LaTeX metadata, extract only the <body> content.
|
|
6029
|
+
if (useStudioHtmlTemplate) {
|
|
5982
6030
|
const bodyMatch = renderedHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
|
5983
6031
|
if (!bodyMatch) {
|
|
5984
6032
|
throw new Error("pandoc HTML render did not include a complete body element.");
|
|
@@ -6787,9 +6835,7 @@ async function renderStudioPdfWithPandoc(
|
|
|
6787
6835
|
});
|
|
6788
6836
|
if (pandocResult.code !== 0) {
|
|
6789
6837
|
const stderr = pandocResult.stderr;
|
|
6790
|
-
const hint =
|
|
6791
|
-
? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
|
|
6792
|
-
: "";
|
|
6838
|
+
const hint = getStudioMissingLatexEngineHint(stderr, pdfEngine);
|
|
6793
6839
|
throw new Error(`pandoc PDF export failed with exit code ${pandocResult.code}${stderr ? `: ${stderr}` : ""}${hint}`);
|
|
6794
6840
|
}
|
|
6795
6841
|
|
|
@@ -6915,9 +6961,7 @@ async function renderStudioPdfWithPandoc(
|
|
|
6915
6961
|
});
|
|
6916
6962
|
if (pandocResult.code !== 0) {
|
|
6917
6963
|
const stderr = pandocResult.stderr;
|
|
6918
|
-
const hint =
|
|
6919
|
-
? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
|
|
6920
|
-
: "";
|
|
6964
|
+
const hint = getStudioMissingLatexEngineHint(stderr, pdfEngine);
|
|
6921
6965
|
throw new Error(`pandoc PDF export failed with exit code ${pandocResult.code}${stderr ? `: ${stderr}` : ""}${hint}`);
|
|
6922
6966
|
}
|
|
6923
6967
|
|
|
@@ -7303,6 +7347,41 @@ function openPathInDefaultViewer(path: string): Promise<void> {
|
|
|
7303
7347
|
});
|
|
7304
7348
|
}
|
|
7305
7349
|
|
|
7350
|
+
async function handleOpenStudioFileBrowserDirectoryRequest(req: IncomingMessage, res: ServerResponse, studioCwd: string): Promise<void> {
|
|
7351
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
7352
|
+
if (method !== "POST") {
|
|
7353
|
+
res.setHeader("Allow", "POST");
|
|
7354
|
+
respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
|
|
7355
|
+
return;
|
|
7356
|
+
}
|
|
7357
|
+
if (isSshSession()) {
|
|
7358
|
+
respondJson(res, 409, { ok: false, error: "Cannot open local file manager from an SSH/headless Studio session. Copy the path instead." });
|
|
7359
|
+
return;
|
|
7360
|
+
}
|
|
7361
|
+
|
|
7362
|
+
const rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
|
|
7363
|
+
let payload: Record<string, unknown> = {};
|
|
7364
|
+
try {
|
|
7365
|
+
payload = rawBody ? JSON.parse(rawBody) : {};
|
|
7366
|
+
} catch {
|
|
7367
|
+
respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
|
|
7368
|
+
return;
|
|
7369
|
+
}
|
|
7370
|
+
|
|
7371
|
+
try {
|
|
7372
|
+
const directory = resolveStudioFileBrowserDirectory(
|
|
7373
|
+
typeof payload.dir === "string" ? payload.dir : undefined,
|
|
7374
|
+
typeof payload.sourcePath === "string" ? payload.sourcePath : undefined,
|
|
7375
|
+
typeof payload.resourceDir === "string" ? payload.resourceDir : undefined,
|
|
7376
|
+
studioCwd,
|
|
7377
|
+
);
|
|
7378
|
+
await openPathInDefaultViewer(directory.currentDir);
|
|
7379
|
+
respondJson(res, 200, { ok: true, message: "Opened folder in file manager.", path: directory.currentDir, rootDir: directory.rootDir });
|
|
7380
|
+
} catch (error) {
|
|
7381
|
+
respondJson(res, 404, { ok: false, error: `Could not open file-browser folder: ${error instanceof Error ? error.message : String(error)}` });
|
|
7382
|
+
}
|
|
7383
|
+
}
|
|
7384
|
+
|
|
7306
7385
|
function detectLensFromText(text: string): Lens {
|
|
7307
7386
|
const lines = text.split("\n");
|
|
7308
7387
|
const fencedCodeBlocks = (text.match(/```[\w-]*\n[\s\S]*?```/g) ?? []).length;
|
|
@@ -7627,12 +7706,13 @@ function getStudioQuizReasoning(model: NonNullable<ExtensionContext["model"]>, t
|
|
|
7627
7706
|
async function runStudioModelText(
|
|
7628
7707
|
ctx: StudioModelRequestContext,
|
|
7629
7708
|
prompt: string,
|
|
7630
|
-
options?: { systemPrompt?: string; maxTokens?: number; signal?: AbortSignal; reasoning?: ThinkingLevel; timeoutMs?: number; trim?: boolean },
|
|
7709
|
+
options?: { systemPrompt?: string; maxTokens?: number; signal?: AbortSignal; reasoning?: ThinkingLevel; timeoutMs?: number; trim?: boolean; model?: NonNullable<ExtensionContext["model"]> },
|
|
7631
7710
|
): Promise<string> {
|
|
7632
|
-
|
|
7633
|
-
|
|
7711
|
+
const model = options?.model ?? ctx.model;
|
|
7712
|
+
if (!model) throw new Error("No active model selected.");
|
|
7713
|
+
const auth = await resolveStudioModelRequestAuth(ctx, model);
|
|
7634
7714
|
const response = await completeSimple(
|
|
7635
|
-
|
|
7715
|
+
model,
|
|
7636
7716
|
{
|
|
7637
7717
|
systemPrompt: options?.systemPrompt ?? "You are a concise assistant inside pi Studio. Return exactly the requested format.",
|
|
7638
7718
|
messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }],
|
|
@@ -7692,6 +7772,37 @@ async function runStudioQuizModelJson(
|
|
|
7692
7772
|
throw lastError ?? new Error("Model did not return valid JSON.");
|
|
7693
7773
|
}
|
|
7694
7774
|
|
|
7775
|
+
function isStudioCompletionCodeLanguage(language: string | undefined): boolean {
|
|
7776
|
+
const normalized = String(language || "").trim().toLowerCase();
|
|
7777
|
+
return new Set([
|
|
7778
|
+
"javascript",
|
|
7779
|
+
"typescript",
|
|
7780
|
+
"python",
|
|
7781
|
+
"bash",
|
|
7782
|
+
"json",
|
|
7783
|
+
"rust",
|
|
7784
|
+
"c",
|
|
7785
|
+
"cpp",
|
|
7786
|
+
"julia",
|
|
7787
|
+
"fortran",
|
|
7788
|
+
"r",
|
|
7789
|
+
"matlab",
|
|
7790
|
+
"diff",
|
|
7791
|
+
"csv",
|
|
7792
|
+
"tsv",
|
|
7793
|
+
"java",
|
|
7794
|
+
"go",
|
|
7795
|
+
"ruby",
|
|
7796
|
+
"swift",
|
|
7797
|
+
"html",
|
|
7798
|
+
"css",
|
|
7799
|
+
"xml",
|
|
7800
|
+
"yaml",
|
|
7801
|
+
"toml",
|
|
7802
|
+
"lua",
|
|
7803
|
+
]).has(normalized);
|
|
7804
|
+
}
|
|
7805
|
+
|
|
7695
7806
|
function buildStudioCompletionSuggestionPrompt(options: {
|
|
7696
7807
|
text: string;
|
|
7697
7808
|
selectionStart: number;
|
|
@@ -7701,6 +7812,7 @@ function buildStudioCompletionSuggestionPrompt(options: {
|
|
|
7701
7812
|
path?: string;
|
|
7702
7813
|
contextMode?: "cursor" | "session";
|
|
7703
7814
|
contextText?: string;
|
|
7815
|
+
previousSuggestion?: string;
|
|
7704
7816
|
}): string {
|
|
7705
7817
|
const text = String(options.text || "");
|
|
7706
7818
|
const start = Math.max(0, Math.min(Math.floor(options.selectionStart || 0), text.length));
|
|
@@ -7711,31 +7823,51 @@ function buildStudioCompletionSuggestionPrompt(options: {
|
|
|
7711
7823
|
const language = String(options.language || "").trim() || "unknown";
|
|
7712
7824
|
const label = String(options.label || options.path || "Studio editor").trim();
|
|
7713
7825
|
const contextText = String(options.contextText || "").trim().slice(-STUDIO_COMPLETION_MAX_CONTEXT_CHARS);
|
|
7826
|
+
const previousSuggestion = String(options.previousSuggestion || "").trim().slice(-4000);
|
|
7827
|
+
const editorExcerpt = selected
|
|
7828
|
+
? `${prefix}⟦SELECTION_START⟧${selected}⟦SELECTION_END⟧${suffix}`
|
|
7829
|
+
: `${prefix}⟦CURSOR⟧${suffix}`;
|
|
7830
|
+
const isCodeCompletion = isStudioCompletionCodeLanguage(language);
|
|
7831
|
+
const modeInstructions = isCodeCompletion
|
|
7832
|
+
? [
|
|
7833
|
+
"You are acting as a tab-completion model for a code editor.",
|
|
7834
|
+
"Return only the exact code/text that should be inserted if the user presses Tab. Do not wrap it in Markdown fences. Do not explain.",
|
|
7835
|
+
"Preserve syntax, indentation, delimiters, local names, comments, and the surrounding coding style.",
|
|
7836
|
+
"Partial identifiers, expressions, arguments, statements, or structured-data fragments are allowed when they are syntactically natural at the marker.",
|
|
7837
|
+
"If the marker is inside a string, comment, docstring, or markup text node, continue that local text naturally rather than applying prose sentence rules globally.",
|
|
7838
|
+
"Keep the completion local and short unless the surrounding code clearly calls for a larger block.",
|
|
7839
|
+
]
|
|
7840
|
+
: [
|
|
7841
|
+
"You are acting as a tab-completion model for a text editor.",
|
|
7842
|
+
"Return only the exact text that should be inserted if the user presses Tab. Do not wrap it in Markdown fences. Do not explain.",
|
|
7843
|
+
"Do not return a sentence fragment, dependent clause, or lowercase noun phrase unless it is grammatically valid immediately at the marker.",
|
|
7844
|
+
"If the marker follows a completed sentence and you continue with prose, begin with any needed whitespace and a complete new sentence using normal capitalization.",
|
|
7845
|
+
"Return a non-empty completion. If the cursor is at the end of a sentence or paragraph, continue with a plausible complete sentence rather than a fragment.",
|
|
7846
|
+
"Match the surrounding language, style, indentation, and register.",
|
|
7847
|
+
"Keep the suggestion short unless the context clearly asks for a longer continuation.",
|
|
7848
|
+
];
|
|
7714
7849
|
return [
|
|
7715
|
-
|
|
7716
|
-
|
|
7717
|
-
|
|
7718
|
-
|
|
7850
|
+
...modeInstructions,
|
|
7851
|
+
selected
|
|
7852
|
+
? "The text between ⟦SELECTION_START⟧ and ⟦SELECTION_END⟧ is selected. Your answer will replace only that selected text."
|
|
7853
|
+
: "The cursor is marked by ⟦CURSOR⟧. Your answer will replace only that marker.",
|
|
7854
|
+
"The text before the marker is already written. Do not rewrite it, paraphrase it, or continue from an earlier point in the excerpt.",
|
|
7855
|
+
"After replacing the marker or selected range with your answer, the excerpt must read naturally at that exact position.",
|
|
7856
|
+
"Include any needed leading whitespace or punctuation; do not assume the editor will add it.",
|
|
7719
7857
|
contextText
|
|
7720
7858
|
? "Use the extra session context only as background. Do not continue the extra context directly unless the editor cursor calls for it."
|
|
7721
7859
|
: "Use only the cursor-local editor context below.",
|
|
7722
|
-
|
|
7723
|
-
? "The selected text will be replaced by the completion."
|
|
7724
|
-
: "The completion will be inserted at the cursor.",
|
|
7860
|
+
previousSuggestion ? "The user asked for another suggestion. Avoid repeating the previous suggestion; offer a materially different continuation that still fits the same cursor context." : "",
|
|
7725
7861
|
"",
|
|
7726
7862
|
`File/context label: ${label}`,
|
|
7727
7863
|
`Language mode: ${language}`,
|
|
7728
7864
|
`Suggestion context mode: ${contextText ? "editor plus latest response" : "editor only"}`,
|
|
7729
7865
|
contextText ? ["", "<extra_context>", contextText, "</extra_context>"].join("\n") : "",
|
|
7866
|
+
previousSuggestion ? ["", "<previous_suggestion>", previousSuggestion, "</previous_suggestion>"].join("\n") : "",
|
|
7730
7867
|
"",
|
|
7731
|
-
"<
|
|
7732
|
-
|
|
7733
|
-
"</
|
|
7734
|
-
selected ? ["", "<selected>", selected, "</selected>"].join("\n") : "",
|
|
7735
|
-
"",
|
|
7736
|
-
"<suffix>",
|
|
7737
|
-
suffix,
|
|
7738
|
-
"</suffix>",
|
|
7868
|
+
"<editor_excerpt>",
|
|
7869
|
+
editorExcerpt,
|
|
7870
|
+
"</editor_excerpt>",
|
|
7739
7871
|
].filter((part) => part !== "").join("\n");
|
|
7740
7872
|
}
|
|
7741
7873
|
|
|
@@ -7754,13 +7886,19 @@ async function runStudioCompletionSuggestion(ctx: StudioModelRequestContext, opt
|
|
|
7754
7886
|
path?: string;
|
|
7755
7887
|
contextMode?: "cursor" | "session";
|
|
7756
7888
|
contextText?: string;
|
|
7889
|
+
previousSuggestion?: string;
|
|
7890
|
+
model?: NonNullable<ExtensionContext["model"]>;
|
|
7757
7891
|
signal?: AbortSignal;
|
|
7758
7892
|
}): Promise<string> {
|
|
7759
7893
|
const prompt = buildStudioCompletionSuggestionPrompt(options);
|
|
7894
|
+
const systemPrompt = isStudioCompletionCodeLanguage(options.language)
|
|
7895
|
+
? "You are a code tab-completion engine inside pi Studio. Return only the exact code/text that replaces the cursor marker or selected range in the provided editor excerpt. The resulting excerpt must be syntactically natural at that exact position. Include needed leading whitespace. Never explain. Never include Markdown fences unless literal fences are the intended insertion."
|
|
7896
|
+
: "You are a prose tab-completion engine inside pi Studio. Return only the exact text that replaces the cursor marker or selected range in the provided editor excerpt. The resulting excerpt must read naturally at that exact position. Include needed leading whitespace. Never explain. Never include Markdown fences unless literal fences are the intended insertion.";
|
|
7760
7897
|
// Intentionally omit `reasoning`: pi-ai treats absent reasoning as off/disabled
|
|
7761
7898
|
// where supported. Passing "minimal" would still enable a reasoning path and slow completions.
|
|
7762
7899
|
const suggestion = cleanStudioCompletionSuggestion(await runStudioModelText(ctx, prompt, {
|
|
7763
|
-
systemPrompt
|
|
7900
|
+
systemPrompt,
|
|
7901
|
+
model: options.model,
|
|
7764
7902
|
maxTokens: 650,
|
|
7765
7903
|
timeoutMs: 60_000,
|
|
7766
7904
|
trim: false,
|
|
@@ -8204,6 +8342,9 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
|
|
|
8204
8342
|
path: typeof msg.path === "string" ? msg.path : undefined,
|
|
8205
8343
|
contextMode,
|
|
8206
8344
|
contextText: contextMode === "session" && typeof msg.contextText === "string" ? msg.contextText.slice(-STUDIO_COMPLETION_MAX_CONTEXT_CHARS) : undefined,
|
|
8345
|
+
previousSuggestion: typeof msg.previousSuggestion === "string" ? msg.previousSuggestion.slice(-4000) : undefined,
|
|
8346
|
+
suggestionModelProvider: typeof msg.suggestionModelProvider === "string" ? msg.suggestionModelProvider : undefined,
|
|
8347
|
+
suggestionModelId: typeof msg.suggestionModelId === "string" ? msg.suggestionModelId : undefined,
|
|
8207
8348
|
};
|
|
8208
8349
|
}
|
|
8209
8350
|
|
|
@@ -9760,6 +9901,12 @@ function formatModelLabelWithThinking(modelLabel: string, thinkingLevel?: string
|
|
|
9760
9901
|
return `${base} (${level})`;
|
|
9761
9902
|
}
|
|
9762
9903
|
|
|
9904
|
+
function formatStudioModelOptionLabel(model: { provider?: string; id?: string; name?: string } | undefined): string {
|
|
9905
|
+
const base = formatModelLabel(model);
|
|
9906
|
+
const name = typeof model?.name === "string" ? model.name.trim() : "";
|
|
9907
|
+
return name && name !== model?.id ? `${name} (${base})` : base;
|
|
9908
|
+
}
|
|
9909
|
+
|
|
9763
9910
|
function buildTerminalSessionLabel(cwd: string, sessionName?: string): string {
|
|
9764
9911
|
const cwdBase = basename(cwd || process.cwd() || "") || cwd || "~";
|
|
9765
9912
|
const termProgram = String(process.env.TERM_PROGRAM ?? "").trim();
|
|
@@ -10073,7 +10220,7 @@ ${cssVarsBlock}
|
|
|
10073
10220
|
<button id="saveOverBtn" type="button" title="Overwrite current file with editor content. Shortcut: Cmd/Ctrl+S.">Save editor</button>
|
|
10074
10221
|
<button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
|
|
10075
10222
|
<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>
|
|
10076
|
-
<label class="file-label" title="
|
|
10223
|
+
<label class="file-label" title="Browser import: load a selected text file as a detached unsaved copy. It will not be refreshable from disk. Use the Files view to open a file-backed document.">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>
|
|
10077
10224
|
<button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
|
|
10078
10225
|
<button id="zenModeBtn" class="zen-mode-btn" type="button" title="Hide secondary Studio controls. Shortcut: F9.">Zen</button>
|
|
10079
10226
|
</div>
|
|
@@ -10127,6 +10274,9 @@ ${cssVarsBlock}
|
|
|
10127
10274
|
<option value="cursor" selected>Context: editor only</option>
|
|
10128
10275
|
<option value="session">Context: editor + latest response</option>
|
|
10129
10276
|
</select>
|
|
10277
|
+
<select id="completionModelSelect" hidden aria-label="Suggestion model" title="Choose the model used for Suggest. Suggestions use direct completion with thinking off and do not change the main Pi model.">
|
|
10278
|
+
<option value="current" selected>Suggestion model: current Pi model</option>
|
|
10279
|
+
</select>
|
|
10130
10280
|
<button id="openCompanionBtn" type="button" title="Open a blank editor-only Studio tab.">New editor tab</button>
|
|
10131
10281
|
<button id="sendEditorBtn" type="button">Send current text to Pi editor</button>
|
|
10132
10282
|
</div>
|
|
@@ -10213,11 +10363,12 @@ ${cssVarsBlock}
|
|
|
10213
10363
|
</div>
|
|
10214
10364
|
<div id="completionSuggestionPanel" class="completion-suggestion-panel" hidden>
|
|
10215
10365
|
<div class="completion-suggestion-header">
|
|
10216
|
-
<strong>Suggested completion</strong>
|
|
10366
|
+
<div><strong>Suggested completion</strong><span id="completionSuggestionMeta" class="completion-suggestion-meta"></span></div>
|
|
10217
10367
|
<button id="completionSuggestionDismissBtn" type="button" title="Dismiss this suggestion">Dismiss</button>
|
|
10218
10368
|
</div>
|
|
10219
10369
|
<pre id="completionSuggestionText" class="completion-suggestion-text"></pre>
|
|
10220
10370
|
<div class="completion-suggestion-actions">
|
|
10371
|
+
<button id="completionSuggestionRegenerateBtn" type="button" title="Ask for a different suggestion at the same cursor position.">Try another</button>
|
|
10221
10372
|
<button id="completionSuggestionInsertBtn" type="button" title="Insert this suggestion at the cursor or original selection. You can also press Tab while the editor is focused.">Insert suggestion (Tab)</button>
|
|
10222
10373
|
</div>
|
|
10223
10374
|
</div>
|
|
@@ -11455,6 +11606,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
11455
11606
|
broadcastState();
|
|
11456
11607
|
};
|
|
11457
11608
|
|
|
11609
|
+
const getSuggestionModelOptions = () => {
|
|
11610
|
+
const registry = lastCommandCtx?.modelRegistry ?? latestModelRequestCtx?.modelRegistry;
|
|
11611
|
+
if (!registry || typeof registry.getAvailable !== "function") return [];
|
|
11612
|
+
return registry.getAvailable().map((model) => ({
|
|
11613
|
+
provider: model.provider,
|
|
11614
|
+
id: model.id,
|
|
11615
|
+
label: formatStudioModelOptionLabel(model),
|
|
11616
|
+
reasoning: Boolean(model.reasoning),
|
|
11617
|
+
}));
|
|
11618
|
+
};
|
|
11619
|
+
|
|
11458
11620
|
const broadcastState = () => {
|
|
11459
11621
|
terminalSessionLabel = buildTerminalSessionLabel(studioCwd, getSessionNameSafe());
|
|
11460
11622
|
terminalSessionDetail = buildTerminalSessionDetail(studioCwd, getSessionNameSafe());
|
|
@@ -11468,6 +11630,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
11468
11630
|
terminalToolName: terminalActivityToolName,
|
|
11469
11631
|
terminalActivityLabel,
|
|
11470
11632
|
modelLabel: currentModelLabel,
|
|
11633
|
+
suggestionModels: getSuggestionModelOptions(),
|
|
11471
11634
|
terminalSessionLabel,
|
|
11472
11635
|
terminalSessionDetail,
|
|
11473
11636
|
contextTokens: contextUsageSnapshot.tokens,
|
|
@@ -11763,6 +11926,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
11763
11926
|
terminalToolName: terminalActivityToolName,
|
|
11764
11927
|
terminalActivityLabel,
|
|
11765
11928
|
modelLabel: currentModelLabel,
|
|
11929
|
+
suggestionModels: getSuggestionModelOptions(),
|
|
11766
11930
|
terminalSessionLabel,
|
|
11767
11931
|
terminalSessionDetail,
|
|
11768
11932
|
contextTokens: contextUsageSnapshot.tokens,
|
|
@@ -12063,10 +12227,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
12063
12227
|
return;
|
|
12064
12228
|
}
|
|
12065
12229
|
sendToClient(client, { type: "completion_suggestion_progress", requestId: msg.requestId, message: "Generating suggestion…" });
|
|
12230
|
+
let suggestionModel: NonNullable<ExtensionContext["model"]> | undefined;
|
|
12231
|
+
if (msg.suggestionModelProvider && msg.suggestionModelId) {
|
|
12232
|
+
suggestionModel = ctx.modelRegistry.find(msg.suggestionModelProvider, msg.suggestionModelId);
|
|
12233
|
+
if (!suggestionModel) {
|
|
12234
|
+
sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: `Suggestion model not found: ${msg.suggestionModelProvider}/${msg.suggestionModelId}` });
|
|
12235
|
+
return;
|
|
12236
|
+
}
|
|
12237
|
+
}
|
|
12066
12238
|
const completionController = new AbortController();
|
|
12067
12239
|
activeCompletionSuggestions.set(msg.requestId, completionController);
|
|
12068
12240
|
void (async () => {
|
|
12069
12241
|
try {
|
|
12242
|
+
const activeSuggestionModel = suggestionModel ?? ctx.model;
|
|
12070
12243
|
const suggestion = await runStudioCompletionSuggestion(ctx, {
|
|
12071
12244
|
text: msg.text,
|
|
12072
12245
|
selectionStart: msg.selectionStart,
|
|
@@ -12076,12 +12249,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
12076
12249
|
path: msg.path,
|
|
12077
12250
|
contextMode: msg.contextMode,
|
|
12078
12251
|
contextText: msg.contextText,
|
|
12252
|
+
previousSuggestion: msg.previousSuggestion,
|
|
12253
|
+
model: suggestionModel,
|
|
12079
12254
|
signal: completionController.signal,
|
|
12080
12255
|
});
|
|
12081
12256
|
sendToClient(client, {
|
|
12082
12257
|
type: "completion_suggestion_result",
|
|
12083
12258
|
requestId: msg.requestId,
|
|
12084
12259
|
suggestion,
|
|
12260
|
+
modelLabel: formatStudioModelOptionLabel(activeSuggestionModel),
|
|
12085
12261
|
selectionStart: msg.selectionStart,
|
|
12086
12262
|
selectionEnd: msg.selectionEnd,
|
|
12087
12263
|
});
|
|
@@ -13706,6 +13882,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
13706
13882
|
return;
|
|
13707
13883
|
}
|
|
13708
13884
|
|
|
13885
|
+
if (requestUrl.pathname === "/file-browser-open") {
|
|
13886
|
+
const token = requestUrl.searchParams.get("token") ?? "";
|
|
13887
|
+
if (token !== serverState.token) {
|
|
13888
|
+
respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
|
|
13889
|
+
return;
|
|
13890
|
+
}
|
|
13891
|
+
|
|
13892
|
+
void handleOpenStudioFileBrowserDirectoryRequest(req, res, studioCwd).catch((error) => {
|
|
13893
|
+
respondJson(res, 500, { ok: false, error: `Open folder failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
13894
|
+
});
|
|
13895
|
+
return;
|
|
13896
|
+
}
|
|
13897
|
+
|
|
13709
13898
|
if (requestUrl.pathname === "/local-preview-link") {
|
|
13710
13899
|
const token = requestUrl.searchParams.get("token") ?? "";
|
|
13711
13900
|
if (token !== serverState.token) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-studio",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.27",
|
|
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",
|