pi-studio 0.9.25 → 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 +23 -0
- package/README.md +1 -1
- package/client/studio-client.js +185 -14
- package/client/studio.css +10 -1
- package/index.ts +246 -35
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
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
|
+
|
|
23
|
+
## [0.9.26] — 2026-06-04
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- Made Studio Markdown previews compatible with older Pandoc versions by falling back to `--self-contained` when `--embed-resources` is unavailable.
|
|
27
|
+
|
|
5
28
|
## [0.9.25] — 2026-06-01
|
|
6
29
|
|
|
7
30
|
### Changed
|
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}\\]` : "\\[\\]";
|
|
@@ -5898,9 +5934,39 @@ function decorateStudioPandocSyntaxHtml(html: string): string {
|
|
|
5898
5934
|
);
|
|
5899
5935
|
}
|
|
5900
5936
|
|
|
5937
|
+
const studioPandocHtmlResourceFlagCache = new Map<string, Promise<"--embed-resources" | "--self-contained">>();
|
|
5938
|
+
|
|
5939
|
+
async function getStudioPandocHtmlResourceFlag(pandocCommand: string): Promise<"--embed-resources" | "--self-contained"> {
|
|
5940
|
+
let cached = studioPandocHtmlResourceFlagCache.get(pandocCommand);
|
|
5941
|
+
if (!cached) {
|
|
5942
|
+
cached = runStudioSubprocess(pandocCommand, ["--help"], {
|
|
5943
|
+
timeoutMs: 5_000,
|
|
5944
|
+
stdoutMaxBytes: 250_000,
|
|
5945
|
+
stderrMaxBytes: 20_000,
|
|
5946
|
+
label: "pandoc capability probe",
|
|
5947
|
+
notFoundMessage: "pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary.",
|
|
5948
|
+
}).then((result) => {
|
|
5949
|
+
if (result.code !== 0) {
|
|
5950
|
+
throw new Error(`pandoc capability probe failed with exit code ${result.code}${result.stderr ? `: ${result.stderr}` : ""}`);
|
|
5951
|
+
}
|
|
5952
|
+
return result.stdout.includes("--embed-resources") ? "--embed-resources" : "--self-contained";
|
|
5953
|
+
});
|
|
5954
|
+
studioPandocHtmlResourceFlagCache.set(pandocCommand, cached);
|
|
5955
|
+
}
|
|
5956
|
+
return cached;
|
|
5957
|
+
}
|
|
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
|
+
|
|
5901
5966
|
async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string, sourcePath?: string): Promise<string> {
|
|
5902
5967
|
const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
|
|
5903
|
-
const
|
|
5968
|
+
const latexPreviewSource = isLatex ? preprocessStudioLatexFootnotemarksForPreview(markdown) : markdown;
|
|
5969
|
+
const markdownWithNormalizedFences = isLatex ? latexPreviewSource : normalizeStudioMarkdownSmartFences(markdown);
|
|
5904
5970
|
const markdownWithoutHtmlComments = isLatex ? markdownWithNormalizedFences : stripStudioMarkdownHtmlCommentsPreservingYamlFrontMatter(markdownWithNormalizedFences);
|
|
5905
5971
|
const markdownWithPreviewPageBreaks = isLatex ? markdownWithoutHtmlComments : replaceStudioPreviewPageBreakCommands(markdownWithoutHtmlComments);
|
|
5906
5972
|
const latexSubfigurePreviewTransform = isLatex
|
|
@@ -5916,16 +5982,20 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
|
|
|
5916
5982
|
const bibliographyArgs = buildStudioPandocBibliographyArgs(markdown, isLatex, resourcePath);
|
|
5917
5983
|
const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none", ...bibliographyArgs];
|
|
5918
5984
|
let htmlTemplateDir: string | null = null;
|
|
5985
|
+
const useStudioHtmlTemplate = Boolean(resourcePath || isLatex);
|
|
5919
5986
|
if (resourcePath) {
|
|
5920
5987
|
args.push(`--resource-path=${resourcePath}`);
|
|
5921
|
-
|
|
5922
|
-
|
|
5923
|
-
//
|
|
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.
|
|
5924
5993
|
htmlTemplateDir = join(tmpdir(), `pi-studio-pandoc-html-${randomUUID()}`);
|
|
5925
5994
|
await mkdir(htmlTemplateDir, { recursive: true });
|
|
5926
5995
|
const htmlTemplatePath = join(htmlTemplateDir, "template.html");
|
|
5927
5996
|
await writeFile(htmlTemplatePath, STUDIO_PANDOC_HTML_FRAGMENT_TEMPLATE, "utf-8");
|
|
5928
|
-
args.push(
|
|
5997
|
+
if (resourcePath) args.push(await getStudioPandocHtmlResourceFlag(pandocCommand));
|
|
5998
|
+
args.push("--standalone", `--template=${htmlTemplatePath}`);
|
|
5929
5999
|
}
|
|
5930
6000
|
const normalizedMarkdown = isLatex
|
|
5931
6001
|
? sourceWithResolvedRefs
|
|
@@ -5955,8 +6025,8 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
|
|
|
5955
6025
|
}
|
|
5956
6026
|
|
|
5957
6027
|
let renderedHtml = pandocResult.stdout;
|
|
5958
|
-
// When --standalone
|
|
5959
|
-
if (
|
|
6028
|
+
// When --standalone is used for embedded resources or LaTeX metadata, extract only the <body> content.
|
|
6029
|
+
if (useStudioHtmlTemplate) {
|
|
5960
6030
|
const bodyMatch = renderedHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
|
5961
6031
|
if (!bodyMatch) {
|
|
5962
6032
|
throw new Error("pandoc HTML render did not include a complete body element.");
|
|
@@ -6765,9 +6835,7 @@ async function renderStudioPdfWithPandoc(
|
|
|
6765
6835
|
});
|
|
6766
6836
|
if (pandocResult.code !== 0) {
|
|
6767
6837
|
const stderr = pandocResult.stderr;
|
|
6768
|
-
const hint =
|
|
6769
|
-
? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
|
|
6770
|
-
: "";
|
|
6838
|
+
const hint = getStudioMissingLatexEngineHint(stderr, pdfEngine);
|
|
6771
6839
|
throw new Error(`pandoc PDF export failed with exit code ${pandocResult.code}${stderr ? `: ${stderr}` : ""}${hint}`);
|
|
6772
6840
|
}
|
|
6773
6841
|
|
|
@@ -6893,9 +6961,7 @@ async function renderStudioPdfWithPandoc(
|
|
|
6893
6961
|
});
|
|
6894
6962
|
if (pandocResult.code !== 0) {
|
|
6895
6963
|
const stderr = pandocResult.stderr;
|
|
6896
|
-
const hint =
|
|
6897
|
-
? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
|
|
6898
|
-
: "";
|
|
6964
|
+
const hint = getStudioMissingLatexEngineHint(stderr, pdfEngine);
|
|
6899
6965
|
throw new Error(`pandoc PDF export failed with exit code ${pandocResult.code}${stderr ? `: ${stderr}` : ""}${hint}`);
|
|
6900
6966
|
}
|
|
6901
6967
|
|
|
@@ -7281,6 +7347,41 @@ function openPathInDefaultViewer(path: string): Promise<void> {
|
|
|
7281
7347
|
});
|
|
7282
7348
|
}
|
|
7283
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
|
+
|
|
7284
7385
|
function detectLensFromText(text: string): Lens {
|
|
7285
7386
|
const lines = text.split("\n");
|
|
7286
7387
|
const fencedCodeBlocks = (text.match(/```[\w-]*\n[\s\S]*?```/g) ?? []).length;
|
|
@@ -7605,12 +7706,13 @@ function getStudioQuizReasoning(model: NonNullable<ExtensionContext["model"]>, t
|
|
|
7605
7706
|
async function runStudioModelText(
|
|
7606
7707
|
ctx: StudioModelRequestContext,
|
|
7607
7708
|
prompt: string,
|
|
7608
|
-
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"]> },
|
|
7609
7710
|
): Promise<string> {
|
|
7610
|
-
|
|
7611
|
-
|
|
7711
|
+
const model = options?.model ?? ctx.model;
|
|
7712
|
+
if (!model) throw new Error("No active model selected.");
|
|
7713
|
+
const auth = await resolveStudioModelRequestAuth(ctx, model);
|
|
7612
7714
|
const response = await completeSimple(
|
|
7613
|
-
|
|
7715
|
+
model,
|
|
7614
7716
|
{
|
|
7615
7717
|
systemPrompt: options?.systemPrompt ?? "You are a concise assistant inside pi Studio. Return exactly the requested format.",
|
|
7616
7718
|
messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }],
|
|
@@ -7670,6 +7772,37 @@ async function runStudioQuizModelJson(
|
|
|
7670
7772
|
throw lastError ?? new Error("Model did not return valid JSON.");
|
|
7671
7773
|
}
|
|
7672
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
|
+
|
|
7673
7806
|
function buildStudioCompletionSuggestionPrompt(options: {
|
|
7674
7807
|
text: string;
|
|
7675
7808
|
selectionStart: number;
|
|
@@ -7679,6 +7812,7 @@ function buildStudioCompletionSuggestionPrompt(options: {
|
|
|
7679
7812
|
path?: string;
|
|
7680
7813
|
contextMode?: "cursor" | "session";
|
|
7681
7814
|
contextText?: string;
|
|
7815
|
+
previousSuggestion?: string;
|
|
7682
7816
|
}): string {
|
|
7683
7817
|
const text = String(options.text || "");
|
|
7684
7818
|
const start = Math.max(0, Math.min(Math.floor(options.selectionStart || 0), text.length));
|
|
@@ -7689,31 +7823,51 @@ function buildStudioCompletionSuggestionPrompt(options: {
|
|
|
7689
7823
|
const language = String(options.language || "").trim() || "unknown";
|
|
7690
7824
|
const label = String(options.label || options.path || "Studio editor").trim();
|
|
7691
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
|
+
];
|
|
7692
7849
|
return [
|
|
7693
|
-
|
|
7694
|
-
|
|
7695
|
-
|
|
7696
|
-
|
|
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.",
|
|
7697
7857
|
contextText
|
|
7698
7858
|
? "Use the extra session context only as background. Do not continue the extra context directly unless the editor cursor calls for it."
|
|
7699
7859
|
: "Use only the cursor-local editor context below.",
|
|
7700
|
-
|
|
7701
|
-
? "The selected text will be replaced by the completion."
|
|
7702
|
-
: "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." : "",
|
|
7703
7861
|
"",
|
|
7704
7862
|
`File/context label: ${label}`,
|
|
7705
7863
|
`Language mode: ${language}`,
|
|
7706
7864
|
`Suggestion context mode: ${contextText ? "editor plus latest response" : "editor only"}`,
|
|
7707
7865
|
contextText ? ["", "<extra_context>", contextText, "</extra_context>"].join("\n") : "",
|
|
7866
|
+
previousSuggestion ? ["", "<previous_suggestion>", previousSuggestion, "</previous_suggestion>"].join("\n") : "",
|
|
7708
7867
|
"",
|
|
7709
|
-
"<
|
|
7710
|
-
|
|
7711
|
-
"</
|
|
7712
|
-
selected ? ["", "<selected>", selected, "</selected>"].join("\n") : "",
|
|
7713
|
-
"",
|
|
7714
|
-
"<suffix>",
|
|
7715
|
-
suffix,
|
|
7716
|
-
"</suffix>",
|
|
7868
|
+
"<editor_excerpt>",
|
|
7869
|
+
editorExcerpt,
|
|
7870
|
+
"</editor_excerpt>",
|
|
7717
7871
|
].filter((part) => part !== "").join("\n");
|
|
7718
7872
|
}
|
|
7719
7873
|
|
|
@@ -7732,13 +7886,19 @@ async function runStudioCompletionSuggestion(ctx: StudioModelRequestContext, opt
|
|
|
7732
7886
|
path?: string;
|
|
7733
7887
|
contextMode?: "cursor" | "session";
|
|
7734
7888
|
contextText?: string;
|
|
7889
|
+
previousSuggestion?: string;
|
|
7890
|
+
model?: NonNullable<ExtensionContext["model"]>;
|
|
7735
7891
|
signal?: AbortSignal;
|
|
7736
7892
|
}): Promise<string> {
|
|
7737
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.";
|
|
7738
7897
|
// Intentionally omit `reasoning`: pi-ai treats absent reasoning as off/disabled
|
|
7739
7898
|
// where supported. Passing "minimal" would still enable a reasoning path and slow completions.
|
|
7740
7899
|
const suggestion = cleanStudioCompletionSuggestion(await runStudioModelText(ctx, prompt, {
|
|
7741
|
-
systemPrompt
|
|
7900
|
+
systemPrompt,
|
|
7901
|
+
model: options.model,
|
|
7742
7902
|
maxTokens: 650,
|
|
7743
7903
|
timeoutMs: 60_000,
|
|
7744
7904
|
trim: false,
|
|
@@ -8182,6 +8342,9 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
|
|
|
8182
8342
|
path: typeof msg.path === "string" ? msg.path : undefined,
|
|
8183
8343
|
contextMode,
|
|
8184
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,
|
|
8185
8348
|
};
|
|
8186
8349
|
}
|
|
8187
8350
|
|
|
@@ -9738,6 +9901,12 @@ function formatModelLabelWithThinking(modelLabel: string, thinkingLevel?: string
|
|
|
9738
9901
|
return `${base} (${level})`;
|
|
9739
9902
|
}
|
|
9740
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
|
+
|
|
9741
9910
|
function buildTerminalSessionLabel(cwd: string, sessionName?: string): string {
|
|
9742
9911
|
const cwdBase = basename(cwd || process.cwd() || "") || cwd || "~";
|
|
9743
9912
|
const termProgram = String(process.env.TERM_PROGRAM ?? "").trim();
|
|
@@ -10051,7 +10220,7 @@ ${cssVarsBlock}
|
|
|
10051
10220
|
<button id="saveOverBtn" type="button" title="Overwrite current file with editor content. Shortcut: Cmd/Ctrl+S.">Save editor</button>
|
|
10052
10221
|
<button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
|
|
10053
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>
|
|
10054
|
-
<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>
|
|
10055
10224
|
<button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
|
|
10056
10225
|
<button id="zenModeBtn" class="zen-mode-btn" type="button" title="Hide secondary Studio controls. Shortcut: F9.">Zen</button>
|
|
10057
10226
|
</div>
|
|
@@ -10105,6 +10274,9 @@ ${cssVarsBlock}
|
|
|
10105
10274
|
<option value="cursor" selected>Context: editor only</option>
|
|
10106
10275
|
<option value="session">Context: editor + latest response</option>
|
|
10107
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>
|
|
10108
10280
|
<button id="openCompanionBtn" type="button" title="Open a blank editor-only Studio tab.">New editor tab</button>
|
|
10109
10281
|
<button id="sendEditorBtn" type="button">Send current text to Pi editor</button>
|
|
10110
10282
|
</div>
|
|
@@ -10191,11 +10363,12 @@ ${cssVarsBlock}
|
|
|
10191
10363
|
</div>
|
|
10192
10364
|
<div id="completionSuggestionPanel" class="completion-suggestion-panel" hidden>
|
|
10193
10365
|
<div class="completion-suggestion-header">
|
|
10194
|
-
<strong>Suggested completion</strong>
|
|
10366
|
+
<div><strong>Suggested completion</strong><span id="completionSuggestionMeta" class="completion-suggestion-meta"></span></div>
|
|
10195
10367
|
<button id="completionSuggestionDismissBtn" type="button" title="Dismiss this suggestion">Dismiss</button>
|
|
10196
10368
|
</div>
|
|
10197
10369
|
<pre id="completionSuggestionText" class="completion-suggestion-text"></pre>
|
|
10198
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>
|
|
10199
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>
|
|
10200
10373
|
</div>
|
|
10201
10374
|
</div>
|
|
@@ -11433,6 +11606,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
11433
11606
|
broadcastState();
|
|
11434
11607
|
};
|
|
11435
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
|
+
|
|
11436
11620
|
const broadcastState = () => {
|
|
11437
11621
|
terminalSessionLabel = buildTerminalSessionLabel(studioCwd, getSessionNameSafe());
|
|
11438
11622
|
terminalSessionDetail = buildTerminalSessionDetail(studioCwd, getSessionNameSafe());
|
|
@@ -11446,6 +11630,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
11446
11630
|
terminalToolName: terminalActivityToolName,
|
|
11447
11631
|
terminalActivityLabel,
|
|
11448
11632
|
modelLabel: currentModelLabel,
|
|
11633
|
+
suggestionModels: getSuggestionModelOptions(),
|
|
11449
11634
|
terminalSessionLabel,
|
|
11450
11635
|
terminalSessionDetail,
|
|
11451
11636
|
contextTokens: contextUsageSnapshot.tokens,
|
|
@@ -11741,6 +11926,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
11741
11926
|
terminalToolName: terminalActivityToolName,
|
|
11742
11927
|
terminalActivityLabel,
|
|
11743
11928
|
modelLabel: currentModelLabel,
|
|
11929
|
+
suggestionModels: getSuggestionModelOptions(),
|
|
11744
11930
|
terminalSessionLabel,
|
|
11745
11931
|
terminalSessionDetail,
|
|
11746
11932
|
contextTokens: contextUsageSnapshot.tokens,
|
|
@@ -12041,10 +12227,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
12041
12227
|
return;
|
|
12042
12228
|
}
|
|
12043
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
|
+
}
|
|
12044
12238
|
const completionController = new AbortController();
|
|
12045
12239
|
activeCompletionSuggestions.set(msg.requestId, completionController);
|
|
12046
12240
|
void (async () => {
|
|
12047
12241
|
try {
|
|
12242
|
+
const activeSuggestionModel = suggestionModel ?? ctx.model;
|
|
12048
12243
|
const suggestion = await runStudioCompletionSuggestion(ctx, {
|
|
12049
12244
|
text: msg.text,
|
|
12050
12245
|
selectionStart: msg.selectionStart,
|
|
@@ -12054,12 +12249,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
12054
12249
|
path: msg.path,
|
|
12055
12250
|
contextMode: msg.contextMode,
|
|
12056
12251
|
contextText: msg.contextText,
|
|
12252
|
+
previousSuggestion: msg.previousSuggestion,
|
|
12253
|
+
model: suggestionModel,
|
|
12057
12254
|
signal: completionController.signal,
|
|
12058
12255
|
});
|
|
12059
12256
|
sendToClient(client, {
|
|
12060
12257
|
type: "completion_suggestion_result",
|
|
12061
12258
|
requestId: msg.requestId,
|
|
12062
12259
|
suggestion,
|
|
12260
|
+
modelLabel: formatStudioModelOptionLabel(activeSuggestionModel),
|
|
12063
12261
|
selectionStart: msg.selectionStart,
|
|
12064
12262
|
selectionEnd: msg.selectionEnd,
|
|
12065
12263
|
});
|
|
@@ -13684,6 +13882,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
13684
13882
|
return;
|
|
13685
13883
|
}
|
|
13686
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
|
+
|
|
13687
13898
|
if (requestUrl.pathname === "/local-preview-link") {
|
|
13688
13899
|
const token = requestUrl.searchParams.get("token") ?? "";
|
|
13689
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",
|