pi-studio 0.9.26 → 0.9.28

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,33 @@
2
2
 
3
3
  All notable changes to `pi-studio` are documented here.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [0.9.28] — 2026-06-08
8
+
9
+ ### Added
10
+ - Added a footer **Pi model & thinking** menu for switching the active Pi model and thinking level from Studio while keeping Studio Suggest model choice separate.
11
+
12
+ ### Changed
13
+ - Clarified the browser-import tooltip to explain that **Save editor as…** can make an imported copy file-backed.
14
+ - Regularized Source & context menu notes so explanatory text uses a quieter, consistent menu-note style.
15
+
16
+ ## [0.9.27] — 2026-06-08
17
+
18
+ ### Added
19
+ - Added an **Open root** action to the Files view for opening the Files root folder in Finder or the system file manager.
20
+
21
+ ### Changed
22
+ - Clarified browser-import and Files-view file-backed open action tooltips.
23
+ - 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.
24
+
25
+ ### Fixed
26
+ - Avoided showing the missing-LaTeX-engine hint for PDF exports when XeLaTeX/PDFLaTeX ran but the document itself had a LaTeX error.
27
+ - Restored Pandoc-rendered LaTeX title/author/abstract metadata in Studio previews that use embedded resources.
28
+ - Preserved optional LaTeX `\footnotemark[n]` markers as linked superscript affiliation markers in Studio previews.
29
+ - Nudged inline completion suggestions to return a non-empty continuation when explicitly requested at the end of a sentence or paragraph.
30
+ - Fixed Markdown blockquotes containing multiline `\[ ... \]` display math so quote markers are not folded into the math content.
31
+
5
32
  ## [0.9.26] — 2026-06-04
6
33
 
7
34
  ### 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
@@ -7,6 +7,7 @@
7
7
  const footerMetaModelEl = document.getElementById("footerMetaModel");
8
8
  const footerMetaTerminalEl = document.getElementById("footerMetaTerminal");
9
9
  const footerMetaContextEl = document.getElementById("footerMetaContext");
10
+ const footerModelMenuEl = document.getElementById("footerModelMenu");
10
11
  let faviconLinkEl = document.querySelector('link[rel="icon"], link[rel="shortcut icon"]');
11
12
  if (!faviconLinkEl) {
12
13
  faviconLinkEl = document.createElement("link");
@@ -118,8 +119,11 @@
118
119
  const suggestCompletionBtn = document.getElementById("suggestCompletionBtn");
119
120
  const suggestCompletionOptionsBtn = document.getElementById("suggestCompletionOptionsBtn");
120
121
  const completionContextSelect = document.getElementById("completionContextSelect");
122
+ const completionModelSelect = document.getElementById("completionModelSelect");
121
123
  const completionSuggestionPanelEl = document.getElementById("completionSuggestionPanel");
122
124
  const completionSuggestionTextEl = document.getElementById("completionSuggestionText");
125
+ const completionSuggestionMetaEl = document.getElementById("completionSuggestionMeta");
126
+ const completionSuggestionRegenerateBtn = document.getElementById("completionSuggestionRegenerateBtn");
123
127
  const completionSuggestionInsertBtn = document.getElementById("completionSuggestionInsertBtn");
124
128
  const completionSuggestionDismissBtn = document.getElementById("completionSuggestionDismissBtn");
125
129
  const saveAnnotatedBtn = document.getElementById("saveAnnotatedBtn");
@@ -325,6 +329,7 @@
325
329
  const EDITOR_TAB_TEXT = " ";
326
330
  const QUIZ_DEFAULT_COUNT = 5;
327
331
  const COMPLETION_CONTEXT_STORAGE_KEY = "piStudio.completionContextMode";
332
+ const COMPLETION_MODEL_STORAGE_KEY = "piStudio.completionModel";
328
333
  const COMPLETION_CONTEXT_MAX_CHARS = 12000;
329
334
  const QUIZ_SCOPES = ["editor", "selection", "file", "folder", "repo"];
330
335
  const QUIZ_ANGLES = ["general", "scientist", "mathematician", "statistician", "developer", "reviewer"];
@@ -518,6 +523,10 @@
518
523
  let previewExportInProgress = false;
519
524
  let compactInProgress = false;
520
525
  let modelLabel = (document.body && document.body.dataset && document.body.dataset.modelLabel) || "none";
526
+ let piModelOptions = [];
527
+ let piCurrentModel = null;
528
+ let piThinkingLevel = "";
529
+ let footerModelMenuOpen = false;
521
530
  let terminalSessionLabel = (document.body && document.body.dataset && document.body.dataset.terminalLabel) || "unknown";
522
531
  let terminalSessionDetail = (document.body && document.body.dataset && document.body.dataset.terminalDetail) || terminalSessionLabel;
523
532
  let contextTokens = null;
@@ -2027,6 +2036,8 @@
2027
2036
  let responseHighlightEnabled = false;
2028
2037
  let completionSuggestionState = null;
2029
2038
  let completionSuggestionContextMode = readCompletionSuggestionContextMode();
2039
+ let completionSuggestionModelValue = readCompletionSuggestionModelValue();
2040
+ let completionSuggestionModelOptions = [];
2030
2041
  let completionSuggestionInFlight = false;
2031
2042
  let completionSuggestionRequestId = null;
2032
2043
  let completionSuggestionPendingSnapshot = null;
@@ -2499,10 +2510,18 @@
2499
2510
  syncActionButtons();
2500
2511
  });
2501
2512
  });
2502
- appendStudioUiRefreshMenuSection(contextMenu.menu, "Suggestions", [cursorContextBtn, sessionContextBtn]);
2513
+ const suggestionItems = [cursorContextBtn, sessionContextBtn];
2514
+ if (completionModelSelect) {
2515
+ completionModelSelect.hidden = false;
2516
+ suggestionItems.push(completionModelSelect);
2517
+ }
2518
+ const completionThinkingNoteEl = makeStudioUiRefreshElement("div", "studio-refresh-menu-note completion-thinking-note", "Suggest: thinking off; model choice only affects suggestions.");
2519
+ completionThinkingNoteEl.setAttribute("aria-label", "Suggestion model note");
2520
+ suggestionItems.push(completionThinkingNoteEl);
2521
+ appendStudioUiRefreshMenuSection(contextMenu.menu, "Suggestions", suggestionItems);
2503
2522
  const statusItems = [];
2504
2523
  if (!isEditorOnlyMode) {
2505
- sourceSessionSummaryEl = makeStudioUiRefreshElement("div", "source-badge source-session-summary", "Session tree: branch history follows the current Pi branch. Editor text is independent.");
2524
+ sourceSessionSummaryEl = makeStudioUiRefreshElement("div", "studio-refresh-menu-note source-session-summary", "Session tree: branch history follows the current Pi branch; editor text stays independent.");
2506
2525
  sourceSessionSummaryEl.setAttribute("aria-label", "Pi session tree and editor sync behaviour");
2507
2526
  sourceSessionSummaryEl.title = "Use /tree in the Pi terminal to navigate branches. Studio updates branch history to match the active branch and leaves editor text unchanged.";
2508
2527
  statusItems.push(sourceSessionSummaryEl);
@@ -3165,6 +3184,118 @@
3165
3184
  }
3166
3185
  }
3167
3186
 
3187
+ function encodePiModelValue(provider, id) {
3188
+ return JSON.stringify([String(provider || ""), String(id || "")]);
3189
+ }
3190
+
3191
+ function decodePiModelValue(value) {
3192
+ try {
3193
+ const parsed = JSON.parse(String(value || ""));
3194
+ if (!Array.isArray(parsed) || parsed.length < 2) return null;
3195
+ const provider = String(parsed[0] || "").trim();
3196
+ const id = String(parsed[1] || "").trim();
3197
+ return provider && id ? { provider, id } : null;
3198
+ } catch {
3199
+ return null;
3200
+ }
3201
+ }
3202
+
3203
+ function normalizePiModelOptions(options) {
3204
+ return Array.isArray(options)
3205
+ ? options.map((option) => ({
3206
+ provider: String(option && option.provider || "").trim(),
3207
+ id: String(option && option.id || "").trim(),
3208
+ label: String(option && option.label || "").trim(),
3209
+ reasoning: Boolean(option && option.reasoning),
3210
+ })).filter((option) => option.provider && option.id)
3211
+ : [];
3212
+ }
3213
+
3214
+ function updatePiSessionModelState(message) {
3215
+ if (!message || typeof message !== "object") return;
3216
+ if (Array.isArray(message.piModels)) {
3217
+ piModelOptions = normalizePiModelOptions(message.piModels);
3218
+ } else if (Array.isArray(message.suggestionModels) && !piModelOptions.length) {
3219
+ piModelOptions = normalizePiModelOptions(message.suggestionModels);
3220
+ }
3221
+ if (message.currentModel && typeof message.currentModel === "object") {
3222
+ const model = message.currentModel;
3223
+ const provider = String(model.provider || "").trim();
3224
+ const id = String(model.id || "").trim();
3225
+ piCurrentModel = provider && id ? {
3226
+ provider,
3227
+ id,
3228
+ label: String(model.label || "").trim(),
3229
+ reasoning: Boolean(model.reasoning),
3230
+ } : null;
3231
+ }
3232
+ if (typeof message.thinkingLevel === "string") {
3233
+ piThinkingLevel = message.thinkingLevel.trim();
3234
+ }
3235
+ renderFooterModelMenu();
3236
+ }
3237
+
3238
+ function getPiCurrentModelValue() {
3239
+ return piCurrentModel && piCurrentModel.provider && piCurrentModel.id
3240
+ ? encodePiModelValue(piCurrentModel.provider, piCurrentModel.id)
3241
+ : "";
3242
+ }
3243
+
3244
+ function getPiThinkingLevels() {
3245
+ return ["off", "minimal", "low", "medium", "high", "xhigh"];
3246
+ }
3247
+
3248
+ function renderFooterModelMenu() {
3249
+ if (!footerModelMenuEl) return;
3250
+ const currentValue = getPiCurrentModelValue();
3251
+ const optionValues = new Set(piModelOptions.map((option) => encodePiModelValue(option.provider, option.id)));
3252
+ const modelOptionsHtml = piModelOptions.map((option) => {
3253
+ const value = encodePiModelValue(option.provider, option.id);
3254
+ const label = option.label || (option.provider + "/" + option.id);
3255
+ return "<option value='" + escapeHtml(value) + "'" + (value === currentValue ? " selected" : "") + ">" + escapeHtml(label) + "</option>";
3256
+ });
3257
+ if (currentValue && !optionValues.has(currentValue)) {
3258
+ const label = piCurrentModel && piCurrentModel.label ? piCurrentModel.label : modelLabel;
3259
+ modelOptionsHtml.unshift("<option value='" + escapeHtml(currentValue) + "' selected>" + escapeHtml(label || "current model") + "</option>");
3260
+ }
3261
+ const thinking = piThinkingLevel || "off";
3262
+ const thinkingOptionsHtml = getPiThinkingLevels().map((level) => {
3263
+ return "<option value='" + escapeHtml(level) + "'" + (level === thinking ? " selected" : "") + ">Thinking: " + escapeHtml(level) + "</option>";
3264
+ });
3265
+ footerModelMenuEl.innerHTML = ""
3266
+ + "<div class='footer-model-menu-heading'>Pi model & thinking</div>"
3267
+ + "<label class='footer-model-menu-field'><span>Pi model</span><select id='footerPiModelSelect'>" + modelOptionsHtml.join("") + "</select></label>"
3268
+ + "<label class='footer-model-menu-field'><span>Thinking</span><select id='footerPiThinkingSelect'>" + thinkingOptionsHtml.join("") + "</select></label>"
3269
+ + "<div class='footer-model-menu-note'>Affects future Pi turns. Studio Suggest has its own model setting.</div>";
3270
+ }
3271
+
3272
+ function setFooterModelMenuOpen(open) {
3273
+ footerModelMenuOpen = Boolean(open);
3274
+ if (footerModelMenuEl) footerModelMenuEl.hidden = !footerModelMenuOpen;
3275
+ if (footerMetaModelEl) {
3276
+ footerMetaModelEl.classList.toggle("is-open", footerModelMenuOpen);
3277
+ footerMetaModelEl.setAttribute("aria-expanded", footerModelMenuOpen ? "true" : "false");
3278
+ }
3279
+ if (footerModelMenuOpen) renderFooterModelMenu();
3280
+ }
3281
+
3282
+ function requestPiModelSelection(value) {
3283
+ const model = decodePiModelValue(value);
3284
+ if (!model) {
3285
+ setStatus("No Pi model selected.", "warning");
3286
+ return;
3287
+ }
3288
+ const sent = sendMessage({ type: "pi_model_select_request", provider: model.provider, id: model.id });
3289
+ if (sent) setStatus("Switching Pi model…", "warning");
3290
+ }
3291
+
3292
+ function requestPiThinkingLevel(level) {
3293
+ const normalized = String(level || "").trim();
3294
+ if (!normalized) return;
3295
+ const sent = sendMessage({ type: "pi_thinking_level_request", level: normalized });
3296
+ if (sent) setStatus("Setting Pi thinking level…", "warning");
3297
+ }
3298
+
3168
3299
  function updateFooterMeta() {
3169
3300
  const modelText = modelLabel && modelLabel.trim() ? modelLabel.trim() : "none";
3170
3301
  const terminalText = terminalSessionLabel && terminalSessionLabel.trim() ? terminalSessionLabel.trim() : "unknown";
@@ -3178,7 +3309,9 @@
3178
3309
  footerMetaModelEl.textContent = modelText;
3179
3310
  footerMetaTerminalEl.textContent = terminalText;
3180
3311
  footerMetaContextEl.textContent = contextDisplayText;
3181
- footerMetaModelEl.title = "Model: " + modelText;
3312
+ footerMetaModelEl.title = "Pi model and thinking: " + modelText;
3313
+ footerMetaModelEl.setAttribute("aria-haspopup", "menu");
3314
+ footerMetaModelEl.setAttribute("aria-expanded", footerModelMenuOpen ? "true" : "false");
3182
3315
  footerMetaTerminalEl.title = terminalDetailText;
3183
3316
  footerMetaContextEl.title = contextTitleText;
3184
3317
  if (footerMetaTextEl) footerMetaTextEl.title = titleText;
@@ -9661,14 +9794,17 @@
9661
9794
  ? "open-new"
9662
9795
  : ((kind === "pdf" || kind === "image") ? "open-preview-new" : "");
9663
9796
  const newTabLabel = kind === "text"
9664
- ? "Open file tab"
9797
+ ? "Open file-backed tab"
9665
9798
  : (kind === "office" ? "Convert tab" : ((kind === "pdf" || kind === "image") ? "Preview tab" : "New tab"));
9799
+ const newTabTitle = kind === "text"
9800
+ ? "Open this file-backed document in a new refreshable editor tab. Save editor and Refresh from disk will use this file."
9801
+ : (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
9802
  const textActions = newTabAction
9667
- ? "<button type='button' data-files-action='" + escapeHtml(newTabAction) + "' data-files-path='" + escapeHtml(entry.path) + "'>" + escapeHtml(newTabLabel) + "</button>"
9803
+ ? "<button type='button' data-files-action='" + escapeHtml(newTabAction) + "' data-files-path='" + escapeHtml(entry.path) + "' title='" + escapeHtml(newTabTitle) + "'>" + escapeHtml(newTabLabel) + "</button>"
9668
9804
  : "";
9669
9805
  const openTitle = type === "directory"
9670
9806
  ? "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"))));
9807
+ : (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
9808
  return "<div class='files-row files-row-" + escapeHtml(type) + " files-kind-" + escapeHtml(kind) + "'>"
9673
9809
  + "<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
9810
  + "<span class='files-icon' aria-hidden='true'>" + icon + "</span>"
@@ -9695,6 +9831,7 @@
9695
9831
  + "<button type='button' data-files-action='refresh'>Refresh</button>"
9696
9832
  + (currentDir ? "<button type='button' data-files-action='copy-current' data-files-path='" + escapeHtml(currentDir) + "'>Copy path</button>" : "")
9697
9833
  + (currentDir ? "<button type='button' data-files-action='use-working-dir' data-files-path='" + escapeHtml(currentDir) + "'>Use as working dir</button>" : "")
9834
+ + (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
9835
  + (rootDir ? "<button type='button' data-files-action='copy-root' data-files-path='" + escapeHtml(rootDir) + "'>Copy root</button>" : "")
9699
9836
  + "</div>"
9700
9837
  + "</div>"
@@ -9845,6 +9982,23 @@
9845
9982
  setStatus("Working dir set to current folder.", "success");
9846
9983
  }
9847
9984
 
9985
+ async function openFileBrowserDirectoryInFileViewer(path) {
9986
+ const targetDir = normalizeStudioResourceDirValue(path || fileBrowserState.rootDir || fileBrowserState.currentDir || "");
9987
+ if (!targetDir) {
9988
+ setStatus("No folder to open.", "warning");
9989
+ return;
9990
+ }
9991
+ const context = getHtmlPreviewResourceContextOptions();
9992
+ const body = { dir: targetDir };
9993
+ if (context.sourcePath) body.sourcePath = context.sourcePath;
9994
+ if (context.resourceDir) body.resourceDir = context.resourceDir;
9995
+ const payload = await fetchStudioJson("/file-browser-open", {
9996
+ method: "POST",
9997
+ body: JSON.stringify(body),
9998
+ });
9999
+ setStatus(payload && payload.message ? payload.message : "Opened folder in file manager.", "success");
10000
+ }
10001
+
9848
10002
  async function handleFilesPaneClick(event) {
9849
10003
  if (rightView !== "files") return;
9850
10004
  const target = event.target;
@@ -9888,6 +10042,10 @@
9888
10042
  setFileBrowserCurrentDirectoryAsWorkingDir(path);
9889
10043
  return;
9890
10044
  }
10045
+ if (action === "open-root") {
10046
+ await openFileBrowserDirectoryInFileViewer(path || fileBrowserState.rootDir || "");
10047
+ return;
10048
+ }
9891
10049
  if (action === "reveal") {
9892
10050
  await revealPreviewLocalLink(path, getFileBrowserLocalLinkContext());
9893
10051
  }
@@ -10437,13 +10595,18 @@
10437
10595
  syncRunAndCritiqueButtons();
10438
10596
  copyDraftBtn.disabled = uiBusy;
10439
10597
  if (suggestCompletionBtn) {
10598
+ const hasSuggestionForCurrentText = Boolean(completionSuggestionState && sourceTextEl && sourceTextEl.value === completionSuggestionState.baseText);
10440
10599
  suggestCompletionBtn.disabled = wsState !== "Ready" || (!completionSuggestionInFlight && (uiBusy || !String(sourceTextEl.value || "").trim()));
10441
- suggestCompletionBtn.textContent = completionSuggestionInFlight ? "Stop" : "Suggest";
10600
+ suggestCompletionBtn.textContent = completionSuggestionInFlight ? "Stop" : (hasSuggestionForCurrentText ? "Try another" : "Suggest");
10442
10601
  suggestCompletionBtn.title = completionSuggestionInFlight
10443
10602
  ? "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.";
10603
+ : (hasSuggestionForCurrentText
10604
+ ? "Ask for a different suggestion at the same cursor position."
10605
+ : "Ask for a short completion at the editor cursor. Shortcut: Option/Alt+Tab where available, or Cmd/Ctrl+Shift+Space from the editor.");
10445
10606
  }
10446
10607
  if (suggestCompletionOptionsBtn) suggestCompletionOptionsBtn.disabled = uiBusy || completionSuggestionInFlight;
10608
+ if (completionModelSelect) completionModelSelect.disabled = uiBusy || completionSuggestionInFlight;
10609
+ if (completionSuggestionRegenerateBtn) completionSuggestionRegenerateBtn.disabled = completionSuggestionInFlight || !completionSuggestionState;
10447
10610
  syncCompletionSuggestionContextUi();
10448
10611
  if (openCompanionBtn) openCompanionBtn.disabled = uiBusy || wsState !== "Ready";
10449
10612
  if (highlightSelect) highlightSelect.disabled = uiBusy;
@@ -10784,9 +10947,37 @@
10784
10947
  }
10785
10948
  }
10786
10949
 
10950
+ function readCompletionSuggestionModelValue() {
10951
+ try {
10952
+ const stored = window.localStorage ? String(window.localStorage.getItem(COMPLETION_MODEL_STORAGE_KEY) || "") : "";
10953
+ return stored && stored !== "undefined" && stored !== "null" ? stored : "current";
10954
+ } catch {
10955
+ return "current";
10956
+ }
10957
+ }
10958
+
10959
+ function encodeCompletionModelValue(provider, id) {
10960
+ return JSON.stringify([String(provider || ""), String(id || "")]);
10961
+ }
10962
+
10963
+ function decodeCompletionModelValue(value) {
10964
+ const raw = String(value || "");
10965
+ if (!raw || raw === "current") return null;
10966
+ try {
10967
+ const parsed = JSON.parse(raw);
10968
+ if (!Array.isArray(parsed) || parsed.length < 2) return null;
10969
+ const provider = String(parsed[0] || "").trim();
10970
+ const id = String(parsed[1] || "").trim();
10971
+ return provider && id ? { provider, id } : null;
10972
+ } catch {
10973
+ return null;
10974
+ }
10975
+ }
10976
+
10787
10977
  function setCompletionSuggestionContextMode(mode) {
10788
10978
  completionSuggestionContextMode = mode === "session" ? "session" : "cursor";
10789
10979
  if (completionContextSelect) completionContextSelect.value = completionSuggestionContextMode;
10980
+ hideCompletionSuggestion();
10790
10981
  try {
10791
10982
  if (window.localStorage) window.localStorage.setItem(COMPLETION_CONTEXT_STORAGE_KEY, completionSuggestionContextMode);
10792
10983
  } catch {}
@@ -10795,8 +10986,65 @@
10795
10986
  : "Suggestions will use cursor-local editor context only.");
10796
10987
  }
10797
10988
 
10989
+ function setCompletionSuggestionModelValue(value) {
10990
+ const normalized = String(value || "current") || "current";
10991
+ completionSuggestionModelValue = normalized;
10992
+ if (completionModelSelect) completionModelSelect.value = normalized;
10993
+ hideCompletionSuggestion();
10994
+ try {
10995
+ if (window.localStorage) window.localStorage.setItem(COMPLETION_MODEL_STORAGE_KEY, normalized);
10996
+ } catch {}
10997
+ const selectedLabel = getCompletionSuggestionModelLabel();
10998
+ setStatus(normalized === "current"
10999
+ ? "Suggestions will use the current Pi model with thinking off."
11000
+ : "Suggestions will use " + selectedLabel + " with thinking off.");
11001
+ }
11002
+
11003
+ function getCompletionSuggestionModelSelection() {
11004
+ return decodeCompletionModelValue(completionSuggestionModelValue);
11005
+ }
11006
+
11007
+ function getCompletionSuggestionModelLabel() {
11008
+ const selected = getCompletionSuggestionModelSelection();
11009
+ if (!selected) return "current Pi model";
11010
+ const match = completionSuggestionModelOptions.find((option) => option.provider === selected.provider && option.id === selected.id);
11011
+ return match && match.label ? match.label : (selected.provider + "/" + selected.id);
11012
+ }
11013
+
11014
+ function updateCompletionSuggestionModelOptions(options) {
11015
+ completionSuggestionModelOptions = Array.isArray(options)
11016
+ ? options.map((option) => ({
11017
+ provider: String(option && option.provider || "").trim(),
11018
+ id: String(option && option.id || "").trim(),
11019
+ label: String(option && option.label || "").trim(),
11020
+ reasoning: Boolean(option && option.reasoning),
11021
+ })).filter((option) => option.provider && option.id)
11022
+ : [];
11023
+ syncCompletionSuggestionModelUi();
11024
+ }
11025
+
11026
+ function syncCompletionSuggestionModelUi() {
11027
+ if (!completionModelSelect) return;
11028
+ const currentValue = completionSuggestionModelValue || "current";
11029
+ const modelOptionsHtml = completionSuggestionModelOptions.map((option) => {
11030
+ const value = encodeCompletionModelValue(option.provider, option.id);
11031
+ const label = option.label || (option.provider + "/" + option.id);
11032
+ return "<option value='" + escapeHtml(value) + "'>Suggestion model: " + escapeHtml(label) + "</option>";
11033
+ });
11034
+ const validValues = new Set(["current", ...completionSuggestionModelOptions.map((option) => encodeCompletionModelValue(option.provider, option.id))]);
11035
+ if (!validValues.has(currentValue) && completionSuggestionModelOptions.length === 0 && currentValue !== "current") {
11036
+ modelOptionsHtml.push("<option value='" + escapeHtml(currentValue) + "'>Suggestion model: saved selection</option>");
11037
+ validValues.add(currentValue);
11038
+ }
11039
+ completionModelSelect.innerHTML = ["<option value='current'>Suggestion model: current Pi model</option>", ...modelOptionsHtml].join("");
11040
+ completionModelSelect.value = validValues.has(currentValue) ? currentValue : "current";
11041
+ if (completionModelSelect.value !== currentValue) completionSuggestionModelValue = "current";
11042
+ completionModelSelect.title = "Choose the model used for Suggest. Suggestions use direct completion with thinking off and do not change the main Pi model.";
11043
+ }
11044
+
10798
11045
  function syncCompletionSuggestionContextUi() {
10799
11046
  if (completionContextSelect) completionContextSelect.value = completionSuggestionContextMode;
11047
+ syncCompletionSuggestionModelUi();
10800
11048
  if (suggestCompletionOptionsBtn) {
10801
11049
  suggestCompletionOptionsBtn.textContent = "Source & context";
10802
11050
  suggestCompletionOptionsBtn.title = completionSuggestionContextMode === "session"
@@ -10841,13 +11089,20 @@
10841
11089
  function hideCompletionSuggestion() {
10842
11090
  completionSuggestionState = null;
10843
11091
  if (completionSuggestionTextEl) completionSuggestionTextEl.textContent = "";
11092
+ if (completionSuggestionMetaEl) completionSuggestionMetaEl.textContent = "";
10844
11093
  if (completionSuggestionPanelEl) completionSuggestionPanelEl.hidden = true;
11094
+ syncActionButtons();
10845
11095
  }
10846
11096
 
10847
11097
  function showCompletionSuggestion(state) {
10848
11098
  completionSuggestionState = state;
10849
11099
  if (completionSuggestionTextEl) completionSuggestionTextEl.textContent = state && state.suggestion ? state.suggestion : "";
11100
+ if (completionSuggestionMetaEl) {
11101
+ const modelLabelText = state && state.modelLabel ? String(state.modelLabel) : getCompletionSuggestionModelLabel();
11102
+ completionSuggestionMetaEl.textContent = modelLabelText ? " · " + modelLabelText + " · thinking off" : " · thinking off";
11103
+ }
10850
11104
  if (completionSuggestionPanelEl) completionSuggestionPanelEl.hidden = false;
11105
+ syncActionButtons();
10851
11106
  }
10852
11107
 
10853
11108
  function focusSourceTextNoScroll() {
@@ -10927,29 +11182,38 @@
10927
11182
  }
10928
11183
  }
10929
11184
 
10930
- function requestCompletionSuggestion() {
11185
+ function requestCompletionSuggestion(options) {
10931
11186
  if (isEditorOnlyMode && !sourceTextEl) return;
10932
11187
  if (completionSuggestionInFlight) {
10933
11188
  cancelCompletionSuggestion();
10934
11189
  return;
10935
11190
  }
10936
- const text = String(sourceTextEl.value || "");
11191
+ const regenerateRequested = Boolean((options && options.regenerate) || (completionSuggestionState && sourceTextEl.value === completionSuggestionState.baseText));
11192
+ const existingSuggestion = regenerateRequested ? completionSuggestionState : null;
11193
+ const text = existingSuggestion ? String(existingSuggestion.baseText || "") : String(sourceTextEl.value || "");
10937
11194
  if (!text.trim()) {
10938
11195
  setStatus("Editor is empty.", "warning");
10939
11196
  return;
10940
11197
  }
10941
- const selectionStart = typeof sourceTextEl.selectionStart === "number" ? sourceTextEl.selectionStart : text.length;
10942
- const selectionEnd = typeof sourceTextEl.selectionEnd === "number" ? sourceTextEl.selectionEnd : selectionStart;
11198
+ if (existingSuggestion && String(sourceTextEl.value || "") !== text) {
11199
+ setStatus("Editor changed. Request a fresh suggestion from the current cursor.", "warning");
11200
+ hideCompletionSuggestion();
11201
+ return;
11202
+ }
11203
+ const selectionStart = existingSuggestion ? existingSuggestion.selectionStart : (typeof sourceTextEl.selectionStart === "number" ? sourceTextEl.selectionStart : text.length);
11204
+ const selectionEnd = existingSuggestion ? existingSuggestion.selectionEnd : (typeof sourceTextEl.selectionEnd === "number" ? sourceTextEl.selectionEnd : selectionStart);
10943
11205
  const contextText = getCompletionSuggestionContextText();
11206
+ const selectedModel = getCompletionSuggestionModelSelection();
10944
11207
  const requestId = makeRequestId();
11208
+ const previousSuggestion = existingSuggestion && existingSuggestion.suggestion ? String(existingSuggestion.suggestion) : "";
10945
11209
  completionSuggestionInFlight = true;
10946
11210
  completionSuggestionRequestId = requestId;
10947
- completionSuggestionPendingSnapshot = { text, selectionStart, selectionEnd };
11211
+ completionSuggestionPendingSnapshot = { text, selectionStart, selectionEnd, previousSuggestion };
10948
11212
  completionSuggestionRefocusEditorOnResult = shouldRefocusEditorForCompletionRequest();
10949
11213
  hideCompletionSuggestion();
10950
11214
  syncActionButtons();
10951
- setStatus("Generating completion suggestion…", "warning");
10952
- const sent = sendMessage({
11215
+ setStatus(existingSuggestion ? "Generating another suggestion…" : "Generating completion suggestion…", "warning");
11216
+ const message = {
10953
11217
  type: "completion_suggestion_request",
10954
11218
  requestId,
10955
11219
  text,
@@ -10960,7 +11224,13 @@
10960
11224
  path: sourceState && sourceState.path ? sourceState.path : undefined,
10961
11225
  contextMode: completionSuggestionContextMode,
10962
11226
  contextText: contextText || undefined,
10963
- });
11227
+ previousSuggestion: previousSuggestion || undefined,
11228
+ };
11229
+ if (selectedModel) {
11230
+ message.suggestionModelProvider = selectedModel.provider;
11231
+ message.suggestionModelId = selectedModel.id;
11232
+ }
11233
+ const sent = sendMessage(message);
10964
11234
  if (!sent) {
10965
11235
  completionSuggestionInFlight = false;
10966
11236
  completionSuggestionRequestId = null;
@@ -11035,6 +11305,8 @@
11035
11305
  baseText,
11036
11306
  selectionStart: start,
11037
11307
  selectionEnd: end,
11308
+ previousSuggestion: pendingSnapshot && pendingSnapshot.previousSuggestion ? pendingSnapshot.previousSuggestion : "",
11309
+ modelLabel: typeof message.modelLabel === "string" ? message.modelLabel : getCompletionSuggestionModelLabel(),
11038
11310
  });
11039
11311
  const activeEl = document.activeElement;
11040
11312
  if (
@@ -18493,6 +18765,10 @@
18493
18765
  if (typeof message.modelLabel === "string") {
18494
18766
  modelLabel = message.modelLabel;
18495
18767
  }
18768
+ if (Array.isArray(message.suggestionModels)) {
18769
+ updateCompletionSuggestionModelOptions(message.suggestionModels);
18770
+ }
18771
+ updatePiSessionModelState(message);
18496
18772
  if (typeof message.terminalSessionLabel === "string") {
18497
18773
  terminalSessionLabel = message.terminalSessionLabel;
18498
18774
  }
@@ -19065,6 +19341,10 @@
19065
19341
  if (typeof message.modelLabel === "string") {
19066
19342
  modelLabel = message.modelLabel;
19067
19343
  }
19344
+ if (Array.isArray(message.suggestionModels)) {
19345
+ updateCompletionSuggestionModelOptions(message.suggestionModels);
19346
+ }
19347
+ updatePiSessionModelState(message);
19068
19348
  if (typeof message.terminalSessionLabel === "string") {
19069
19349
  terminalSessionLabel = message.terminalSessionLabel;
19070
19350
  }
@@ -19944,9 +20224,39 @@
19944
20224
  if (event.key === "Escape") {
19945
20225
  closeExportPreviewMenu();
19946
20226
  closePreviewLinkMenu();
20227
+ setFooterModelMenuOpen(false);
19947
20228
  }
19948
20229
  });
19949
20230
 
20231
+ if (footerMetaModelEl) {
20232
+ footerMetaModelEl.addEventListener("click", (event) => {
20233
+ event.preventDefault();
20234
+ event.stopPropagation();
20235
+ setFooterModelMenuOpen(!footerModelMenuOpen);
20236
+ });
20237
+ }
20238
+ if (footerModelMenuEl) {
20239
+ footerModelMenuEl.addEventListener("click", (event) => {
20240
+ event.stopPropagation();
20241
+ });
20242
+ footerModelMenuEl.addEventListener("change", (event) => {
20243
+ const target = event.target;
20244
+ if (!(target instanceof HTMLSelectElement)) return;
20245
+ if (target.id === "footerPiModelSelect") {
20246
+ requestPiModelSelection(target.value);
20247
+ setFooterModelMenuOpen(false);
20248
+ } else if (target.id === "footerPiThinkingSelect") {
20249
+ requestPiThinkingLevel(target.value);
20250
+ setFooterModelMenuOpen(false);
20251
+ }
20252
+ });
20253
+ }
20254
+ document.addEventListener("click", (event) => {
20255
+ const target = event.target;
20256
+ if (target instanceof Element && (target.closest("#footerModelMenu") || target.closest("#footerMetaModel"))) return;
20257
+ setFooterModelMenuOpen(false);
20258
+ });
20259
+
19950
20260
  saveAsBtn.addEventListener("click", () => {
19951
20261
  const content = sourceTextEl.value;
19952
20262
  if (!content.trim()) {
@@ -20190,6 +20500,18 @@
20190
20500
  syncActionButtons();
20191
20501
  });
20192
20502
  }
20503
+ if (completionModelSelect) {
20504
+ completionModelSelect.value = completionSuggestionModelValue;
20505
+ completionModelSelect.addEventListener("change", () => {
20506
+ setCompletionSuggestionModelValue(completionModelSelect.value || "current");
20507
+ syncActionButtons();
20508
+ });
20509
+ }
20510
+ if (completionSuggestionRegenerateBtn) {
20511
+ completionSuggestionRegenerateBtn.addEventListener("click", () => {
20512
+ requestCompletionSuggestion({ regenerate: true });
20513
+ });
20514
+ }
20193
20515
  if (completionSuggestionInsertBtn) {
20194
20516
  completionSuggestionInsertBtn.addEventListener("click", () => {
20195
20517
  insertCompletionSuggestion();