pi-studio 0.9.57 → 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/client/studio.css CHANGED
@@ -362,6 +362,55 @@
362
362
  user-select: none;
363
363
  }
364
364
 
365
+ body[data-studio-layout="editor-top"] main,
366
+ body[data-studio-layout="response-top"] main {
367
+ grid-template-columns: minmax(0, 1fr);
368
+ gap: 0;
369
+ }
370
+
371
+ body[data-studio-layout="editor-top"] main {
372
+ grid-template-rows: minmax(140px, var(--studio-left-pane-fr, 1fr)) 10px minmax(140px, var(--studio-right-pane-fr, 1fr));
373
+ }
374
+
375
+ body[data-studio-layout="response-top"] main {
376
+ grid-template-rows: minmax(140px, var(--studio-right-pane-fr, 1fr)) 10px minmax(140px, var(--studio-left-pane-fr, 1fr));
377
+ }
378
+
379
+ body[data-studio-layout="editor-top"] .pane-resize-handle,
380
+ body[data-studio-layout="response-top"] .pane-resize-handle {
381
+ display: block;
382
+ align-self: stretch;
383
+ min-width: 0;
384
+ min-height: 10px;
385
+ cursor: row-resize;
386
+ }
387
+
388
+ body[data-studio-layout="editor-top"] .pane-resize-handle::before,
389
+ body[data-studio-layout="response-top"] .pane-resize-handle::before {
390
+ top: 50%;
391
+ right: 8px;
392
+ bottom: auto;
393
+ left: 8px;
394
+ width: auto;
395
+ height: 2px;
396
+ transform: translateY(-50%);
397
+ }
398
+
399
+ body[data-studio-layout="editor-top"] .pane-resize-handle:hover::before,
400
+ body[data-studio-layout="editor-top"] .pane-resize-handle:focus-visible::before,
401
+ body[data-studio-layout="editor-top"].pane-resizing .pane-resize-handle::before,
402
+ body[data-studio-layout="response-top"] .pane-resize-handle:hover::before,
403
+ body[data-studio-layout="response-top"] .pane-resize-handle:focus-visible::before,
404
+ body[data-studio-layout="response-top"].pane-resizing .pane-resize-handle::before {
405
+ width: auto;
406
+ height: 3px;
407
+ }
408
+
409
+ body[data-studio-layout="editor-top"].pane-resizing,
410
+ body[data-studio-layout="response-top"].pane-resizing {
411
+ cursor: row-resize;
412
+ }
413
+
365
414
  section {
366
415
  border: 1px solid var(--panel-border);
367
416
  border-radius: 10px;
@@ -387,6 +436,13 @@
387
436
  gap: 12px;
388
437
  }
389
438
 
439
+ body[data-studio-layout="editor-top"].pane-focus-left main,
440
+ body[data-studio-layout="editor-top"].pane-focus-right main,
441
+ body[data-studio-layout="response-top"].pane-focus-left main,
442
+ body[data-studio-layout="response-top"].pane-focus-right main {
443
+ grid-template-rows: minmax(0, 1fr);
444
+ }
445
+
390
446
  body.pane-focus-left #rightPane,
391
447
  body.pane-focus-right #leftPane,
392
448
  body.pane-focus-left #paneResizeHandle,
@@ -513,6 +569,7 @@
513
569
  body[data-studio-mode="editor-only"] #sendRunBtn,
514
570
  body[data-studio-mode="editor-only"] #queueSteerBtn,
515
571
  body[data-studio-mode="editor-only"] #sendEditorBtn,
572
+ body[data-studio-mode="editor-only"] #clearPiEditorBtn,
516
573
  body[data-studio-mode="editor-only"] #lensSelect,
517
574
  body[data-studio-mode="editor-only"] #critiqueBtn,
518
575
  body[data-studio-mode="editor-only"] #showMeBtn,
@@ -4579,6 +4636,107 @@
4579
4636
  background: var(--error-soft);
4580
4637
  }
4581
4638
 
4639
+ .repl-quick-composer {
4640
+ display: flex;
4641
+ flex-direction: column;
4642
+ gap: 8px;
4643
+ padding: 10px;
4644
+ border: 1px solid var(--panel-border);
4645
+ border-radius: 8px;
4646
+ background: color-mix(in srgb, var(--panel-2) 58%, var(--panel));
4647
+ }
4648
+
4649
+ .repl-quick-header,
4650
+ .repl-quick-actions {
4651
+ display: flex;
4652
+ align-items: center;
4653
+ justify-content: space-between;
4654
+ gap: 10px;
4655
+ }
4656
+
4657
+ .repl-quick-header > div,
4658
+ .repl-quick-actions > div {
4659
+ display: flex;
4660
+ align-items: center;
4661
+ gap: 8px;
4662
+ min-width: 0;
4663
+ }
4664
+
4665
+ .repl-quick-target {
4666
+ display: inline-flex;
4667
+ align-items: center;
4668
+ gap: 4px;
4669
+ max-width: min(48vw, 460px);
4670
+ min-height: 0;
4671
+ margin-left: 5px;
4672
+ padding: 2px 4px;
4673
+ overflow: hidden;
4674
+ border-color: transparent;
4675
+ border-radius: 5px;
4676
+ background: transparent;
4677
+ color: var(--muted);
4678
+ font-family: var(--font-mono);
4679
+ font-size: 11px;
4680
+ line-height: 1.25;
4681
+ vertical-align: bottom;
4682
+ white-space: nowrap;
4683
+ }
4684
+
4685
+ .repl-quick-target:not(:disabled):hover,
4686
+ .repl-quick-target:focus-visible {
4687
+ border-color: transparent;
4688
+ background: var(--studio-header-action-hover-bg, var(--panel-2));
4689
+ color: var(--text);
4690
+ }
4691
+
4692
+ .repl-quick-target-label {
4693
+ min-width: 0;
4694
+ overflow: hidden;
4695
+ text-overflow: ellipsis;
4696
+ }
4697
+
4698
+ .repl-quick-target-arrow {
4699
+ flex: 0 0 auto;
4700
+ color: var(--studio-info-text, var(--muted));
4701
+ font-family: var(--font-ui);
4702
+ font-size: 10px;
4703
+ font-weight: 700;
4704
+ }
4705
+
4706
+ .repl-quick-hint,
4707
+ .repl-quick-actions > span {
4708
+ color: var(--muted);
4709
+ font-size: 11px;
4710
+ }
4711
+
4712
+ .repl-quick-composer textarea {
4713
+ box-sizing: border-box;
4714
+ min-height: 72px;
4715
+ max-height: 260px;
4716
+ margin: 0;
4717
+ resize: vertical;
4718
+ }
4719
+
4720
+ .repl-quick-composer textarea:focus-visible {
4721
+ border-color: color-mix(in srgb, var(--accent) 65%, var(--control-border));
4722
+ outline: 2px solid color-mix(in srgb, var(--accent) 28%, transparent);
4723
+ outline-offset: 1px;
4724
+ }
4725
+
4726
+ .repl-quick-actions {
4727
+ align-items: flex-end;
4728
+ }
4729
+
4730
+ .repl-quick-actions > span {
4731
+ line-height: 1.35;
4732
+ }
4733
+
4734
+ .repl-quick-send-btn {
4735
+ border-color: color-mix(in srgb, var(--accent) 58%, var(--control-border));
4736
+ background: color-mix(in srgb, var(--accent) 14%, var(--panel));
4737
+ color: var(--text);
4738
+ }
4739
+
4582
4740
  .repl-journal,
4583
4741
  .repl-mirror {
4584
4742
  border: 1px solid var(--panel-border);
@@ -5726,6 +5884,20 @@
5726
5884
  .pane-resize-handle {
5727
5885
  display: none;
5728
5886
  }
5887
+
5888
+ .repl-quick-header,
5889
+ .repl-quick-actions {
5890
+ align-items: stretch;
5891
+ flex-direction: column;
5892
+ }
5893
+
5894
+ .repl-quick-target {
5895
+ max-width: 66vw;
5896
+ }
5897
+
5898
+ .repl-quick-actions > div {
5899
+ align-self: flex-end;
5900
+ }
5729
5901
  }
5730
5902
 
5731
5903
  /* Default Studio layout. The legacy classic layout remains available only via ?uiRefresh=0. */
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"
@@ -11530,10 +11559,11 @@ function stripStudioReplSubmissionEcho(output: string): string {
11530
11559
  let value = String(output || "").replace(/^\s+/, "");
11531
11560
  // The raw tmux mirror should stay raw, but Studio/tool result output should not
11532
11561
  // expose the temp-file wrapper used to submit multiline snippets safely. The
11533
- // The root fragment catches both legacy long Studio paths and compact private
11534
- // control paths when IPython wraps them across continuation prompt lines.
11562
+ // root fragment catches both legacy long Studio paths and compact private
11563
+ // control paths. Match the Python wrapper by its final double close rather
11564
+ // than the word `globals`, which IPython may split across continuation lines.
11535
11565
  const submissionEchoPatterns = [
11536
- /^.*exec\(open\([\s\S]*?(?:pi-studio-re|pi-rc-)[\s\S]*?globals\(\)\)\s*$/gm,
11566
+ /^.*exec\(open\([\s\S]*?(?:pi-studio-re|pi-rc-)[\s\S]*?\)\)\s*$/gm,
11537
11567
  /^.*include\([\s\S]*?(?:pi-studio-re|pi-rc-)[\s\S]*?\.jl"\)\s*$/gm,
11538
11568
  /^.*source\([\s\S]*?(?:pi-studio-re|pi-rc-)[\s\S]*?local\s*=\s*\.GlobalEnv\)\s*$/gm,
11539
11569
  /^.*:script\s+[\s\S]*?(?:pi-studio-re|pi-rc-)[\s\S]*?\.ghci"?\s*$/gm,
@@ -12384,7 +12414,7 @@ ${cssVarsBlock}
12384
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>
12385
12415
  <button id="importFileBtn" type="button" title="Import a file as an editable copy.">Import file copy…</button>
12386
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" />
12387
- <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>
12388
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>
12389
12419
  <button id="zenModeBtn" class="zen-mode-btn" type="button" title="Hide the Studio header and secondary controls. Shortcut: F9.">Zen</button>
12390
12420
  </div>
@@ -12425,7 +12455,7 @@ ${cssVarsBlock}
12425
12455
  </div>
12426
12456
  <div class="source-actions">
12427
12457
  <div class="source-actions-row">
12428
- <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>
12429
12459
  <button id="queueSteerBtn" type="button" title="Queue steering is available while Run editor text is active." disabled>Queue steering</button>
12430
12460
  </div>
12431
12461
  <div class="source-actions-row repl-action-line" hidden>
@@ -12452,7 +12482,8 @@ ${cssVarsBlock}
12452
12482
  <option value="current" selected>Suggestion model: current Pi model</option>
12453
12483
  </select>
12454
12484
  <button id="openCompanionBtn" type="button" title="Open a blank editor-only Studio tab.">New editor tab</button>
12455
- <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>
12456
12487
  </div>
12457
12488
  <div class="source-actions-row">
12458
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>
@@ -12508,6 +12539,15 @@ ${cssVarsBlock}
12508
12539
  <option value="off">Line numbers: Off</option>
12509
12540
  <option value="on" selected>Line numbers: On</option>
12510
12541
  </select>
12542
+ <select id="studioPaneLayoutSelect" class="studio-flat-select" aria-label="Studio pane layout" title="Choose whether the two main panes sit side by side or in a vertical stack.">
12543
+ <option value="side-by-side">Layout: Side by side</option>
12544
+ <option value="editor-top">Layout: Editor above</option>
12545
+ <option value="response-top">Layout: Response above</option>
12546
+ </select>
12547
+ <select id="activityTrackingSelect" class="studio-flat-select" aria-label="Follow main Pi activity" title="Optionally follow main Pi activity from Working to the response view; off by default.">
12548
+ <option value="off">Follow activity: Off</option>
12549
+ <option value="on">Follow activity: On</option>
12550
+ </select>
12511
12551
  <select id="editorFontSizeSelect" class="studio-flat-select" aria-label="Editor text size" title="Adjust raw editor text size.">
12512
12552
  <option value="10">Editor text: 10px</option>
12513
12553
  <option value="11">Editor text: 11px</option>
@@ -12607,7 +12647,7 @@ ${cssVarsBlock}
12607
12647
  <div id="rightSectionHeader" class="section-header">
12608
12648
  <div class="section-header-main">
12609
12649
  <span id="rightViewSelectWrap" class="studio-header-select-wrap">
12610
- <select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+1–8 switches directly between all right-pane views. Cmd/Ctrl+Alt+P/E/W/F/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, and Side questions.">
12650
+ <select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+1–8 switches directly between all right-pane views. Cmd/Ctrl+Alt+P/E/W/F/R/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, REPL, and Side questions.">
12611
12651
  <option value="markdown">Response (Raw)</option>
12612
12652
  <option value="preview" selected>Response (Preview)</option>
12613
12653
  <option value="editor-preview">Editor (Preview)</option>
@@ -12725,6 +12765,7 @@ ${cssVarsBlock}
12725
12765
  <div><dt>Cmd/Ctrl+Alt+E</dt><dd>Switch the right pane directly to Editor Preview</dd></div>
12726
12766
  <div><dt>Cmd/Ctrl+Alt+W</dt><dd>Switch the right pane directly to Working</dd></div>
12727
12767
  <div><dt>Cmd/Ctrl+Alt+F</dt><dd>Switch the right pane directly to Files</dd></div>
12768
+ <div><dt>Cmd/Ctrl+Alt+R</dt><dd>Switch the right pane directly to REPL and focus Quick send when a session is selected</dd></div>
12728
12769
  <div><dt>Cmd/Ctrl+Alt+Q</dt><dd>Switch the right pane directly to Side questions</dd></div>
12729
12770
  <div><dt>F8</dt><dd>Focus editor text</dd></div>
12730
12771
  <div><dt>Shift+F8</dt><dd>Focus right-pane content</dd></div>
@@ -12740,7 +12781,7 @@ ${cssVarsBlock}
12740
12781
  <div><dt>Alt/Option+=</dt><dd>Increase the active pane's text size when not editing text</dd></div>
12741
12782
  <div><dt>Alt/Option+-</dt><dd>Decrease the active pane's text size when not editing text</dd></div>
12742
12783
  <div><dt>Alt/Option+0</dt><dd>Reset the active pane's text size when not editing text</dd></div>
12743
- <div><dt>Cmd/Ctrl+Alt+R</dt><dd>Refresh the focused or visible PDF preview from disk</dd></div>
12784
+ <div><dt>Cmd/Ctrl+Alt+Shift+R</dt><dd>Refresh the focused or visible PDF preview from disk</dd></div>
12744
12785
  </dl>
12745
12786
  </section>
12746
12787
  <section class="shortcuts-group">
@@ -12748,6 +12789,7 @@ ${cssVarsBlock}
12748
12789
  <dl>
12749
12790
  <div><dt>Cmd/Ctrl+S</dt><dd>Save editor when the disk revision still matches</dd></div>
12750
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>
12751
12793
  <div class="shortcuts-full-only"><dt>Cmd/Ctrl+Enter</dt><dd>Run editor text, or queue steering during an active run</dd></div>
12752
12794
  <div><dt>Option/Alt+Tab or Cmd/Ctrl+Shift+Space</dt><dd>Suggest a completion at the editor cursor</dd></div>
12753
12795
  <div><dt>Tab</dt><dd>Insert a visible completion suggestion; otherwise indent selected editor text</dd></div>
@@ -12772,6 +12814,7 @@ ${cssVarsBlock}
12772
12814
  <section class="shortcuts-group">
12773
12815
  <h3>REPL</h3>
12774
12816
  <dl>
12817
+ <div><dt>Cmd/Ctrl+Enter</dt><dd>Send exact Quick send text while its REPL composer is focused; Enter adds a new line</dd></div>
12775
12818
  <div><dt>Cmd/Ctrl+Shift+Enter</dt><dd>Send selection, chunks, or editor text to the active REPL when the right pane is REPL</dd></div>
12776
12819
  </dl>
12777
12820
  </section>
@@ -13637,6 +13680,23 @@ export default function (pi: ExtensionAPI) {
13637
13680
  }
13638
13681
  };
13639
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
+
13640
13700
  const broadcast = (payload: unknown) => {
13641
13701
  if (!serverState) return;
13642
13702
  const serialized = JSON.stringify(payload);
@@ -15731,6 +15791,7 @@ export default function (pi: ExtensionAPI) {
15731
15791
  queuedSteeringCount: getQueuedStudioSteeringCount(),
15732
15792
  });
15733
15793
  broadcastState();
15794
+ reportPiEditorDraftDisposition(client, msg.requestId, msg.piEditorDraftSnapshot);
15734
15795
  } catch (error) {
15735
15796
  queuedStudioDirectRequests = queuedStudioDirectRequests.filter((request) => request.requestId !== msg.requestId);
15736
15797
  if (studioDirectRunChain?.steeringPrompts.length) {
@@ -15754,6 +15815,7 @@ export default function (pi: ExtensionAPI) {
15754
15815
 
15755
15816
  try {
15756
15817
  pi.sendUserMessage(msg.text);
15818
+ reportPiEditorDraftDisposition(client, msg.requestId, msg.piEditorDraftSnapshot);
15757
15819
  } catch (error) {
15758
15820
  clearStudioDirectRunState();
15759
15821
  clearActiveRequest();
@@ -16498,7 +16560,8 @@ export default function (pi: ExtensionAPI) {
16498
16560
  sendToClient(client, {
16499
16561
  type: "editor_loaded",
16500
16562
  requestId: msg.requestId,
16501
- 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),
16502
16565
  });
16503
16566
  } catch (error) {
16504
16567
  sendToClient(client, {
@@ -16534,6 +16597,7 @@ export default function (pi: ExtensionAPI) {
16534
16597
  type: "editor_snapshot",
16535
16598
  requestId: msg.requestId,
16536
16599
  content,
16600
+ piEditorDraftSnapshot: createStudioPiEditorDraftSnapshot(content),
16537
16601
  });
16538
16602
  } catch (error) {
16539
16603
  sendToClient(client, {
@@ -16544,6 +16608,43 @@ export default function (pi: ExtensionAPI) {
16544
16608
  }
16545
16609
  return;
16546
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
+ }
16547
16648
  };
16548
16649
 
16549
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.57",
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
+ }