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 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
@@ -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
- appendStudioUiRefreshMenuSection(contextMenu.menu, "Suggestions", [cursorContextBtn, sessionContextBtn]);
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
- : "Ask the current model for a short completion at the editor cursor. Shortcut: Option/Alt+Tab where available, or Cmd/Ctrl+Shift+Space from the editor.";
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 text = String(sourceTextEl.value || "");
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
- const selectionStart = typeof sourceTextEl.selectionStart === "number" ? sourceTextEl.selectionStart : text.length;
10942
- const selectionEnd = typeof sourceTextEl.selectionEnd === "number" ? sourceTextEl.selectionEnd : selectionStart;
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 sent = sendMessage({
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 markdownWithNormalizedFences = isLatex ? markdown : normalizeStudioMarkdownSmartFences(markdown);
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
- // Embed images as data URIs so browser previews and exported HTML keep local figures.
5944
- // A minimal template prevents Pandoc's standalone default CSS/title block from leaking
5945
- // into Studio's own standalone export wrapper.
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), "--standalone", `--template=${htmlTemplatePath}`);
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 (resourcePath) {
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 = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
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 = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
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
- if (!ctx.model) throw new Error("No active model selected.");
7633
- const auth = await resolveStudioModelRequestAuth(ctx, ctx.model);
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
- ctx.model,
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
- "Generate an inline completion for the current editor cursor position.",
7716
- "Return only the exact text to insert. Do not wrap it in Markdown fences. Do not explain.",
7717
- "Match the surrounding language, style, indentation, and register.",
7718
- "Keep the suggestion short unless the context clearly asks for a longer continuation.",
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
- selected
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
- "<prefix>",
7732
- prefix,
7733
- "</prefix>",
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: "You are an inline autocomplete engine inside pi Studio. Return only text to insert at the cursor. Never explain. Never include Markdown fences unless literal fences are the intended insertion.",
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="Import a browser-selected text file into the editor as an unsaved copy. It will not be refreshable from disk until you save it.">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>
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.26",
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",