pi-studio 0.9.15 → 0.9.17

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
@@ -4,6 +4,18 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.17] — 2026-05-25
8
+
9
+ ### Changed
10
+ - Escape now dismisses a visible editor completion suggestion before falling through to other Studio Escape actions.
11
+ - Added a compact **Source & context** dropdown beside the editor-mode selector, with editor-only suggestions by default and an optional editor-plus-latest-response context mode; in-flight suggestion requests can now be stopped from the **Suggest** button.
12
+ - Kept Zen mode focused by hiding **Suggest** with the other secondary editor utilities while still showing **Send to REPL** controls when the REPL pane is active.
13
+
14
+ ## [0.9.16] — 2026-05-24
15
+
16
+ ### Added
17
+ - Added a first-cut manual **Suggest completion** editor action beside Run/Queue that asks the active model for a short cursor/selection-aware continuation, previews it before insertion, supports Option/Alt+Tab where available plus Cmd/Ctrl+Shift+Space from the editor, and lets Tab insert a visible suggestion.
18
+
7
19
  ## [0.9.15] — 2026-05-23
8
20
 
9
21
  ### Added
package/README.md CHANGED
@@ -21,7 +21,7 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
21
21
  - Opens a two-pane browser workspace: **Editor** (left) + **Response/Working/Editor Preview** (right)
22
22
  - Supports one canonical full Studio view per Pi session, plus additional editor-only companion views when you just want extra editing/preview surfaces; the editor toolbar can open a detached copy of the current editor text as a companion view
23
23
  - Includes a global **Zen** mode for hiding secondary Studio chrome without changing the current left/right pane layout
24
- - Runs editor text directly, asks for structured critique (auto/writing/code focus), 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
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 **Files** view for browsing the current Pi session/resource directory, opening folders, loading text/code documents into the editor, previewing PDFs/images, copying paths, and revealing files in the file manager
27
27
  - 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
@@ -114,6 +114,13 @@
114
114
  const sendReplBtn = document.getElementById("sendReplBtn");
115
115
  const replSendModeSelect = document.getElementById("replSendModeSelect");
116
116
  const copyDraftBtn = document.getElementById("copyDraftBtn");
117
+ const suggestCompletionBtn = document.getElementById("suggestCompletionBtn");
118
+ const suggestCompletionOptionsBtn = document.getElementById("suggestCompletionOptionsBtn");
119
+ const completionContextSelect = document.getElementById("completionContextSelect");
120
+ const completionSuggestionPanelEl = document.getElementById("completionSuggestionPanel");
121
+ const completionSuggestionTextEl = document.getElementById("completionSuggestionText");
122
+ const completionSuggestionInsertBtn = document.getElementById("completionSuggestionInsertBtn");
123
+ const completionSuggestionDismissBtn = document.getElementById("completionSuggestionDismissBtn");
117
124
  const saveAnnotatedBtn = document.getElementById("saveAnnotatedBtn");
118
125
  const stripAnnotationsBtn = document.getElementById("stripAnnotationsBtn");
119
126
  const highlightSelect = document.getElementById("highlightSelect");
@@ -251,6 +258,8 @@
251
258
  const HTML_ARTIFACT_RESOURCE_FETCH_TIMEOUT_MS = 30_000;
252
259
  const EDITOR_TAB_TEXT = " ";
253
260
  const QUIZ_DEFAULT_COUNT = 5;
261
+ const COMPLETION_CONTEXT_STORAGE_KEY = "piStudio.completionContextMode";
262
+ const COMPLETION_CONTEXT_MAX_CHARS = 12000;
254
263
  const QUIZ_SCOPES = ["editor", "selection", "file", "folder", "repo"];
255
264
  const QUIZ_ANGLES = ["general", "scientist", "mathematician", "statistician", "developer", "reviewer"];
256
265
  const QUIZ_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"];
@@ -1943,6 +1952,12 @@
1943
1952
  let editorHighlightEnabled = false;
1944
1953
  let editorLanguage = "markdown";
1945
1954
  let responseHighlightEnabled = false;
1955
+ let completionSuggestionState = null;
1956
+ let completionSuggestionContextMode = readCompletionSuggestionContextMode();
1957
+ let completionSuggestionInFlight = false;
1958
+ let completionSuggestionRequestId = null;
1959
+ let completionSuggestionPendingSnapshot = null;
1960
+ let completionSuggestionRefocusEditorOnResult = false;
1946
1961
  let editorHighlightRenderRaf = null;
1947
1962
  let lineNumbersEnabled = false;
1948
1963
  let lineNumbersRenderRaf = null;
@@ -2329,6 +2344,34 @@
2329
2344
  appendStudioUiRefreshMenuSection(reviewMenu.menu, "Setting", [lensSelect]);
2330
2345
  }
2331
2346
 
2347
+ let contextMenu = null;
2348
+ if (suggestCompletionOptionsBtn) {
2349
+ suggestCompletionOptionsBtn.hidden = false;
2350
+ if (completionContextSelect) completionContextSelect.hidden = true;
2351
+ contextMenu = makeStudioUiRefreshMenu(suggestCompletionOptionsBtn, "context", "studio-refresh-context-anchor");
2352
+ if (sourceBadgeEl) appendStudioUiRefreshMenuSection(contextMenu.menu, "Document", [sourceBadgeEl]);
2353
+ appendStudioUiRefreshMenuSection(contextMenu.menu, "Working directory", [resourceDirBtn, resourceDirLabel, resourceDirInputWrap]);
2354
+ const cursorContextBtn = makeStudioUiRefreshElement("button", "completion-context-option", "Editor only");
2355
+ cursorContextBtn.type = "button";
2356
+ cursorContextBtn.setAttribute("data-completion-context-mode", "cursor");
2357
+ const sessionContextBtn = makeStudioUiRefreshElement("button", "completion-context-option", "Editor + latest response");
2358
+ sessionContextBtn.type = "button";
2359
+ sessionContextBtn.setAttribute("data-completion-context-mode", "session");
2360
+ [cursorContextBtn, sessionContextBtn].forEach((button) => {
2361
+ button.addEventListener("click", (event) => {
2362
+ event.preventDefault();
2363
+ event.stopPropagation();
2364
+ setCompletionSuggestionContextMode(button.getAttribute("data-completion-context-mode") === "session" ? "session" : "cursor");
2365
+ syncActionButtons();
2366
+ });
2367
+ });
2368
+ appendStudioUiRefreshMenuSection(contextMenu.menu, "Suggestions", [cursorContextBtn, sessionContextBtn]);
2369
+ if (syncBadgeEl) {
2370
+ syncBadgeEl.hidden = false;
2371
+ appendStudioUiRefreshMenuSection(contextMenu.menu, "Status", [syncBadgeEl]);
2372
+ }
2373
+ }
2374
+
2332
2375
  const headerTopEl = makeStudioUiRefreshElement("div", "studio-refresh-header-top");
2333
2376
  const titleGroupEl = makeStudioUiRefreshElement("div", "studio-refresh-title-group");
2334
2377
  if (leftFocusBtn) {
@@ -2341,6 +2384,10 @@
2341
2384
  } else if (editorViewSelect) {
2342
2385
  titleGroupEl.appendChild(editorViewSelect);
2343
2386
  }
2387
+ if (contextMenu) {
2388
+ titleGroupEl.appendChild(makeStudioUiRefreshSeparator());
2389
+ titleGroupEl.appendChild(contextMenu.anchor);
2390
+ }
2344
2391
  headerTopEl.appendChild(titleGroupEl);
2345
2392
  const headerToolsEl = makeStudioUiRefreshElement("div", "studio-refresh-pane-tools");
2346
2393
  if (reviewNotesBtn) headerToolsEl.appendChild(reviewNotesBtn);
@@ -2349,18 +2396,7 @@
2349
2396
  if (reviewMenu) headerToolsEl.appendChild(reviewMenu.anchor);
2350
2397
  headerTopEl.appendChild(headerToolsEl);
2351
2398
 
2352
- const headerUtilityEl = makeStudioUiRefreshElement("div", "studio-refresh-header-utility");
2353
- const utilityLeftEl = makeStudioUiRefreshElement("div", "studio-refresh-utility-left");
2354
- if (sourceBadgeEl) utilityLeftEl.appendChild(sourceBadgeEl);
2355
- if (sourceBadgeEl && (resourceDirBtn || resourceDirLabel || resourceDirInputWrap || syncBadgeEl)) {
2356
- utilityLeftEl.appendChild(makeStudioUiRefreshSeparator());
2357
- }
2358
- if (resourceDirBtn) utilityLeftEl.appendChild(resourceDirBtn);
2359
- if (resourceDirLabel) utilityLeftEl.appendChild(resourceDirLabel);
2360
- if (resourceDirInputWrap) utilityLeftEl.appendChild(resourceDirInputWrap);
2361
- if (syncBadgeEl) utilityLeftEl.appendChild(syncBadgeEl);
2362
- headerUtilityEl.appendChild(utilityLeftEl);
2363
- leftHeaderEl.replaceChildren(headerTopEl, headerUtilityEl);
2399
+ leftHeaderEl.replaceChildren(headerTopEl);
2364
2400
 
2365
2401
  const rightHeaderEl = document.getElementById("rightSectionHeader");
2366
2402
  if (rightHeaderEl && rightViewSelect) {
@@ -2392,17 +2428,18 @@
2392
2428
  const actionLineOneEl = makeStudioUiRefreshElement("div", "studio-refresh-action-line");
2393
2429
  if (!isEditorOnlyMode && sendRunBtn) actionLineOneEl.appendChild(sendRunBtn);
2394
2430
  if (!isEditorOnlyMode && queueSteerBtn) actionLineOneEl.appendChild(queueSteerBtn);
2431
+ const actionLineTwoEl = makeStudioUiRefreshElement("div", "studio-refresh-action-line studio-refresh-utility-action-line");
2432
+ actionLineTwoEl.appendChild(copyDraftBtn);
2433
+ if (suggestCompletionBtn) actionLineTwoEl.appendChild(suggestCompletionBtn);
2434
+ if (openCompanionBtn) actionLineTwoEl.appendChild(openCompanionBtn);
2435
+ if (!isEditorOnlyMode && sendEditorBtn) actionLineTwoEl.appendChild(sendEditorBtn);
2395
2436
  const replActionLineEl = makeStudioUiRefreshElement("div", "studio-refresh-action-line repl-action-line");
2396
2437
  replActionLineEl.hidden = true;
2397
2438
  if (!isEditorOnlyMode && sendReplBtn) replActionLineEl.appendChild(sendReplBtn);
2398
2439
  if (!isEditorOnlyMode && replSendModeSelect) replActionLineEl.appendChild(replSendModeSelect);
2399
- const actionLineTwoEl = makeStudioUiRefreshElement("div", "studio-refresh-action-line");
2400
- actionLineTwoEl.appendChild(copyDraftBtn);
2401
- if (openCompanionBtn) actionLineTwoEl.appendChild(openCompanionBtn);
2402
- if (!isEditorOnlyMode && sendEditorBtn) actionLineTwoEl.appendChild(sendEditorBtn);
2403
2440
  if (actionLineOneEl.childNodes.length > 0) actionsEl.appendChild(actionLineOneEl);
2404
- if (replActionLineEl.childNodes.length > 0) actionsEl.appendChild(replActionLineEl);
2405
2441
  actionsEl.appendChild(actionLineTwoEl);
2442
+ if (replActionLineEl.childNodes.length > 0) actionsEl.appendChild(replActionLineEl);
2406
2443
 
2407
2444
  const stateEl = makeStudioUiRefreshElement("div", "studio-refresh-toolbar-state");
2408
2445
  const annotationsButton = makeStudioUiRefreshElement("button", "", "Annotations");
@@ -2424,7 +2461,9 @@
2424
2461
  annotationsButton,
2425
2462
  viewButton,
2426
2463
  reviewButton: reviewMenu ? reviewMenu.button : null,
2427
- menus: [annotationsMenu, viewMenu].concat(reviewMenu ? [reviewMenu] : []),
2464
+ menus: [annotationsMenu, viewMenu]
2465
+ .concat(contextMenu ? [contextMenu] : [])
2466
+ .concat(reviewMenu ? [reviewMenu] : []),
2428
2467
  };
2429
2468
 
2430
2469
  document.addEventListener("click", (event) => {
@@ -3680,6 +3719,16 @@
3680
3719
  return;
3681
3720
  }
3682
3721
 
3722
+ if (plainEscape && completionSuggestionState) {
3723
+ event.preventDefault();
3724
+ hideCompletionSuggestion();
3725
+ focusSourceTextNoScroll();
3726
+ setStatus("Dismissed completion suggestion.");
3727
+ return;
3728
+ }
3729
+
3730
+ if (handleCompletionSuggestionAcceptKey(event)) return;
3731
+
3683
3732
  if ((key === "?" || (key === "/" && event.shiftKey)) && !event.metaKey && !event.ctrlKey && !event.altKey && !isTextEntryShortcutTarget(event.target)) {
3684
3733
  event.preventDefault();
3685
3734
  toggleShortcuts();
@@ -4231,20 +4280,15 @@
4231
4280
 
4232
4281
  if (isEditorOnlyMode) {
4233
4282
  syncBadgeEl.hidden = true;
4234
- syncBadgeEl.classList.remove("sync");
4235
- return;
4236
- }
4237
-
4238
- if (rightView === "trace") {
4239
- syncBadgeEl.hidden = true;
4240
- syncBadgeEl.classList.remove("sync");
4283
+ syncBadgeEl.textContent = "Editor-only tab";
4284
+ syncBadgeEl.classList.remove("sync", "out-of-sync");
4241
4285
  return;
4242
4286
  }
4243
4287
 
4244
4288
  if (!latestResponseHasContent) {
4245
- syncBadgeEl.hidden = true;
4246
- syncBadgeEl.textContent = "In sync with response";
4247
- syncBadgeEl.classList.remove("sync");
4289
+ syncBadgeEl.hidden = false;
4290
+ syncBadgeEl.textContent = "No latest response";
4291
+ syncBadgeEl.classList.remove("sync", "out-of-sync");
4248
4292
  return;
4249
4293
  }
4250
4294
 
@@ -4252,15 +4296,10 @@
4252
4296
  ? normalizedEditorText
4253
4297
  : normalizeForCompare(sourceTextEl.value);
4254
4298
  const inSync = normalizedEditor === latestResponseNormalized;
4255
- syncBadgeEl.hidden = !inSync;
4256
- syncBadgeEl.textContent = "In sync with response";
4257
-
4258
- if (inSync) {
4259
- syncBadgeEl.classList.add("sync");
4260
- return;
4261
- }
4262
-
4263
- syncBadgeEl.classList.remove("sync");
4299
+ syncBadgeEl.hidden = false;
4300
+ syncBadgeEl.textContent = inSync ? "In sync with response" : "Editor differs from latest response";
4301
+ syncBadgeEl.classList.toggle("sync", inSync);
4302
+ syncBadgeEl.classList.toggle("out-of-sync", !inSync);
4264
4303
  }
4265
4304
 
4266
4305
  function buildPlainMarkdownHtml(markdown, options) {
@@ -8653,6 +8692,15 @@
8653
8692
  if (loadGitDiffBtn) loadGitDiffBtn.disabled = uiBusy;
8654
8693
  syncRunAndCritiqueButtons();
8655
8694
  copyDraftBtn.disabled = uiBusy;
8695
+ if (suggestCompletionBtn) {
8696
+ suggestCompletionBtn.disabled = wsState !== "Ready" || (!completionSuggestionInFlight && (uiBusy || !String(sourceTextEl.value || "").trim()));
8697
+ suggestCompletionBtn.textContent = completionSuggestionInFlight ? "Stop" : "Suggest";
8698
+ suggestCompletionBtn.title = completionSuggestionInFlight
8699
+ ? "Stop the current suggestion request."
8700
+ : "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.";
8701
+ }
8702
+ if (suggestCompletionOptionsBtn) suggestCompletionOptionsBtn.disabled = uiBusy || completionSuggestionInFlight;
8703
+ syncCompletionSuggestionContextUi();
8656
8704
  if (openCompanionBtn) openCompanionBtn.disabled = uiBusy || wsState !== "Ready";
8657
8705
  if (highlightSelect) highlightSelect.disabled = uiBusy;
8658
8706
  if (lineNumbersSelect) lineNumbersSelect.disabled = uiBusy;
@@ -8986,6 +9034,282 @@
8986
9034
  }
8987
9035
  }
8988
9036
 
9037
+ function readCompletionSuggestionContextMode() {
9038
+ try {
9039
+ const stored = window.localStorage ? String(window.localStorage.getItem(COMPLETION_CONTEXT_STORAGE_KEY) || "") : "";
9040
+ return stored === "session" ? "session" : "cursor";
9041
+ } catch {
9042
+ return "cursor";
9043
+ }
9044
+ }
9045
+
9046
+ function setCompletionSuggestionContextMode(mode) {
9047
+ completionSuggestionContextMode = mode === "session" ? "session" : "cursor";
9048
+ if (completionContextSelect) completionContextSelect.value = completionSuggestionContextMode;
9049
+ try {
9050
+ if (window.localStorage) window.localStorage.setItem(COMPLETION_CONTEXT_STORAGE_KEY, completionSuggestionContextMode);
9051
+ } catch {}
9052
+ setStatus(completionSuggestionContextMode === "session"
9053
+ ? "Suggestions will include the latest response as context."
9054
+ : "Suggestions will use cursor-local editor context only.");
9055
+ }
9056
+
9057
+ function syncCompletionSuggestionContextUi() {
9058
+ if (completionContextSelect) completionContextSelect.value = completionSuggestionContextMode;
9059
+ if (suggestCompletionOptionsBtn) {
9060
+ suggestCompletionOptionsBtn.textContent = "Source & context";
9061
+ suggestCompletionOptionsBtn.title = completionSuggestionContextMode === "session"
9062
+ ? "Document source, working directory, status, and suggestion context. Suggestions include editor plus latest response."
9063
+ : "Document source, working directory, status, and suggestion context. Suggestions use editor-only context.";
9064
+ suggestCompletionOptionsBtn.setAttribute("aria-label", suggestCompletionOptionsBtn.title);
9065
+ }
9066
+ document.querySelectorAll("[data-completion-context-mode]").forEach((button) => {
9067
+ if (!(button instanceof HTMLElement)) return;
9068
+ const mode = button.getAttribute("data-completion-context-mode") === "session" ? "session" : "cursor";
9069
+ const selected = mode === completionSuggestionContextMode;
9070
+ button.classList.toggle("is-selected", selected);
9071
+ button.setAttribute("aria-pressed", selected ? "true" : "false");
9072
+ button.textContent = (selected ? "✓ " : " ") + (mode === "session" ? "Editor + latest response" : "Editor only");
9073
+ });
9074
+ }
9075
+
9076
+ function trimCompletionContextText(text) {
9077
+ const value = String(text || "").trim();
9078
+ if (value.length <= COMPLETION_CONTEXT_MAX_CHARS) return value;
9079
+ return value.slice(value.length - COMPLETION_CONTEXT_MAX_CHARS);
9080
+ }
9081
+
9082
+ function getCompletionSuggestionContextText() {
9083
+ if (completionSuggestionContextMode !== "session") return "";
9084
+ const selected = getSelectedHistoryItem ? getSelectedHistoryItem() : null;
9085
+ const responseText = selected && typeof selected.markdown === "string" && selected.markdown.trim()
9086
+ ? selected.markdown
9087
+ : latestResponseMarkdown;
9088
+ const parts = [];
9089
+ if (selected && typeof selected.promptTriggerText === "string" && selected.promptTriggerText.trim()) {
9090
+ parts.push("Latest request/steering:\n" + trimCompletionContextText(selected.promptTriggerText));
9091
+ } else if (selected && typeof selected.prompt === "string" && selected.prompt.trim()) {
9092
+ parts.push("Latest prompt:\n" + trimCompletionContextText(selected.prompt));
9093
+ }
9094
+ if (String(responseText || "").trim()) {
9095
+ parts.push("Latest response:\n" + trimCompletionContextText(responseText));
9096
+ }
9097
+ return trimCompletionContextText(parts.join("\n\n---\n\n"));
9098
+ }
9099
+
9100
+ function hideCompletionSuggestion() {
9101
+ completionSuggestionState = null;
9102
+ if (completionSuggestionTextEl) completionSuggestionTextEl.textContent = "";
9103
+ if (completionSuggestionPanelEl) completionSuggestionPanelEl.hidden = true;
9104
+ }
9105
+
9106
+ function showCompletionSuggestion(state) {
9107
+ completionSuggestionState = state;
9108
+ if (completionSuggestionTextEl) completionSuggestionTextEl.textContent = state && state.suggestion ? state.suggestion : "";
9109
+ if (completionSuggestionPanelEl) completionSuggestionPanelEl.hidden = false;
9110
+ }
9111
+
9112
+ function focusSourceTextNoScroll() {
9113
+ if (!sourceTextEl || typeof sourceTextEl.focus !== "function") return;
9114
+ try {
9115
+ sourceTextEl.focus({ preventScroll: true });
9116
+ } catch {
9117
+ try { sourceTextEl.focus(); } catch {}
9118
+ }
9119
+ }
9120
+
9121
+ function focusSourceEditorForCompletion() {
9122
+ const snapshot = snapshotStudioScrollablePositions();
9123
+ if (editorView !== "markdown") {
9124
+ setEditorView("markdown");
9125
+ scheduleStudioScrollablePositionRestore(snapshot);
9126
+ }
9127
+ window.setTimeout(focusSourceTextNoScroll, 0);
9128
+ }
9129
+
9130
+ function isCompletionSuggestionRequestShortcut(event) {
9131
+ if (!event) return false;
9132
+ const key = typeof event.key === "string" ? event.key : "";
9133
+ const code = typeof event.code === "string" ? event.code : "";
9134
+ const commandSpace = (event.metaKey || event.ctrlKey)
9135
+ && event.shiftKey
9136
+ && !event.altKey
9137
+ && (code === "Space" || key === " " || key === "Spacebar");
9138
+ const optionTab = event.altKey
9139
+ && !event.metaKey
9140
+ && !event.ctrlKey
9141
+ && !event.shiftKey
9142
+ && key === "Tab";
9143
+ return commandSpace || optionTab;
9144
+ }
9145
+
9146
+ function handleCompletionSuggestionAcceptKey(event) {
9147
+ if (!event || !completionSuggestionState) return false;
9148
+ if (event.key !== "Tab" || event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return false;
9149
+ const target = event.target;
9150
+ const focusIsUnclaimed = target === document.body || target === document.documentElement;
9151
+ const targetCanAccept = focusIsUnclaimed
9152
+ || target === sourceTextEl
9153
+ || target === suggestCompletionBtn
9154
+ || Boolean(completionSuggestionPanelEl && target instanceof Element && completionSuggestionPanelEl.contains(target));
9155
+ if (!targetCanAccept) return false;
9156
+ event.preventDefault();
9157
+ insertCompletionSuggestion();
9158
+ return true;
9159
+ }
9160
+
9161
+ function shouldRefocusEditorForCompletionRequest() {
9162
+ const activeEl = document.activeElement;
9163
+ return activeEl === sourceTextEl
9164
+ || activeEl === suggestCompletionBtn
9165
+ || activeEl === document.body
9166
+ || activeEl === document.documentElement
9167
+ || Boolean(completionSuggestionPanelEl && activeEl instanceof Element && completionSuggestionPanelEl.contains(activeEl));
9168
+ }
9169
+
9170
+ function cancelCompletionSuggestion() {
9171
+ if (!completionSuggestionInFlight || !completionSuggestionRequestId) {
9172
+ setStatus("No suggestion request is running.", "warning");
9173
+ return;
9174
+ }
9175
+ setStatus("Stopping suggestion…", "warning");
9176
+ const sent = sendMessage({
9177
+ type: "completion_suggestion_cancel_request",
9178
+ requestId: completionSuggestionRequestId,
9179
+ });
9180
+ if (!sent) {
9181
+ completionSuggestionInFlight = false;
9182
+ completionSuggestionRequestId = null;
9183
+ completionSuggestionPendingSnapshot = null;
9184
+ completionSuggestionRefocusEditorOnResult = false;
9185
+ syncActionButtons();
9186
+ }
9187
+ }
9188
+
9189
+ function requestCompletionSuggestion() {
9190
+ if (isEditorOnlyMode && !sourceTextEl) return;
9191
+ if (completionSuggestionInFlight) {
9192
+ cancelCompletionSuggestion();
9193
+ return;
9194
+ }
9195
+ const text = String(sourceTextEl.value || "");
9196
+ if (!text.trim()) {
9197
+ setStatus("Editor is empty.", "warning");
9198
+ return;
9199
+ }
9200
+ const selectionStart = typeof sourceTextEl.selectionStart === "number" ? sourceTextEl.selectionStart : text.length;
9201
+ const selectionEnd = typeof sourceTextEl.selectionEnd === "number" ? sourceTextEl.selectionEnd : selectionStart;
9202
+ const contextText = getCompletionSuggestionContextText();
9203
+ const requestId = makeRequestId();
9204
+ completionSuggestionInFlight = true;
9205
+ completionSuggestionRequestId = requestId;
9206
+ completionSuggestionPendingSnapshot = { text, selectionStart, selectionEnd };
9207
+ completionSuggestionRefocusEditorOnResult = shouldRefocusEditorForCompletionRequest();
9208
+ hideCompletionSuggestion();
9209
+ syncActionButtons();
9210
+ setStatus("Generating completion suggestion…", "warning");
9211
+ const sent = sendMessage({
9212
+ type: "completion_suggestion_request",
9213
+ requestId,
9214
+ text,
9215
+ selectionStart,
9216
+ selectionEnd,
9217
+ language: editorLanguage || "",
9218
+ label: sourceState && sourceState.label ? sourceState.label : "Studio editor",
9219
+ path: sourceState && sourceState.path ? sourceState.path : undefined,
9220
+ contextMode: completionSuggestionContextMode,
9221
+ contextText: contextText || undefined,
9222
+ });
9223
+ if (!sent) {
9224
+ completionSuggestionInFlight = false;
9225
+ completionSuggestionRequestId = null;
9226
+ completionSuggestionPendingSnapshot = null;
9227
+ completionSuggestionRefocusEditorOnResult = false;
9228
+ syncActionButtons();
9229
+ }
9230
+ }
9231
+
9232
+ function insertCompletionSuggestion() {
9233
+ const state = completionSuggestionState;
9234
+ if (!state || typeof state.suggestion !== "string") {
9235
+ setStatus("No suggestion to insert.", "warning");
9236
+ return;
9237
+ }
9238
+ const currentText = String(sourceTextEl.value || "");
9239
+ const useOriginalRange = currentText === state.baseText;
9240
+ const start = useOriginalRange
9241
+ ? Math.max(0, Math.min(state.selectionStart, currentText.length))
9242
+ : (typeof sourceTextEl.selectionStart === "number" ? sourceTextEl.selectionStart : currentText.length);
9243
+ const end = useOriginalRange
9244
+ ? Math.max(start, Math.min(state.selectionEnd, currentText.length))
9245
+ : (typeof sourceTextEl.selectionEnd === "number" ? sourceTextEl.selectionEnd : start);
9246
+ const nextText = currentText.slice(0, start) + state.suggestion + currentText.slice(end);
9247
+ const caret = start + state.suggestion.length;
9248
+ applySourceTextEdit(nextText, caret, caret);
9249
+ hideCompletionSuggestion();
9250
+ focusSourceTextNoScroll();
9251
+ setStatus("Inserted completion suggestion.", "success");
9252
+ }
9253
+
9254
+ function handleCompletionSuggestionServerMessage(message) {
9255
+ if (!message || typeof message !== "object") return false;
9256
+ if (
9257
+ message.type !== "completion_suggestion_progress"
9258
+ && message.type !== "completion_suggestion_result"
9259
+ && message.type !== "completion_suggestion_error"
9260
+ ) return false;
9261
+ if (typeof message.requestId === "string" && completionSuggestionRequestId && message.requestId !== completionSuggestionRequestId) {
9262
+ return true;
9263
+ }
9264
+ if (message.type === "completion_suggestion_progress") {
9265
+ setStatus(typeof message.message === "string" ? message.message : "Generating suggestion…", "warning");
9266
+ return true;
9267
+ }
9268
+ const pendingSnapshot = completionSuggestionPendingSnapshot;
9269
+ const shouldRefocusEditor = completionSuggestionRefocusEditorOnResult;
9270
+ completionSuggestionInFlight = false;
9271
+ completionSuggestionRequestId = null;
9272
+ completionSuggestionPendingSnapshot = null;
9273
+ completionSuggestionRefocusEditorOnResult = false;
9274
+ syncActionButtons();
9275
+ if (message.type === "completion_suggestion_error") {
9276
+ setStatus(typeof message.message === "string" ? message.message : "Suggestion failed.", "warning");
9277
+ return true;
9278
+ }
9279
+ const suggestion = typeof message.suggestion === "string" ? message.suggestion : "";
9280
+ if (!suggestion.trim()) {
9281
+ setStatus("Model returned an empty suggestion.", "warning");
9282
+ return true;
9283
+ }
9284
+ const text = String(sourceTextEl.value || "");
9285
+ if (pendingSnapshot && text !== pendingSnapshot.text) {
9286
+ setStatus("Editor changed while the suggestion was generating. Please request a fresh suggestion.", "warning");
9287
+ return true;
9288
+ }
9289
+ const baseText = pendingSnapshot ? pendingSnapshot.text : text;
9290
+ const start = Math.max(0, Math.min(pendingSnapshot ? pendingSnapshot.selectionStart : (Number(message.selectionStart) || 0), baseText.length));
9291
+ const end = Math.max(start, Math.min(pendingSnapshot ? pendingSnapshot.selectionEnd : (Number(message.selectionEnd) || start), baseText.length));
9292
+ showCompletionSuggestion({
9293
+ suggestion,
9294
+ baseText,
9295
+ selectionStart: start,
9296
+ selectionEnd: end,
9297
+ });
9298
+ const activeEl = document.activeElement;
9299
+ if (
9300
+ shouldRefocusEditor
9301
+ || activeEl === sourceTextEl
9302
+ || activeEl === suggestCompletionBtn
9303
+ || activeEl === document.body
9304
+ || activeEl === document.documentElement
9305
+ || Boolean(completionSuggestionPanelEl && activeEl instanceof Element && completionSuggestionPanelEl.contains(activeEl))
9306
+ ) {
9307
+ focusSourceEditorForCompletion();
9308
+ }
9309
+ setStatus("Suggestion ready. Press Tab to insert it, or use the Insert suggestion button.", "success");
9310
+ return true;
9311
+ }
9312
+
8989
9313
  function getSourceTextLineEditBounds(text, selectionStart, selectionEnd) {
8990
9314
  const source = String(text || "");
8991
9315
  const safeStart = Math.max(0, Math.min(Math.floor(Number(selectionStart) || 0), source.length));
@@ -9065,7 +9389,14 @@
9065
9389
  }
9066
9390
 
9067
9391
  function handleSourceTextTabKey(event) {
9068
- if (!event || event.key !== "Tab" || event.metaKey || event.ctrlKey || event.altKey) return;
9392
+ if (!event) return;
9393
+ if (isCompletionSuggestionRequestShortcut(event)) {
9394
+ event.preventDefault();
9395
+ requestCompletionSuggestion();
9396
+ return;
9397
+ }
9398
+ if (handleCompletionSuggestionAcceptKey(event)) return;
9399
+ if (event.key !== "Tab" || event.metaKey || event.ctrlKey || event.altKey) return;
9069
9400
  event.preventDefault();
9070
9401
  if (event.shiftKey) {
9071
9402
  unindentSourceTextSelection();
@@ -16007,6 +16338,8 @@
16007
16338
  updateFooterMeta();
16008
16339
  }
16009
16340
 
16341
+ if (handleCompletionSuggestionServerMessage(message)) return;
16342
+
16010
16343
  if (
16011
16344
  message.type === "quiz_progress" ||
16012
16345
  message.type === "quiz_generated" ||
@@ -17258,6 +17591,9 @@
17258
17591
  sourceTextEl.addEventListener("keydown", handleSourceTextTabKey);
17259
17592
 
17260
17593
  sourceTextEl.addEventListener("input", () => {
17594
+ if (completionSuggestionState && sourceTextEl.value !== completionSuggestionState.baseText) {
17595
+ hideCompletionSuggestion();
17596
+ }
17261
17597
  if (activePreviewCommentSelection) {
17262
17598
  clearPreviewCommentSelection();
17263
17599
  }
@@ -17266,6 +17602,7 @@
17266
17602
  scheduleEditorMetaUpdate();
17267
17603
  updateEditorSelectionCommentUi();
17268
17604
  updateOutlineUi();
17605
+ syncActionButtons();
17269
17606
  if (isReviewNotesOpen() && reviewNotes.length > 0) {
17270
17607
  renderReviewNotesList();
17271
17608
  updateReviewNotesUi();
@@ -17721,6 +18058,31 @@
17721
18058
  }
17722
18059
  });
17723
18060
 
18061
+ if (suggestCompletionBtn) {
18062
+ suggestCompletionBtn.addEventListener("click", () => {
18063
+ requestCompletionSuggestion();
18064
+ });
18065
+ }
18066
+ if (completionContextSelect) {
18067
+ completionContextSelect.value = completionSuggestionContextMode;
18068
+ completionContextSelect.addEventListener("change", () => {
18069
+ setCompletionSuggestionContextMode(completionContextSelect.value);
18070
+ syncActionButtons();
18071
+ });
18072
+ }
18073
+ if (completionSuggestionInsertBtn) {
18074
+ completionSuggestionInsertBtn.addEventListener("click", () => {
18075
+ insertCompletionSuggestion();
18076
+ });
18077
+ }
18078
+ if (completionSuggestionDismissBtn) {
18079
+ completionSuggestionDismissBtn.addEventListener("click", () => {
18080
+ hideCompletionSuggestion();
18081
+ focusSourceTextNoScroll();
18082
+ setStatus("Dismissed completion suggestion.");
18083
+ });
18084
+ }
18085
+
17724
18086
  if (reviewNotesBtn) {
17725
18087
  reviewNotesBtn.addEventListener("click", () => {
17726
18088
  toggleReviewNotes();
package/client/studio.css CHANGED
@@ -849,6 +849,63 @@
849
849
  -webkit-text-fill-color: transparent;
850
850
  }
851
851
 
852
+ .completion-suggestion-panel {
853
+ border: 1px solid var(--control-border);
854
+ border-radius: 10px;
855
+ background: var(--panel-2);
856
+ box-shadow: var(--panel-shadow);
857
+ overflow: hidden;
858
+ }
859
+
860
+ .completion-suggestion-panel[hidden] {
861
+ display: none !important;
862
+ }
863
+
864
+ .completion-suggestion-header,
865
+ .completion-suggestion-actions {
866
+ display: flex;
867
+ align-items: center;
868
+ justify-content: space-between;
869
+ gap: 8px;
870
+ padding: 8px 10px;
871
+ }
872
+
873
+ .completion-suggestion-header {
874
+ border-bottom: 1px solid var(--border-subtle);
875
+ color: var(--studio-info-text, var(--muted));
876
+ font-size: 12px;
877
+ }
878
+
879
+ .completion-suggestion-header strong {
880
+ color: var(--text);
881
+ font-weight: 650;
882
+ }
883
+
884
+ .completion-suggestion-text {
885
+ margin: 0;
886
+ max-height: 180px;
887
+ overflow: auto;
888
+ padding: 10px;
889
+ white-space: pre-wrap;
890
+ word-break: break-word;
891
+ font-family: var(--font-mono);
892
+ font-size: var(--studio-editor-font-size);
893
+ line-height: 1.45;
894
+ color: var(--text);
895
+ background: var(--editor-bg);
896
+ }
897
+
898
+ .completion-suggestion-actions {
899
+ justify-content: flex-end;
900
+ border-top: 1px solid var(--border-subtle);
901
+ }
902
+
903
+ .completion-suggestion-header button,
904
+ .completion-suggestion-actions button {
905
+ padding: 4px 8px;
906
+ font-size: 12px;
907
+ }
908
+
852
909
  .editor-selection-actions {
853
910
  position: absolute;
854
911
  top: 12px;
@@ -3766,8 +3823,8 @@
3766
3823
 
3767
3824
  .shortcuts-group dl > div {
3768
3825
  display: grid;
3769
- grid-template-columns: minmax(130px, max-content) minmax(0, 1fr);
3770
- gap: 12px;
3826
+ grid-template-columns: minmax(260px, 260px) minmax(0, 1fr);
3827
+ gap: 24px;
3771
3828
  align-items: baseline;
3772
3829
  padding: 6px 10px;
3773
3830
  border-top: 1px solid var(--border-subtle);
@@ -4188,13 +4245,18 @@
4188
4245
  justify-content: stretch;
4189
4246
  }
4190
4247
 
4191
- body.studio-ui-refresh .studio-refresh-pane-tools,
4192
- body.studio-ui-refresh .studio-refresh-toolbar-state {
4248
+ body.studio-ui-refresh .studio-refresh-pane-tools {
4193
4249
  justify-content: flex-start;
4194
4250
  justify-items: start;
4195
4251
  min-width: 0;
4196
4252
  }
4197
4253
 
4254
+ body.studio-ui-refresh .studio-refresh-toolbar-state {
4255
+ justify-content: flex-end;
4256
+ justify-items: end;
4257
+ min-width: 0;
4258
+ }
4259
+
4198
4260
  body.studio-ui-refresh .studio-refresh-pane-tools,
4199
4261
  body.studio-ui-refresh .studio-refresh-title-group,
4200
4262
  body.studio-ui-refresh .studio-refresh-utility-left {
@@ -4202,7 +4264,7 @@
4202
4264
  }
4203
4265
 
4204
4266
  body.studio-ui-refresh .studio-refresh-toolbar-state .studio-refresh-action-line {
4205
- justify-content: flex-start;
4267
+ justify-content: flex-end;
4206
4268
  }
4207
4269
  }
4208
4270
 
@@ -4274,7 +4336,8 @@
4274
4336
  }
4275
4337
 
4276
4338
  body.studio-ui-refresh .studio-refresh-header-utility {
4277
- grid-template-columns: minmax(0, 1fr) auto;
4339
+ grid-template-columns: minmax(0, 1fr);
4340
+ justify-items: start;
4278
4341
  }
4279
4342
 
4280
4343
  body.studio-ui-refresh .studio-refresh-pane-identity {
@@ -4291,12 +4354,18 @@
4291
4354
  flex-wrap: nowrap;
4292
4355
  }
4293
4356
 
4294
- body.studio-ui-refresh .studio-refresh-context-group,
4295
- body.studio-ui-refresh .studio-refresh-utility-left {
4357
+ body.studio-ui-refresh .studio-refresh-context-group {
4296
4358
  overflow: hidden;
4297
4359
  white-space: nowrap;
4298
4360
  }
4299
4361
 
4362
+ body.studio-ui-refresh .studio-refresh-utility-left {
4363
+ overflow: visible;
4364
+ white-space: nowrap;
4365
+ justify-content: flex-start;
4366
+ justify-self: start;
4367
+ }
4368
+
4300
4369
  body.studio-ui-refresh .studio-refresh-title-group {
4301
4370
  position: relative;
4302
4371
  z-index: 2;
@@ -4400,12 +4469,14 @@
4400
4469
  border-color: transparent;
4401
4470
  background: transparent;
4402
4471
  padding: 3px 5px;
4403
- font-size: 13px;
4472
+ font-size: 12px;
4473
+ font-weight: 400;
4474
+ line-height: 1.25;
4404
4475
  border-radius: 8px;
4405
4476
  }
4406
4477
 
4407
4478
  body.studio-ui-refresh #sourceBadge {
4408
- color: var(--text);
4479
+ color: var(--studio-info-text, var(--muted));
4409
4480
  max-width: min(34rem, 54vw);
4410
4481
  min-width: 0;
4411
4482
  overflow: hidden;
@@ -4413,7 +4484,6 @@
4413
4484
  white-space: nowrap;
4414
4485
  }
4415
4486
 
4416
- body.studio-ui-refresh #resourceDirBtn,
4417
4487
  body.studio-ui-refresh #reviewNotesBtn,
4418
4488
  body.studio-ui-refresh #outlineBtn,
4419
4489
  body.studio-ui-refresh #scratchpadBtn,
@@ -4421,11 +4491,19 @@
4421
4491
  color: var(--text);
4422
4492
  }
4423
4493
 
4494
+ body.studio-ui-refresh #resourceDirBtn,
4424
4495
  body.studio-ui-refresh #resourceDirLabel {
4425
4496
  color: var(--studio-info-text, var(--muted));
4426
4497
  font-weight: 400;
4427
4498
  }
4428
4499
 
4500
+ body.studio-ui-refresh #resourceDirBtn:hover,
4501
+ body.studio-ui-refresh #resourceDirLabel:hover {
4502
+ color: var(--text);
4503
+ background: var(--studio-header-action-hover-bg, var(--panel-2));
4504
+ border-color: transparent;
4505
+ }
4506
+
4429
4507
  body.studio-ui-refresh #resourceDirInputWrap.visible {
4430
4508
  display: inline-flex;
4431
4509
  }
@@ -4439,9 +4517,11 @@
4439
4517
  border-radius: 999px;
4440
4518
  padding: 2px 7px;
4441
4519
  background: transparent;
4442
- color: var(--muted);
4443
- opacity: 0.82;
4444
- font-weight: 450;
4520
+ color: var(--studio-info-text, var(--muted));
4521
+ opacity: 1;
4522
+ font-size: 12px;
4523
+ font-weight: 400;
4524
+ line-height: 1.25;
4445
4525
  }
4446
4526
 
4447
4527
  body.studio-ui-refresh #syncBadge[hidden] {
@@ -4457,6 +4537,11 @@
4457
4537
  opacity: 0.72;
4458
4538
  }
4459
4539
 
4540
+ body.studio-ui-refresh #syncBadge.out-of-sync::before {
4541
+ background: var(--warning, var(--accent));
4542
+ opacity: 0.95;
4543
+ }
4544
+
4460
4545
  body.studio-ui-refresh #reviewNotesBtn,
4461
4546
  body.studio-ui-refresh #outlineBtn,
4462
4547
  body.studio-ui-refresh #scratchpadBtn,
@@ -4474,9 +4559,10 @@
4474
4559
  body.studio-zen-mode #exportPdfBtn,
4475
4560
  body.studio-zen-mode .studio-refresh-tool-tab,
4476
4561
  body.studio-zen-mode .studio-refresh-toolbar-state,
4477
- body.studio-zen-mode .studio-refresh-toolbar-actions .studio-refresh-action-line:nth-child(n+3),
4562
+ body.studio-zen-mode .studio-refresh-toolbar-actions .studio-refresh-utility-action-line,
4478
4563
  body.studio-zen-mode .source-actions-row:nth-child(n+3),
4479
4564
  body.studio-zen-mode #copyDraftBtn,
4565
+ body.studio-zen-mode #suggestCompletionBtn,
4480
4566
  body.studio-zen-mode #openCompanionBtn,
4481
4567
  body.studio-zen-mode #sendEditorBtn {
4482
4568
  display: none !important;
@@ -4664,6 +4750,12 @@
4664
4750
  font-size: 12px;
4665
4751
  }
4666
4752
 
4753
+ body.studio-ui-refresh .studio-refresh-toolbar-actions .studio-refresh-action-line button:not(#sendRunBtn):not(#queueSteerBtn):not(#sendReplBtn):not(.request-stop-active) {
4754
+ min-height: 26px;
4755
+ padding: 5px 8px;
4756
+ line-height: 1.2;
4757
+ }
4758
+
4667
4759
  body.studio-ui-refresh .studio-refresh-toolbar button:not(#sendRunBtn):not(#queueSteerBtn):not(#sendReplBtn):not(.request-stop-active),
4668
4760
  body.studio-ui-refresh .studio-refresh-toolbar select {
4669
4761
  color: color-mix(in srgb, var(--text) 72%, var(--muted));
@@ -4737,6 +4829,19 @@
4737
4829
  width: min(320px, calc(100vw - 48px));
4738
4830
  }
4739
4831
 
4832
+ body.studio-ui-refresh .studio-refresh-title-group .studio-refresh-context-anchor .studio-refresh-chip {
4833
+ color: var(--studio-info-text, var(--muted));
4834
+ font-size: 13px;
4835
+ font-weight: 500;
4836
+ padding: 3px 5px;
4837
+ }
4838
+
4839
+ body.studio-ui-refresh .studio-refresh-context-anchor .studio-refresh-menu {
4840
+ left: 0;
4841
+ right: auto;
4842
+ width: min(360px, calc(100vw - 48px));
4843
+ }
4844
+
4740
4845
  body.studio-ui-refresh .studio-refresh-menu[hidden] {
4741
4846
  display: none !important;
4742
4847
  }
@@ -4784,6 +4889,11 @@
4784
4889
  text-align: left;
4785
4890
  }
4786
4891
 
4892
+ body.studio-ui-refresh .completion-context-option.is-selected {
4893
+ color: var(--text);
4894
+ font-weight: 650;
4895
+ }
4896
+
4787
4897
  body.studio-ui-refresh .studio-refresh-menu-item > button:not(:disabled):hover,
4788
4898
  body.studio-ui-refresh .studio-refresh-menu-item > select:not(:disabled):hover {
4789
4899
  background: var(--studio-header-action-hover-bg, var(--panel-2));
package/index.ts CHANGED
@@ -311,6 +311,24 @@ interface SendRunRequestMessage {
311
311
  text: string;
312
312
  }
313
313
 
314
+ interface CompletionSuggestionRequestMessage {
315
+ type: "completion_suggestion_request";
316
+ requestId: string;
317
+ text: string;
318
+ selectionStart: number;
319
+ selectionEnd: number;
320
+ language?: string;
321
+ label?: string;
322
+ path?: string;
323
+ contextMode?: "cursor" | "session";
324
+ contextText?: string;
325
+ }
326
+
327
+ interface CompletionSuggestionCancelRequestMessage {
328
+ type: "completion_suggestion_cancel_request";
329
+ requestId: string;
330
+ }
331
+
314
332
  interface QuizGenerateRequestMessage {
315
333
  type: "quiz_generate_request";
316
334
  requestId: string;
@@ -451,6 +469,8 @@ type IncomingStudioMessage =
451
469
  | CritiqueRequestMessage
452
470
  | AnnotationRequestMessage
453
471
  | SendRunRequestMessage
472
+ | CompletionSuggestionRequestMessage
473
+ | CompletionSuggestionCancelRequestMessage
454
474
  | QuizGenerateRequestMessage
455
475
  | QuizAnswerRequestMessage
456
476
  | QuizDiscussRequestMessage
@@ -472,6 +492,10 @@ type IncomingStudioMessage =
472
492
 
473
493
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
474
494
  const PREVIEW_RENDER_MAX_CHARS = 400_000;
495
+ const STUDIO_COMPLETION_MAX_TEXT_CHARS = 250_000;
496
+ const STUDIO_COMPLETION_MAX_CONTEXT_CHARS = 12_000;
497
+ const STUDIO_COMPLETION_PREFIX_CHARS = 12_000;
498
+ const STUDIO_COMPLETION_SUFFIX_CHARS = 6_000;
475
499
  const PDF_EXPORT_MAX_CHARS = 400_000;
476
500
  const HTML_EXPORT_MAX_CHARS = 400_000;
477
501
  const HTML_PREVIEW_MATH_RENDER_MAX_ITEMS = 250;
@@ -7118,7 +7142,7 @@ async function resolveStudioModelRequestAuth(ctx: StudioModelRequestContext, mod
7118
7142
  if (typeof registry.getApiKey === "function") {
7119
7143
  return { apiKey: await registry.getApiKey(model) };
7120
7144
  }
7121
- throw new Error("Current pi model registry does not expose model credentials for Studio quiz.");
7145
+ throw new Error("Current pi model registry does not expose model credentials for Studio model requests.");
7122
7146
  }
7123
7147
 
7124
7148
  function getStudioQuizReasoning(model: NonNullable<ExtensionContext["model"]>, thinking: StudioQuizThinking | undefined): ThinkingLevel | undefined {
@@ -7127,33 +7151,47 @@ function getStudioQuizReasoning(model: NonNullable<ExtensionContext["model"]>, t
7127
7151
  return normalized === "off" ? undefined : normalized;
7128
7152
  }
7129
7153
 
7130
- async function runStudioQuizModelText(ctx: StudioModelRequestContext, prompt: string, options?: { maxTokens?: number; signal?: AbortSignal; thinking?: StudioQuizThinking }): Promise<string> {
7154
+ async function runStudioModelText(
7155
+ ctx: StudioModelRequestContext,
7156
+ prompt: string,
7157
+ options?: { systemPrompt?: string; maxTokens?: number; signal?: AbortSignal; reasoning?: ThinkingLevel; timeoutMs?: number; trim?: boolean },
7158
+ ): Promise<string> {
7131
7159
  if (!ctx.model) throw new Error("No active model selected.");
7132
7160
  const auth = await resolveStudioModelRequestAuth(ctx, ctx.model);
7133
7161
  const response = await completeSimple(
7134
7162
  ctx.model,
7135
7163
  {
7136
- systemPrompt: "You are an active tutor inside pi Studio. Ask and mark concise, probing quiz questions. Return exactly the requested format.",
7164
+ systemPrompt: options?.systemPrompt ?? "You are a concise assistant inside pi Studio. Return exactly the requested format.",
7137
7165
  messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }],
7138
7166
  },
7139
7167
  {
7140
7168
  apiKey: auth.apiKey,
7141
7169
  headers: auth.headers,
7142
- reasoning: getStudioQuizReasoning(ctx.model, options?.thinking),
7170
+ reasoning: options?.reasoning,
7143
7171
  maxTokens: options?.maxTokens ?? 2500,
7144
7172
  signal: options?.signal,
7145
- timeoutMs: 120_000,
7173
+ timeoutMs: options?.timeoutMs ?? 120_000,
7146
7174
  },
7147
7175
  );
7148
- const text = response.content
7176
+ const rawText = response.content
7149
7177
  .filter((part): part is { type: "text"; text: string } => part.type === "text")
7150
7178
  .map((part) => part.text)
7151
- .join("\n")
7152
- .trim();
7153
- if (!text) throw new Error("Model returned no text response.");
7179
+ .join("\n");
7180
+ const text = options?.trim === false ? rawText : rawText.trim();
7181
+ if (!text.trim()) throw new Error("Model returned no text response.");
7154
7182
  return text;
7155
7183
  }
7156
7184
 
7185
+ async function runStudioQuizModelText(ctx: StudioModelRequestContext, prompt: string, options?: { maxTokens?: number; signal?: AbortSignal; thinking?: StudioQuizThinking }): Promise<string> {
7186
+ return runStudioModelText(ctx, prompt, {
7187
+ systemPrompt: "You are an active tutor inside pi Studio. Ask and mark concise, probing quiz questions. Return exactly the requested format.",
7188
+ reasoning: ctx.model ? getStudioQuizReasoning(ctx.model, options?.thinking) : undefined,
7189
+ maxTokens: options?.maxTokens ?? 2500,
7190
+ signal: options?.signal,
7191
+ timeoutMs: 120_000,
7192
+ });
7193
+ }
7194
+
7157
7195
  async function runStudioQuizModelJson(
7158
7196
  ctx: StudioModelRequestContext,
7159
7197
  prompt: string,
@@ -7181,6 +7219,84 @@ async function runStudioQuizModelJson(
7181
7219
  throw lastError ?? new Error("Model did not return valid JSON.");
7182
7220
  }
7183
7221
 
7222
+ function buildStudioCompletionSuggestionPrompt(options: {
7223
+ text: string;
7224
+ selectionStart: number;
7225
+ selectionEnd: number;
7226
+ language?: string;
7227
+ label?: string;
7228
+ path?: string;
7229
+ contextMode?: "cursor" | "session";
7230
+ contextText?: string;
7231
+ }): string {
7232
+ const text = String(options.text || "");
7233
+ const start = Math.max(0, Math.min(Math.floor(options.selectionStart || 0), text.length));
7234
+ const end = Math.max(start, Math.min(Math.floor(options.selectionEnd || start), text.length));
7235
+ const prefix = text.slice(Math.max(0, start - STUDIO_COMPLETION_PREFIX_CHARS), start);
7236
+ const selected = text.slice(start, end);
7237
+ const suffix = text.slice(end, Math.min(text.length, end + STUDIO_COMPLETION_SUFFIX_CHARS));
7238
+ const language = String(options.language || "").trim() || "unknown";
7239
+ const label = String(options.label || options.path || "Studio editor").trim();
7240
+ const contextText = String(options.contextText || "").trim().slice(-STUDIO_COMPLETION_MAX_CONTEXT_CHARS);
7241
+ return [
7242
+ "Generate an inline completion for the current editor cursor position.",
7243
+ "Return only the exact text to insert. Do not wrap it in Markdown fences. Do not explain.",
7244
+ "Match the surrounding language, style, indentation, and register.",
7245
+ "Keep the suggestion short unless the context clearly asks for a longer continuation.",
7246
+ contextText
7247
+ ? "Use the extra session context only as background. Do not continue the extra context directly unless the editor cursor calls for it."
7248
+ : "Use only the cursor-local editor context below.",
7249
+ selected
7250
+ ? "The selected text will be replaced by the completion."
7251
+ : "The completion will be inserted at the cursor.",
7252
+ "",
7253
+ `File/context label: ${label}`,
7254
+ `Language mode: ${language}`,
7255
+ `Suggestion context mode: ${contextText ? "editor plus latest response" : "editor only"}`,
7256
+ contextText ? ["", "<extra_context>", contextText, "</extra_context>"].join("\n") : "",
7257
+ "",
7258
+ "<prefix>",
7259
+ prefix,
7260
+ "</prefix>",
7261
+ selected ? ["", "<selected>", selected, "</selected>"].join("\n") : "",
7262
+ "",
7263
+ "<suffix>",
7264
+ suffix,
7265
+ "</suffix>",
7266
+ ].filter((part) => part !== "").join("\n");
7267
+ }
7268
+
7269
+ function cleanStudioCompletionSuggestion(text: string): string {
7270
+ let value = String(text || "").replace(/\r\n/g, "\n");
7271
+ value = value.replace(/^\s*(?:Here(?:'s| is) (?:the )?(?:completion|suggestion):|Completion:|Suggestion:)\s*/i, "");
7272
+ return value;
7273
+ }
7274
+
7275
+ async function runStudioCompletionSuggestion(ctx: StudioModelRequestContext, options: {
7276
+ text: string;
7277
+ selectionStart: number;
7278
+ selectionEnd: number;
7279
+ language?: string;
7280
+ label?: string;
7281
+ path?: string;
7282
+ contextMode?: "cursor" | "session";
7283
+ contextText?: string;
7284
+ signal?: AbortSignal;
7285
+ }): Promise<string> {
7286
+ const prompt = buildStudioCompletionSuggestionPrompt(options);
7287
+ // Intentionally omit `reasoning`: pi-ai treats absent reasoning as off/disabled
7288
+ // where supported. Passing "minimal" would still enable a reasoning path and slow completions.
7289
+ const suggestion = cleanStudioCompletionSuggestion(await runStudioModelText(ctx, prompt, {
7290
+ 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.",
7291
+ maxTokens: 650,
7292
+ timeoutMs: 60_000,
7293
+ trim: false,
7294
+ signal: options.signal,
7295
+ }));
7296
+ if (!suggestion.trim()) throw new Error("Model returned an empty completion suggestion.");
7297
+ return suggestion;
7298
+ }
7299
+
7184
7300
  function inferStudioResponseKind(markdown: string): StudioRequestKind {
7185
7301
  const lower = markdown.toLowerCase();
7186
7302
  if (lower.includes("## critiques") && lower.includes("## document")) return "critique";
@@ -7590,6 +7706,34 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
7590
7706
  };
7591
7707
  }
7592
7708
 
7709
+ if (msg.type === "completion_suggestion_cancel_request" && typeof msg.requestId === "string") {
7710
+ return {
7711
+ type: "completion_suggestion_cancel_request",
7712
+ requestId: msg.requestId,
7713
+ };
7714
+ }
7715
+
7716
+ if (msg.type === "completion_suggestion_request" && typeof msg.requestId === "string" && typeof msg.text === "string") {
7717
+ const textLength = msg.text.length;
7718
+ const rawStart = typeof msg.selectionStart === "number" && Number.isFinite(msg.selectionStart) ? msg.selectionStart : textLength;
7719
+ const rawEnd = typeof msg.selectionEnd === "number" && Number.isFinite(msg.selectionEnd) ? msg.selectionEnd : rawStart;
7720
+ const selectionStart = Math.max(0, Math.min(Math.floor(rawStart), textLength));
7721
+ const selectionEnd = Math.max(selectionStart, Math.min(Math.floor(rawEnd), textLength));
7722
+ const contextMode = msg.contextMode === "session" ? "session" : "cursor";
7723
+ return {
7724
+ type: "completion_suggestion_request",
7725
+ requestId: msg.requestId,
7726
+ text: msg.text,
7727
+ selectionStart,
7728
+ selectionEnd,
7729
+ language: typeof msg.language === "string" ? msg.language : undefined,
7730
+ label: typeof msg.label === "string" ? msg.label : undefined,
7731
+ path: typeof msg.path === "string" ? msg.path : undefined,
7732
+ contextMode,
7733
+ contextText: contextMode === "session" && typeof msg.contextText === "string" ? msg.contextText.slice(-STUDIO_COMPLETION_MAX_CONTEXT_CHARS) : undefined,
7734
+ };
7735
+ }
7736
+
7593
7737
  if (msg.type === "quiz_generate_request" && typeof msg.requestId === "string" && typeof msg.sourceText === "string") {
7594
7738
  const rawCount = typeof msg.questionCount === "number" && Number.isFinite(msg.questionCount) ? msg.questionCount : 5;
7595
7739
  return {
@@ -9467,8 +9611,14 @@ ${cssVarsBlock}
9467
9611
  </select>
9468
9612
  </div>
9469
9613
  <div class="source-actions-row">
9470
- <button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy text</button>
9471
- <button id="openCompanionBtn" type="button" title="Open a detached copy of the current editor text in a new editor-only Studio tab.">Open new editor</button>
9614
+ <button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy</button>
9615
+ <button id="suggestCompletionBtn" type="button" title="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.">Suggest</button>
9616
+ <button id="suggestCompletionOptionsBtn" type="button" hidden title="Suggestion context options">▾</button>
9617
+ <select id="completionContextSelect" hidden aria-label="Suggestion context mode" title="Choose how much context Suggest includes.">
9618
+ <option value="cursor" selected>Context: editor only</option>
9619
+ <option value="session">Context: editor + latest response</option>
9620
+ </select>
9621
+ <button id="openCompanionBtn" type="button" title="Open a detached copy of the current editor text in a new editor-only Studio tab.">New editor</button>
9472
9622
  <button id="sendEditorBtn" type="button">Send to pi editor</button>
9473
9623
  </div>
9474
9624
  <div class="source-actions-row">
@@ -9550,6 +9700,16 @@ ${cssVarsBlock}
9550
9700
  <button id="editorSelectionJumpBtn" type="button" class="editor-selection-action-btn" hidden title="Jump to the current editor selection in the preview.">Jump</button>
9551
9701
  </div>
9552
9702
  </div>
9703
+ <div id="completionSuggestionPanel" class="completion-suggestion-panel" hidden>
9704
+ <div class="completion-suggestion-header">
9705
+ <strong>Suggested completion</strong>
9706
+ <button id="completionSuggestionDismissBtn" type="button" title="Dismiss this suggestion">Dismiss</button>
9707
+ </div>
9708
+ <pre id="completionSuggestionText" class="completion-suggestion-text"></pre>
9709
+ <div class="completion-suggestion-actions">
9710
+ <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>
9711
+ </div>
9712
+ </div>
9553
9713
  <div id="sourcePreview" class="panel-scroll rendered-markdown" hidden><pre class="plain-markdown"></pre></div>
9554
9714
  </div>
9555
9715
  <aside id="outlineOverlay" class="outline-dock-wrap" hidden>
@@ -9718,7 +9878,10 @@ ${cssVarsBlock}
9718
9878
  <dl>
9719
9879
  <div><dt>Cmd/Ctrl+S</dt><dd>Save editor</dd></div>
9720
9880
  <div><dt>Cmd/Ctrl+Enter</dt><dd>Run editor text, or queue steering during an active run</dd></div>
9721
- <div><dt>Tab / Shift+Tab</dt><dd>Indent or unindent selected editor text</dd></div>
9881
+ <div><dt>Option/Alt+Tab or Cmd/Ctrl+Shift+Space</dt><dd>Suggest a completion at the editor cursor</dd></div>
9882
+ <div><dt>Tab</dt><dd>Insert a visible completion suggestion; otherwise indent selected editor text</dd></div>
9883
+ <div><dt>Esc</dt><dd>Dismiss a visible completion suggestion, close overlays, exit pane focus, or stop an active request</dd></div>
9884
+ <div><dt>Shift+Tab</dt><dd>Unindent selected editor text</dd></div>
9722
9885
  </dl>
9723
9886
  </section>
9724
9887
  <section class="shortcuts-group">
@@ -9812,6 +9975,7 @@ export default function (pi: ExtensionAPI) {
9812
9975
  let studioReplActiveSessionName: string | null = null;
9813
9976
  let compactInProgress = false;
9814
9977
  let compactRequestId: string | null = null;
9978
+ const activeCompletionSuggestions = new Map<string, AbortController>();
9815
9979
 
9816
9980
  const selectStudioReplSessionForTool = (params: { sessionName?: string; target?: string }): { session: StudioReplSessionInfo | null; error?: string; sessions: StudioReplSessionInfo[] } => {
9817
9981
  const state = listStudioReplSessions();
@@ -11298,6 +11462,77 @@ export default function (pi: ExtensionAPI) {
11298
11462
  return;
11299
11463
  }
11300
11464
 
11465
+ if (msg.type === "completion_suggestion_cancel_request") {
11466
+ if (!isValidRequestId(msg.requestId)) {
11467
+ sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: "Invalid request ID." });
11468
+ return;
11469
+ }
11470
+ const controller = activeCompletionSuggestions.get(msg.requestId);
11471
+ if (!controller) {
11472
+ sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: "No matching suggestion request is running." });
11473
+ return;
11474
+ }
11475
+ controller.abort();
11476
+ sendToClient(client, { type: "completion_suggestion_progress", requestId: msg.requestId, message: "Stopping suggestion…" });
11477
+ return;
11478
+ }
11479
+
11480
+ if (msg.type === "completion_suggestion_request") {
11481
+ if (!isValidRequestId(msg.requestId)) {
11482
+ sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: "Invalid request ID." });
11483
+ return;
11484
+ }
11485
+ const ctx = latestModelRequestCtx ?? lastCommandCtx;
11486
+ if (!ctx) {
11487
+ sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: "No active pi model context is available for editor suggestions." });
11488
+ return;
11489
+ }
11490
+ if (!msg.text.trim()) {
11491
+ sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: "Editor is empty." });
11492
+ return;
11493
+ }
11494
+ if (msg.text.length > STUDIO_COMPLETION_MAX_TEXT_CHARS) {
11495
+ sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: `Editor text is too large for suggestions (${STUDIO_COMPLETION_MAX_TEXT_CHARS} character limit).` });
11496
+ return;
11497
+ }
11498
+ sendToClient(client, { type: "completion_suggestion_progress", requestId: msg.requestId, message: "Generating suggestion…" });
11499
+ const completionController = new AbortController();
11500
+ activeCompletionSuggestions.set(msg.requestId, completionController);
11501
+ void (async () => {
11502
+ try {
11503
+ const suggestion = await runStudioCompletionSuggestion(ctx, {
11504
+ text: msg.text,
11505
+ selectionStart: msg.selectionStart,
11506
+ selectionEnd: msg.selectionEnd,
11507
+ language: msg.language,
11508
+ label: msg.label,
11509
+ path: msg.path,
11510
+ contextMode: msg.contextMode,
11511
+ contextText: msg.contextText,
11512
+ signal: completionController.signal,
11513
+ });
11514
+ sendToClient(client, {
11515
+ type: "completion_suggestion_result",
11516
+ requestId: msg.requestId,
11517
+ suggestion,
11518
+ selectionStart: msg.selectionStart,
11519
+ selectionEnd: msg.selectionEnd,
11520
+ });
11521
+ } catch (error) {
11522
+ sendToClient(client, {
11523
+ type: "completion_suggestion_error",
11524
+ requestId: msg.requestId,
11525
+ message: completionController.signal.aborted
11526
+ ? "Suggestion stopped."
11527
+ : `Suggestion failed: ${error instanceof Error ? error.message : String(error)}`,
11528
+ });
11529
+ } finally {
11530
+ activeCompletionSuggestions.delete(msg.requestId);
11531
+ }
11532
+ })();
11533
+ return;
11534
+ }
11535
+
11301
11536
  if (msg.type === "quiz_generate_request") {
11302
11537
  if (!isValidRequestId(msg.requestId)) {
11303
11538
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.15",
3
+ "version": "0.9.17",
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",