pi-studio 0.5.6 → 0.5.8

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
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to `pi-studio` are documented here.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [0.5.8] — 2026-03-12
8
+
9
+ ### Changed
10
+ - Studio browser tabs now auto-reconnect after unexpected websocket disconnects (for example transient local connection loss or sleep/wake), while intentional invalidation/shutdown still requires a fresh `/studio`.
11
+ - Same-tab reconnect now preserves the currently selected response-history item instead of jumping back to the latest response on every `hello_ack` resync.
12
+
13
+ ## [0.5.7] — 2026-03-12
14
+
15
+ ### Changed
16
+ - Preview rendering now passes `--wrap=none` to pandoc and preview-side annotation matching now tolerates embedded newlines, fixing missed `[an: ...]` highlights in preview for longer annotations.
17
+ - Editor sync indicator is now intentionally quiet: Studio only shows the badge when the editor exactly matches the current response/thinking, and hides it while drafting/out-of-sync.
18
+ - Response history navigation now includes **Last response ▶|** for jumping straight back to the newest loaded history item.
19
+ - Renamed **Get latest response** to **Fetch latest response** for clearer distinction from history navigation, and moved **Load response into editor** ahead of **Load response prompt into editor** in the action row.
20
+
5
21
  ## [0.4.3] — 2026-03-04
6
22
 
7
23
  ### Added
@@ -86,8 +102,6 @@ All notable changes to `pi-studio` are documented here.
86
102
  - Active pane indicator simplified to subtle border color change (removed thick top accent bar).
87
103
  - Panel shadows, button hierarchy (filled accent for primary actions), heading scale, blockquote/table styling improvements.
88
104
 
89
- ## [Unreleased]
90
-
91
105
  ## [0.5.6] — 2026-03-10
92
106
 
93
107
  ### Changed
package/README.md CHANGED
@@ -16,7 +16,7 @@ Experimental extension for [pi](https://github.com/badlogic/pi-mono) that opens
16
16
 
17
17
  - Opens a two-pane browser workspace: **Editor** (left) + **Response/Thinking/Editor Preview** (right)
18
18
  - Runs editor text directly, or asks for structured critique (auto/writing/code focus)
19
- - Browses response history (`Prev/Next`) and loads either:
19
+ - Browses response history (`Prev/Next/Last`) and loads either:
20
20
  - response text
21
21
  - critique notes/full critique
22
22
  - assistant thinking (when available)
package/WORKFLOW.md CHANGED
@@ -79,10 +79,10 @@ Rules:
79
79
  - Header view toggles: `Left: Editor (Raw|Preview)`, `Right: Response (Raw|Preview) | Editor (Preview)`
80
80
  - Preview mode uses server-side `pandoc` rendering (math-aware) with plain-markdown fallback when renderer is unavailable.
81
81
  - Editor actions: **Insert/Remove annotated reply header**, **Annotations: On|Hidden**, **Strip annotations…**, **Run editor text**, **Critique editor text** (+ critique focus), **Send to pi editor**, **Copy editor text**, **Save .annotated.md**
82
- - Response actions include `Auto-update response: On|Off`, **Get latest response**, response-history browse (`Prev/Next`), and **Load response prompt into editor**
82
+ - Response actions include `Auto-update response: On|Off`, **Fetch latest response**, response-history browse (`Prev/Next/Last`), **Load response into editor**, and **Load response prompt into editor**
83
83
  - Source badge: `blank | last model response | file <path> | upload`
84
84
  - Response badge: `none | assistant response | assistant critique` (+ timestamp)
85
- - Sync badge: `No response loaded | In sync with response | Edited since response`
85
+ - Sync badge: shown only when the editor exactly matches the currently viewed response/thinking (`In sync with response | In sync with thinking`)
86
86
  - Footer WS/status phases: `Connecting`, `Ready`, `Submitting`, `Disconnected`
87
87
 
88
88
  ---
package/index.ts CHANGED
@@ -1070,7 +1070,7 @@ function normalizeObsidianImages(markdown: string): string {
1070
1070
  async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string): Promise<string> {
1071
1071
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
1072
1072
  const inputFormat = isLatex ? "latex" : "gfm+tex_math_dollars-raw_html";
1073
- const args = ["-f", inputFormat, "-t", "html5", "--mathml"];
1073
+ const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none"];
1074
1074
  if (resourcePath) {
1075
1075
  args.push(`--resource-path=${resourcePath}`);
1076
1076
  // Embed images as data URIs so they render in the browser preview
@@ -2312,13 +2312,9 @@ ${cssVarsBlock}
2312
2312
  }
2313
2313
 
2314
2314
  .sync-badge.sync {
2315
- border-color: var(--ok-border);
2316
- color: var(--ok);
2317
- }
2318
-
2319
- .sync-badge.edited {
2320
- border-color: var(--warn-border);
2321
- color: var(--warn);
2315
+ border-color: var(--border-muted);
2316
+ color: var(--muted);
2317
+ opacity: 0.88;
2322
2318
  }
2323
2319
 
2324
2320
  .source-actions {
@@ -3082,7 +3078,7 @@ ${cssVarsBlock}
3082
3078
  <input id="resourceDirInput" type="text" placeholder="/path/to/working/directory" title="Absolute path to working directory" />
3083
3079
  <button id="resourceDirClearBtn" type="button" title="Clear working directory">✕</button>
3084
3080
  </span>
3085
- <span id="syncBadge" class="source-badge sync-badge">No response loaded</span>
3081
+ <span id="syncBadge" class="source-badge sync-badge" hidden>In sync with response</span>
3086
3082
  </div>
3087
3083
  <div class="source-actions">
3088
3084
  <div class="source-actions-row">
@@ -3181,16 +3177,17 @@ ${cssVarsBlock}
3181
3177
  </select>
3182
3178
  </div>
3183
3179
  <div class="response-actions-row history-row">
3184
- <button id="pullLatestBtn" type="button" title="Fetch the latest assistant response when auto-update is off.">Get latest response</button>
3180
+ <button id="pullLatestBtn" type="button" title="Fetch the latest assistant response when auto-update is off.">Fetch latest response</button>
3185
3181
  <button id="historyPrevBtn" type="button" title="Show previous response in history.">◀ Prev response</button>
3186
3182
  <span id="historyIndexBadge" class="source-badge">History: 0/0</span>
3187
3183
  <button id="historyNextBtn" type="button" title="Show next response in history.">Next response ▶</button>
3184
+ <button id="historyLastBtn" type="button" title="Jump to the latest loaded response in history.">Last response ▶|</button>
3188
3185
  </div>
3189
3186
  <div class="response-actions-row">
3190
- <button id="loadHistoryPromptBtn" type="button" title="Load the prompt that generated the selected response into the editor.">Load response prompt into editor</button>
3191
3187
  <button id="loadResponseBtn" type="button">Load response into editor</button>
3192
3188
  <button id="loadCritiqueNotesBtn" type="button" hidden>Load critique notes into editor</button>
3193
3189
  <button id="loadCritiqueFullBtn" type="button" hidden>Load full critique into editor</button>
3190
+ <button id="loadHistoryPromptBtn" type="button" title="Load the prompt that generated the selected response into the editor.">Load response prompt into editor</button>
3194
3191
  <button id="copyResponseBtn" type="button">Copy response text</button>
3195
3192
  </div>
3196
3193
  </div>
@@ -3278,6 +3275,7 @@ ${cssVarsBlock}
3278
3275
  const exportPdfBtn = document.getElementById("exportPdfBtn");
3279
3276
  const historyPrevBtn = document.getElementById("historyPrevBtn");
3280
3277
  const historyNextBtn = document.getElementById("historyNextBtn");
3278
+ const historyLastBtn = document.getElementById("historyLastBtn");
3281
3279
  const historyIndexBadgeEl = document.getElementById("historyIndexBadge");
3282
3280
  const loadHistoryPromptBtn = document.getElementById("loadHistoryPromptBtn");
3283
3281
  const saveAsBtn = document.getElementById("saveAsBtn");
@@ -3303,6 +3301,8 @@ ${cssVarsBlock}
3303
3301
  let wsState = "Connecting";
3304
3302
  let statusMessage = "Connecting · Studio script starting…";
3305
3303
  let statusLevel = "";
3304
+ let reconnectTimer = null;
3305
+ let reconnectAttempt = 0;
3306
3306
  let pendingRequestId = null;
3307
3307
  let pendingKind = null;
3308
3308
  let stickyStudioKind = null;
@@ -3419,7 +3419,7 @@ ${cssVarsBlock}
3419
3419
  let responseHighlightEnabled = false;
3420
3420
  let editorHighlightRenderRaf = null;
3421
3421
  let annotationsEnabled = true;
3422
- const ANNOTATION_MARKER_REGEX = /\\[an:\\s*([^\\]\\n]+?)\\]/gi;
3422
+ const ANNOTATION_MARKER_REGEX = /\\[an:\\s*([^\\]]+?)\\]/gi;
3423
3423
  const EMPTY_OVERLAY_LINE = "\\u200b";
3424
3424
  const MERMAID_CDN_URL = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
3425
3425
  const MERMAID_CONFIG = ${JSON.stringify(mermaidConfig)};
@@ -3992,6 +3992,9 @@ ${cssVarsBlock}
3992
3992
  if (historyNextBtn) {
3993
3993
  historyNextBtn.disabled = uiBusy || total <= 1 || responseHistoryIndex < 0 || responseHistoryIndex >= total - 1;
3994
3994
  }
3995
+ if (historyLastBtn) {
3996
+ historyLastBtn.disabled = uiBusy || total <= 1 || responseHistoryIndex < 0 || responseHistoryIndex >= total - 1;
3997
+ }
3995
3998
 
3996
3999
  const selectedItem = getSelectedHistoryItem();
3997
4000
  const hasPrompt = Boolean(selectedItem && typeof selectedItem.prompt === "string" && selectedItem.prompt.trim());
@@ -4168,8 +4171,9 @@ ${cssVarsBlock}
4168
4171
  : latestResponseHasContent;
4169
4172
 
4170
4173
  if (!hasComparableContent) {
4171
- syncBadgeEl.textContent = showingThinking ? "No thinking loaded" : "No response loaded";
4172
- syncBadgeEl.classList.remove("sync", "edited");
4174
+ syncBadgeEl.hidden = true;
4175
+ syncBadgeEl.textContent = showingThinking ? "In sync with thinking" : "In sync with response";
4176
+ syncBadgeEl.classList.remove("sync");
4173
4177
  return;
4174
4178
  }
4175
4179
 
@@ -4178,15 +4182,15 @@ ${cssVarsBlock}
4178
4182
  : normalizeForCompare(sourceTextEl.value);
4179
4183
  const targetNormalized = showingThinking ? latestResponseThinkingNormalized : latestResponseNormalized;
4180
4184
  const inSync = normalizedEditor === targetNormalized;
4185
+ syncBadgeEl.hidden = !inSync;
4186
+ syncBadgeEl.textContent = showingThinking ? "In sync with thinking" : "In sync with response";
4187
+
4181
4188
  if (inSync) {
4182
- syncBadgeEl.textContent = showingThinking ? "In sync with thinking" : "In sync with response";
4183
4189
  syncBadgeEl.classList.add("sync");
4184
- syncBadgeEl.classList.remove("edited");
4185
- } else {
4186
- syncBadgeEl.textContent = showingThinking ? "Out of sync with thinking" : "Out of sync with response";
4187
- syncBadgeEl.classList.add("edited");
4188
- syncBadgeEl.classList.remove("sync");
4190
+ return;
4189
4191
  }
4192
+
4193
+ syncBadgeEl.classList.remove("sync");
4190
4194
  }
4191
4195
 
4192
4196
  function buildPlainMarkdownHtml(markdown) {
@@ -4818,7 +4822,7 @@ ${cssVarsBlock}
4818
4822
  }
4819
4823
 
4820
4824
  pullLatestBtn.disabled = uiBusy || followLatest;
4821
- pullLatestBtn.textContent = queuedLatestResponse ? "Get latest response *" : "Get latest response";
4825
+ pullLatestBtn.textContent = queuedLatestResponse ? "Fetch latest response *" : "Fetch latest response";
4822
4826
 
4823
4827
  updateSyncBadge(normalizedEditor);
4824
4828
  }
@@ -5029,7 +5033,7 @@ ${cssVarsBlock}
5029
5033
 
5030
5034
  function highlightInlineMarkdown(text) {
5031
5035
  const source = String(text || "");
5032
- const pattern = /(\\x60[^\\x60]*\\x60)|(\\[[^\\]]+\\]\\([^)]+\\))|(\\[an:\\s*[^\\]\\n]+\\])/gi;
5036
+ const pattern = /(\\x60[^\\x60]*\\x60)|(\\[[^\\]]+\\]\\([^)]+\\))|(\\[an:\\s*[^\\]]+\\])/gi;
5033
5037
  let lastIndex = 0;
5034
5038
  let out = "";
5035
5039
 
@@ -5822,8 +5826,8 @@ ${cssVarsBlock}
5822
5826
  let appliedHistory = false;
5823
5827
  if (Array.isArray(message.responseHistory)) {
5824
5828
  appliedHistory = setResponseHistory(message.responseHistory, {
5825
- autoSelectLatest: true,
5826
- preserveSelection: false,
5829
+ autoSelectLatest: !initialDocumentApplied,
5830
+ preserveSelection: initialDocumentApplied,
5827
5831
  silent: true,
5828
5832
  });
5829
5833
  }
@@ -5967,7 +5971,7 @@ ${cssVarsBlock}
5967
5971
  if (!followLatest) {
5968
5972
  queuedLatestResponse = payload;
5969
5973
  updateResultActionButtons();
5970
- setStatus("New response available — click Get latest response.", "warning");
5974
+ setStatus("New response available — click Fetch latest response.", "warning");
5971
5975
  return;
5972
5976
  }
5973
5977
 
@@ -6182,7 +6186,42 @@ ${cssVarsBlock}
6182
6186
  }
6183
6187
  }
6184
6188
 
6189
+ function clearScheduledReconnect() {
6190
+ if (reconnectTimer !== null) {
6191
+ window.clearTimeout(reconnectTimer);
6192
+ reconnectTimer = null;
6193
+ }
6194
+ }
6195
+
6196
+ function formatReconnectDelay(delayMs) {
6197
+ const delay = Math.max(0, Number(delayMs) || 0);
6198
+ if (delay < 1000) return delay + "ms";
6199
+ const seconds = delay / 1000;
6200
+ return (Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(1)) + "s";
6201
+ }
6202
+
6203
+ function scheduleReconnect(reasonMessage) {
6204
+ if (reconnectTimer !== null) return;
6205
+
6206
+ reconnectAttempt += 1;
6207
+ const delayMs = Math.min(8000, 600 * Math.pow(2, Math.max(0, reconnectAttempt - 1)));
6208
+ setBusy(true);
6209
+ setWsState("Connecting");
6210
+ setStatus((reasonMessage || "Connection lost.") + " Reconnecting in " + formatReconnectDelay(delayMs) + "…", "warning");
6211
+
6212
+ reconnectTimer = window.setTimeout(() => {
6213
+ reconnectTimer = null;
6214
+ connect();
6215
+ }, delayMs);
6216
+ }
6217
+
6185
6218
  function connect() {
6219
+ clearScheduledReconnect();
6220
+
6221
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
6222
+ return;
6223
+ }
6224
+
6186
6225
  const token = getToken();
6187
6226
  if (!token) {
6188
6227
  setWsState("Disconnected");
@@ -6193,26 +6232,61 @@ ${cssVarsBlock}
6193
6232
 
6194
6233
  const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
6195
6234
  const wsUrl = wsProtocol + "://" + window.location.host + "/ws?token=" + encodeURIComponent(token) + (DEBUG_ENABLED ? "&debug=1" : "");
6235
+ const wasReconnect = reconnectAttempt > 0;
6236
+ let disconnectHandled = false;
6196
6237
 
6197
6238
  setWsState("Connecting");
6198
- setStatus("Connecting to Studio server…");
6199
- ws = new WebSocket(wsUrl);
6239
+ setStatus(wasReconnect ? "Reconnecting to Studio server…" : "Connecting to Studio server…");
6240
+ const socket = new WebSocket(wsUrl);
6241
+ ws = socket;
6200
6242
 
6201
6243
  const connectWatchdog = window.setTimeout(() => {
6202
- if (ws && ws.readyState === WebSocket.CONNECTING) {
6244
+ if (ws === socket && socket.readyState === WebSocket.CONNECTING) {
6203
6245
  setWsState("Connecting");
6204
- setStatus("Still connecting…", "warning");
6246
+ setStatus(wasReconnect ? "Still reconnecting…" : "Still connecting…", "warning");
6205
6247
  }
6206
6248
  }, 3000);
6207
6249
 
6208
- ws.addEventListener("open", () => {
6250
+ const handleDisconnect = (kind, code) => {
6251
+ if (disconnectHandled) return;
6252
+ disconnectHandled = true;
6253
+ window.clearTimeout(connectWatchdog);
6254
+ if (ws === socket) {
6255
+ ws = null;
6256
+ }
6257
+ setBusy(true);
6258
+
6259
+ if (kind === "invalidated") {
6260
+ clearScheduledReconnect();
6261
+ reconnectAttempt = 0;
6262
+ setWsState("Disconnected");
6263
+ setStatus("This tab was invalidated by a newer /studio session.", "warning");
6264
+ return;
6265
+ }
6266
+
6267
+ if (kind === "shutdown") {
6268
+ clearScheduledReconnect();
6269
+ reconnectAttempt = 0;
6270
+ setWsState("Disconnected");
6271
+ setStatus("Studio server shut down. Re-run /studio.", "warning");
6272
+ return;
6273
+ }
6274
+
6275
+ const detail = typeof code === "number" && code > 0
6276
+ ? "Disconnected (code " + code + ")."
6277
+ : (kind === "error" ? "WebSocket error." : "Connection lost.");
6278
+ scheduleReconnect(detail);
6279
+ };
6280
+
6281
+ socket.addEventListener("open", () => {
6209
6282
  window.clearTimeout(connectWatchdog);
6210
6283
  setWsState("Ready");
6211
- setStatus("Connected. Syncing…");
6284
+ setStatus(wasReconnect ? "Reconnected. Syncing…" : "Connected. Syncing…");
6212
6285
  sendMessage({ type: "hello" });
6286
+ reconnectAttempt = 0;
6213
6287
  });
6214
6288
 
6215
- ws.addEventListener("message", (event) => {
6289
+ socket.addEventListener("message", (event) => {
6216
6290
  try {
6217
6291
  const message = JSON.parse(event.data);
6218
6292
  handleServerMessage(message);
@@ -6222,22 +6296,21 @@ ${cssVarsBlock}
6222
6296
  }
6223
6297
  });
6224
6298
 
6225
- ws.addEventListener("close", (event) => {
6226
- window.clearTimeout(connectWatchdog);
6227
- setBusy(true);
6228
- setWsState("Disconnected");
6299
+ socket.addEventListener("close", (event) => {
6229
6300
  if (event && event.code === 4001) {
6230
- setStatus("This tab was invalidated by a newer /studio session.", "warning");
6231
- } else {
6232
- const code = event && typeof event.code === "number" ? event.code : 0;
6233
- setStatus("Disconnected (code " + code + "). Re-run /studio.", "error");
6301
+ handleDisconnect("invalidated", 4001);
6302
+ return;
6234
6303
  }
6304
+ if (event && event.code === 1001) {
6305
+ handleDisconnect("shutdown", 1001);
6306
+ return;
6307
+ }
6308
+ const code = event && typeof event.code === "number" ? event.code : 0;
6309
+ handleDisconnect("close", code);
6235
6310
  });
6236
6311
 
6237
- ws.addEventListener("error", () => {
6238
- window.clearTimeout(connectWatchdog);
6239
- setWsState("Disconnected");
6240
- setStatus("WebSocket error. Check /studio --status and reopen.", "error");
6312
+ socket.addEventListener("error", () => {
6313
+ handleDisconnect("error");
6241
6314
  });
6242
6315
  }
6243
6316
 
@@ -6342,7 +6415,7 @@ ${cssVarsBlock}
6342
6415
  function requestLatestResponse() {
6343
6416
  const sent = sendMessage({ type: "get_latest_response" });
6344
6417
  if (!sent) return;
6345
- setStatus("Requested latest response.");
6418
+ setStatus("Fetching latest response");
6346
6419
  }
6347
6420
 
6348
6421
  if (leftPaneEl) {
@@ -6380,7 +6453,7 @@ ${cssVarsBlock}
6380
6453
  setStatus("Applied queued response.", "success");
6381
6454
  }
6382
6455
  } else if (!followLatest) {
6383
- setStatus("Auto-update is off. Use Get latest response.");
6456
+ setStatus("Auto-update is off. Use Fetch latest response.");
6384
6457
  }
6385
6458
  updateResultActionButtons();
6386
6459
  });
@@ -6464,6 +6537,16 @@ ${cssVarsBlock}
6464
6537
  });
6465
6538
  }
6466
6539
 
6540
+ if (historyLastBtn) {
6541
+ historyLastBtn.addEventListener("click", () => {
6542
+ if (!responseHistory.length) {
6543
+ setStatus("No response history available yet.", "warning");
6544
+ return;
6545
+ }
6546
+ selectHistoryIndex(responseHistory.length - 1);
6547
+ });
6548
+ }
6549
+
6467
6550
  if (loadHistoryPromptBtn) {
6468
6551
  loadHistoryPromptBtn.addEventListener("click", () => {
6469
6552
  const item = getSelectedHistoryItem();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "Browser GUI for structured critique workflows in pi",
5
5
  "type": "module",
6
6
  "license": "MIT",