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 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
@@ -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}\\]` : "\\[\\]";
@@ -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 markdownWithNormalizedFences = isLatex ? markdown : normalizeStudioMarkdownSmartFences(markdown);
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
- // Embed images as data URIs so browser previews and exported HTML keep local figures.
5922
- // A minimal template prevents Pandoc's standalone default CSS/title block from leaking
5923
- // 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.
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("--embed-resources", "--standalone", `--template=${htmlTemplatePath}`);
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 was used for --embed-resources, extract only the <body> content.
5959
- if (resourcePath) {
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 = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
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 = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
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
- if (!ctx.model) throw new Error("No active model selected.");
7611
- 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);
7612
7714
  const response = await completeSimple(
7613
- ctx.model,
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
- "Generate an inline completion for the current editor cursor position.",
7694
- "Return only the exact text to insert. Do not wrap it in Markdown fences. Do not explain.",
7695
- "Match the surrounding language, style, indentation, and register.",
7696
- "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.",
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
- selected
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
- "<prefix>",
7710
- prefix,
7711
- "</prefix>",
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: "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,
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="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>
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.25",
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",