pi-studio 0.10.4 → 0.10.5

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,13 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.10.5] — 2026-10-20
8
+
9
+ ### Fixed
10
+ - After a Quick send, the record shows the result while the cursor stays in the box. Since 0.10.3 it waited for the next change, such as your next send. If the pane is redrawn while you type, the text, the selection and where typing goes are kept.
11
+ - While a prompt runs, the Prompt toolbar's tools stay on one row on a 13-inch screen. **Queue steering** took more room than its label needed, which left a gap before it and pushed them onto a second row.
12
+ - With Muxy 2.2, `/studio` opens in Muxy's built-in browser. Muxy 2.2 marks its terminals differently, so Studio didn't recognise them and opened the system browser instead.
13
+
7
14
  ## [0.10.4] — 2026-10-19
8
15
 
9
16
  ### Fixed
@@ -716,6 +716,18 @@
716
716
  // After a Quick send from the box, the cursor goes back to it (Oliver, 19 Oct):
717
717
  // { requestId, settled }, cleared when restored or when anything else is chosen.
718
718
  let replQuickRefocus = null;
719
+ // A REPL pane redraw that waited (for focus to leave a control, or for a pause in the
720
+ // Quick send box), and what the box is in the middle of (Oliver, 19 Oct).
721
+ let replRedrawDeferred = false;
722
+ let replRedrawDeferredForce = false;
723
+ let replRedrawForced = false;
724
+ let replRedrawRetryTimer = null;
725
+ // A height the box was dragged to, kept across redraws.
726
+ let replQuickBoxHeight = "";
727
+ let replQuickComposing = false;
728
+ let replQuickPointerDown = false;
729
+ let replQuickLastInputAt = 0;
730
+ const REPL_QUICK_TYPING_PAUSE_MS = 800;
719
731
  let replSendMode = (() => {
720
732
  try {
721
733
  const stored = window.localStorage && window.localStorage.getItem("piStudio.replSendMode");
@@ -1669,7 +1681,10 @@
1669
1681
  }
1670
1682
 
1671
1683
  function setReplQuickDraft(session, value) {
1672
- const key = getReplQuickDraftKey(session);
1684
+ return setReplQuickDraftForKey(getReplQuickDraftKey(session), value);
1685
+ }
1686
+
1687
+ function setReplQuickDraftForKey(key, value) {
1673
1688
  if (!key) return "";
1674
1689
  const draft = String(value || "").slice(0, REPL_QUICK_DRAFT_MAX_CHARS);
1675
1690
  if (!draft) {
@@ -2006,6 +2021,113 @@
2006
2021
  return activeEl instanceof Element && Boolean(activeEl.closest(".repl-controls, .repl-quick-composer"));
2007
2022
  }
2008
2023
 
2024
+ // The pane can be redrawn under the Quick send box: its text is kept as a draft on
2025
+ // every keystroke, and its cursor, selection and scroll are put back. Otherwise a
2026
+ // result that arrived while the cursor stayed there wasn't shown (since 0.10.3).
2027
+ // Not mid-typing, mid-composition or mid-drag, though: the redraw waits for a pause.
2028
+ // Other REPL controls wait until focus leaves them.
2029
+ function canRedrawReplUnderFocus() {
2030
+ const activeEl = document.activeElement;
2031
+ return activeEl instanceof HTMLTextAreaElement && activeEl.hasAttribute("data-repl-quick-draft")
2032
+ && !replQuickComposing && !replQuickPointerDown && Date.now() - replQuickLastInputAt >= REPL_QUICK_TYPING_PAUSE_MS;
2033
+ }
2034
+
2035
+ function retryReplRedrawAfterPause() {
2036
+ if (replRedrawRetryTimer !== null) return;
2037
+ const wait = Math.max(0, replQuickLastInputAt + REPL_QUICK_TYPING_PAUSE_MS - Date.now()) + 20;
2038
+ replRedrawRetryTimer = window.setTimeout(() => {
2039
+ replRedrawRetryTimer = null;
2040
+ replayDeferredReplRedraw();
2041
+ }, wait);
2042
+ }
2043
+
2044
+ // Whether a REPL redraw has to wait. Asked when the redraw is requested and again when
2045
+ // its frame runs: composing, typing, a drag or focus elsewhere can start in between
2046
+ // (Sol, Astra). Composing holds even a forced redraw.
2047
+ function replRedrawMustWait(force) {
2048
+ return replQuickComposing || (!force && isReplControlFocused() && !canRedrawReplUnderFocus());
2049
+ }
2050
+
2051
+ function deferReplRedraw(force) {
2052
+ replRedrawDeferred = true;
2053
+ replRedrawDeferredForce = replRedrawDeferredForce || force;
2054
+ // Only typing needs a timer: composing and a drag end with their own events (Sol).
2055
+ const activeEl = document.activeElement;
2056
+ if (!replQuickComposing && !replQuickPointerDown && activeEl instanceof HTMLTextAreaElement && activeEl.hasAttribute("data-repl-quick-draft")) retryReplRedrawAfterPause();
2057
+ }
2058
+
2059
+ // The session lifetime a drawn Quick send box belongs to. While a redraw waits (for
2060
+ // composing, say), the selection can move on and leave the box showing another one.
2061
+ function getReplQuickBoxKey(box) {
2062
+ try {
2063
+ return box instanceof Element ? decodeURIComponent(box.getAttribute("data-repl-quick-lifetime") || "") : "";
2064
+ } catch {
2065
+ return "";
2066
+ }
2067
+ }
2068
+
2069
+ // What the REPL pane is drawn for: the runtime, and the selected session's name and lifetime.
2070
+ function getReplPaneTargetKey() {
2071
+ const session = getActiveReplSessionForCurrentRuntime();
2072
+ return [normalizeReplRuntime(replRuntime), session ? session.sessionName : "", session ? getReplQuickDraftKey(session) : ""].join("\u001e");
2073
+ }
2074
+
2075
+ // A pane left on screen for another session, or a replacement with the same name. Its
2076
+ // controls do nothing then, and the redraw shows the current one (Sol, Astra).
2077
+ function isReplPaneStale(el) {
2078
+ const pane = el instanceof Element ? el.closest("[data-repl-drawn-target]") : null;
2079
+ if (!pane) return false;
2080
+ try {
2081
+ return decodeURIComponent(pane.getAttribute("data-repl-drawn-target") || "") !== getReplPaneTargetKey();
2082
+ } catch {
2083
+ return true;
2084
+ }
2085
+ }
2086
+
2087
+ function refuseStaleReplPaneAction(message) {
2088
+ setStatus(message, "warning");
2089
+ renderReplViewIfActive({ force: true });
2090
+ }
2091
+
2092
+ function replayDeferredReplRedraw() {
2093
+ if (!replRedrawDeferred) return;
2094
+ renderReplViewIfActive({ force: replRedrawDeferredForce });
2095
+ }
2096
+
2097
+ // The cursor goes back only into the box for the same session lifetime: a redraw that
2098
+ // shows another session, or a replacement with the same name, leaves focus to the page.
2099
+ function captureReplQuickComposerCursor() {
2100
+ const activeEl = document.activeElement;
2101
+ if (!(activeEl instanceof HTMLTextAreaElement) || !activeEl.hasAttribute("data-repl-quick-draft")) return null;
2102
+ return {
2103
+ lifetime: activeEl.getAttribute("data-repl-quick-lifetime") || "",
2104
+ start: activeEl.selectionStart,
2105
+ end: activeEl.selectionEnd,
2106
+ direction: activeEl.selectionDirection,
2107
+ scrollTop: activeEl.scrollTop,
2108
+ };
2109
+ }
2110
+
2111
+ function restoreReplQuickComposerCursor(cursor) {
2112
+ if (!cursor || rightView !== "repl" || !critiqueViewEl) return;
2113
+ const composer = critiqueViewEl.querySelector("[data-repl-quick-draft]");
2114
+ if (!(composer instanceof HTMLTextAreaElement) || composer.disabled) return;
2115
+ if (!cursor.lifetime || composer.getAttribute("data-repl-quick-lifetime") !== cursor.lifetime) return;
2116
+ if (document.activeElement !== composer) {
2117
+ try {
2118
+ composer.focus({ preventScroll: true });
2119
+ } catch {
2120
+ try { composer.focus(); } catch {}
2121
+ }
2122
+ }
2123
+ if (document.activeElement !== composer) return;
2124
+ const length = composer.value.length;
2125
+ try {
2126
+ composer.setSelectionRange(Math.min(cursor.start, length), Math.min(cursor.end, length), cursor.direction || "none");
2127
+ } catch {}
2128
+ composer.scrollTop = cursor.scrollTop;
2129
+ }
2130
+
2009
2131
  function focusReplQuickComposer() {
2010
2132
  replQuickFocusRequested = true;
2011
2133
  window.setTimeout(() => {
@@ -2054,11 +2176,26 @@
2054
2176
 
2055
2177
  function renderReplViewIfActive(options) {
2056
2178
  if (rightView !== "repl") return;
2057
- if ((!options || options.force !== true) && isReplControlFocused()) return;
2179
+ const force = Boolean(options && options.force === true);
2180
+ if (replRedrawMustWait(force)) {
2181
+ deferReplRedraw(force);
2182
+ return;
2183
+ }
2184
+ replRedrawDeferred = false;
2185
+ replRedrawDeferredForce = false;
2186
+ replRedrawForced = replRedrawForced || force;
2058
2187
  if (traceRenderRaf !== null) return;
2059
2188
  traceRenderRaf = window.requestAnimationFrame(() => {
2060
2189
  traceRenderRaf = null;
2190
+ const forced = replRedrawForced;
2191
+ replRedrawForced = false;
2192
+ if (rightView === "repl" && replRedrawMustWait(forced)) {
2193
+ deferReplRedraw(forced);
2194
+ return;
2195
+ }
2196
+ const cursor = captureReplQuickComposerCursor();
2061
2197
  refreshResponseUi();
2198
+ restoreReplQuickComposerCursor(cursor);
2062
2199
  applyReplQuickRefocus();
2063
2200
  if (replQuickFocusRequested) focusReplQuickComposer();
2064
2201
  });
@@ -2800,6 +2937,11 @@
2800
2937
  return false;
2801
2938
  }
2802
2939
  const key = getReplQuickDraftKey(session);
2940
+ const box = critiqueViewEl ? critiqueViewEl.querySelector("[data-repl-quick-draft]") : null;
2941
+ if (isReplPaneStale(box)) {
2942
+ refuseStaleReplPaneAction("The REPL session changed, so nothing was sent.");
2943
+ return false;
2944
+ }
2803
2945
  const text = getReplQuickDraft(session);
2804
2946
  if (!text.trim()) {
2805
2947
  setStatus("Quick send is empty.", "warning");
@@ -11693,6 +11835,10 @@
11693
11835
  if (!actionBtn) return;
11694
11836
  event.preventDefault();
11695
11837
  const action = actionBtn.getAttribute("data-repl-action") || "";
11838
+ if (isReplPaneStale(actionBtn)) {
11839
+ refuseStaleReplPaneAction(action === "quick-send" ? "The REPL session changed, so nothing was sent." : "The REPL session changed, so nothing was done.");
11840
+ return;
11841
+ }
11696
11842
  if (action === "quick-send") {
11697
11843
  submitReplQuickDraft();
11698
11844
  return;
@@ -11834,13 +11980,44 @@
11834
11980
  }
11835
11981
  }
11836
11982
 
11983
+ // A redraw that waited for a control runs once focus has left it.
11984
+ function handleReplPaneFocusOut() {
11985
+ if (!replRedrawDeferred) return;
11986
+ window.setTimeout(replayDeferredReplRedraw, 0);
11987
+ }
11988
+
11989
+ function handleReplPaneComposition(event) {
11990
+ const target = event && event.target;
11991
+ if (!(target instanceof HTMLTextAreaElement) || !target.hasAttribute("data-repl-quick-draft")) return;
11992
+ replQuickComposing = event.type === "compositionstart";
11993
+ if (!replQuickComposing) replayDeferredReplRedraw();
11994
+ }
11995
+
11996
+ // A drag that starts in the box ends wherever the button comes up.
11997
+ function handleReplQuickPointer(event) {
11998
+ if (event.type === "pointerdown") {
11999
+ const target = event.target;
12000
+ replQuickPointerDown = target instanceof Element && Boolean(target.closest("[data-repl-quick-draft]"));
12001
+ return;
12002
+ }
12003
+ if (!replQuickPointerDown) return;
12004
+ replQuickPointerDown = false;
12005
+ // Dragging the box's corner sets its height; keep it for the boxes drawn after it.
12006
+ const composer = critiqueViewEl ? critiqueViewEl.querySelector("[data-repl-quick-draft]") : null;
12007
+ if (composer instanceof HTMLTextAreaElement && /^\d+(?:\.\d+)?px$/.test(composer.style.height)) replQuickBoxHeight = composer.style.height;
12008
+ replayDeferredReplRedraw();
12009
+ }
12010
+
11837
12011
  function handleReplPaneInput(event) {
11838
12012
  if (rightView !== "repl") return;
11839
12013
  const target = event && event.target;
11840
12014
  if (!(target instanceof HTMLTextAreaElement) || !target.hasAttribute("data-repl-quick-draft")) return;
11841
- const session = getActiveReplSessionForCurrentRuntime();
11842
- if (!session) return;
11843
- const draft = setReplQuickDraft(session, target.value);
12015
+ replQuickLastInputAt = Date.now();
12016
+ // The text belongs to the session the box was drawn for, even if another is selected
12017
+ // now and the box hasn't been redrawn yet (Astra).
12018
+ const key = getReplQuickBoxKey(target);
12019
+ if (!key) return;
12020
+ const draft = setReplQuickDraftForKey(key, target.value);
11844
12021
  if (target.value !== draft) target.value = draft;
11845
12022
  updateReplQuickComposerActions();
11846
12023
  }
@@ -11889,7 +12066,8 @@
11889
12066
  }
11890
12067
  const commandInput = target.closest("[data-repl-command]");
11891
12068
  if (commandInput && "value" in commandInput) {
11892
- setReplCommandOverride(replRuntime, commandInput.value);
12069
+ // For the runtime it was drawn for, even if another was selected meanwhile.
12070
+ setReplCommandOverride(commandInput.getAttribute("data-repl-command") || replRuntime, commandInput.value);
11893
12071
  return;
11894
12072
  }
11895
12073
  const sessionSelect = target.closest("[data-repl-session]");
@@ -11914,6 +12092,12 @@
11914
12092
  critiqueViewEl.addEventListener("click", handleStudioQuartoPreviewClick);
11915
12093
  critiqueViewEl.addEventListener("click", (event) => { void handleSideQuestionClick(event); });
11916
12094
  critiqueViewEl.addEventListener("focusin", handleReplPaneFocusIn);
12095
+ critiqueViewEl.addEventListener("focusout", handleReplPaneFocusOut);
12096
+ critiqueViewEl.addEventListener("compositionstart", handleReplPaneComposition);
12097
+ critiqueViewEl.addEventListener("compositionend", handleReplPaneComposition);
12098
+ document.addEventListener("pointerdown", handleReplQuickPointer, true);
12099
+ document.addEventListener("pointerup", handleReplQuickPointer, true);
12100
+ document.addEventListener("pointercancel", handleReplQuickPointer, true);
11917
12101
  document.addEventListener("focusin", cancelReplQuickRefocusOutside, true);
11918
12102
  document.addEventListener("pointerdown", cancelReplQuickRefocusOutside, true);
11919
12103
  critiqueViewEl.addEventListener("input", handleSideQuestionInput);
@@ -13952,7 +14136,7 @@
13952
14136
  return "<section class='repl-quick-composer' aria-labelledby='replQuickComposerTitle'>"
13953
14137
  + "<div class='repl-quick-header'><div><strong id='replQuickComposerTitle'>Quick send</strong><button type='button' class='repl-quick-target' data-repl-action='jump-controls' title='Target: " + escapeHtml(targetDetails) + ". Jump to REPL session controls.' aria-label='Target: " + escapeHtml(targetDetails) + ". Jump to REPL session controls.'><span class='repl-quick-target-label'>" + escapeHtml(visibleTargetLabel) + "</span><span class='repl-quick-target-arrow' aria-hidden='true'>↑</span></button></div>"
13954
14138
  + "<span class='repl-quick-hint'>Enter adds a line · Cmd/Ctrl+Enter sends</span></div>"
13955
- + "<textarea data-repl-quick-draft rows='3' maxlength='" + REPL_QUICK_DRAFT_MAX_CHARS + "' aria-label='Quick code for " + escapeHtml(targetDetails) + "' aria-keyshortcuts='Meta+Enter Control+Enter' placeholder='" + (activeSession ? (key ? "Type a short command or snippet…" : "Exact tmux identity unavailable; refresh the session list") : "Start or select a REPL session first") + "'" + (inputDisabled ? " disabled" : "") + ">" + escapeHtml(draft) + "</textarea>"
14139
+ + "<textarea data-repl-quick-draft data-repl-quick-lifetime='" + escapeHtml(encodeURIComponent(key)) + "'" + (replQuickBoxHeight ? " style='height: " + replQuickBoxHeight + "'" : "") + " rows='3' maxlength='" + REPL_QUICK_DRAFT_MAX_CHARS + "' aria-label='Quick code for " + escapeHtml(targetDetails) + "' aria-keyshortcuts='Meta+Enter Control+Enter' placeholder='" + (activeSession ? (key ? "Type a short command or snippet…" : "Exact tmux identity unavailable; refresh the session list") : "Start or select a REPL session first") + "'" + (inputDisabled ? " disabled" : "") + ">" + escapeHtml(draft) + "</textarea>"
13956
14140
  + "<div class='repl-quick-actions'><span></span><div>"
13957
14141
  + "<button type='button' data-repl-action='quick-clear'" + (pending || !draft ? " disabled" : "") + ">Clear</button>"
13958
14142
  + "<button class='repl-quick-send-btn' type='button' data-repl-action='quick-send'" + (sendDisabled ? " disabled" : "") + ">" + (pending ? "Sending…" : "Send") + "</button>"
@@ -13985,11 +14169,11 @@
13985
14169
  : "<div class='repl-empty'>" + escapeHtml(emptyMessage) + "</div>";
13986
14170
  const canSendToActiveSession = Boolean(activeSession) && !replBusy && replTmuxAvailable !== false;
13987
14171
  const canStopActiveSession = Boolean(activeSession && activeSession.source === "studio" && !replBusy && replTmuxAvailable !== false);
13988
- return "<div class='repl-panel'>"
14172
+ return "<div class='repl-panel' data-repl-drawn-target='" + escapeHtml(encodeURIComponent(getReplPaneTargetKey())) + "'>"
13989
14173
  + "<div class='repl-toolbar'>"
13990
14174
  + "<div class='repl-controls'>"
13991
14175
  + "<label class='repl-control-label'>Runtime <select class='studio-flat-select' data-repl-runtime aria-label='REPL runtime'>" + runtimeOptions + "</select></label>"
13992
- + "<label class='repl-control-label repl-command-label'>Start cmd <input data-repl-command type='text' value='" + escapeHtml(replCommand) + "' placeholder='default' aria-label='REPL start command' title='Command used by Start for this runtime. Leave blank for the default; use this for envs, e.g. .venv/bin/python, uv run python, or conda run --no-capture-output -n env python.'></label>"
14176
+ + "<label class='repl-control-label repl-command-label'>Start cmd <input data-repl-command='" + escapeHtml(normalizeReplRuntime(replRuntime)) + "' type='text' value='" + escapeHtml(replCommand) + "' placeholder='default' aria-label='REPL start command' title='Command used by Start for this runtime. Leave blank for the default; use this for envs, e.g. .venv/bin/python, uv run python, or conda run --no-capture-output -n env python.'></label>"
13993
14177
  + "<button class='repl-start-btn' type='button' data-repl-action='start'" + (replBusy || replTmuxAvailable === false ? " disabled" : "") + " title='Start or switch to a " + escapeHtml(runtimeLabel) + " session using the selected runtime and start command.'>Start</button>"
13994
14178
  + "<label class='repl-control-label repl-session-label'>Session <select class='studio-flat-select' data-repl-session aria-label='REPL session'" + (visibleSessions.length ? "" : " disabled") + ">" + sessionOptions + "</select></label>"
13995
14179
  + "<details class='repl-more-controls'>"
package/client/studio.css CHANGED
@@ -6819,6 +6819,12 @@
6819
6819
  background: var(--studio-header-action-hover-bg, var(--panel-2));
6820
6820
  }
6821
6821
 
6822
+ /* Plain text needs no button width. The old 10rem left a gap before it and, on a
6823
+ 13-inch screen, pushed the tools onto a second row while running (Oliver, 19 Oct). */
6824
+ body.studio-ui-refresh #queueSteerBtn {
6825
+ min-width: 0;
6826
+ }
6827
+
6822
6828
  body.studio-ui-refresh .studio-refresh-review-btn.request-stop-active {
6823
6829
  background: color-mix(in srgb, var(--error) 82%, var(--panel));
6824
6830
  border-color: color-mix(in srgb, var(--error) 88%, var(--panel));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.10.4",
3
+ "version": "0.10.5",
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",
@@ -12,6 +12,32 @@ function isStudioNestedZedTerminal(env) {
12
12
  return zedTerm === "1" || zedTerm === "true";
13
13
  }
14
14
 
15
+ /**
16
+ * Muxy before 2.2 marked each pane with its ID and the app's socket.
17
+ *
18
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} env
19
+ */
20
+ function isStudioLegacyMuxyPane(env) {
21
+ const paneId = String(env.MUXY_PANE_ID ?? "").trim();
22
+ const socketPath = String(env.MUXY_SOCKET_PATH ?? "").trim();
23
+ return Boolean(paneId && socketPath);
24
+ }
25
+
26
+ /**
27
+ * Muxy 2.2 marks each terminal with its server's and session's IDs, and sets
28
+ * TERM_PROGRAM=muxy. Another terminal app opened from a Muxy pane inherits the IDs
29
+ * but sets its own TERM_PROGRAM (tmux sets "tmux"), so the IDs alone aren't enough,
30
+ * and TERM_PROGRAM alone isn't either.
31
+ *
32
+ * @param {NodeJS.ProcessEnv | Record<string, string | undefined>} env
33
+ */
34
+ function isStudioMuxyTerminal(env) {
35
+ const serverId = String(env.MUXY_SERVER_ID ?? "").trim();
36
+ const sessionId = String(env.MUXY_SESSION_ID ?? "").trim();
37
+ const termProgram = String(env.TERM_PROGRAM ?? "").trim().toLowerCase();
38
+ return Boolean(serverId && sessionId && termProgram === "muxy");
39
+ }
40
+
15
41
  /**
16
42
  * Detect whether the current process is running inside Muxy itself rather than
17
43
  * a nested application terminal that inherited Muxy's environment.
@@ -20,9 +46,8 @@ function isStudioNestedZedTerminal(env) {
20
46
  * @returns {boolean}
21
47
  */
22
48
  export function isStudioMuxySession(env = process.env) {
23
- const paneId = String(env.MUXY_PANE_ID ?? "").trim();
24
- const socketPath = String(env.MUXY_SOCKET_PATH ?? "").trim();
25
- return Boolean(paneId && socketPath && !isStudioNestedZedTerminal(env));
49
+ if (isStudioNestedZedTerminal(env)) return false;
50
+ return isStudioLegacyMuxyPane(env) || isStudioMuxyTerminal(env);
26
51
  }
27
52
 
28
53
  /**