pi-studio 0.9.15 → 0.9.16

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,11 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.16] — 2026-05-24
8
+
9
+ ### Added
10
+ - 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.
11
+
7
12
  ## [0.9.15] — 2026-05-23
8
13
 
9
14
  ### 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), 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,11 @@
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 completionSuggestionPanelEl = document.getElementById("completionSuggestionPanel");
119
+ const completionSuggestionTextEl = document.getElementById("completionSuggestionText");
120
+ const completionSuggestionInsertBtn = document.getElementById("completionSuggestionInsertBtn");
121
+ const completionSuggestionDismissBtn = document.getElementById("completionSuggestionDismissBtn");
117
122
  const saveAnnotatedBtn = document.getElementById("saveAnnotatedBtn");
118
123
  const stripAnnotationsBtn = document.getElementById("stripAnnotationsBtn");
119
124
  const highlightSelect = document.getElementById("highlightSelect");
@@ -1943,6 +1948,11 @@
1943
1948
  let editorHighlightEnabled = false;
1944
1949
  let editorLanguage = "markdown";
1945
1950
  let responseHighlightEnabled = false;
1951
+ let completionSuggestionState = null;
1952
+ let completionSuggestionInFlight = false;
1953
+ let completionSuggestionRequestId = null;
1954
+ let completionSuggestionPendingSnapshot = null;
1955
+ let completionSuggestionRefocusEditorOnResult = false;
1946
1956
  let editorHighlightRenderRaf = null;
1947
1957
  let lineNumbersEnabled = false;
1948
1958
  let lineNumbersRenderRaf = null;
@@ -2398,6 +2408,7 @@
2398
2408
  if (!isEditorOnlyMode && replSendModeSelect) replActionLineEl.appendChild(replSendModeSelect);
2399
2409
  const actionLineTwoEl = makeStudioUiRefreshElement("div", "studio-refresh-action-line");
2400
2410
  actionLineTwoEl.appendChild(copyDraftBtn);
2411
+ if (suggestCompletionBtn) actionLineTwoEl.appendChild(suggestCompletionBtn);
2401
2412
  if (openCompanionBtn) actionLineTwoEl.appendChild(openCompanionBtn);
2402
2413
  if (!isEditorOnlyMode && sendEditorBtn) actionLineTwoEl.appendChild(sendEditorBtn);
2403
2414
  if (actionLineOneEl.childNodes.length > 0) actionsEl.appendChild(actionLineOneEl);
@@ -3680,6 +3691,8 @@
3680
3691
  return;
3681
3692
  }
3682
3693
 
3694
+ if (handleCompletionSuggestionAcceptKey(event)) return;
3695
+
3683
3696
  if ((key === "?" || (key === "/" && event.shiftKey)) && !event.metaKey && !event.ctrlKey && !event.altKey && !isTextEntryShortcutTarget(event.target)) {
3684
3697
  event.preventDefault();
3685
3698
  toggleShortcuts();
@@ -8653,6 +8666,10 @@
8653
8666
  if (loadGitDiffBtn) loadGitDiffBtn.disabled = uiBusy;
8654
8667
  syncRunAndCritiqueButtons();
8655
8668
  copyDraftBtn.disabled = uiBusy;
8669
+ if (suggestCompletionBtn) {
8670
+ suggestCompletionBtn.disabled = uiBusy || completionSuggestionInFlight || wsState !== "Ready" || !String(sourceTextEl.value || "").trim();
8671
+ suggestCompletionBtn.textContent = completionSuggestionInFlight ? "Suggesting…" : "Suggest";
8672
+ }
8656
8673
  if (openCompanionBtn) openCompanionBtn.disabled = uiBusy || wsState !== "Ready";
8657
8674
  if (highlightSelect) highlightSelect.disabled = uiBusy;
8658
8675
  if (lineNumbersSelect) lineNumbersSelect.disabled = uiBusy;
@@ -8986,6 +9003,197 @@
8986
9003
  }
8987
9004
  }
8988
9005
 
9006
+ function hideCompletionSuggestion() {
9007
+ completionSuggestionState = null;
9008
+ if (completionSuggestionTextEl) completionSuggestionTextEl.textContent = "";
9009
+ if (completionSuggestionPanelEl) completionSuggestionPanelEl.hidden = true;
9010
+ }
9011
+
9012
+ function showCompletionSuggestion(state) {
9013
+ completionSuggestionState = state;
9014
+ if (completionSuggestionTextEl) completionSuggestionTextEl.textContent = state && state.suggestion ? state.suggestion : "";
9015
+ if (completionSuggestionPanelEl) completionSuggestionPanelEl.hidden = false;
9016
+ }
9017
+
9018
+ function focusSourceTextNoScroll() {
9019
+ if (!sourceTextEl || typeof sourceTextEl.focus !== "function") return;
9020
+ try {
9021
+ sourceTextEl.focus({ preventScroll: true });
9022
+ } catch {
9023
+ try { sourceTextEl.focus(); } catch {}
9024
+ }
9025
+ }
9026
+
9027
+ function focusSourceEditorForCompletion() {
9028
+ const snapshot = snapshotStudioScrollablePositions();
9029
+ if (editorView !== "markdown") {
9030
+ setEditorView("markdown");
9031
+ scheduleStudioScrollablePositionRestore(snapshot);
9032
+ }
9033
+ window.setTimeout(focusSourceTextNoScroll, 0);
9034
+ }
9035
+
9036
+ function isCompletionSuggestionRequestShortcut(event) {
9037
+ if (!event) return false;
9038
+ const key = typeof event.key === "string" ? event.key : "";
9039
+ const code = typeof event.code === "string" ? event.code : "";
9040
+ const commandSpace = (event.metaKey || event.ctrlKey)
9041
+ && event.shiftKey
9042
+ && !event.altKey
9043
+ && (code === "Space" || key === " " || key === "Spacebar");
9044
+ const optionTab = event.altKey
9045
+ && !event.metaKey
9046
+ && !event.ctrlKey
9047
+ && !event.shiftKey
9048
+ && key === "Tab";
9049
+ return commandSpace || optionTab;
9050
+ }
9051
+
9052
+ function handleCompletionSuggestionAcceptKey(event) {
9053
+ if (!event || !completionSuggestionState) return false;
9054
+ if (event.key !== "Tab" || event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return false;
9055
+ const target = event.target;
9056
+ const focusIsUnclaimed = target === document.body || target === document.documentElement;
9057
+ const targetCanAccept = focusIsUnclaimed
9058
+ || target === sourceTextEl
9059
+ || target === suggestCompletionBtn
9060
+ || Boolean(completionSuggestionPanelEl && target instanceof Element && completionSuggestionPanelEl.contains(target));
9061
+ if (!targetCanAccept) return false;
9062
+ event.preventDefault();
9063
+ insertCompletionSuggestion();
9064
+ return true;
9065
+ }
9066
+
9067
+ function shouldRefocusEditorForCompletionRequest() {
9068
+ const activeEl = document.activeElement;
9069
+ return activeEl === sourceTextEl
9070
+ || activeEl === suggestCompletionBtn
9071
+ || activeEl === document.body
9072
+ || activeEl === document.documentElement
9073
+ || Boolean(completionSuggestionPanelEl && activeEl instanceof Element && completionSuggestionPanelEl.contains(activeEl));
9074
+ }
9075
+
9076
+ function requestCompletionSuggestion() {
9077
+ if (isEditorOnlyMode && !sourceTextEl) return;
9078
+ if (completionSuggestionInFlight) {
9079
+ setStatus("Suggestion request already in progress.", "warning");
9080
+ return;
9081
+ }
9082
+ const text = String(sourceTextEl.value || "");
9083
+ if (!text.trim()) {
9084
+ setStatus("Editor is empty.", "warning");
9085
+ return;
9086
+ }
9087
+ const selectionStart = typeof sourceTextEl.selectionStart === "number" ? sourceTextEl.selectionStart : text.length;
9088
+ const selectionEnd = typeof sourceTextEl.selectionEnd === "number" ? sourceTextEl.selectionEnd : selectionStart;
9089
+ const requestId = makeRequestId();
9090
+ completionSuggestionInFlight = true;
9091
+ completionSuggestionRequestId = requestId;
9092
+ completionSuggestionPendingSnapshot = { text, selectionStart, selectionEnd };
9093
+ completionSuggestionRefocusEditorOnResult = shouldRefocusEditorForCompletionRequest();
9094
+ hideCompletionSuggestion();
9095
+ syncActionButtons();
9096
+ setStatus("Generating completion suggestion…", "warning");
9097
+ const sent = sendMessage({
9098
+ type: "completion_suggestion_request",
9099
+ requestId,
9100
+ text,
9101
+ selectionStart,
9102
+ selectionEnd,
9103
+ language: editorLanguage || "",
9104
+ label: sourceState && sourceState.label ? sourceState.label : "Studio editor",
9105
+ path: sourceState && sourceState.path ? sourceState.path : undefined,
9106
+ });
9107
+ if (!sent) {
9108
+ completionSuggestionInFlight = false;
9109
+ completionSuggestionRequestId = null;
9110
+ completionSuggestionPendingSnapshot = null;
9111
+ completionSuggestionRefocusEditorOnResult = false;
9112
+ syncActionButtons();
9113
+ }
9114
+ }
9115
+
9116
+ function insertCompletionSuggestion() {
9117
+ const state = completionSuggestionState;
9118
+ if (!state || typeof state.suggestion !== "string") {
9119
+ setStatus("No suggestion to insert.", "warning");
9120
+ return;
9121
+ }
9122
+ const currentText = String(sourceTextEl.value || "");
9123
+ const useOriginalRange = currentText === state.baseText;
9124
+ const start = useOriginalRange
9125
+ ? Math.max(0, Math.min(state.selectionStart, currentText.length))
9126
+ : (typeof sourceTextEl.selectionStart === "number" ? sourceTextEl.selectionStart : currentText.length);
9127
+ const end = useOriginalRange
9128
+ ? Math.max(start, Math.min(state.selectionEnd, currentText.length))
9129
+ : (typeof sourceTextEl.selectionEnd === "number" ? sourceTextEl.selectionEnd : start);
9130
+ const nextText = currentText.slice(0, start) + state.suggestion + currentText.slice(end);
9131
+ const caret = start + state.suggestion.length;
9132
+ applySourceTextEdit(nextText, caret, caret);
9133
+ hideCompletionSuggestion();
9134
+ focusSourceTextNoScroll();
9135
+ setStatus("Inserted completion suggestion.", "success");
9136
+ }
9137
+
9138
+ function handleCompletionSuggestionServerMessage(message) {
9139
+ if (!message || typeof message !== "object") return false;
9140
+ if (
9141
+ message.type !== "completion_suggestion_progress"
9142
+ && message.type !== "completion_suggestion_result"
9143
+ && message.type !== "completion_suggestion_error"
9144
+ ) return false;
9145
+ if (typeof message.requestId === "string" && completionSuggestionRequestId && message.requestId !== completionSuggestionRequestId) {
9146
+ return true;
9147
+ }
9148
+ if (message.type === "completion_suggestion_progress") {
9149
+ setStatus(typeof message.message === "string" ? message.message : "Generating suggestion…", "warning");
9150
+ return true;
9151
+ }
9152
+ const pendingSnapshot = completionSuggestionPendingSnapshot;
9153
+ const shouldRefocusEditor = completionSuggestionRefocusEditorOnResult;
9154
+ completionSuggestionInFlight = false;
9155
+ completionSuggestionRequestId = null;
9156
+ completionSuggestionPendingSnapshot = null;
9157
+ completionSuggestionRefocusEditorOnResult = false;
9158
+ syncActionButtons();
9159
+ if (message.type === "completion_suggestion_error") {
9160
+ setStatus(typeof message.message === "string" ? message.message : "Suggestion failed.", "warning");
9161
+ return true;
9162
+ }
9163
+ const suggestion = typeof message.suggestion === "string" ? message.suggestion : "";
9164
+ if (!suggestion.trim()) {
9165
+ setStatus("Model returned an empty suggestion.", "warning");
9166
+ return true;
9167
+ }
9168
+ const text = String(sourceTextEl.value || "");
9169
+ if (pendingSnapshot && text !== pendingSnapshot.text) {
9170
+ setStatus("Editor changed while the suggestion was generating. Please request a fresh suggestion.", "warning");
9171
+ return true;
9172
+ }
9173
+ const baseText = pendingSnapshot ? pendingSnapshot.text : text;
9174
+ const start = Math.max(0, Math.min(pendingSnapshot ? pendingSnapshot.selectionStart : (Number(message.selectionStart) || 0), baseText.length));
9175
+ const end = Math.max(start, Math.min(pendingSnapshot ? pendingSnapshot.selectionEnd : (Number(message.selectionEnd) || start), baseText.length));
9176
+ showCompletionSuggestion({
9177
+ suggestion,
9178
+ baseText,
9179
+ selectionStart: start,
9180
+ selectionEnd: end,
9181
+ });
9182
+ const activeEl = document.activeElement;
9183
+ if (
9184
+ shouldRefocusEditor
9185
+ || activeEl === sourceTextEl
9186
+ || activeEl === suggestCompletionBtn
9187
+ || activeEl === document.body
9188
+ || activeEl === document.documentElement
9189
+ || Boolean(completionSuggestionPanelEl && activeEl instanceof Element && completionSuggestionPanelEl.contains(activeEl))
9190
+ ) {
9191
+ focusSourceEditorForCompletion();
9192
+ }
9193
+ setStatus("Suggestion ready. Press Tab to insert it, or use the Insert suggestion button.", "success");
9194
+ return true;
9195
+ }
9196
+
8989
9197
  function getSourceTextLineEditBounds(text, selectionStart, selectionEnd) {
8990
9198
  const source = String(text || "");
8991
9199
  const safeStart = Math.max(0, Math.min(Math.floor(Number(selectionStart) || 0), source.length));
@@ -9065,7 +9273,14 @@
9065
9273
  }
9066
9274
 
9067
9275
  function handleSourceTextTabKey(event) {
9068
- if (!event || event.key !== "Tab" || event.metaKey || event.ctrlKey || event.altKey) return;
9276
+ if (!event) return;
9277
+ if (isCompletionSuggestionRequestShortcut(event)) {
9278
+ event.preventDefault();
9279
+ requestCompletionSuggestion();
9280
+ return;
9281
+ }
9282
+ if (handleCompletionSuggestionAcceptKey(event)) return;
9283
+ if (event.key !== "Tab" || event.metaKey || event.ctrlKey || event.altKey) return;
9069
9284
  event.preventDefault();
9070
9285
  if (event.shiftKey) {
9071
9286
  unindentSourceTextSelection();
@@ -16007,6 +16222,8 @@
16007
16222
  updateFooterMeta();
16008
16223
  }
16009
16224
 
16225
+ if (handleCompletionSuggestionServerMessage(message)) return;
16226
+
16010
16227
  if (
16011
16228
  message.type === "quiz_progress" ||
16012
16229
  message.type === "quiz_generated" ||
@@ -17258,6 +17475,9 @@
17258
17475
  sourceTextEl.addEventListener("keydown", handleSourceTextTabKey);
17259
17476
 
17260
17477
  sourceTextEl.addEventListener("input", () => {
17478
+ if (completionSuggestionState && sourceTextEl.value !== completionSuggestionState.baseText) {
17479
+ hideCompletionSuggestion();
17480
+ }
17261
17481
  if (activePreviewCommentSelection) {
17262
17482
  clearPreviewCommentSelection();
17263
17483
  }
@@ -17266,6 +17486,7 @@
17266
17486
  scheduleEditorMetaUpdate();
17267
17487
  updateEditorSelectionCommentUi();
17268
17488
  updateOutlineUi();
17489
+ syncActionButtons();
17269
17490
  if (isReviewNotesOpen() && reviewNotes.length > 0) {
17270
17491
  renderReviewNotesList();
17271
17492
  updateReviewNotesUi();
@@ -17721,6 +17942,24 @@
17721
17942
  }
17722
17943
  });
17723
17944
 
17945
+ if (suggestCompletionBtn) {
17946
+ suggestCompletionBtn.addEventListener("click", () => {
17947
+ requestCompletionSuggestion();
17948
+ });
17949
+ }
17950
+ if (completionSuggestionInsertBtn) {
17951
+ completionSuggestionInsertBtn.addEventListener("click", () => {
17952
+ insertCompletionSuggestion();
17953
+ });
17954
+ }
17955
+ if (completionSuggestionDismissBtn) {
17956
+ completionSuggestionDismissBtn.addEventListener("click", () => {
17957
+ hideCompletionSuggestion();
17958
+ focusSourceTextNoScroll();
17959
+ setStatus("Dismissed completion suggestion.");
17960
+ });
17961
+ }
17962
+
17724
17963
  if (reviewNotesBtn) {
17725
17964
  reviewNotesBtn.addEventListener("click", () => {
17726
17965
  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
 
@@ -4664,6 +4726,12 @@
4664
4726
  font-size: 12px;
4665
4727
  }
4666
4728
 
4729
+ body.studio-ui-refresh .studio-refresh-toolbar-actions .studio-refresh-action-line button:not(#sendRunBtn):not(#queueSteerBtn):not(#sendReplBtn):not(.request-stop-active) {
4730
+ min-height: 26px;
4731
+ padding: 5px 8px;
4732
+ line-height: 1.2;
4733
+ }
4734
+
4667
4735
  body.studio-ui-refresh .studio-refresh-toolbar button:not(#sendRunBtn):not(#queueSteerBtn):not(#sendReplBtn):not(.request-stop-active),
4668
4736
  body.studio-ui-refresh .studio-refresh-toolbar select {
4669
4737
  color: color-mix(in srgb, var(--text) 72%, var(--muted));
package/index.ts CHANGED
@@ -311,6 +311,17 @@ 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
+ }
324
+
314
325
  interface QuizGenerateRequestMessage {
315
326
  type: "quiz_generate_request";
316
327
  requestId: string;
@@ -451,6 +462,7 @@ type IncomingStudioMessage =
451
462
  | CritiqueRequestMessage
452
463
  | AnnotationRequestMessage
453
464
  | SendRunRequestMessage
465
+ | CompletionSuggestionRequestMessage
454
466
  | QuizGenerateRequestMessage
455
467
  | QuizAnswerRequestMessage
456
468
  | QuizDiscussRequestMessage
@@ -472,6 +484,9 @@ type IncomingStudioMessage =
472
484
 
473
485
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
474
486
  const PREVIEW_RENDER_MAX_CHARS = 400_000;
487
+ const STUDIO_COMPLETION_MAX_TEXT_CHARS = 250_000;
488
+ const STUDIO_COMPLETION_PREFIX_CHARS = 12_000;
489
+ const STUDIO_COMPLETION_SUFFIX_CHARS = 6_000;
475
490
  const PDF_EXPORT_MAX_CHARS = 400_000;
476
491
  const HTML_EXPORT_MAX_CHARS = 400_000;
477
492
  const HTML_PREVIEW_MATH_RENDER_MAX_ITEMS = 250;
@@ -7118,7 +7133,7 @@ async function resolveStudioModelRequestAuth(ctx: StudioModelRequestContext, mod
7118
7133
  if (typeof registry.getApiKey === "function") {
7119
7134
  return { apiKey: await registry.getApiKey(model) };
7120
7135
  }
7121
- throw new Error("Current pi model registry does not expose model credentials for Studio quiz.");
7136
+ throw new Error("Current pi model registry does not expose model credentials for Studio model requests.");
7122
7137
  }
7123
7138
 
7124
7139
  function getStudioQuizReasoning(model: NonNullable<ExtensionContext["model"]>, thinking: StudioQuizThinking | undefined): ThinkingLevel | undefined {
@@ -7127,33 +7142,47 @@ function getStudioQuizReasoning(model: NonNullable<ExtensionContext["model"]>, t
7127
7142
  return normalized === "off" ? undefined : normalized;
7128
7143
  }
7129
7144
 
7130
- async function runStudioQuizModelText(ctx: StudioModelRequestContext, prompt: string, options?: { maxTokens?: number; signal?: AbortSignal; thinking?: StudioQuizThinking }): Promise<string> {
7145
+ async function runStudioModelText(
7146
+ ctx: StudioModelRequestContext,
7147
+ prompt: string,
7148
+ options?: { systemPrompt?: string; maxTokens?: number; signal?: AbortSignal; reasoning?: ThinkingLevel; timeoutMs?: number; trim?: boolean },
7149
+ ): Promise<string> {
7131
7150
  if (!ctx.model) throw new Error("No active model selected.");
7132
7151
  const auth = await resolveStudioModelRequestAuth(ctx, ctx.model);
7133
7152
  const response = await completeSimple(
7134
7153
  ctx.model,
7135
7154
  {
7136
- systemPrompt: "You are an active tutor inside pi Studio. Ask and mark concise, probing quiz questions. Return exactly the requested format.",
7155
+ systemPrompt: options?.systemPrompt ?? "You are a concise assistant inside pi Studio. Return exactly the requested format.",
7137
7156
  messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }],
7138
7157
  },
7139
7158
  {
7140
7159
  apiKey: auth.apiKey,
7141
7160
  headers: auth.headers,
7142
- reasoning: getStudioQuizReasoning(ctx.model, options?.thinking),
7161
+ reasoning: options?.reasoning,
7143
7162
  maxTokens: options?.maxTokens ?? 2500,
7144
7163
  signal: options?.signal,
7145
- timeoutMs: 120_000,
7164
+ timeoutMs: options?.timeoutMs ?? 120_000,
7146
7165
  },
7147
7166
  );
7148
- const text = response.content
7167
+ const rawText = response.content
7149
7168
  .filter((part): part is { type: "text"; text: string } => part.type === "text")
7150
7169
  .map((part) => part.text)
7151
- .join("\n")
7152
- .trim();
7153
- if (!text) throw new Error("Model returned no text response.");
7170
+ .join("\n");
7171
+ const text = options?.trim === false ? rawText : rawText.trim();
7172
+ if (!text.trim()) throw new Error("Model returned no text response.");
7154
7173
  return text;
7155
7174
  }
7156
7175
 
7176
+ async function runStudioQuizModelText(ctx: StudioModelRequestContext, prompt: string, options?: { maxTokens?: number; signal?: AbortSignal; thinking?: StudioQuizThinking }): Promise<string> {
7177
+ return runStudioModelText(ctx, prompt, {
7178
+ systemPrompt: "You are an active tutor inside pi Studio. Ask and mark concise, probing quiz questions. Return exactly the requested format.",
7179
+ reasoning: ctx.model ? getStudioQuizReasoning(ctx.model, options?.thinking) : undefined,
7180
+ maxTokens: options?.maxTokens ?? 2500,
7181
+ signal: options?.signal,
7182
+ timeoutMs: 120_000,
7183
+ });
7184
+ }
7185
+
7157
7186
  async function runStudioQuizModelJson(
7158
7187
  ctx: StudioModelRequestContext,
7159
7188
  prompt: string,
@@ -7181,6 +7210,72 @@ async function runStudioQuizModelJson(
7181
7210
  throw lastError ?? new Error("Model did not return valid JSON.");
7182
7211
  }
7183
7212
 
7213
+ function buildStudioCompletionSuggestionPrompt(options: {
7214
+ text: string;
7215
+ selectionStart: number;
7216
+ selectionEnd: number;
7217
+ language?: string;
7218
+ label?: string;
7219
+ path?: string;
7220
+ }): string {
7221
+ const text = String(options.text || "");
7222
+ const start = Math.max(0, Math.min(Math.floor(options.selectionStart || 0), text.length));
7223
+ const end = Math.max(start, Math.min(Math.floor(options.selectionEnd || start), text.length));
7224
+ const prefix = text.slice(Math.max(0, start - STUDIO_COMPLETION_PREFIX_CHARS), start);
7225
+ const selected = text.slice(start, end);
7226
+ const suffix = text.slice(end, Math.min(text.length, end + STUDIO_COMPLETION_SUFFIX_CHARS));
7227
+ const language = String(options.language || "").trim() || "unknown";
7228
+ const label = String(options.label || options.path || "Studio editor").trim();
7229
+ return [
7230
+ "Generate an inline completion for the current editor cursor position.",
7231
+ "Return only the exact text to insert. Do not wrap it in Markdown fences. Do not explain.",
7232
+ "Match the surrounding language, style, indentation, and register.",
7233
+ "Keep the suggestion short unless the context clearly asks for a longer continuation.",
7234
+ selected
7235
+ ? "The selected text will be replaced by the completion."
7236
+ : "The completion will be inserted at the cursor.",
7237
+ "",
7238
+ `File/context label: ${label}`,
7239
+ `Language mode: ${language}`,
7240
+ "",
7241
+ "<prefix>",
7242
+ prefix,
7243
+ "</prefix>",
7244
+ selected ? ["", "<selected>", selected, "</selected>"].join("\n") : "",
7245
+ "",
7246
+ "<suffix>",
7247
+ suffix,
7248
+ "</suffix>",
7249
+ ].filter((part) => part !== "").join("\n");
7250
+ }
7251
+
7252
+ function cleanStudioCompletionSuggestion(text: string): string {
7253
+ let value = String(text || "").replace(/\r\n/g, "\n");
7254
+ value = value.replace(/^\s*(?:Here(?:'s| is) (?:the )?(?:completion|suggestion):|Completion:|Suggestion:)\s*/i, "");
7255
+ return value;
7256
+ }
7257
+
7258
+ async function runStudioCompletionSuggestion(ctx: StudioModelRequestContext, options: {
7259
+ text: string;
7260
+ selectionStart: number;
7261
+ selectionEnd: number;
7262
+ language?: string;
7263
+ label?: string;
7264
+ path?: string;
7265
+ }): Promise<string> {
7266
+ const prompt = buildStudioCompletionSuggestionPrompt(options);
7267
+ // Intentionally omit `reasoning`: pi-ai treats absent reasoning as off/disabled
7268
+ // where supported. Passing "minimal" would still enable a reasoning path and slow completions.
7269
+ const suggestion = cleanStudioCompletionSuggestion(await runStudioModelText(ctx, prompt, {
7270
+ 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.",
7271
+ maxTokens: 650,
7272
+ timeoutMs: 60_000,
7273
+ trim: false,
7274
+ }));
7275
+ if (!suggestion.trim()) throw new Error("Model returned an empty completion suggestion.");
7276
+ return suggestion;
7277
+ }
7278
+
7184
7279
  function inferStudioResponseKind(markdown: string): StudioRequestKind {
7185
7280
  const lower = markdown.toLowerCase();
7186
7281
  if (lower.includes("## critiques") && lower.includes("## document")) return "critique";
@@ -7590,6 +7685,24 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
7590
7685
  };
7591
7686
  }
7592
7687
 
7688
+ if (msg.type === "completion_suggestion_request" && typeof msg.requestId === "string" && typeof msg.text === "string") {
7689
+ const textLength = msg.text.length;
7690
+ const rawStart = typeof msg.selectionStart === "number" && Number.isFinite(msg.selectionStart) ? msg.selectionStart : textLength;
7691
+ const rawEnd = typeof msg.selectionEnd === "number" && Number.isFinite(msg.selectionEnd) ? msg.selectionEnd : rawStart;
7692
+ const selectionStart = Math.max(0, Math.min(Math.floor(rawStart), textLength));
7693
+ const selectionEnd = Math.max(selectionStart, Math.min(Math.floor(rawEnd), textLength));
7694
+ return {
7695
+ type: "completion_suggestion_request",
7696
+ requestId: msg.requestId,
7697
+ text: msg.text,
7698
+ selectionStart,
7699
+ selectionEnd,
7700
+ language: typeof msg.language === "string" ? msg.language : undefined,
7701
+ label: typeof msg.label === "string" ? msg.label : undefined,
7702
+ path: typeof msg.path === "string" ? msg.path : undefined,
7703
+ };
7704
+ }
7705
+
7593
7706
  if (msg.type === "quiz_generate_request" && typeof msg.requestId === "string" && typeof msg.sourceText === "string") {
7594
7707
  const rawCount = typeof msg.questionCount === "number" && Number.isFinite(msg.questionCount) ? msg.questionCount : 5;
7595
7708
  return {
@@ -9467,8 +9580,9 @@ ${cssVarsBlock}
9467
9580
  </select>
9468
9581
  </div>
9469
9582
  <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>
9583
+ <button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy</button>
9584
+ <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>
9585
+ <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
9586
  <button id="sendEditorBtn" type="button">Send to pi editor</button>
9473
9587
  </div>
9474
9588
  <div class="source-actions-row">
@@ -9550,6 +9664,16 @@ ${cssVarsBlock}
9550
9664
  <button id="editorSelectionJumpBtn" type="button" class="editor-selection-action-btn" hidden title="Jump to the current editor selection in the preview.">Jump</button>
9551
9665
  </div>
9552
9666
  </div>
9667
+ <div id="completionSuggestionPanel" class="completion-suggestion-panel" hidden>
9668
+ <div class="completion-suggestion-header">
9669
+ <strong>Suggested completion</strong>
9670
+ <button id="completionSuggestionDismissBtn" type="button" title="Dismiss this suggestion">Dismiss</button>
9671
+ </div>
9672
+ <pre id="completionSuggestionText" class="completion-suggestion-text"></pre>
9673
+ <div class="completion-suggestion-actions">
9674
+ <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>
9675
+ </div>
9676
+ </div>
9553
9677
  <div id="sourcePreview" class="panel-scroll rendered-markdown" hidden><pre class="plain-markdown"></pre></div>
9554
9678
  </div>
9555
9679
  <aside id="outlineOverlay" class="outline-dock-wrap" hidden>
@@ -9718,7 +9842,9 @@ ${cssVarsBlock}
9718
9842
  <dl>
9719
9843
  <div><dt>Cmd/Ctrl+S</dt><dd>Save editor</dd></div>
9720
9844
  <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>
9845
+ <div><dt>Option/Alt+Tab or Cmd/Ctrl+Shift+Space</dt><dd>Suggest a completion at the editor cursor</dd></div>
9846
+ <div><dt>Tab</dt><dd>Insert a visible completion suggestion; otherwise indent selected editor text</dd></div>
9847
+ <div><dt>Shift+Tab</dt><dd>Unindent selected editor text</dd></div>
9722
9848
  </dl>
9723
9849
  </section>
9724
9850
  <section class="shortcuts-group">
@@ -11298,6 +11424,53 @@ export default function (pi: ExtensionAPI) {
11298
11424
  return;
11299
11425
  }
11300
11426
 
11427
+ if (msg.type === "completion_suggestion_request") {
11428
+ if (!isValidRequestId(msg.requestId)) {
11429
+ sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: "Invalid request ID." });
11430
+ return;
11431
+ }
11432
+ const ctx = latestModelRequestCtx ?? lastCommandCtx;
11433
+ if (!ctx) {
11434
+ sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: "No active pi model context is available for editor suggestions." });
11435
+ return;
11436
+ }
11437
+ if (!msg.text.trim()) {
11438
+ sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: "Editor is empty." });
11439
+ return;
11440
+ }
11441
+ if (msg.text.length > STUDIO_COMPLETION_MAX_TEXT_CHARS) {
11442
+ sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: `Editor text is too large for suggestions (${STUDIO_COMPLETION_MAX_TEXT_CHARS} character limit).` });
11443
+ return;
11444
+ }
11445
+ sendToClient(client, { type: "completion_suggestion_progress", requestId: msg.requestId, message: "Generating suggestion…" });
11446
+ void (async () => {
11447
+ try {
11448
+ const suggestion = await runStudioCompletionSuggestion(ctx, {
11449
+ text: msg.text,
11450
+ selectionStart: msg.selectionStart,
11451
+ selectionEnd: msg.selectionEnd,
11452
+ language: msg.language,
11453
+ label: msg.label,
11454
+ path: msg.path,
11455
+ });
11456
+ sendToClient(client, {
11457
+ type: "completion_suggestion_result",
11458
+ requestId: msg.requestId,
11459
+ suggestion,
11460
+ selectionStart: msg.selectionStart,
11461
+ selectionEnd: msg.selectionEnd,
11462
+ });
11463
+ } catch (error) {
11464
+ sendToClient(client, {
11465
+ type: "completion_suggestion_error",
11466
+ requestId: msg.requestId,
11467
+ message: `Suggestion failed: ${error instanceof Error ? error.message : String(error)}`,
11468
+ });
11469
+ }
11470
+ })();
11471
+ return;
11472
+ }
11473
+
11301
11474
  if (msg.type === "quiz_generate_request") {
11302
11475
  if (!isValidRequestId(msg.requestId)) {
11303
11476
  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.16",
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",