pi-studio 0.4.0 → 0.4.2

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,30 @@
2
2
 
3
3
  All notable changes to `pi-studio` are documented here.
4
4
 
5
+ ## [0.4.2] — 2026-03-03
6
+
7
+ ### Added
8
+ - New editor action: **Load from pi editor** to pull the current terminal editor draft into Studio.
9
+ - Optional Studio debug tracing (`?debug=1`) with client/server lifecycle events for request/state/tool diagnostics.
10
+
11
+ ### Changed
12
+ - Footer busy status now reflects Studio-owned and terminal-owned activity phases more clearly (`running`, `tool`, `responding`).
13
+ - Tool activity labels are derived from tool calls/executions with improved command classification for shell workflows (including current/parent directory listings and listing-like `find` commands).
14
+ - Studio request ownership remains sticky during active/agent-busy phases to avoid confusing Studio → Terminal label flips mid-turn.
15
+ - Editor and response preview panes keep previous rendered content visible while a new render is in flight, using a subtle delayed **Updating** indicator instead of replacing content with a loading screen.
16
+ - Footer shortcut hint and run-button tooltip now explicitly document `Cmd/Ctrl+Enter` for **Run editor text**.
17
+
18
+ ### Fixed
19
+ - Studio requests are no longer cleared prematurely when assistant messages end with `stopReason: "toolUse"`.
20
+ - Embedded-script activity label normalization now preserves whitespace correctly (fixes corrupted labels caused by escaped regex mismatch).
21
+
22
+ ## [0.4.1] — 2026-03-03
23
+
24
+ ### Changed
25
+ - Editor input keeps preview refreshes immediate (no added typing debounce) while keeping editor syntax highlighting immediate in Raw view.
26
+ - Response/sync state checks now reuse cached normalized response data and critique-note extracts instead of recomputing on each keystroke.
27
+ - Editor action/sync UI updates are now coalesced with `requestAnimationFrame` during typing.
28
+
5
29
  ## [0.3.0] — 2026-03-02
6
30
 
7
31
  ### Added
package/README.md CHANGED
@@ -49,9 +49,9 @@ Experimental extension for [pi](https://github.com/badlogic/pi-mono) that opens
49
49
  - Response load helpers:
50
50
  - non-critique: **Load response into editor**
51
51
  - critique: **Load critique notes into editor** / **Load full critique into editor**
52
- - File actions: **Save editor as…**, **Save editor**, **Load file content**
52
+ - File/editor actions: **Save editor as…**, **Save editor**, **Load file content**, **Send to pi editor**, **Load from pi editor**
53
53
  - View toggles: panel header dropdowns for `Editor (Raw|Preview)` and `Response (Raw|Preview) | Editor (Preview)`
54
- - **Editor Preview in response pane**: side-by-side source/rendered view (Overleaf-style) — select `Right: Editor (Preview)` to render editor text in the right pane with live debounced updates
54
+ - **Editor Preview in response pane**: side-by-side source/rendered view (Overleaf-style) — select `Right: Editor (Preview)` to render editor text in the right pane with live updates
55
55
  - Preview mode supports MathML equations and Mermaid fenced diagrams
56
56
  - **Language-aware syntax highlighting** with selectable language mode:
57
57
  - Markdown (default): headings, links, code fences, lists, quotes, inline code
@@ -67,6 +67,8 @@ Experimental extension for [pi](https://github.com/badlogic/pi-mono) that opens
67
67
  - **Working directory**: "Set working dir" button for uploaded files — resolves relative image paths and enables "Save editor" for uploaded content
68
68
  - **Live theme sync**: changing the pi theme in the terminal updates the studio browser UI automatically (polled every 2 seconds)
69
69
  - Separate syntax highlight toggles for editor and response Raw views, with local preference persistence
70
+ - Keyboard shortcuts: `Cmd/Ctrl+Enter` runs **Run editor text** when editor pane is active; `Cmd/Ctrl+Esc` / `F10` toggles focus mode; `Esc` exits focus mode
71
+ - Footer status reflects Studio/terminal activity phases (connecting, ready, submitting, terminal activity)
70
72
  - Theme-aware browser UI derived from current pi theme
71
73
  - View mode selectors integrated into panel headers for a cleaner layout
72
74
 
package/index.ts CHANGED
@@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { readFileSync, statSync, writeFileSync } from "node:fs";
5
5
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
6
- import { dirname, isAbsolute, join, resolve } from "node:path";
6
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
7
7
  import { URL } from "node:url";
8
8
  import { WebSocketServer, WebSocket, type RawData } from "ws";
9
9
 
@@ -11,6 +11,7 @@ type Lens = "writing" | "code";
11
11
  type RequestedLens = Lens | "auto";
12
12
  type StudioRequestKind = "critique" | "annotation" | "direct";
13
13
  type StudioSourceKind = "file" | "last-response" | "blank";
14
+ type TerminalActivityPhase = "idle" | "running" | "tool" | "responding";
14
15
 
15
16
  interface StudioServerState {
16
17
  server: Server;
@@ -90,6 +91,11 @@ interface SendToEditorRequestMessage {
90
91
  content: string;
91
92
  }
92
93
 
94
+ interface GetFromEditorRequestMessage {
95
+ type: "get_from_editor_request";
96
+ requestId: string;
97
+ }
98
+
93
99
  type IncomingStudioMessage =
94
100
  | HelloMessage
95
101
  | PingMessage
@@ -99,7 +105,8 @@ type IncomingStudioMessage =
99
105
  | SendRunRequestMessage
100
106
  | SaveAsRequestMessage
101
107
  | SaveOverRequestMessage
102
- | SendToEditorRequestMessage;
108
+ | SendToEditorRequestMessage
109
+ | GetFromEditorRequestMessage;
103
110
 
104
111
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
105
112
  const PREVIEW_RENDER_MAX_CHARS = 400_000;
@@ -1133,9 +1140,146 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
1133
1140
  };
1134
1141
  }
1135
1142
 
1143
+ if (msg.type === "get_from_editor_request" && typeof msg.requestId === "string") {
1144
+ return {
1145
+ type: "get_from_editor_request",
1146
+ requestId: msg.requestId,
1147
+ };
1148
+ }
1149
+
1136
1150
  return null;
1137
1151
  }
1138
1152
 
1153
+ function normalizeActivityLabel(label: string): string | null {
1154
+ const compact = String(label || "").replace(/\s+/g, " ").trim();
1155
+ if (!compact) return null;
1156
+ if (compact.length <= 96) return compact;
1157
+ return `${compact.slice(0, 93).trimEnd()}…`;
1158
+ }
1159
+
1160
+ function isGenericToolActivityLabel(label: string | null | undefined): boolean {
1161
+ const normalized = String(label || "").trim().toLowerCase();
1162
+ if (!normalized) return true;
1163
+ return normalized.startsWith("running ")
1164
+ || normalized === "reading file"
1165
+ || normalized === "writing file"
1166
+ || normalized === "editing file";
1167
+ }
1168
+
1169
+ function deriveBashActivityLabel(command: string): string | null {
1170
+ const normalized = String(command || "").trim();
1171
+ if (!normalized) return null;
1172
+ const lower = normalized.toLowerCase();
1173
+
1174
+ const segments = lower
1175
+ .split(/(?:&&|\|\||;|\n)+/g)
1176
+ .map((segment) => segment.trim())
1177
+ .filter((segment) => segment.length > 0);
1178
+
1179
+ let hasPwd = false;
1180
+ let hasLsCurrent = false;
1181
+ let hasLsParent = false;
1182
+ let hasFind = false;
1183
+ let hasFindCurrentListing = false;
1184
+ let hasFindParentListing = false;
1185
+
1186
+ for (const segment of segments) {
1187
+ if (/\bpwd\b/.test(segment)) hasPwd = true;
1188
+
1189
+ if (/\bls\b/.test(segment)) {
1190
+ if (/\.\./.test(segment)) hasLsParent = true;
1191
+ else hasLsCurrent = true;
1192
+ }
1193
+
1194
+ if (/\bfind\b/.test(segment)) {
1195
+ hasFind = true;
1196
+ const pathMatch = segment.match(/\bfind\s+([^\s]+)/);
1197
+ const pathToken = pathMatch ? pathMatch[1] : "";
1198
+ const hasSelector = /-(?:name|iname|regex|path|ipath|newer|mtime|mmin|size|user|group)\b/.test(segment);
1199
+ const listingLike = /-maxdepth\s+\d+\b/.test(segment) && !hasSelector;
1200
+
1201
+ if (listingLike) {
1202
+ if (pathToken === ".." || pathToken === "../") {
1203
+ hasFindParentListing = true;
1204
+ } else if (pathToken === "." || pathToken === "./" || pathToken === "") {
1205
+ hasFindCurrentListing = true;
1206
+ }
1207
+ }
1208
+ }
1209
+ }
1210
+
1211
+ const hasCurrentListing = hasLsCurrent || hasFindCurrentListing;
1212
+ const hasParentListing = hasLsParent || hasFindParentListing;
1213
+
1214
+ if (hasCurrentListing && hasParentListing) {
1215
+ return "Listing directory and parent directory files";
1216
+ }
1217
+ if (hasPwd && hasCurrentListing) {
1218
+ return "Listing current directory files";
1219
+ }
1220
+ if (hasParentListing) {
1221
+ return "Listing parent directory files";
1222
+ }
1223
+ if (hasCurrentListing || /\bls\b/.test(lower)) {
1224
+ return "Listing directory files";
1225
+ }
1226
+ if (hasFind || /\bfind\b/.test(lower)) {
1227
+ return "Searching files";
1228
+ }
1229
+ if (/\brg\b/.test(lower) || /\bgrep\b/.test(lower)) {
1230
+ return "Searching text in files";
1231
+ }
1232
+ if (/\bcat\b/.test(lower) || /\bsed\b/.test(lower) || /\bawk\b/.test(lower)) {
1233
+ return "Reading file content";
1234
+ }
1235
+ if (/\bgit\s+status\b/.test(lower)) {
1236
+ return "Checking git status";
1237
+ }
1238
+ if (/\bgit\s+diff\b/.test(lower)) {
1239
+ return "Reviewing git changes";
1240
+ }
1241
+ if (/\bgit\b/.test(lower)) {
1242
+ return "Running git command";
1243
+ }
1244
+ if (/\bnpm\b/.test(lower)) {
1245
+ return "Running npm command";
1246
+ }
1247
+ if (/\bpython3?\b/.test(lower)) {
1248
+ return "Running Python command";
1249
+ }
1250
+ if (/\bnode\b/.test(lower)) {
1251
+ return "Running Node.js command";
1252
+ }
1253
+ return "Running shell command";
1254
+ }
1255
+
1256
+ function deriveToolActivityLabel(toolName: string, args: unknown): string | null {
1257
+ const normalizedTool = String(toolName || "").trim().toLowerCase();
1258
+ const payload = (args && typeof args === "object") ? (args as Record<string, unknown>) : {};
1259
+
1260
+ if (normalizedTool === "bash") {
1261
+ const command = typeof payload.command === "string" ? payload.command : "";
1262
+ return deriveBashActivityLabel(command);
1263
+ }
1264
+ if (normalizedTool === "read") {
1265
+ const path = typeof payload.path === "string" ? payload.path : "";
1266
+ return path ? `Reading ${basename(path)}` : "Reading file";
1267
+ }
1268
+ if (normalizedTool === "write") {
1269
+ const path = typeof payload.path === "string" ? payload.path : "";
1270
+ return path ? `Writing ${basename(path)}` : "Writing file";
1271
+ }
1272
+ if (normalizedTool === "edit") {
1273
+ const path = typeof payload.path === "string" ? payload.path : "";
1274
+ return path ? `Editing ${basename(path)}` : "Editing file";
1275
+ }
1276
+ if (normalizedTool === "find") return "Searching files";
1277
+ if (normalizedTool === "grep") return "Searching text in files";
1278
+ if (normalizedTool === "ls") return "Listing directory files";
1279
+
1280
+ return normalizeActivityLabel(`Running ${normalizedTool || "tool"}`);
1281
+ }
1282
+
1139
1283
  function isAllowedOrigin(_origin: string | undefined, _port: number): boolean {
1140
1284
  // For local-only studio, token auth is the primary guard. In practice,
1141
1285
  // browser origin headers can vary (or be omitted) across wrappers/browsers,
@@ -1696,6 +1840,7 @@ ${cssVarsBlock}
1696
1840
  }
1697
1841
 
1698
1842
  .panel-scroll {
1843
+ position: relative;
1699
1844
  min-height: 0;
1700
1845
  overflow: auto;
1701
1846
  padding: 12px;
@@ -1968,6 +2113,19 @@ ${cssVarsBlock}
1968
2113
  font-style: italic;
1969
2114
  }
1970
2115
 
2116
+ .panel-scroll.preview-pending::after {
2117
+ content: "Updating";
2118
+ position: absolute;
2119
+ top: 10px;
2120
+ right: 12px;
2121
+ color: var(--muted);
2122
+ font-size: 10px;
2123
+ line-height: 1.2;
2124
+ letter-spacing: 0.01em;
2125
+ pointer-events: none;
2126
+ opacity: 0.64;
2127
+ }
2128
+
1971
2129
  .preview-error {
1972
2130
  color: var(--warn);
1973
2131
  margin-bottom: 0.75em;
@@ -2089,7 +2247,7 @@ ${cssVarsBlock}
2089
2247
  <span id="syncBadge" class="source-badge sync-badge">No response loaded</span>
2090
2248
  </div>
2091
2249
  <div class="source-actions">
2092
- <button id="sendRunBtn" type="button" title="Send editor text directly to the model as-is.">Run editor text</button>
2250
+ <button id="sendRunBtn" type="button" title="Send editor text directly to the model as-is. Shortcut: Cmd/Ctrl+Enter when editor pane is active.">Run editor text</button>
2093
2251
  <button id="insertHeaderBtn" type="button" title="Prepends/updates the annotated-reply header in the editor.">Insert annotation header</button>
2094
2252
  <select id="lensSelect" aria-label="Critique focus">
2095
2253
  <option value="auto" selected>Critique focus: Auto</option>
@@ -2098,6 +2256,7 @@ ${cssVarsBlock}
2098
2256
  </select>
2099
2257
  <button id="critiqueBtn" type="button">Critique editor text</button>
2100
2258
  <button id="sendEditorBtn" type="button">Send to pi editor</button>
2259
+ <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
2101
2260
  <button id="copyDraftBtn" type="button">Copy editor text</button>
2102
2261
  <select id="highlightSelect" aria-label="Editor syntax highlighting">
2103
2262
  <option value="off">Syntax highlight: Off</option>
@@ -2175,7 +2334,7 @@ ${cssVarsBlock}
2175
2334
 
2176
2335
  <footer>
2177
2336
  <span id="status">Booting studio…</span>
2178
- <span class="shortcut-hint">Focus pane: Cmd/Ctrl+Esc (or F10), Esc to exit</span>
2337
+ <span class="shortcut-hint">Focus pane: Cmd/Ctrl+Esc (or F10), Esc to exit · Run editor text: Cmd/Ctrl+Enter</span>
2179
2338
  </footer>
2180
2339
 
2181
2340
  <!-- Defer sanitizer script so studio can boot/connect even if CDN is slow or blocked. -->
@@ -2235,6 +2394,7 @@ ${cssVarsBlock}
2235
2394
  const saveAsBtn = document.getElementById("saveAsBtn");
2236
2395
  const saveOverBtn = document.getElementById("saveOverBtn");
2237
2396
  const sendEditorBtn = document.getElementById("sendEditorBtn");
2397
+ const getEditorBtn = document.getElementById("getEditorBtn");
2238
2398
  const sendRunBtn = document.getElementById("sendRunBtn");
2239
2399
  const copyDraftBtn = document.getElementById("copyDraftBtn");
2240
2400
  const highlightSelect = document.getElementById("highlightSelect");
@@ -2252,6 +2412,7 @@ ${cssVarsBlock}
2252
2412
  let statusLevel = "";
2253
2413
  let pendingRequestId = null;
2254
2414
  let pendingKind = null;
2415
+ let stickyStudioKind = null;
2255
2416
  let initialDocumentApplied = false;
2256
2417
  let editorView = "markdown";
2257
2418
  let rightView = "preview";
@@ -2261,6 +2422,15 @@ ${cssVarsBlock}
2261
2422
  let latestResponseTimestamp = 0;
2262
2423
  let latestResponseKind = "annotation";
2263
2424
  let latestResponseIsStructuredCritique = false;
2425
+ let latestResponseHasContent = false;
2426
+ let latestResponseNormalized = "";
2427
+ let latestCritiqueNotes = "";
2428
+ let latestCritiqueNotesNormalized = "";
2429
+ let agentBusyFromServer = false;
2430
+ let terminalActivityPhase = "idle";
2431
+ let terminalActivityToolName = "";
2432
+ let terminalActivityLabel = "";
2433
+ let lastSpecificToolLabel = "";
2264
2434
  let uiBusy = false;
2265
2435
  let sourceState = {
2266
2436
  source: initialSourceState.source,
@@ -2312,10 +2482,14 @@ ${cssVarsBlock}
2312
2482
  var SUPPORTED_LANGUAGES = Object.keys(LANG_EXT_MAP);
2313
2483
  const RESPONSE_HIGHLIGHT_MAX_CHARS = 120_000;
2314
2484
  const RESPONSE_HIGHLIGHT_STORAGE_KEY = "piStudio.responseHighlightEnabled";
2485
+ const PREVIEW_INPUT_DEBOUNCE_MS = 0;
2486
+ const PREVIEW_PENDING_BADGE_DELAY_MS = 220;
2487
+ const previewPendingTimers = new WeakMap();
2315
2488
  let sourcePreviewRenderTimer = null;
2316
2489
  let sourcePreviewRenderNonce = 0;
2317
2490
  let responsePreviewRenderNonce = 0;
2318
2491
  let responseEditorPreviewTimer = null;
2492
+ let editorMetaUpdateRaf = null;
2319
2493
  let editorHighlightEnabled = false;
2320
2494
  let editorLanguage = "markdown";
2321
2495
  let responseHighlightEnabled = false;
@@ -2327,8 +2501,153 @@ ${cssVarsBlock}
2327
2501
  let mermaidModulePromise = null;
2328
2502
  let mermaidInitialized = false;
2329
2503
 
2504
+ const DEBUG_ENABLED = (() => {
2505
+ try {
2506
+ const query = new URLSearchParams(window.location.search || "");
2507
+ const hash = new URLSearchParams((window.location.hash || "").replace(/^#/, ""));
2508
+ const value = String(query.get("debug") || hash.get("debug") || "").trim().toLowerCase();
2509
+ return value === "1" || value === "true" || value === "yes" || value === "on";
2510
+ } catch {
2511
+ return false;
2512
+ }
2513
+ })();
2514
+ const DEBUG_LOG_MAX = 400;
2515
+ const debugLog = [];
2516
+
2517
+ function debugTrace(eventName, payload) {
2518
+ if (!DEBUG_ENABLED) return;
2519
+ const entry = {
2520
+ ts: Date.now(),
2521
+ event: String(eventName || ""),
2522
+ payload: payload || null,
2523
+ };
2524
+ debugLog.push(entry);
2525
+ if (debugLog.length > DEBUG_LOG_MAX) debugLog.shift();
2526
+ window.__piStudioDebugLog = debugLog.slice();
2527
+ try {
2528
+ console.debug("[pi-studio]", new Date(entry.ts).toISOString(), entry.event, entry.payload);
2529
+ } catch {
2530
+ // ignore console errors
2531
+ }
2532
+ }
2533
+
2534
+ function summarizeServerMessage(message) {
2535
+ if (!message || typeof message !== "object") return { type: "invalid" };
2536
+ const summary = {
2537
+ type: typeof message.type === "string" ? message.type : "unknown",
2538
+ };
2539
+ if (typeof message.requestId === "string") summary.requestId = message.requestId;
2540
+ if (typeof message.activeRequestId === "string") summary.activeRequestId = message.activeRequestId;
2541
+ if (typeof message.activeRequestKind === "string") summary.activeRequestKind = message.activeRequestKind;
2542
+ if (typeof message.kind === "string") summary.kind = message.kind;
2543
+ if (typeof message.event === "string") summary.event = message.event;
2544
+ if (typeof message.timestamp === "number") summary.timestamp = message.timestamp;
2545
+ if (typeof message.busy === "boolean") summary.busy = message.busy;
2546
+ if (typeof message.agentBusy === "boolean") summary.agentBusy = message.agentBusy;
2547
+ if (typeof message.terminalPhase === "string") summary.terminalPhase = message.terminalPhase;
2548
+ if (typeof message.terminalToolName === "string") summary.terminalToolName = message.terminalToolName;
2549
+ if (typeof message.terminalActivityLabel === "string") summary.terminalActivityLabel = message.terminalActivityLabel;
2550
+ if (typeof message.stopReason === "string") summary.stopReason = message.stopReason;
2551
+ if (typeof message.markdown === "string") summary.markdownLength = message.markdown.length;
2552
+ if (typeof message.label === "string") summary.label = message.label;
2553
+ if (typeof message.details === "object" && message.details !== null) summary.details = message.details;
2554
+ return summary;
2555
+ }
2556
+
2330
2557
  function getIdleStatus() {
2331
- return "Ready. Edit text, then run or critique (insert annotation header if needed).";
2558
+ return "Ready. Edit, load, or annotate text, then run, save, send to pi editor, or critique.";
2559
+ }
2560
+
2561
+ function normalizeTerminalPhase(phase) {
2562
+ if (phase === "running" || phase === "tool" || phase === "responding") return phase;
2563
+ return "idle";
2564
+ }
2565
+
2566
+ function normalizeActivityLabel(label) {
2567
+ if (typeof label !== "string") return "";
2568
+ return label.replace(/\\s+/g, " ").trim();
2569
+ }
2570
+
2571
+ function isGenericToolLabel(label) {
2572
+ const normalized = normalizeActivityLabel(label).toLowerCase();
2573
+ if (!normalized) return true;
2574
+ return normalized.startsWith("running ")
2575
+ || normalized === "reading file"
2576
+ || normalized === "writing file"
2577
+ || normalized === "editing file";
2578
+ }
2579
+
2580
+ function withEllipsis(text) {
2581
+ const value = String(text || "").trim();
2582
+ if (!value) return "";
2583
+ if (/[….!?]$/.test(value)) return value;
2584
+ return value + "…";
2585
+ }
2586
+
2587
+ function updateTerminalActivityState(phase, toolName, label) {
2588
+ terminalActivityPhase = normalizeTerminalPhase(phase);
2589
+ terminalActivityToolName = typeof toolName === "string" ? toolName.trim() : "";
2590
+ terminalActivityLabel = normalizeActivityLabel(label);
2591
+
2592
+ if (terminalActivityPhase === "tool" && terminalActivityLabel && !isGenericToolLabel(terminalActivityLabel)) {
2593
+ lastSpecificToolLabel = terminalActivityLabel;
2594
+ }
2595
+ if (terminalActivityPhase === "idle") {
2596
+ lastSpecificToolLabel = "";
2597
+ }
2598
+ }
2599
+
2600
+ function getTerminalBusyStatus() {
2601
+ if (terminalActivityPhase === "tool") {
2602
+ if (terminalActivityLabel) {
2603
+ return "Terminal: " + withEllipsis(terminalActivityLabel);
2604
+ }
2605
+ return terminalActivityToolName
2606
+ ? "Terminal: running tool: " + terminalActivityToolName + "…"
2607
+ : "Terminal: running tool…";
2608
+ }
2609
+ if (terminalActivityPhase === "responding") {
2610
+ if (lastSpecificToolLabel) {
2611
+ return "Terminal: " + lastSpecificToolLabel + " (generating response)…";
2612
+ }
2613
+ return "Terminal: generating response…";
2614
+ }
2615
+ if (terminalActivityPhase === "running" && lastSpecificToolLabel) {
2616
+ return "Terminal: " + withEllipsis(lastSpecificToolLabel);
2617
+ }
2618
+ return "Terminal: running…";
2619
+ }
2620
+
2621
+ function getStudioActionLabel(kind) {
2622
+ if (kind === "annotation") return "sending annotated reply";
2623
+ if (kind === "critique") return "running critique";
2624
+ if (kind === "direct") return "running editor text";
2625
+ if (kind === "send_to_editor") return "sending to pi editor";
2626
+ if (kind === "get_from_editor") return "loading from pi editor";
2627
+ if (kind === "save_as" || kind === "save_over") return "saving editor text";
2628
+ return "submitting request";
2629
+ }
2630
+
2631
+ function getStudioBusyStatus(kind) {
2632
+ const action = getStudioActionLabel(kind);
2633
+ if (terminalActivityPhase === "tool") {
2634
+ if (terminalActivityLabel) {
2635
+ return "Studio: " + withEllipsis(terminalActivityLabel);
2636
+ }
2637
+ return terminalActivityToolName
2638
+ ? "Studio: " + action + " (tool: " + terminalActivityToolName + ")…"
2639
+ : "Studio: " + action + " (running tool)…";
2640
+ }
2641
+ if (terminalActivityPhase === "responding") {
2642
+ if (lastSpecificToolLabel) {
2643
+ return "Studio: " + lastSpecificToolLabel + " (generating response)…";
2644
+ }
2645
+ return "Studio: " + action + " (generating response)…";
2646
+ }
2647
+ if (terminalActivityPhase === "running" && lastSpecificToolLabel) {
2648
+ return "Studio: " + withEllipsis(lastSpecificToolLabel);
2649
+ }
2650
+ return "Studio: " + action + "…";
2332
2651
  }
2333
2652
 
2334
2653
  function renderStatus() {
@@ -2346,6 +2665,19 @@ ${cssVarsBlock}
2346
2665
  statusMessage = message;
2347
2666
  statusLevel = level || "";
2348
2667
  renderStatus();
2668
+ debugTrace("status", {
2669
+ wsState,
2670
+ message: statusMessage,
2671
+ level: statusLevel,
2672
+ pendingRequestId,
2673
+ pendingKind,
2674
+ uiBusy,
2675
+ agentBusyFromServer,
2676
+ terminalPhase: terminalActivityPhase,
2677
+ terminalToolName: terminalActivityToolName,
2678
+ terminalActivityLabel,
2679
+ lastSpecificToolLabel,
2680
+ });
2349
2681
  }
2350
2682
 
2351
2683
  renderStatus();
@@ -2514,23 +2846,19 @@ ${cssVarsBlock}
2514
2846
  return normalizeForCompare(a) === normalizeForCompare(b);
2515
2847
  }
2516
2848
 
2517
- function getCurrentResponseMarkdown() {
2518
- return latestResponseMarkdown;
2519
- }
2520
-
2521
- function updateSyncBadge() {
2849
+ function updateSyncBadge(normalizedEditorText) {
2522
2850
  if (!syncBadgeEl) return;
2523
2851
 
2524
- const response = getCurrentResponseMarkdown();
2525
- const hasResponse = Boolean(response && response.trim());
2526
-
2527
- if (!hasResponse) {
2852
+ if (!latestResponseHasContent) {
2528
2853
  syncBadgeEl.textContent = "No response loaded";
2529
2854
  syncBadgeEl.classList.remove("sync", "edited");
2530
2855
  return;
2531
2856
  }
2532
2857
 
2533
- const inSync = isTextEquivalent(sourceTextEl.value, response);
2858
+ const normalizedEditor = typeof normalizedEditorText === "string"
2859
+ ? normalizedEditorText
2860
+ : normalizeForCompare(sourceTextEl.value);
2861
+ const inSync = normalizedEditor === latestResponseNormalized;
2534
2862
  if (inSync) {
2535
2863
  syncBadgeEl.textContent = "In sync with response";
2536
2864
  syncBadgeEl.classList.add("sync");
@@ -2592,6 +2920,48 @@ ${cssVarsBlock}
2592
2920
  targetEl.appendChild(el);
2593
2921
  }
2594
2922
 
2923
+ function hasMeaningfulPreviewContent(targetEl) {
2924
+ if (!targetEl || typeof targetEl.querySelector !== "function") return false;
2925
+ if (targetEl.querySelector(".preview-loading")) return false;
2926
+ const text = typeof targetEl.textContent === "string" ? targetEl.textContent.trim() : "";
2927
+ return text.length > 0;
2928
+ }
2929
+
2930
+ function beginPreviewRender(targetEl) {
2931
+ if (!targetEl || !targetEl.classList) return;
2932
+
2933
+ const pendingTimer = previewPendingTimers.get(targetEl);
2934
+ if (pendingTimer !== undefined) {
2935
+ window.clearTimeout(pendingTimer);
2936
+ previewPendingTimers.delete(targetEl);
2937
+ }
2938
+
2939
+ if (hasMeaningfulPreviewContent(targetEl)) {
2940
+ targetEl.classList.remove("preview-pending");
2941
+ const timerId = window.setTimeout(() => {
2942
+ previewPendingTimers.delete(targetEl);
2943
+ if (!targetEl || !targetEl.classList) return;
2944
+ if (!hasMeaningfulPreviewContent(targetEl)) return;
2945
+ targetEl.classList.add("preview-pending");
2946
+ }, PREVIEW_PENDING_BADGE_DELAY_MS);
2947
+ previewPendingTimers.set(targetEl, timerId);
2948
+ return;
2949
+ }
2950
+
2951
+ targetEl.classList.remove("preview-pending");
2952
+ targetEl.innerHTML = "<div class='preview-loading'>Rendering preview…</div>";
2953
+ }
2954
+
2955
+ function finishPreviewRender(targetEl) {
2956
+ if (!targetEl || !targetEl.classList) return;
2957
+ const pendingTimer = previewPendingTimers.get(targetEl);
2958
+ if (pendingTimer !== undefined) {
2959
+ window.clearTimeout(pendingTimer);
2960
+ previewPendingTimers.delete(targetEl);
2961
+ }
2962
+ targetEl.classList.remove("preview-pending");
2963
+ }
2964
+
2595
2965
  async function getMermaidApi() {
2596
2966
  if (mermaidModulePromise) {
2597
2967
  return mermaidModulePromise;
@@ -2737,6 +3107,7 @@ ${cssVarsBlock}
2737
3107
  if (nonce !== responsePreviewRenderNonce || (rightView !== "preview" && rightView !== "editor-preview")) return;
2738
3108
  }
2739
3109
 
3110
+ finishPreviewRender(targetEl);
2740
3111
  targetEl.innerHTML = sanitizeRenderedHtml(renderedHtml, markdown);
2741
3112
  await renderMermaidInElement(targetEl);
2742
3113
 
@@ -2756,6 +3127,7 @@ ${cssVarsBlock}
2756
3127
  }
2757
3128
 
2758
3129
  const detail = error && error.message ? error.message : String(error || "unknown error");
3130
+ finishPreviewRender(targetEl);
2759
3131
  targetEl.innerHTML = buildPreviewErrorHtml("Preview renderer unavailable (" + detail + "). Showing plain markdown.", markdown);
2760
3132
  }
2761
3133
  }
@@ -2764,11 +3136,12 @@ ${cssVarsBlock}
2764
3136
  if (editorView !== "preview") return;
2765
3137
  const text = sourceTextEl.value || "";
2766
3138
  if (editorLanguage && editorLanguage !== "markdown" && editorLanguage !== "latex") {
3139
+ finishPreviewRender(sourcePreviewEl);
2767
3140
  sourcePreviewEl.innerHTML = "<div class='response-markdown-highlight' style='white-space:pre;font-family:var(--font-mono);font-size:13px;line-height:1.5;padding:16px;overflow:auto;'>" + highlightCode(text, editorLanguage) + "</div>";
2768
3141
  return;
2769
3142
  }
2770
3143
  const nonce = ++sourcePreviewRenderNonce;
2771
- sourcePreviewEl.innerHTML = "<div class='preview-loading'>Rendering preview…</div>";
3144
+ beginPreviewRender(sourcePreviewEl);
2772
3145
  void applyRenderedMarkdown(sourcePreviewEl, text, "source", nonce);
2773
3146
  }
2774
3147
 
@@ -2787,15 +3160,20 @@ ${cssVarsBlock}
2787
3160
  }, delay);
2788
3161
  }
2789
3162
 
2790
- function renderSourcePreview() {
3163
+ function renderSourcePreview(options) {
3164
+ const previewDelayMs =
3165
+ options && typeof options.previewDelayMs === "number"
3166
+ ? Math.max(0, options.previewDelayMs)
3167
+ : 0;
3168
+
2791
3169
  if (editorView === "preview") {
2792
- scheduleSourcePreviewRender(0);
3170
+ scheduleSourcePreviewRender(previewDelayMs);
2793
3171
  }
2794
3172
  if (editorHighlightEnabled && editorView === "markdown") {
2795
3173
  scheduleEditorHighlightRender();
2796
3174
  }
2797
3175
  if (rightView === "editor-preview") {
2798
- scheduleResponseEditorPreviewRender(0);
3176
+ scheduleResponseEditorPreviewRender(previewDelayMs);
2799
3177
  }
2800
3178
  }
2801
3179
 
@@ -2818,34 +3196,38 @@ ${cssVarsBlock}
2818
3196
  if (rightView === "editor-preview") {
2819
3197
  const editorText = sourceTextEl.value || "";
2820
3198
  if (!editorText.trim()) {
3199
+ finishPreviewRender(critiqueViewEl);
2821
3200
  critiqueViewEl.innerHTML = "<pre class='plain-markdown'>Editor is empty.</pre>";
2822
3201
  return;
2823
3202
  }
2824
3203
  if (editorLanguage && editorLanguage !== "markdown" && editorLanguage !== "latex") {
3204
+ finishPreviewRender(critiqueViewEl);
2825
3205
  critiqueViewEl.innerHTML = "<div class='response-markdown-highlight' style='white-space:pre;font-family:var(--font-mono);font-size:13px;line-height:1.5;padding:16px;overflow:auto;'>" + highlightCode(editorText, editorLanguage) + "</div>";
2826
3206
  return;
2827
3207
  }
2828
3208
  const nonce = ++responsePreviewRenderNonce;
2829
- critiqueViewEl.innerHTML = "<div class='preview-loading'>Rendering preview…</div>";
3209
+ beginPreviewRender(critiqueViewEl);
2830
3210
  void applyRenderedMarkdown(critiqueViewEl, editorText, "response", nonce);
2831
3211
  return;
2832
3212
  }
2833
3213
 
2834
3214
  const markdown = latestResponseMarkdown;
2835
3215
  if (!markdown || !markdown.trim()) {
3216
+ finishPreviewRender(critiqueViewEl);
2836
3217
  critiqueViewEl.innerHTML = "<pre class='plain-markdown'>No response yet. Run editor text or critique editor text.</pre>";
2837
3218
  return;
2838
3219
  }
2839
3220
 
2840
3221
  if (rightView === "preview") {
2841
3222
  const nonce = ++responsePreviewRenderNonce;
2842
- critiqueViewEl.innerHTML = "<div class='preview-loading'>Rendering preview…</div>";
3223
+ beginPreviewRender(critiqueViewEl);
2843
3224
  void applyRenderedMarkdown(critiqueViewEl, markdown, "response", nonce);
2844
3225
  return;
2845
3226
  }
2846
3227
 
2847
3228
  if (responseHighlightEnabled) {
2848
3229
  if (markdown.length > RESPONSE_HIGHLIGHT_MAX_CHARS) {
3230
+ finishPreviewRender(critiqueViewEl);
2849
3231
  critiqueViewEl.innerHTML = buildPreviewErrorHtml(
2850
3232
  "Response is too large for markdown highlighting. Showing plain markdown.",
2851
3233
  markdown,
@@ -2853,21 +3235,25 @@ ${cssVarsBlock}
2853
3235
  return;
2854
3236
  }
2855
3237
 
3238
+ finishPreviewRender(critiqueViewEl);
2856
3239
  critiqueViewEl.innerHTML = "<div class='response-markdown-highlight'>" + highlightMarkdown(markdown) + "</div>";
2857
3240
  return;
2858
3241
  }
2859
3242
 
3243
+ finishPreviewRender(critiqueViewEl);
2860
3244
  critiqueViewEl.innerHTML = buildPlainMarkdownHtml(markdown);
2861
3245
  }
2862
3246
 
2863
- function updateResultActionButtons() {
2864
- const responseMarkdown = getCurrentResponseMarkdown();
2865
- const hasResponse = Boolean(responseMarkdown && responseMarkdown.trim());
2866
- const responseLoaded = hasResponse && isTextEquivalent(sourceTextEl.value, responseMarkdown);
3247
+ function updateResultActionButtons(normalizedEditorText) {
3248
+ const hasResponse = latestResponseHasContent;
3249
+ const normalizedEditor = typeof normalizedEditorText === "string"
3250
+ ? normalizedEditorText
3251
+ : normalizeForCompare(sourceTextEl.value);
3252
+ const responseLoaded = hasResponse && normalizedEditor === latestResponseNormalized;
2867
3253
  const isCritiqueResponse = hasResponse && latestResponseIsStructuredCritique;
2868
3254
 
2869
- const critiqueNotes = isCritiqueResponse ? buildCritiqueNotesMarkdown(responseMarkdown) : "";
2870
- const critiqueNotesLoaded = Boolean(critiqueNotes) && isTextEquivalent(sourceTextEl.value, critiqueNotes);
3255
+ const critiqueNotes = isCritiqueResponse ? latestCritiqueNotes : "";
3256
+ const critiqueNotesLoaded = Boolean(critiqueNotes) && normalizedEditor === latestCritiqueNotesNormalized;
2871
3257
 
2872
3258
  loadResponseBtn.hidden = isCritiqueResponse;
2873
3259
  loadCritiqueNotesBtn.hidden = !isCritiqueResponse;
@@ -2887,7 +3273,7 @@ ${cssVarsBlock}
2887
3273
  pullLatestBtn.disabled = uiBusy || followLatest;
2888
3274
  pullLatestBtn.textContent = queuedLatestResponse ? "Get latest response *" : "Get latest response";
2889
3275
 
2890
- updateSyncBadge();
3276
+ updateSyncBadge(normalizedEditor);
2891
3277
  }
2892
3278
 
2893
3279
  function refreshResponseUi() {
@@ -2927,6 +3313,7 @@ ${cssVarsBlock}
2927
3313
  saveAsBtn.disabled = uiBusy;
2928
3314
  saveOverBtn.disabled = uiBusy || !canSaveOver;
2929
3315
  sendEditorBtn.disabled = uiBusy;
3316
+ if (getEditorBtn) getEditorBtn.disabled = uiBusy;
2930
3317
  sendRunBtn.disabled = uiBusy;
2931
3318
  copyDraftBtn.disabled = uiBusy;
2932
3319
  if (highlightSelect) highlightSelect.disabled = uiBusy;
@@ -2972,6 +3359,10 @@ ${cssVarsBlock}
2972
3359
  sourcePreviewRenderTimer = null;
2973
3360
  }
2974
3361
 
3362
+ if (!showPreview) {
3363
+ finishPreviewRender(sourcePreviewEl);
3364
+ }
3365
+
2975
3366
  if (showPreview) {
2976
3367
  renderSourcePreview();
2977
3368
  }
@@ -3411,6 +3802,31 @@ ${cssVarsBlock}
3411
3802
  sourceHighlightEl.scrollLeft = sourceTextEl.scrollLeft;
3412
3803
  }
3413
3804
 
3805
+ function runEditorMetaUpdateNow() {
3806
+ const normalizedEditor = normalizeForCompare(sourceTextEl.value);
3807
+ updateResultActionButtons(normalizedEditor);
3808
+ }
3809
+
3810
+ function scheduleEditorMetaUpdate() {
3811
+ if (editorMetaUpdateRaf !== null) {
3812
+ if (typeof window.cancelAnimationFrame === "function") {
3813
+ window.cancelAnimationFrame(editorMetaUpdateRaf);
3814
+ } else {
3815
+ window.clearTimeout(editorMetaUpdateRaf);
3816
+ }
3817
+ editorMetaUpdateRaf = null;
3818
+ }
3819
+
3820
+ const schedule = typeof window.requestAnimationFrame === "function"
3821
+ ? window.requestAnimationFrame.bind(window)
3822
+ : (cb) => window.setTimeout(cb, 16);
3823
+
3824
+ editorMetaUpdateRaf = schedule(() => {
3825
+ editorMetaUpdateRaf = null;
3826
+ runEditorMetaUpdateNow();
3827
+ });
3828
+ }
3829
+
3414
3830
  function readStoredToggle(storageKey) {
3415
3831
  if (!window.localStorage) return null;
3416
3832
  try {
@@ -3597,9 +4013,18 @@ ${cssVarsBlock}
3597
4013
  latestResponseKind = kind === "critique" ? "critique" : "annotation";
3598
4014
  latestResponseTimestamp = responseTimestamp;
3599
4015
  latestResponseIsStructuredCritique = isStructuredCritique(markdown);
4016
+ latestResponseHasContent = Boolean(markdown && markdown.trim());
4017
+ latestResponseNormalized = normalizeForCompare(markdown);
4018
+
4019
+ if (latestResponseIsStructuredCritique) {
4020
+ latestCritiqueNotes = buildCritiqueNotesMarkdown(markdown);
4021
+ latestCritiqueNotesNormalized = normalizeForCompare(latestCritiqueNotes);
4022
+ } else {
4023
+ latestCritiqueNotes = "";
4024
+ latestCritiqueNotesNormalized = "";
4025
+ }
3600
4026
 
3601
4027
  refreshResponseUi();
3602
- syncActionButtons();
3603
4028
  }
3604
4029
 
3605
4030
  function applyLatestPayload(payload) {
@@ -3622,14 +4047,30 @@ ${cssVarsBlock}
3622
4047
  function handleServerMessage(message) {
3623
4048
  if (!message || typeof message !== "object") return;
3624
4049
 
4050
+ debugTrace("server_message", summarizeServerMessage(message));
4051
+
4052
+ if (message.type === "debug_event") {
4053
+ debugTrace("server_debug_event", summarizeServerMessage(message));
4054
+ return;
4055
+ }
4056
+
3625
4057
  if (message.type === "hello_ack") {
3626
4058
  const busy = Boolean(message.busy);
4059
+ agentBusyFromServer = Boolean(message.agentBusy);
4060
+ updateTerminalActivityState(message.terminalPhase, message.terminalToolName, message.terminalActivityLabel);
3627
4061
  setBusy(busy);
3628
4062
  setWsState(busy ? "Submitting" : "Ready");
3629
- if (message.activeRequestId) {
3630
- pendingRequestId = String(message.activeRequestId);
3631
- pendingKind = "unknown";
3632
- setStatus("Request in progress…", "warning");
4063
+ if (typeof message.activeRequestId === "string" && message.activeRequestId.length > 0) {
4064
+ pendingRequestId = message.activeRequestId;
4065
+ if (typeof message.activeRequestKind === "string" && message.activeRequestKind.length > 0) {
4066
+ pendingKind = message.activeRequestKind;
4067
+ } else if (!pendingKind) {
4068
+ pendingKind = "unknown";
4069
+ }
4070
+ stickyStudioKind = pendingKind;
4071
+ } else {
4072
+ pendingRequestId = null;
4073
+ pendingKind = null;
3633
4074
  }
3634
4075
 
3635
4076
  let loadedInitialDocument = false;
@@ -3662,7 +4103,26 @@ ${cssVarsBlock}
3662
4103
  handleIncomingResponse(lastMarkdown, lastResponseKind, message.lastResponse.timestamp);
3663
4104
  }
3664
4105
 
3665
- if (!busy && !loadedInitialDocument) {
4106
+ if (pendingRequestId) {
4107
+ if (busy) {
4108
+ setStatus(getStudioBusyStatus(pendingKind), "warning");
4109
+ }
4110
+ return;
4111
+ }
4112
+
4113
+ if (busy) {
4114
+ if (agentBusyFromServer && stickyStudioKind) {
4115
+ setStatus(getStudioBusyStatus(stickyStudioKind), "warning");
4116
+ } else if (agentBusyFromServer) {
4117
+ setStatus(getTerminalBusyStatus(), "warning");
4118
+ } else {
4119
+ setStatus("Studio is busy.", "warning");
4120
+ }
4121
+ return;
4122
+ }
4123
+
4124
+ stickyStudioKind = null;
4125
+ if (!loadedInitialDocument) {
3666
4126
  refreshResponseUi();
3667
4127
  setStatus(getIdleStatus());
3668
4128
  }
@@ -3672,17 +4132,10 @@ ${cssVarsBlock}
3672
4132
  if (message.type === "request_started") {
3673
4133
  pendingRequestId = typeof message.requestId === "string" ? message.requestId : pendingRequestId;
3674
4134
  pendingKind = typeof message.kind === "string" ? message.kind : "unknown";
4135
+ stickyStudioKind = pendingKind;
3675
4136
  setBusy(true);
3676
4137
  setWsState("Submitting");
3677
- if (pendingKind === "annotation") {
3678
- setStatus("Sending annotated reply…", "warning");
3679
- } else if (pendingKind === "critique") {
3680
- setStatus("Running critique…", "warning");
3681
- } else if (pendingKind === "direct") {
3682
- setStatus("Running editor text…", "warning");
3683
- } else {
3684
- setStatus("Submitting…", "warning");
3685
- }
4138
+ setStatus(getStudioBusyStatus(pendingKind), "warning");
3686
4139
  return;
3687
4140
  }
3688
4141
 
@@ -3696,6 +4149,7 @@ ${cssVarsBlock}
3696
4149
  ? message.kind
3697
4150
  : (pendingKind === "critique" ? "critique" : "annotation");
3698
4151
 
4152
+ stickyStudioKind = responseKind;
3699
4153
  pendingRequestId = null;
3700
4154
  pendingKind = null;
3701
4155
  setBusy(false);
@@ -3767,13 +4221,71 @@ ${cssVarsBlock}
3767
4221
  return;
3768
4222
  }
3769
4223
 
4224
+ if (message.type === "editor_snapshot") {
4225
+ if (typeof message.requestId === "string" && pendingRequestId && message.requestId !== pendingRequestId) {
4226
+ return;
4227
+ }
4228
+ if (typeof message.requestId === "string" && pendingRequestId === message.requestId) {
4229
+ pendingRequestId = null;
4230
+ pendingKind = null;
4231
+ }
4232
+
4233
+ const content = typeof message.content === "string" ? message.content : "";
4234
+ sourceTextEl.value = content;
4235
+ renderSourcePreview();
4236
+ setSourceState({ source: "pi-editor", label: "pi editor draft", path: null });
4237
+ setBusy(false);
4238
+ setWsState("Ready");
4239
+ setStatus(
4240
+ content.trim()
4241
+ ? "Loaded draft from pi editor."
4242
+ : "pi editor is empty. Loaded blank text.",
4243
+ content.trim() ? "success" : "warning",
4244
+ );
4245
+ return;
4246
+ }
4247
+
3770
4248
  if (message.type === "studio_state") {
3771
4249
  const busy = Boolean(message.busy);
4250
+ agentBusyFromServer = Boolean(message.agentBusy);
4251
+ updateTerminalActivityState(message.terminalPhase, message.terminalToolName, message.terminalActivityLabel);
4252
+
4253
+ if (typeof message.activeRequestId === "string" && message.activeRequestId.length > 0) {
4254
+ pendingRequestId = message.activeRequestId;
4255
+ if (typeof message.activeRequestKind === "string" && message.activeRequestKind.length > 0) {
4256
+ pendingKind = message.activeRequestKind;
4257
+ } else if (!pendingKind) {
4258
+ pendingKind = "unknown";
4259
+ }
4260
+ stickyStudioKind = pendingKind;
4261
+ } else {
4262
+ pendingRequestId = null;
4263
+ pendingKind = null;
4264
+ }
4265
+
3772
4266
  setBusy(busy);
3773
4267
  setWsState(busy ? "Submitting" : "Ready");
3774
- if (!busy && !pendingRequestId) {
3775
- setStatus(getIdleStatus());
4268
+
4269
+ if (pendingRequestId) {
4270
+ if (busy) {
4271
+ setStatus(getStudioBusyStatus(pendingKind), "warning");
4272
+ }
4273
+ return;
3776
4274
  }
4275
+
4276
+ if (busy) {
4277
+ if (agentBusyFromServer && stickyStudioKind) {
4278
+ setStatus(getStudioBusyStatus(stickyStudioKind), "warning");
4279
+ } else if (agentBusyFromServer) {
4280
+ setStatus(getTerminalBusyStatus(), "warning");
4281
+ } else {
4282
+ setStatus("Studio is busy.", "warning");
4283
+ }
4284
+ return;
4285
+ }
4286
+
4287
+ stickyStudioKind = null;
4288
+ setStatus(getIdleStatus());
3777
4289
  return;
3778
4290
  }
3779
4291
 
@@ -3782,6 +4294,7 @@ ${cssVarsBlock}
3782
4294
  pendingRequestId = null;
3783
4295
  pendingKind = null;
3784
4296
  }
4297
+ stickyStudioKind = null;
3785
4298
  setBusy(false);
3786
4299
  setWsState("Ready");
3787
4300
  setStatus(typeof message.message === "string" ? message.message : "Studio is busy.", "warning");
@@ -3793,6 +4306,7 @@ ${cssVarsBlock}
3793
4306
  pendingRequestId = null;
3794
4307
  pendingKind = null;
3795
4308
  }
4309
+ stickyStudioKind = null;
3796
4310
  setBusy(false);
3797
4311
  setWsState("Ready");
3798
4312
  setStatus(typeof message.message === "string" ? message.message : "Request failed.", "error");
@@ -3827,7 +4341,7 @@ ${cssVarsBlock}
3827
4341
  }
3828
4342
 
3829
4343
  const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
3830
- const wsUrl = wsProtocol + "://" + window.location.host + "/ws?token=" + encodeURIComponent(token);
4344
+ const wsUrl = wsProtocol + "://" + window.location.host + "/ws?token=" + encodeURIComponent(token) + (DEBUG_ENABLED ? "&debug=1" : "");
3831
4345
 
3832
4346
  setWsState("Connecting");
3833
4347
  setStatus("Connecting to Studio server…");
@@ -3884,8 +4398,10 @@ ${cssVarsBlock}
3884
4398
  const requestId = makeRequestId();
3885
4399
  pendingRequestId = requestId;
3886
4400
  pendingKind = kind;
4401
+ stickyStudioKind = kind;
3887
4402
  setBusy(true);
3888
4403
  setWsState("Submitting");
4404
+ setStatus(getStudioBusyStatus(kind), "warning");
3889
4405
  return requestId;
3890
4406
  }
3891
4407
 
@@ -4016,8 +4532,8 @@ ${cssVarsBlock}
4016
4532
  });
4017
4533
 
4018
4534
  sourceTextEl.addEventListener("input", () => {
4019
- renderSourcePreview();
4020
- updateResultActionButtons();
4535
+ renderSourcePreview({ previewDelayMs: PREVIEW_INPUT_DEBOUNCE_MS });
4536
+ scheduleEditorMetaUpdate();
4021
4537
  });
4022
4538
 
4023
4539
  sourceTextEl.addEventListener("scroll", () => {
@@ -4190,6 +4706,24 @@ ${cssVarsBlock}
4190
4706
  }
4191
4707
  });
4192
4708
 
4709
+ if (getEditorBtn) {
4710
+ getEditorBtn.addEventListener("click", () => {
4711
+ const requestId = beginUiAction("get_from_editor");
4712
+ if (!requestId) return;
4713
+
4714
+ const sent = sendMessage({
4715
+ type: "get_from_editor_request",
4716
+ requestId,
4717
+ });
4718
+
4719
+ if (!sent) {
4720
+ pendingRequestId = null;
4721
+ pendingKind = null;
4722
+ setBusy(false);
4723
+ }
4724
+ });
4725
+ }
4726
+
4193
4727
  sendRunBtn.addEventListener("click", () => {
4194
4728
  const content = sourceTextEl.value;
4195
4729
  if (!content.trim()) {
@@ -4354,6 +4888,10 @@ export default function (pi: ExtensionAPI) {
4354
4888
  let lastCommandCtx: ExtensionCommandContext | null = null;
4355
4889
  let lastThemeVarsJson = "";
4356
4890
  let agentBusy = false;
4891
+ let terminalActivityPhase: TerminalActivityPhase = "idle";
4892
+ let terminalActivityToolName: string | null = null;
4893
+ let terminalActivityLabel: string | null = null;
4894
+ let lastSpecificToolActivityLabel: string | null = null;
4357
4895
 
4358
4896
  const isStudioBusy = () => agentBusy || activeRequest !== null;
4359
4897
 
@@ -4384,18 +4922,94 @@ export default function (pi: ExtensionAPI) {
4384
4922
  }
4385
4923
  };
4386
4924
 
4925
+ const emitDebugEvent = (event: string, details?: Record<string, unknown>) => {
4926
+ broadcast({
4927
+ type: "debug_event",
4928
+ event,
4929
+ timestamp: Date.now(),
4930
+ details: details ?? null,
4931
+ });
4932
+ };
4933
+
4934
+ const setTerminalActivity = (phase: TerminalActivityPhase, toolName?: string | null, label?: string | null) => {
4935
+ const nextPhase: TerminalActivityPhase =
4936
+ phase === "running" || phase === "tool" || phase === "responding"
4937
+ ? phase
4938
+ : "idle";
4939
+ const nextToolName = nextPhase === "tool" ? (toolName?.trim() || null) : null;
4940
+ const baseLabel = nextPhase === "tool" ? normalizeActivityLabel(label || "") : null;
4941
+ let nextLabel: string | null = null;
4942
+
4943
+ if (nextPhase === "tool") {
4944
+ if (baseLabel && !isGenericToolActivityLabel(baseLabel)) {
4945
+ if (
4946
+ lastSpecificToolActivityLabel
4947
+ && lastSpecificToolActivityLabel !== baseLabel
4948
+ && !isGenericToolActivityLabel(lastSpecificToolActivityLabel)
4949
+ ) {
4950
+ nextLabel = normalizeActivityLabel(`${lastSpecificToolActivityLabel} → ${baseLabel}`);
4951
+ } else {
4952
+ nextLabel = baseLabel;
4953
+ }
4954
+ lastSpecificToolActivityLabel = baseLabel;
4955
+ } else {
4956
+ nextLabel = baseLabel;
4957
+ }
4958
+ } else {
4959
+ nextLabel = null;
4960
+ if (nextPhase === "idle") {
4961
+ lastSpecificToolActivityLabel = null;
4962
+ }
4963
+ }
4964
+
4965
+ if (
4966
+ terminalActivityPhase === nextPhase
4967
+ && terminalActivityToolName === nextToolName
4968
+ && terminalActivityLabel === nextLabel
4969
+ ) {
4970
+ return;
4971
+ }
4972
+ terminalActivityPhase = nextPhase;
4973
+ terminalActivityToolName = nextToolName;
4974
+ terminalActivityLabel = nextLabel;
4975
+ emitDebugEvent("terminal_activity", {
4976
+ phase: terminalActivityPhase,
4977
+ toolName: terminalActivityToolName,
4978
+ label: terminalActivityLabel,
4979
+ baseLabel,
4980
+ lastSpecificToolActivityLabel,
4981
+ activeRequestId: activeRequest?.id ?? null,
4982
+ activeRequestKind: activeRequest?.kind ?? null,
4983
+ agentBusy,
4984
+ });
4985
+ broadcastState();
4986
+ };
4987
+
4387
4988
  const broadcastState = () => {
4388
4989
  broadcast({
4389
4990
  type: "studio_state",
4390
4991
  busy: isStudioBusy(),
4992
+ agentBusy,
4993
+ terminalPhase: terminalActivityPhase,
4994
+ terminalToolName: terminalActivityToolName,
4995
+ terminalActivityLabel,
4391
4996
  activeRequestId: activeRequest?.id ?? null,
4997
+ activeRequestKind: activeRequest?.kind ?? null,
4392
4998
  });
4393
4999
  };
4394
5000
 
4395
5001
  const clearActiveRequest = (options?: { notify?: string; level?: "info" | "warning" | "error" }) => {
4396
5002
  if (!activeRequest) return;
5003
+ const completedRequestId = activeRequest.id;
5004
+ const completedKind = activeRequest.kind;
4397
5005
  clearTimeout(activeRequest.timer);
4398
5006
  activeRequest = null;
5007
+ emitDebugEvent("clear_active_request", {
5008
+ requestId: completedRequestId,
5009
+ kind: completedKind,
5010
+ notify: options?.notify ?? null,
5011
+ agentBusy,
5012
+ });
4399
5013
  broadcastState();
4400
5014
  if (options?.notify) {
4401
5015
  broadcast({ type: "info", message: options.notify, level: options.level ?? "info" });
@@ -4403,6 +5017,12 @@ export default function (pi: ExtensionAPI) {
4403
5017
  };
4404
5018
 
4405
5019
  const beginRequest = (requestId: string, kind: StudioRequestKind): boolean => {
5020
+ emitDebugEvent("begin_request_attempt", {
5021
+ requestId,
5022
+ kind,
5023
+ hasActiveRequest: Boolean(activeRequest),
5024
+ agentBusy,
5025
+ });
4406
5026
  if (activeRequest) {
4407
5027
  broadcast({ type: "busy", requestId, message: "A studio request is already in progress." });
4408
5028
  return false;
@@ -4414,6 +5034,7 @@ export default function (pi: ExtensionAPI) {
4414
5034
 
4415
5035
  const timer = setTimeout(() => {
4416
5036
  if (!activeRequest || activeRequest.id !== requestId) return;
5037
+ emitDebugEvent("request_timeout", { requestId, kind });
4417
5038
  broadcast({ type: "error", requestId, message: "Studio request timed out. Please try again." });
4418
5039
  clearActiveRequest();
4419
5040
  }, REQUEST_TIMEOUT_MS);
@@ -4425,6 +5046,7 @@ export default function (pi: ExtensionAPI) {
4425
5046
  timer,
4426
5047
  };
4427
5048
 
5049
+ emitDebugEvent("begin_request", { requestId, kind });
4428
5050
  broadcast({ type: "request_started", requestId, kind });
4429
5051
  broadcastState();
4430
5052
  return true;
@@ -4448,11 +5070,24 @@ export default function (pi: ExtensionAPI) {
4448
5070
  return;
4449
5071
  }
4450
5072
 
5073
+ emitDebugEvent("studio_message", {
5074
+ type: msg.type,
5075
+ requestId: "requestId" in msg ? msg.requestId : null,
5076
+ activeRequestId: activeRequest?.id ?? null,
5077
+ activeRequestKind: activeRequest?.kind ?? null,
5078
+ agentBusy,
5079
+ });
5080
+
4451
5081
  if (msg.type === "hello") {
4452
5082
  sendToClient(client, {
4453
5083
  type: "hello_ack",
4454
5084
  busy: isStudioBusy(),
5085
+ agentBusy,
5086
+ terminalPhase: terminalActivityPhase,
5087
+ terminalToolName: terminalActivityToolName,
5088
+ terminalActivityLabel,
4455
5089
  activeRequestId: activeRequest?.id ?? null,
5090
+ activeRequestKind: activeRequest?.kind ?? null,
4456
5091
  lastResponse: lastStudioResponse,
4457
5092
  initialDocument: initialStudioDocument,
4458
5093
  });
@@ -4684,6 +5319,41 @@ export default function (pi: ExtensionAPI) {
4684
5319
  }
4685
5320
  return;
4686
5321
  }
5322
+
5323
+ if (msg.type === "get_from_editor_request") {
5324
+ if (!isValidRequestId(msg.requestId)) {
5325
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
5326
+ return;
5327
+ }
5328
+ if (isStudioBusy()) {
5329
+ sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
5330
+ return;
5331
+ }
5332
+ if (!lastCommandCtx || !lastCommandCtx.hasUI) {
5333
+ sendToClient(client, {
5334
+ type: "error",
5335
+ requestId: msg.requestId,
5336
+ message: "No interactive pi editor context is available.",
5337
+ });
5338
+ return;
5339
+ }
5340
+
5341
+ try {
5342
+ const content = lastCommandCtx.ui.getEditorText();
5343
+ sendToClient(client, {
5344
+ type: "editor_snapshot",
5345
+ requestId: msg.requestId,
5346
+ content,
5347
+ });
5348
+ } catch (error) {
5349
+ sendToClient(client, {
5350
+ type: "error",
5351
+ requestId: msg.requestId,
5352
+ message: `Failed to read pi editor text: ${error instanceof Error ? error.message : String(error)}`,
5353
+ });
5354
+ }
5355
+ return;
5356
+ }
4687
5357
  };
4688
5358
 
4689
5359
  const handleRenderPreviewRequest = async (req: IncomingMessage, res: ServerResponse) => {
@@ -4961,21 +5631,81 @@ export default function (pi: ExtensionAPI) {
4961
5631
 
4962
5632
  pi.on("session_start", async (_event, ctx) => {
4963
5633
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
5634
+ agentBusy = false;
5635
+ emitDebugEvent("session_start", { entryCount: ctx.sessionManager.getBranch().length });
5636
+ setTerminalActivity("idle");
4964
5637
  });
4965
5638
 
4966
5639
  pi.on("session_switch", async (_event, ctx) => {
4967
5640
  clearActiveRequest({ notify: "Session switched. Studio request state cleared.", level: "warning" });
4968
5641
  lastCommandCtx = null;
4969
5642
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
5643
+ agentBusy = false;
5644
+ emitDebugEvent("session_switch", { entryCount: ctx.sessionManager.getBranch().length });
5645
+ setTerminalActivity("idle");
4970
5646
  });
4971
5647
 
4972
5648
  pi.on("agent_start", async () => {
4973
5649
  agentBusy = true;
4974
- broadcastState();
5650
+ emitDebugEvent("agent_start", { activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
5651
+ setTerminalActivity("running");
5652
+ });
5653
+
5654
+ pi.on("tool_call", async (event) => {
5655
+ if (!agentBusy) return;
5656
+ const toolName = typeof event.toolName === "string" ? event.toolName : "";
5657
+ const input = (event as { input?: unknown }).input;
5658
+ const label = deriveToolActivityLabel(toolName, input);
5659
+ emitDebugEvent("tool_call", { toolName, label, activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
5660
+ setTerminalActivity("tool", toolName, label);
5661
+ });
5662
+
5663
+ pi.on("tool_execution_start", async (event) => {
5664
+ if (!agentBusy) return;
5665
+ const label = deriveToolActivityLabel(event.toolName, event.args);
5666
+ emitDebugEvent("tool_execution_start", { toolName: event.toolName, label, activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
5667
+ setTerminalActivity("tool", event.toolName, label);
5668
+ });
5669
+
5670
+ pi.on("tool_execution_end", async (event) => {
5671
+ if (!agentBusy) return;
5672
+ emitDebugEvent("tool_execution_end", { toolName: event.toolName, activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
5673
+ // Keep tool phase visible until the next tool call, assistant response phase,
5674
+ // or agent_end. This avoids tool labels flashing too quickly to read.
5675
+ });
5676
+
5677
+ pi.on("message_start", async (event) => {
5678
+ const role = (event.message as { role?: string } | undefined)?.role;
5679
+ emitDebugEvent("message_start", { role: role ?? "", activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
5680
+ if (agentBusy && role === "assistant") {
5681
+ setTerminalActivity("responding");
5682
+ }
4975
5683
  });
4976
5684
 
4977
5685
  pi.on("message_end", async (event) => {
5686
+ const message = event.message as { stopReason?: string; role?: string };
5687
+ const stopReason = typeof message.stopReason === "string" ? message.stopReason : "";
5688
+ const role = typeof message.role === "string" ? message.role : "";
4978
5689
  const markdown = extractAssistantText(event.message);
5690
+ emitDebugEvent("message_end", {
5691
+ role,
5692
+ stopReason,
5693
+ hasMarkdown: Boolean(markdown),
5694
+ markdownLength: markdown ? markdown.length : 0,
5695
+ activeRequestId: activeRequest?.id ?? null,
5696
+ activeRequestKind: activeRequest?.kind ?? null,
5697
+ });
5698
+
5699
+ // Assistant is handing off to tool calls; request is still in progress.
5700
+ if (stopReason === "toolUse") {
5701
+ emitDebugEvent("message_end_tool_use", {
5702
+ role,
5703
+ activeRequestId: activeRequest?.id ?? null,
5704
+ activeRequestKind: activeRequest?.kind ?? null,
5705
+ });
5706
+ return;
5707
+ }
5708
+
4979
5709
  if (!markdown) return;
4980
5710
 
4981
5711
  if (activeRequest) {
@@ -4986,6 +5716,12 @@ export default function (pi: ExtensionAPI) {
4986
5716
  timestamp: Date.now(),
4987
5717
  kind,
4988
5718
  };
5719
+ emitDebugEvent("broadcast_response", {
5720
+ requestId,
5721
+ kind,
5722
+ markdownLength: markdown.length,
5723
+ stopReason,
5724
+ });
4989
5725
  broadcast({
4990
5726
  type: "response",
4991
5727
  requestId,
@@ -5003,6 +5739,11 @@ export default function (pi: ExtensionAPI) {
5003
5739
  timestamp: Date.now(),
5004
5740
  kind: inferredKind,
5005
5741
  };
5742
+ emitDebugEvent("broadcast_latest_response", {
5743
+ kind: inferredKind,
5744
+ markdownLength: markdown.length,
5745
+ stopReason,
5746
+ });
5006
5747
  broadcast({
5007
5748
  type: "latest_response",
5008
5749
  kind: inferredKind,
@@ -5013,7 +5754,8 @@ export default function (pi: ExtensionAPI) {
5013
5754
 
5014
5755
  pi.on("agent_end", async () => {
5015
5756
  agentBusy = false;
5016
- broadcastState();
5757
+ emitDebugEvent("agent_end", { activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
5758
+ setTerminalActivity("idle");
5017
5759
  if (activeRequest) {
5018
5760
  const requestId = activeRequest.id;
5019
5761
  broadcast({
@@ -5027,6 +5769,8 @@ export default function (pi: ExtensionAPI) {
5027
5769
 
5028
5770
  pi.on("session_shutdown", async () => {
5029
5771
  lastCommandCtx = null;
5772
+ agentBusy = false;
5773
+ setTerminalActivity("idle");
5030
5774
  await stopServer();
5031
5775
  });
5032
5776
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Browser GUI for structured critique workflows in pi",
5
5
  "type": "module",
6
6
  "license": "MIT",