pi-studio 0.9.58 → 0.9.59

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,15 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.59] — 2026-09-03
8
+
9
+ ### Added
10
+ - Add `Cmd/Ctrl+Shift+L` for loading the current Pi terminal input draft into Studio without clearing it.
11
+ - Add a confirmed **Clear Pi editor text…** action for removing text waiting in Pi's terminal input editor without changing Studio text or conversation history.
12
+
13
+ ### Changed
14
+ - Treat Pi terminal drafts loaded into Studio or staged from Studio as transient linked handoffs. An accepted **Run editor text** now clears the terminal draft only when its SHA-256 fingerprint and byte length still match; later terminal typing, added `pi-nvim-context` context, another client change, and failed submissions all preserve it.
15
+
7
16
  ## [0.9.58] — 2026-09-03
8
17
 
9
18
  ### Added
package/README.md CHANGED
@@ -35,6 +35,7 @@ _The video shows an earlier version of the Studio interface. The basic workflow
35
35
  - Supports one canonical full Studio view per Pi session, plus additional editor-only companion views when you want extra editing/preview surfaces; editor-only views can also browse files and use the Studio REPL send controls without taking over the full Studio session view
36
36
  - Includes a global **Zen** mode that hides the Studio header and secondary chrome without changing the current pane layout; the header can also be hidden independently and restored from the top-right edge
37
37
  - Runs editor text directly, asks for structured critique (auto/writing/code focus), offers explicit **Show me** actions for a one-turn compact visual or structural explanation of the editor selection/document, displayed response, or current conversation topic, provides 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
38
+ - Treats text copied from or staged in Pi's terminal input editor as a linked draft: ordinary Studio edits retain the link, and an accepted **Run editor text** clears the terminal draft only when it has not changed in the meantime; a confirmed **Clear Pi editor text…** action remains available explicitly
38
39
  - Adds a right-pane **Side questions** thread for contextual questions that stay outside the main Pi conversation unless explicitly promoted. It can start with the editor selection, the Markdown/LaTeX heading block at the cursor, nearby unstructured text, the whole editor, the displayed response, or no starting text; independently, it can use read-only tools to map, search, and read the document folder, repository, or another chosen folder—including extracted PDF, DOCX, ODT, and EPUB text—and can optionally search the web through Brave Search.
39
40
  - 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; optional browser-stored **Follow activity** (off by default under **View → Workspace**) switches to Working when the main Pi agent starts generating and to Response Preview when it completes, unless you manually choose another view during that request. When cycling response history, Working follows saved working details for the selected response when available, and `Cmd/Ctrl+Alt+1–8` switches directly between right-pane views while `Cmd/Ctrl+Alt+P` / `Cmd/Ctrl+Alt+E` / `Cmd/Ctrl+Alt+W` / `Cmd/Ctrl+Alt+F` / `Cmd/Ctrl+Alt+R` / `Cmd/Ctrl+Alt+Q` keep quick mnemonic shortcuts for Response Preview, Editor Preview, Working, Files, REPL, and Side questions
40
41
  - Includes a right-pane **Changes** view for browsing the current git diff by file, previewing per-file diffs, opening changed files, loading the full diff into the editor, and copying the diff
@@ -66,6 +67,12 @@ _The video shows an earlier version of the Studio interface. The basic workflow
66
67
  - Exports local files headlessly via `/studio-pdf <path>` to `<name>.studio.pdf` or `/studio-html <path>` to `<name>.studio.html`; without a path, those commands export the last model response to a timestamped file. Agent tools `studio_export_pdf` and `studio_export_html` expose the same export pipeline for remote/Telegram-style sessions.
67
68
  - Shows model/session/context usage in the footer, plus compact-context and active-theme controls
68
69
 
70
+ ## Pi input-draft handoff
71
+
72
+ Tools such as [`pi-nvim-context`](https://github.com/omaclaren/pi-nvim-context) deliberately accumulate visible context in Pi's terminal input draft without submitting it. **Load from pi editor** (`Cmd/Ctrl+Shift+L`) copies that draft into Studio without clearing it and records only a transient SHA-256 fingerprint and byte length for the handoff—no additional snapshot copy beyond the text already in Studio. You can then edit the text freely in Studio.
73
+
74
+ When Pi accepts **Run editor text**, Studio clears the linked terminal draft only if its current fingerprint and byte length still match those captured at load time. Terminal typing, additional Neovim context, or another client changing the draft causes Studio to preserve it silently. A failed or rejected run also leaves it intact. **Send current text to Pi editor** establishes the same safe link, while **Clear Pi editor text…** provides a separate confirmed, unconditional cleanup action. Studio never clears terminal history or its own editor through this handoff.
75
+
69
76
  ## Commands
70
77
 
71
78
  | Command | Description |
package/ROADMAP.md CHANGED
@@ -106,6 +106,16 @@ A small interaction increment before the buffer-first architecture:
106
106
  - add opt-in activity following from Working during main-agent generation to Response Preview on completion, disabled by default and cancelled when the user manually changes views;
107
107
  - keep Quick send separate from the durable editor, the raw tmux mirror read-only, and protocol-v1 authority unchanged.
108
108
 
109
+ ## 0.9.59 — Safe Pi input-draft handoff (shipped 2026-09-03)
110
+
111
+ A small workflow refinement for moving Neovim-provided context through Pi and Studio:
112
+
113
+ - load or stage Pi's terminal input draft without clearing it, retaining only a transient SHA-256 fingerprint and byte length for provenance;
114
+ - keep that link through unrestricted Studio edits, then clear the original Pi draft only after Pi accepts the Studio run and the current terminal text still matches exactly;
115
+ - silently preserve later terminal typing, added `pi-nvim-context` context, or another client's changes, and never clear after a rejected submission;
116
+ - provide `Cmd/Ctrl+Shift+L` for loading and a confirmed **Clear Pi editor text…** fallback without changing Studio text or conversation history;
117
+ - keep `pi-nvim-context`, protocol v1, networking, and the buffer-first architecture unchanged.
118
+
109
119
  ## 0.10.0 — Buffer-first editing
110
120
 
111
121
  The first architectural `0.10` release should add:
@@ -121,6 +121,7 @@
121
121
  const refreshFromDiskBtn = document.getElementById("refreshFromDiskBtn");
122
122
  const clearWorkspaceBtn = document.getElementById("clearWorkspaceBtn");
123
123
  const sendEditorBtn = document.getElementById("sendEditorBtn");
124
+ const clearPiEditorBtn = document.getElementById("clearPiEditorBtn");
124
125
  const openCompanionBtn = document.getElementById("openCompanionBtn");
125
126
  const getEditorBtn = document.getElementById("getEditorBtn");
126
127
  const studioHeaderEl = document.getElementById("studioHeader");
@@ -361,6 +362,9 @@
361
362
  const pendingSaveOperations = new Map();
362
363
  const pendingCompanionLaunches = new Map();
363
364
  const activeStudioTabLaunches = new Set();
365
+ // Deliberately page-memory-only: never persist Pi input-draft handoff fingerprints.
366
+ const pendingPiEditorDraftSnapshots = new Map();
367
+ let linkedPiEditorDraftSnapshot = null;
364
368
  let sourceOriginSummaryEl = null;
365
369
  let sourceResetOriginBtn = null;
366
370
  let sourceOpenCurrentFileTabBtn = null;
@@ -3186,7 +3190,9 @@
3186
3190
  if (sendEditorBtn) sendEditorBtn.textContent = "Send current text to Pi editor";
3187
3191
  appendStudioUiRefreshMenuSection(contextMenu.menu, "Document", [sourceOriginSummaryEl, sourceResetOriginBtn, sourceOpenCurrentFileTabBtn, sourceOpenCurrentTextCopyTabBtn]);
3188
3192
  appendStudioUiRefreshMenuSection(contextMenu.menu, "Working directory", [resourceDirBtn, resourceDirLabel, resourceDirInputWrap]);
3189
- if (!isEditorOnlyMode && sendEditorBtn) appendStudioUiRefreshMenuSection(contextMenu.menu, "Pi editor", [sendEditorBtn]);
3193
+ if (!isEditorOnlyMode) {
3194
+ appendStudioUiRefreshMenuSection(contextMenu.menu, "Pi editor", [sendEditorBtn, clearPiEditorBtn]);
3195
+ }
3190
3196
  const cursorContextBtn = makeStudioUiRefreshElement("button", "completion-context-option", "Editor only");
3191
3197
  cursorContextBtn.type = "button";
3192
3198
  cursorContextBtn.setAttribute("data-completion-context-mode", "cursor");
@@ -3545,6 +3551,7 @@
3545
3551
  if (kind === "compact") return "compacting context";
3546
3552
  if (kind === "send_to_editor") return "sending to pi editor";
3547
3553
  if (kind === "get_from_editor") return "loading from pi editor";
3554
+ if (kind === "clear_pi_editor") return "clearing Pi editor text";
3548
3555
  if (kind === "load_git_diff") return "loading git diff";
3549
3556
  if (kind === "open_editor_only") return "opening companion editor";
3550
3557
  if (kind === "refresh_from_disk") return "refreshing from disk";
@@ -3803,6 +3810,7 @@
3803
3810
  if (kind === "compact") return "Compacting…";
3804
3811
  if (kind === "send_to_editor") return "Sending to editor…";
3805
3812
  if (kind === "get_from_editor") return "Loading from editor…";
3813
+ if (kind === "clear_pi_editor") return "Clearing Pi editor text…";
3806
3814
  if (kind === "load_git_diff") return "Loading git diff…";
3807
3815
  if (kind === "refresh_from_disk") return "Refreshing from disk…";
3808
3816
  if (kind === "save_as" || kind === "save_over") return "Saving…";
@@ -4464,6 +4472,58 @@
4464
4472
  return /^sha256:[a-f0-9]{64}$/.test(revision) ? revision : null;
4465
4473
  }
4466
4474
 
4475
+ function normalizePiEditorDraftSnapshot(value) {
4476
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
4477
+ const fingerprint = normalizeStudioDiskRevision(value.fingerprint);
4478
+ const byteLength = value.byteLength;
4479
+ if (!fingerprint || typeof byteLength !== "number" || !Number.isSafeInteger(byteLength) || byteLength < 0) return null;
4480
+ return { fingerprint, byteLength };
4481
+ }
4482
+
4483
+ function piEditorDraftSnapshotsMatch(left, right) {
4484
+ const normalizedLeft = normalizePiEditorDraftSnapshot(left);
4485
+ const normalizedRight = normalizePiEditorDraftSnapshot(right);
4486
+ return Boolean(
4487
+ normalizedLeft
4488
+ && normalizedRight
4489
+ && normalizedLeft.fingerprint === normalizedRight.fingerprint
4490
+ && normalizedLeft.byteLength === normalizedRight.byteLength
4491
+ );
4492
+ }
4493
+
4494
+ function setLinkedPiEditorDraftSnapshot(value, hasContent) {
4495
+ linkedPiEditorDraftSnapshot = hasContent ? normalizePiEditorDraftSnapshot(value) : null;
4496
+ }
4497
+
4498
+ function clearLinkedPiEditorDraftSnapshot() {
4499
+ linkedPiEditorDraftSnapshot = null;
4500
+ }
4501
+
4502
+ function reserveLinkedPiEditorDraftSnapshot(requestId) {
4503
+ const snapshot = normalizePiEditorDraftSnapshot(linkedPiEditorDraftSnapshot);
4504
+ if (!snapshot || typeof requestId !== "string" || !requestId) return null;
4505
+ linkedPiEditorDraftSnapshot = null;
4506
+ pendingPiEditorDraftSnapshots.set(requestId, snapshot);
4507
+ return snapshot;
4508
+ }
4509
+
4510
+ function restoreReservedPiEditorDraftSnapshot(requestId) {
4511
+ if (typeof requestId !== "string" || !requestId) return;
4512
+ const snapshot = pendingPiEditorDraftSnapshots.get(requestId);
4513
+ if (!snapshot) return;
4514
+ pendingPiEditorDraftSnapshots.delete(requestId);
4515
+ if (!linkedPiEditorDraftSnapshot) linkedPiEditorDraftSnapshot = snapshot;
4516
+ }
4517
+
4518
+ function settleReservedPiEditorDraftSnapshot(requestId, reportedSnapshot) {
4519
+ if (typeof requestId !== "string" || !requestId) return false;
4520
+ const snapshot = pendingPiEditorDraftSnapshots.get(requestId);
4521
+ if (!snapshot) return false;
4522
+ if (reportedSnapshot && !piEditorDraftSnapshotsMatch(snapshot, reportedSnapshot)) return false;
4523
+ pendingPiEditorDraftSnapshots.delete(requestId);
4524
+ return true;
4525
+ }
4526
+
4467
4527
  function markFileBackedBaseline(text, diskRevision) {
4468
4528
  fileBackedBaselineText = String(text || "");
4469
4529
  fileBackedDiskRevision = normalizeStudioDiskRevision(diskRevision);
@@ -5098,6 +5158,20 @@
5098
5158
  return true;
5099
5159
  }
5100
5160
 
5161
+ function triggerLoadFromPiEditorShortcut() {
5162
+ if (isWatchedFilePreview) {
5163
+ setStatus("This preview is read-only. Open full Studio to load the Pi draft.", "warning");
5164
+ return false;
5165
+ }
5166
+ if (!getEditorBtn || getEditorBtn.hidden) return false;
5167
+ if (getEditorBtn.disabled) {
5168
+ setStatus("Load from Pi editor is unavailable while Studio is busy.", "warning");
5169
+ return false;
5170
+ }
5171
+ getEditorBtn.click();
5172
+ return true;
5173
+ }
5174
+
5101
5175
  function triggerEditorSaveShortcut() {
5102
5176
  if (hasRefreshableFilePath() && saveOverBtn && !saveOverBtn.disabled && !saveOverBtn.hidden) {
5103
5177
  saveOverBtn.click();
@@ -5459,6 +5533,18 @@
5459
5533
  return;
5460
5534
  }
5461
5535
 
5536
+ const isLoadFromPiEditorShortcut =
5537
+ (key.toLowerCase() === "l" || code === "KeyL")
5538
+ && (event.metaKey || event.ctrlKey)
5539
+ && !event.altKey
5540
+ && event.shiftKey;
5541
+
5542
+ if (isLoadFromPiEditorShortcut) {
5543
+ event.preventDefault();
5544
+ triggerLoadFromPiEditorShortcut();
5545
+ return;
5546
+ }
5547
+
5462
5548
  const isSaveAsShortcut =
5463
5549
  key.toLowerCase() === "s"
5464
5550
  && (event.metaKey || event.ctrlKey)
@@ -13888,7 +13974,7 @@
13888
13974
  const safeStart = Math.max(0, Math.min(start, current.length));
13889
13975
  const safeEnd = Math.max(safeStart, Math.min(end, current.length));
13890
13976
  const next = current.slice(0, safeStart) + answer.text + current.slice(safeEnd);
13891
- setEditorText(next, { preserveScroll: false, preserveSelection: false });
13977
+ setEditorText(next, { preserveScroll: false, preserveSelection: false, preservePiEditorDraftLink: true });
13892
13978
  const caret = safeStart + answer.text.length;
13893
13979
  sourceTextEl.setSelectionRange(caret, caret);
13894
13980
  setActivePane("left");
@@ -14504,6 +14590,7 @@
14504
14590
  if (refreshFromDiskBtn) refreshFromDiskBtn.disabled = uiBusy || isWatchedFilePreview || !canRefreshFromDisk;
14505
14591
  if (clearWorkspaceBtn) clearWorkspaceBtn.disabled = uiBusy || isWatchedFilePreview;
14506
14592
  sendEditorBtn.disabled = uiBusy || isEditorOnlyMode;
14593
+ if (clearPiEditorBtn) clearPiEditorBtn.disabled = uiBusy || isEditorOnlyMode;
14507
14594
  if (getEditorBtn) getEditorBtn.disabled = uiBusy || isWatchedFilePreview;
14508
14595
  if (watchedOpenEditableBtn) {
14509
14596
  watchedOpenEditableBtn.hidden = !isWatchedFilePreview;
@@ -14885,6 +14972,9 @@
14885
14972
  const value = String(nextText || "");
14886
14973
  const preserveScroll = Boolean(options && options.preserveScroll);
14887
14974
  const preserveSelection = Boolean(options && options.preserveSelection);
14975
+ if (!(options && options.preservePiEditorDraftLink === true)) {
14976
+ clearLinkedPiEditorDraftSnapshot();
14977
+ }
14888
14978
  if (activePreviewCommentSelection) {
14889
14979
  clearPreviewCommentSelection();
14890
14980
  }
@@ -22387,7 +22477,7 @@
22387
22477
  ? current.slice(0, inlineState.range.end) + current.slice(inlineState.range.end + inlineState.markerText.length)
22388
22478
  : current.slice(0, inlineState.range.end) + inlineState.markerText + current.slice(inlineState.range.end);
22389
22479
  setEditorView("markdown");
22390
- setEditorText(next, { preserveScroll: true, preserveSelection: true });
22480
+ setEditorText(next, { preserveScroll: true, preserveSelection: true, preservePiEditorDraftLink: true });
22391
22481
  pendingReviewNoteInlineFocusId = note.id;
22392
22482
  renderReviewNotesList();
22393
22483
  updateReviewNotesUi();
@@ -22432,7 +22522,7 @@
22432
22522
  }
22433
22523
 
22434
22524
  setEditorView("markdown");
22435
- setEditorText(currentText, { preserveScroll: true, preserveSelection: true });
22525
+ setEditorText(currentText, { preserveScroll: true, preserveSelection: true, preservePiEditorDraftLink: true });
22436
22526
  renderReviewNotesList();
22437
22527
  updateReviewNotesUi();
22438
22528
  if (reviewNotesInlineAllBtn && typeof reviewNotesInlineAllBtn.focus === "function") {
@@ -22618,7 +22708,7 @@
22618
22708
  const safeStart = Math.max(0, Math.min(start, current.length));
22619
22709
  const safeEnd = Math.max(safeStart, Math.min(end, current.length));
22620
22710
  const next = current.slice(0, safeStart) + content + current.slice(safeEnd);
22621
- setEditorText(next, { preserveScroll: false, preserveSelection: false });
22711
+ setEditorText(next, { preserveScroll: false, preserveSelection: false, preservePiEditorDraftLink: true });
22622
22712
  const caret = safeStart + content.length;
22623
22713
  sourceTextEl.setSelectionRange(caret, caret);
22624
22714
  setActivePane("left");
@@ -22821,11 +22911,14 @@
22821
22911
  const replHint = rightView === "repl" && getActiveReplSessionForCurrentRuntime()
22822
22912
  ? " Sends text to Pi, not the REPL; use Send chunk/selection or Send to REPL to execute code in the active REPL."
22823
22913
  : "";
22914
+ const piEditorDraftHint = linkedPiEditorDraftSnapshot
22915
+ ? " The linked Pi terminal draft will be cleared after Pi accepts this run only if that draft remains unchanged."
22916
+ : "";
22824
22917
  sendRunBtn.title = directIsStop
22825
22918
  ? "Stop the active run. Shortcut: Esc."
22826
22919
  : (annotationsEnabled
22827
- ? "Run editor text as-is (includes [an: ...] markers). Shortcut: Cmd/Ctrl+Enter. Stop the active request with Esc." + replHint
22828
- : "Run editor text with [an: ...] markers stripped. Shortcut: Cmd/Ctrl+Enter. Stop the active request with Esc." + replHint);
22920
+ ? "Run editor text as-is (includes [an: ...] markers). Shortcut: Cmd/Ctrl+Enter. Stop the active request with Esc." + piEditorDraftHint + replHint
22921
+ : "Run editor text with [an: ...] markers stripped. Shortcut: Cmd/Ctrl+Enter. Stop the active request with Esc." + piEditorDraftHint + replHint);
22829
22922
  }
22830
22923
 
22831
22924
  if (queueSteerBtn) {
@@ -23670,6 +23763,7 @@
23670
23763
  }
23671
23764
 
23672
23765
  const completedRequestId = typeof message.requestId === "string" ? message.requestId : pendingRequestId;
23766
+ if (completedRequestId) pendingPiEditorDraftSnapshots.delete(completedRequestId);
23673
23767
  const responseKind =
23674
23768
  typeof message.kind === "string"
23675
23769
  ? message.kind
@@ -23804,17 +23898,46 @@
23804
23898
  return;
23805
23899
  }
23806
23900
 
23901
+ if (message.type === "pi_editor_draft_result") {
23902
+ const requestId = typeof message.requestId === "string" ? message.requestId : "";
23903
+ if (!settleReservedPiEditorDraftSnapshot(requestId, message.piEditorDraftSnapshot)) return;
23904
+ const outcome = String(message.outcome || "");
23905
+ syncActionButtons();
23906
+ if (outcome === "cleared") {
23907
+ setStatus("Submission accepted; cleared the unchanged Pi draft.", "success");
23908
+ } else if (outcome === "already-empty") {
23909
+ setStatus("Submission accepted; the Pi draft was already empty.", "success");
23910
+ } else if (outcome !== "changed") {
23911
+ setStatus("Submission accepted, but Studio could not confirm Pi-draft cleanup; check the terminal draft.", "warning");
23912
+ }
23913
+ return;
23914
+ }
23915
+
23807
23916
  if (message.type === "editor_loaded") {
23808
23917
  if (typeof message.requestId === "string" && pendingRequestId === message.requestId) {
23809
23918
  pendingRequestId = null;
23810
23919
  pendingKind = null;
23811
23920
  }
23921
+ const snapshot = normalizePiEditorDraftSnapshot(message.piEditorDraftSnapshot);
23922
+ setLinkedPiEditorDraftSnapshot(snapshot, Boolean(snapshot && snapshot.byteLength > 0));
23812
23923
  setBusy(false);
23813
23924
  setWsState("Ready");
23814
23925
  setStatus(typeof message.message === "string" ? message.message : "Loaded into pi editor.", "success");
23815
23926
  return;
23816
23927
  }
23817
23928
 
23929
+ if (message.type === "pi_editor_cleared") {
23930
+ if (typeof message.requestId === "string" && pendingRequestId === message.requestId) {
23931
+ pendingRequestId = null;
23932
+ pendingKind = null;
23933
+ }
23934
+ clearLinkedPiEditorDraftSnapshot();
23935
+ setBusy(false);
23936
+ setWsState("Ready");
23937
+ setStatus(message.cleared ? "Cleared Pi editor text." : "Pi editor text was already empty.", "success");
23938
+ return;
23939
+ }
23940
+
23818
23941
  if (message.type === "editor_snapshot") {
23819
23942
  if (isWatchedFilePreview) {
23820
23943
  setStatus("Ignored editor snapshot because this preview follows its watched file on disk.", "warning");
@@ -23830,12 +23953,14 @@
23830
23953
 
23831
23954
  const content = typeof message.content === "string" ? message.content : "";
23832
23955
  setEditorText(content, { preserveScroll: false, preserveSelection: false });
23956
+ const snapshot = normalizePiEditorDraftSnapshot(message.piEditorDraftSnapshot);
23957
+ setLinkedPiEditorDraftSnapshot(snapshot, Boolean(content.length > 0 && snapshot));
23833
23958
  setSourceState({ source: "pi-editor", label: "pi editor draft", path: null });
23834
23959
  setBusy(false);
23835
23960
  setWsState("Ready");
23836
23961
  setStatus(
23837
23962
  content.trim()
23838
- ? "Loaded draft from pi editor."
23963
+ ? "Loaded draft from pi editor. A Studio Run will clear it if the Pi draft remains unchanged."
23839
23964
  : "pi editor is empty. Loaded blank text.",
23840
23965
  content.trim() ? "success" : "warning",
23841
23966
  );
@@ -24032,6 +24157,7 @@
24032
24157
 
24033
24158
  if (message.type === "busy") {
24034
24159
  if (typeof message.requestId === "string") {
24160
+ restoreReservedPiEditorDraftSnapshot(message.requestId);
24035
24161
  pendingSaveOperations.delete(message.requestId);
24036
24162
  failPendingCompanionLaunch(message.requestId, "Studio could not start the companion editor because another request was busy.");
24037
24163
  }
@@ -24055,6 +24181,7 @@
24055
24181
 
24056
24182
  if (message.type === "error") {
24057
24183
  if (typeof message.requestId === "string") {
24184
+ restoreReservedPiEditorDraftSnapshot(message.requestId);
24058
24185
  pendingSaveOperations.delete(message.requestId);
24059
24186
  failPendingCompanionLaunch(message.requestId, "Studio could not prepare the companion editor. Return to the originating Studio page for details.");
24060
24187
  }
@@ -24203,6 +24330,7 @@
24203
24330
  quartoPreviewCheckSourcePath = "";
24204
24331
  quartoPreviewActionRequestId = null;
24205
24332
  sideQuestionMarkdownExportRequest = null;
24333
+ pendingPiEditorDraftSnapshots.clear();
24206
24334
  failAllPendingCompanionLaunches("The originating Studio connection was lost before the companion editor was ready.");
24207
24335
  if (rightView === "editor-quarto-preview") renderQuartoPreviewView();
24208
24336
  setBusy(true);
@@ -24473,7 +24601,7 @@
24473
24601
 
24474
24602
  if (stripped.hadHeader) {
24475
24603
  const updated = stripped.body;
24476
- setEditorText(updated, { preserveScroll: true, preserveSelection: true });
24604
+ setEditorText(updated, { preserveScroll: true, preserveSelection: true, preservePiEditorDraftLink: true });
24477
24605
  updateResultActionButtons();
24478
24606
  setStatus("Removed annotated reply header.", "success");
24479
24607
  return;
@@ -24486,7 +24614,7 @@
24486
24614
  return;
24487
24615
  }
24488
24616
 
24489
- setEditorText(updated, { preserveScroll: true, preserveSelection: true });
24617
+ setEditorText(updated, { preserveScroll: true, preserveSelection: true, preservePiEditorDraftLink: true });
24490
24618
  updateResultActionButtons();
24491
24619
  setStatus("Inserted annotated reply header.", "success");
24492
24620
  }
@@ -25356,6 +25484,29 @@
25356
25484
  }
25357
25485
  });
25358
25486
 
25487
+ if (clearPiEditorBtn) {
25488
+ clearPiEditorBtn.addEventListener("click", async () => {
25489
+ closeStudioUiRefreshMenus();
25490
+ const confirmed = await requestStudioConfirmation(
25491
+ "Clear the current Pi input draft? This removes text waiting in the terminal editor, including context added from Neovim. Studio text and Pi conversation history will not change.",
25492
+ { title: "Clear Pi editor text?", confirmLabel: "Clear text", destructive: true },
25493
+ );
25494
+ if (!confirmed) {
25495
+ setStatus("Pi editor text was not cleared.");
25496
+ return;
25497
+ }
25498
+
25499
+ const requestId = beginUiAction("clear_pi_editor");
25500
+ if (!requestId) return;
25501
+ const sent = sendMessage({ type: "clear_pi_editor_request", requestId });
25502
+ if (!sent) {
25503
+ pendingRequestId = null;
25504
+ pendingKind = null;
25505
+ setBusy(false);
25506
+ }
25507
+ });
25508
+ }
25509
+
25359
25510
  if (openCompanionBtn) {
25360
25511
  openCompanionBtn.addEventListener("click", () => {
25361
25512
  requestOpenEditorOnlyDocument("", {
@@ -25423,14 +25574,17 @@
25423
25574
 
25424
25575
  const requestId = beginUiAction("direct");
25425
25576
  if (!requestId) return;
25577
+ const piEditorDraftSnapshot = reserveLinkedPiEditorDraftSnapshot(requestId);
25426
25578
 
25427
25579
  const sent = sendMessage({
25428
25580
  type: "send_run_request",
25429
25581
  requestId,
25430
25582
  text: prepared,
25583
+ piEditorDraftSnapshot: piEditorDraftSnapshot || undefined,
25431
25584
  });
25432
25585
 
25433
25586
  if (!sent) {
25587
+ restoreReservedPiEditorDraftSnapshot(requestId);
25434
25588
  pendingRequestId = null;
25435
25589
  pendingKind = null;
25436
25590
  setBusy(false);
@@ -25451,12 +25605,17 @@
25451
25605
 
25452
25606
  const requestId = makeRequestId();
25453
25607
  clearTitleAttention();
25608
+ const piEditorDraftSnapshot = reserveLinkedPiEditorDraftSnapshot(requestId);
25454
25609
  const sent = sendMessage({
25455
25610
  type: "send_run_request",
25456
25611
  requestId,
25457
25612
  text: prepared,
25613
+ piEditorDraftSnapshot: piEditorDraftSnapshot || undefined,
25458
25614
  });
25459
- if (!sent) return;
25615
+ if (!sent) {
25616
+ restoreReservedPiEditorDraftSnapshot(requestId);
25617
+ return;
25618
+ }
25460
25619
  setStatus("Queueing steering…", "warning");
25461
25620
  });
25462
25621
  }
@@ -25892,7 +26051,7 @@
25892
26051
  if (!confirmed) return;
25893
26052
 
25894
26053
  const strippedContent = stripAnnotationMarkers(content);
25895
- setEditorText(strippedContent, { preserveScroll: true, preserveSelection: false });
26054
+ setEditorText(strippedContent, { preserveScroll: true, preserveSelection: false, preservePiEditorDraftLink: true });
25896
26055
  setStatus("Removed annotation markers from editor text.", "success");
25897
26056
  });
25898
26057
  }
package/client/studio.css CHANGED
@@ -569,6 +569,7 @@
569
569
  body[data-studio-mode="editor-only"] #sendRunBtn,
570
570
  body[data-studio-mode="editor-only"] #queueSteerBtn,
571
571
  body[data-studio-mode="editor-only"] #sendEditorBtn,
572
+ body[data-studio-mode="editor-only"] #clearPiEditorBtn,
572
573
  body[data-studio-mode="editor-only"] #lensSelect,
573
574
  body[data-studio-mode="editor-only"] #critiqueBtn,
574
575
  body[data-studio-mode="editor-only"] #showMeBtn,
package/index.ts CHANGED
@@ -29,6 +29,11 @@ import {
29
29
  saveStudioDiskFileAs,
30
30
  saveStudioDiskFileIfRevision,
31
31
  } from "./shared/studio-disk-revisions.js";
32
+ import {
33
+ consumeStudioPiEditorDraftSnapshot,
34
+ createStudioPiEditorDraftSnapshot,
35
+ normalizeStudioPiEditorDraftSnapshot,
36
+ } from "./shared/studio-pi-editor-draft.js";
32
37
  import { normalizeStudioMarkdownSmartFences } from "./shared/studio-markdown-fences.js";
33
38
  import {
34
39
  extractStandaloneLatexDefinitionsFromMarkdown,
@@ -646,10 +651,16 @@ interface AnnotationRequestMessage {
646
651
  text: string;
647
652
  }
648
653
 
654
+ interface StudioPiEditorDraftSnapshot {
655
+ fingerprint: string;
656
+ byteLength: number;
657
+ }
658
+
649
659
  interface SendRunRequestMessage {
650
660
  type: "send_run_request";
651
661
  requestId: string;
652
662
  text: string;
663
+ piEditorDraftSnapshot?: StudioPiEditorDraftSnapshot;
653
664
  }
654
665
 
655
666
  interface CompletionSuggestionRequestMessage {
@@ -839,6 +850,11 @@ interface GetFromEditorRequestMessage {
839
850
  requestId: string;
840
851
  }
841
852
 
853
+ interface ClearPiEditorRequestMessage {
854
+ type: "clear_pi_editor_request";
855
+ requestId: string;
856
+ }
857
+
842
858
  interface QuartoPreviewCheckRequestMessage {
843
859
  type: "quarto_preview_check_request";
844
860
  requestId: string;
@@ -922,6 +938,7 @@ type IncomingStudioMessage =
922
938
  | WatchFileSubscribeMessage
923
939
  | SendToEditorRequestMessage
924
940
  | GetFromEditorRequestMessage
941
+ | ClearPiEditorRequestMessage
925
942
  | QuartoPreviewCheckRequestMessage
926
943
  | QuartoPreviewStartRequestMessage
927
944
  | QuartoPreviewStopRequestMessage
@@ -9917,10 +9934,15 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
9917
9934
  }
9918
9935
 
9919
9936
  if (msg.type === "send_run_request" && typeof msg.requestId === "string" && typeof msg.text === "string") {
9937
+ const piEditorDraftSnapshot = msg.piEditorDraftSnapshot === undefined
9938
+ ? undefined
9939
+ : normalizeStudioPiEditorDraftSnapshot(msg.piEditorDraftSnapshot);
9940
+ if (msg.piEditorDraftSnapshot !== undefined && !piEditorDraftSnapshot) return null;
9920
9941
  return {
9921
9942
  type: "send_run_request",
9922
9943
  requestId: msg.requestId,
9923
9944
  text: msg.text,
9945
+ piEditorDraftSnapshot: piEditorDraftSnapshot ?? undefined,
9924
9946
  };
9925
9947
  }
9926
9948
 
@@ -10200,6 +10222,13 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
10200
10222
  };
10201
10223
  }
10202
10224
 
10225
+ if (msg.type === "clear_pi_editor_request" && typeof msg.requestId === "string") {
10226
+ return {
10227
+ type: "clear_pi_editor_request",
10228
+ requestId: msg.requestId,
10229
+ };
10230
+ }
10231
+
10203
10232
  if (
10204
10233
  msg.type === "quarto_preview_check_request"
10205
10234
  && typeof msg.requestId === "string"
@@ -12385,7 +12414,7 @@ ${cssVarsBlock}
12385
12414
  <button id="clearWorkspaceBtn" type="button" title="Clear editor text and reset this tab to a fresh blank draft. Saved files and responses are not changed.">Reset editor</button>
12386
12415
  <button id="importFileBtn" type="button" title="Import a file as an editable copy.">Import file copy…</button>
12387
12416
  <input id="fileInput" class="file-input-hidden" type="file" tabindex="-1" aria-hidden="true" accept=".md,.markdown,.mdx,.qmd,.js,.mjs,.cjs,.jsx,.ts,.mts,.cts,.tsx,.py,.pyw,.sh,.bash,.zsh,.json,.jsonc,.json5,.rs,.c,.h,.cpp,.cxx,.cc,.hpp,.hxx,.jl,.f90,.f95,.f03,.f,.for,.r,.R,.m,.tex,.latex,.diff,.patch,.java,.go,.rb,.swift,.html,.htm,.css,.xml,.yaml,.yml,.toml,.lua,.txt,.rst,.adoc" />
12388
- <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
12417
+ <button id="getEditorBtn" type="button" aria-keyshortcuts="Meta+Shift+L Control+Shift+L" title="Copy the current Pi terminal input draft into Studio without clearing it. A later Studio Run clears that draft only if it remains unchanged. Shortcut: Cmd/Ctrl+Shift+L.">Load from pi editor</button>
12389
12418
  <button id="hideStudioHeaderBtn" class="header-visibility-btn" type="button" aria-controls="studioHeader" title="Hide the global Studio header. Restore it from the top-right edge.">Hide header</button>
12390
12419
  <button id="zenModeBtn" class="zen-mode-btn" type="button" title="Hide the Studio header and secondary controls. Shortcut: F9.">Zen</button>
12391
12420
  </div>
@@ -12426,7 +12455,7 @@ ${cssVarsBlock}
12426
12455
  </div>
12427
12456
  <div class="source-actions">
12428
12457
  <div class="source-actions-row">
12429
- <button id="sendRunBtn" type="button" title="Run editor text. While a direct run is active, this button becomes Stop. Cmd/Ctrl+Enter queues steering from the current editor text. Stop the active request with Esc.">Run editor text</button>
12458
+ <button id="sendRunBtn" type="button" title="Run editor text. If Studio loaded or staged an unchanged Pi terminal draft, clear that draft after Pi accepts the run. While a direct run is active, this button becomes Stop. Cmd/Ctrl+Enter queues steering from the current editor text. Stop the active request with Esc.">Run editor text</button>
12430
12459
  <button id="queueSteerBtn" type="button" title="Queue steering is available while Run editor text is active." disabled>Queue steering</button>
12431
12460
  </div>
12432
12461
  <div class="source-actions-row repl-action-line" hidden>
@@ -12453,7 +12482,8 @@ ${cssVarsBlock}
12453
12482
  <option value="current" selected>Suggestion model: current Pi model</option>
12454
12483
  </select>
12455
12484
  <button id="openCompanionBtn" type="button" title="Open a blank editor-only Studio tab.">New editor tab</button>
12456
- <button id="sendEditorBtn" type="button">Send current text to Pi editor</button>
12485
+ <button id="sendEditorBtn" type="button" title="Replace the Pi terminal input draft with the current Studio text. A later Studio Run clears that staged draft only if it remains unchanged.">Send current text to Pi editor</button>
12486
+ <button id="clearPiEditorBtn" type="button" title="Clear text currently waiting in the Pi terminal input editor without changing Studio text or conversation history.">Clear Pi editor text…</button>
12457
12487
  </div>
12458
12488
  <div class="source-actions-row">
12459
12489
  <button id="insertHeaderBtn" type="button" title="Insert annotated-reply protocol header (source metadata, [an: ...] syntax hint, precedence note, and end marker).">Annotation header</button>
@@ -12759,6 +12789,7 @@ ${cssVarsBlock}
12759
12789
  <dl>
12760
12790
  <div><dt>Cmd/Ctrl+S</dt><dd>Save editor when the disk revision still matches</dd></div>
12761
12791
  <div><dt>Cmd/Ctrl+Shift+S</dt><dd>Save editor as a new file</dd></div>
12792
+ <div><dt>Cmd/Ctrl+Shift+L</dt><dd>Load the current Pi terminal input draft without clearing it</dd></div>
12762
12793
  <div class="shortcuts-full-only"><dt>Cmd/Ctrl+Enter</dt><dd>Run editor text, or queue steering during an active run</dd></div>
12763
12794
  <div><dt>Option/Alt+Tab or Cmd/Ctrl+Shift+Space</dt><dd>Suggest a completion at the editor cursor</dd></div>
12764
12795
  <div><dt>Tab</dt><dd>Insert a visible completion suggestion; otherwise indent selected editor text</dd></div>
@@ -13649,6 +13680,23 @@ export default function (pi: ExtensionAPI) {
13649
13680
  }
13650
13681
  };
13651
13682
 
13683
+ const reportPiEditorDraftDisposition = (
13684
+ client: WebSocket,
13685
+ requestId: string,
13686
+ snapshot: StudioPiEditorDraftSnapshot | undefined,
13687
+ ) => {
13688
+ if (!snapshot) return;
13689
+ const result = consumeStudioPiEditorDraftSnapshot(lastCommandCtx?.ui, snapshot);
13690
+ if (result.status === "not-requested") return;
13691
+ emitDebugEvent("pi_editor_draft_disposition", { requestId, outcome: result.status });
13692
+ sendToClient(client, {
13693
+ type: "pi_editor_draft_result",
13694
+ requestId,
13695
+ outcome: result.status,
13696
+ piEditorDraftSnapshot: snapshot,
13697
+ });
13698
+ };
13699
+
13652
13700
  const broadcast = (payload: unknown) => {
13653
13701
  if (!serverState) return;
13654
13702
  const serialized = JSON.stringify(payload);
@@ -15743,6 +15791,7 @@ export default function (pi: ExtensionAPI) {
15743
15791
  queuedSteeringCount: getQueuedStudioSteeringCount(),
15744
15792
  });
15745
15793
  broadcastState();
15794
+ reportPiEditorDraftDisposition(client, msg.requestId, msg.piEditorDraftSnapshot);
15746
15795
  } catch (error) {
15747
15796
  queuedStudioDirectRequests = queuedStudioDirectRequests.filter((request) => request.requestId !== msg.requestId);
15748
15797
  if (studioDirectRunChain?.steeringPrompts.length) {
@@ -15766,6 +15815,7 @@ export default function (pi: ExtensionAPI) {
15766
15815
 
15767
15816
  try {
15768
15817
  pi.sendUserMessage(msg.text);
15818
+ reportPiEditorDraftDisposition(client, msg.requestId, msg.piEditorDraftSnapshot);
15769
15819
  } catch (error) {
15770
15820
  clearStudioDirectRunState();
15771
15821
  clearActiveRequest();
@@ -16510,7 +16560,8 @@ export default function (pi: ExtensionAPI) {
16510
16560
  sendToClient(client, {
16511
16561
  type: "editor_loaded",
16512
16562
  requestId: msg.requestId,
16513
- message: "Draft loaded into pi editor.",
16563
+ message: "Draft loaded into pi editor. A Studio Run will clear it if it remains unchanged.",
16564
+ piEditorDraftSnapshot: createStudioPiEditorDraftSnapshot(msg.content),
16514
16565
  });
16515
16566
  } catch (error) {
16516
16567
  sendToClient(client, {
@@ -16546,6 +16597,7 @@ export default function (pi: ExtensionAPI) {
16546
16597
  type: "editor_snapshot",
16547
16598
  requestId: msg.requestId,
16548
16599
  content,
16600
+ piEditorDraftSnapshot: createStudioPiEditorDraftSnapshot(content),
16549
16601
  });
16550
16602
  } catch (error) {
16551
16603
  sendToClient(client, {
@@ -16556,6 +16608,43 @@ export default function (pi: ExtensionAPI) {
16556
16608
  }
16557
16609
  return;
16558
16610
  }
16611
+
16612
+ if (msg.type === "clear_pi_editor_request") {
16613
+ if (!isValidRequestId(msg.requestId)) {
16614
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
16615
+ return;
16616
+ }
16617
+ if (isStudioBusy()) {
16618
+ sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
16619
+ return;
16620
+ }
16621
+ if (!lastCommandCtx || !lastCommandCtx.hasUI) {
16622
+ sendToClient(client, {
16623
+ type: "error",
16624
+ requestId: msg.requestId,
16625
+ message: "No interactive pi editor context is available.",
16626
+ });
16627
+ return;
16628
+ }
16629
+
16630
+ try {
16631
+ const hadContent = lastCommandCtx.ui.getEditorText().length > 0;
16632
+ if (hadContent) lastCommandCtx.ui.setEditorText("");
16633
+ lastCommandCtx.ui.notify(hadContent ? "Pi editor text cleared from Studio." : "Pi editor text is already empty.", "info");
16634
+ sendToClient(client, {
16635
+ type: "pi_editor_cleared",
16636
+ requestId: msg.requestId,
16637
+ cleared: hadContent,
16638
+ });
16639
+ } catch (error) {
16640
+ sendToClient(client, {
16641
+ type: "error",
16642
+ requestId: msg.requestId,
16643
+ message: `Failed to clear pi editor text: ${error instanceof Error ? error.message : String(error)}`,
16644
+ });
16645
+ }
16646
+ return;
16647
+ }
16559
16648
  };
16560
16649
 
16561
16650
  const disposePreparedPdfExport = (entry: PreparedStudioPdfExport | null | undefined) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.58",
3
+ "version": "0.9.59",
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",
@@ -0,0 +1,49 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export const STUDIO_PI_EDITOR_DRAFT_FINGERPRINT_PATTERN = /^sha256:[a-f0-9]{64}$/;
4
+
5
+ export function createStudioPiEditorDraftSnapshot(content) {
6
+ const buffer = Buffer.from(String(content ?? ""), "utf8");
7
+ return Object.freeze({
8
+ fingerprint: `sha256:${createHash("sha256").update(buffer).digest("hex")}`,
9
+ byteLength: buffer.length,
10
+ });
11
+ }
12
+
13
+ export function normalizeStudioPiEditorDraftSnapshot(value) {
14
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
15
+ const fingerprint = typeof value.fingerprint === "string"
16
+ ? value.fingerprint.trim().toLowerCase()
17
+ : "";
18
+ const byteLength = value.byteLength;
19
+ if (!STUDIO_PI_EDITOR_DRAFT_FINGERPRINT_PATTERN.test(fingerprint)) return null;
20
+ if (typeof byteLength !== "number" || !Number.isSafeInteger(byteLength) || byteLength < 0) return null;
21
+ return Object.freeze({ fingerprint, byteLength });
22
+ }
23
+
24
+ export function studioPiEditorDraftMatchesSnapshot(content, snapshot) {
25
+ const expected = normalizeStudioPiEditorDraftSnapshot(snapshot);
26
+ if (!expected) return false;
27
+ const current = createStudioPiEditorDraftSnapshot(content);
28
+ return current.byteLength === expected.byteLength && current.fingerprint === expected.fingerprint;
29
+ }
30
+
31
+ export function consumeStudioPiEditorDraftSnapshot(ui, snapshot) {
32
+ const expected = normalizeStudioPiEditorDraftSnapshot(snapshot);
33
+ if (!expected) return Object.freeze({ status: "not-requested" });
34
+ if (!ui || typeof ui.getEditorText !== "function" || typeof ui.setEditorText !== "function") {
35
+ return Object.freeze({ status: "unavailable" });
36
+ }
37
+
38
+ try {
39
+ const current = String(ui.getEditorText() ?? "");
40
+ if (current.length === 0) return Object.freeze({ status: "already-empty" });
41
+ if (!studioPiEditorDraftMatchesSnapshot(current, expected)) {
42
+ return Object.freeze({ status: "changed" });
43
+ }
44
+ ui.setEditorText("");
45
+ return Object.freeze({ status: "cleared" });
46
+ } catch {
47
+ return Object.freeze({ status: "unavailable" });
48
+ }
49
+ }