pi-studio 0.9.53 → 0.9.54

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/index.ts CHANGED
@@ -23,6 +23,12 @@ import {
23
23
  transformStudioMarkdownOutsideFences,
24
24
  } from "./shared/studio-annotation-scanner.js";
25
25
  import { stripStudioMarkdownHtmlComments } from "./shared/studio-markdown-html-comments.js";
26
+ import { createStudioFileWatcher } from "./shared/studio-file-watcher.js";
27
+ import {
28
+ readStudioDiskFileSnapshot,
29
+ saveStudioDiskFileAs,
30
+ saveStudioDiskFileIfRevision,
31
+ } from "./shared/studio-disk-revisions.js";
26
32
  import { normalizeStudioMarkdownSmartFences } from "./shared/studio-markdown-fences.js";
27
33
  import {
28
34
  extractStandaloneLatexDefinitionsFromMarkdown,
@@ -314,11 +320,24 @@ interface InitialStudioDocument {
314
320
  path?: string;
315
321
  draftId?: string;
316
322
  resourceDir?: string;
323
+ diskRevision?: string;
324
+ watchFile?: boolean;
317
325
  }
318
326
 
327
+ type StudioDiskWriteResult =
328
+ | { ok: true; path: string; revision: string; size: number }
329
+ | {
330
+ ok: false;
331
+ conflict?: boolean;
332
+ reason?: string;
333
+ path?: string;
334
+ currentRevision?: string | null;
335
+ message: string;
336
+ };
337
+
319
338
  interface StudioLaunchSelection {
320
339
  document: InitialStudioDocument;
321
- kind: "document" | "pdf-preview";
340
+ kind: "document" | "pdf-preview" | "watched-preview";
322
341
  mode?: StudioUiMode;
323
342
  transient?: boolean;
324
343
  skipWorkspaceRestore?: boolean;
@@ -719,12 +738,17 @@ interface SaveAsRequestMessage {
719
738
  requestId: string;
720
739
  path: string;
721
740
  content: string;
741
+ overwrite?: boolean;
742
+ expectedRevision?: string;
722
743
  }
723
744
 
724
745
  interface SaveOverRequestMessage {
725
746
  type: "save_over_request";
726
747
  requestId: string;
748
+ path: string;
727
749
  content: string;
750
+ expectedRevision?: string;
751
+ force?: boolean;
728
752
  }
729
753
 
730
754
  interface RefreshFromDiskRequestMessage {
@@ -733,6 +757,12 @@ interface RefreshFromDiskRequestMessage {
733
757
  path?: string;
734
758
  }
735
759
 
760
+ interface WatchFileSubscribeMessage {
761
+ type: "watch_file_subscribe";
762
+ path: string;
763
+ revision?: string;
764
+ }
765
+
736
766
  interface SendToEditorRequestMessage {
737
767
  type: "send_to_editor_request";
738
768
  requestId: string;
@@ -821,6 +851,7 @@ type IncomingStudioMessage =
821
851
  | SaveAsRequestMessage
822
852
  | SaveOverRequestMessage
823
853
  | RefreshFromDiskRequestMessage
854
+ | WatchFileSubscribeMessage
824
855
  | SendToEditorRequestMessage
825
856
  | GetFromEditorRequestMessage
826
857
  | QuartoPreviewCheckRequestMessage
@@ -833,6 +864,7 @@ type IncomingStudioMessage =
833
864
 
834
865
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
835
866
  const PREVIEW_RENDER_MAX_CHARS = 400_000;
867
+ const STUDIO_WATCHED_FILE_MAX_CLIENTS = 16;
836
868
  const STUDIO_COMPLETION_MAX_TEXT_CHARS = 250_000;
837
869
  const STUDIO_COMPLETION_MAX_CONTEXT_CHARS = 12_000;
838
870
  const STUDIO_COMPLETION_PREFIX_CHARS = 12_000;
@@ -2463,8 +2495,8 @@ function resolveStudioPath(pathArg: string, cwd: string): { ok: true; resolved:
2463
2495
  return { ok: true, resolved, label: normalized };
2464
2496
  }
2465
2497
 
2466
- function readStudioFile(pathArg: string, cwd: string):
2467
- | { ok: true; text: string; label: string; resolvedPath: string }
2498
+ function readStudioFile(pathArg: string, cwd: string, options?: { requireCanonicalPath?: boolean }):
2499
+ | { ok: true; text: string; label: string; resolvedPath: string; diskRevision: string }
2468
2500
  | { ok: false; message: string } {
2469
2501
  const resolved = resolveStudioPath(pathArg, cwd);
2470
2502
  if (resolved.ok === false) {
@@ -2472,39 +2504,41 @@ function readStudioFile(pathArg: string, cwd: string):
2472
2504
  }
2473
2505
 
2474
2506
  try {
2475
- const stats = statSync(resolved.resolved);
2476
- if (!stats.isFile()) {
2477
- return { ok: false, message: `Path is not a file: ${resolved.label}` };
2478
- }
2479
- } catch (error) {
2480
- return {
2481
- ok: false,
2482
- message: `Could not access file: ${resolved.label} (${error instanceof Error ? error.message : String(error)})`,
2483
- };
2484
- }
2485
-
2486
- try {
2487
- // Read raw bytes first to detect binary content before UTF-8 decode
2488
- const buf = readFileSync(resolved.resolved);
2489
- // Heuristic: check the first 8KB for binary indicators
2490
- const sample = buf.subarray(0, 8192);
2507
+ const snapshot = readStudioDiskFileSnapshot(resolved.resolved);
2508
+ if (options?.requireCanonicalPath) {
2509
+ const requestedPath = resolve(resolved.resolved);
2510
+ const canonicalPath = resolve(snapshot.path);
2511
+ const pathsMatch = process.platform === "win32"
2512
+ ? requestedPath.toLowerCase() === canonicalPath.toLowerCase()
2513
+ : requestedPath === canonicalPath;
2514
+ if (!pathsMatch) {
2515
+ return { ok: false, message: `Could not read file: ${resolved.label} (the file location now resolves somewhere else)` };
2516
+ }
2517
+ }
2518
+ // Heuristic: check the first 8KB for binary indicators.
2519
+ const sample = snapshot.buffer.subarray(0, 8192);
2491
2520
  let nulCount = 0;
2492
2521
  let controlCount = 0;
2493
2522
  for (let i = 0; i < sample.length; i++) {
2494
2523
  const b = sample[i];
2495
2524
  if (b === 0x00) nulCount++;
2496
- // Control chars excluding tab (0x09), newline (0x0A), carriage return (0x0D)
2525
+ // Control chars excluding tab (0x09), newline (0x0A), carriage return (0x0D).
2497
2526
  else if (b < 0x08 || (b > 0x0D && b < 0x20 && b !== 0x1B)) controlCount++;
2498
2527
  }
2499
2528
  if (nulCount > 0 || (sample.length > 0 && controlCount / sample.length > 0.1)) {
2500
2529
  return { ok: false, message: `File appears to be binary: ${resolved.label}` };
2501
2530
  }
2502
- const text = buf.toString("utf-8");
2503
- return { ok: true, text, label: resolved.label, resolvedPath: resolved.resolved };
2531
+ return {
2532
+ ok: true,
2533
+ text: snapshot.buffer.toString("utf-8"),
2534
+ label: resolved.label,
2535
+ resolvedPath: snapshot.path,
2536
+ diskRevision: snapshot.revision,
2537
+ };
2504
2538
  } catch (error) {
2505
2539
  return {
2506
2540
  ok: false,
2507
- message: `Failed to read file: ${resolved.label} (${error instanceof Error ? error.message : String(error)})`,
2541
+ message: `Could not read file: ${resolved.label} (${error instanceof Error ? error.message : String(error)})`,
2508
2542
  };
2509
2543
  }
2510
2544
  }
@@ -2864,23 +2898,18 @@ function writeStudioPreviewExportFile(path: string | null, data: Buffer): { file
2864
2898
  }
2865
2899
  }
2866
2900
 
2867
- function writeStudioFile(pathArg: string, cwd: string, content: string):
2868
- | { ok: true; label: string; resolvedPath: string }
2869
- | { ok: false; message: string } {
2870
- const resolved = resolveStudioPath(pathArg, cwd);
2871
- if (resolved.ok === false) {
2872
- return { ok: false, message: resolved.message };
2873
- }
2874
-
2875
- try {
2876
- writeFileSync(resolved.resolved, content, "utf-8");
2877
- return { ok: true, label: resolved.label, resolvedPath: resolved.resolved };
2878
- } catch (error) {
2879
- return {
2880
- ok: false,
2881
- message: `Failed to write file: ${resolved.label} (${error instanceof Error ? error.message : String(error)})`,
2882
- };
2883
- }
2901
+ function writeStudioFile(pathArg: string, cwd: string, content: string, overwrite = false, expectedRevision?: string):
2902
+ | { ok: true; label: string; resolvedPath: string; diskRevision: string }
2903
+ | { ok: false; conflict?: boolean; reason?: string; path?: string; currentRevision?: string | null; message: string } {
2904
+ const normalizedLabel = normalizePathInput(pathArg);
2905
+ const result = saveStudioDiskFileAs({ path: pathArg, cwd, content, overwrite, expectedRevision }) as StudioDiskWriteResult;
2906
+ if (result.ok === false) return result;
2907
+ return {
2908
+ ok: true,
2909
+ label: normalizedLabel || basename(result.path),
2910
+ resolvedPath: result.path,
2911
+ diskRevision: result.revision,
2912
+ };
2884
2913
  }
2885
2914
 
2886
2915
  function writeStudioSideQuestionMarkdownFile(
@@ -7961,6 +7990,13 @@ async function respondLocalPreviewLinkJson(
7961
7990
  }
7962
7991
 
7963
7992
  const action = (requestUrl.searchParams.get("action") ?? "resolve").trim().toLowerCase();
7993
+ const originDocId = (requestUrl.searchParams.get("docId") ?? "").trim();
7994
+ const originDocument = originDocId ? readTransientStudioDocument(originDocId) : null;
7995
+ const originatesFromWatchedPreview = requestUrl.searchParams.get("watchedFile") === "1" || originDocument?.watchFile === true;
7996
+ if (action === "document" && originatesFromWatchedPreview) {
7997
+ respondJson(res, 409, { ok: false, error: "Open here is unavailable in a watched preview. Open a separate editable file tab instead." });
7998
+ return;
7999
+ }
7964
8000
  const basePayload = {
7965
8001
  ok: true,
7966
8002
  kind: resource.kind,
@@ -7990,10 +8026,14 @@ async function respondLocalPreviewLinkJson(
7990
8026
  return;
7991
8027
  }
7992
8028
 
7993
- if (action !== "document" && action !== "editor-url") {
8029
+ if (action !== "document" && action !== "editor-url" && action !== "watch-url") {
7994
8030
  respondJson(res, 400, { ok: false, error: "Unsupported local link action." });
7995
8031
  return;
7996
8032
  }
8033
+ if (action === "watch-url" && resource.kind !== "text") {
8034
+ respondJson(res, 400, { ok: false, error: "Follow-changes preview is available only for readable text files." });
8035
+ return;
8036
+ }
7997
8037
  if (resource.kind !== "text" && resource.kind !== "office") {
7998
8038
  respondJson(res, 400, { ok: false, error: "This local resource is not a document Studio can load into the editor." });
7999
8039
  return;
@@ -8038,6 +8078,7 @@ async function respondLocalPreviewLinkJson(
8038
8078
  source: "file",
8039
8079
  path: file.resolvedPath,
8040
8080
  resourceDir: documentResourceDir,
8081
+ diskRevision: file.diskRevision,
8041
8082
  };
8042
8083
  }
8043
8084
  if (action === "document") {
@@ -8047,10 +8088,18 @@ async function respondLocalPreviewLinkJson(
8047
8088
  label: document.label,
8048
8089
  converted,
8049
8090
  resourceDir: documentResourceDir,
8091
+ diskRevision: document.diskRevision || null,
8050
8092
  });
8051
8093
  return;
8052
8094
  }
8053
8095
 
8096
+ if (action === "watch-url") {
8097
+ if (responseText.length > PREVIEW_RENDER_MAX_CHARS) {
8098
+ respondJson(res, 413, { ok: false, error: `Watched preview source exceeds ${PREVIEW_RENDER_MAX_CHARS.toLocaleString()} characters.` });
8099
+ return;
8100
+ }
8101
+ document = { ...document, watchFile: true };
8102
+ }
8054
8103
  const docId = storeTransientStudioDocument(document);
8055
8104
  respondJson(res, 200, {
8056
8105
  ...basePayload,
@@ -9956,21 +10005,35 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
9956
10005
  msg.type === "save_as_request" &&
9957
10006
  typeof msg.requestId === "string" &&
9958
10007
  typeof msg.path === "string" &&
9959
- typeof msg.content === "string"
10008
+ typeof msg.content === "string" &&
10009
+ (msg.overwrite === undefined || typeof msg.overwrite === "boolean") &&
10010
+ (msg.expectedRevision === undefined || typeof msg.expectedRevision === "string")
9960
10011
  ) {
9961
10012
  return {
9962
10013
  type: "save_as_request",
9963
10014
  requestId: msg.requestId,
9964
10015
  path: msg.path,
9965
10016
  content: msg.content,
10017
+ overwrite: msg.overwrite === true,
10018
+ expectedRevision: typeof msg.expectedRevision === "string" ? msg.expectedRevision : undefined,
9966
10019
  };
9967
10020
  }
9968
10021
 
9969
- if (msg.type === "save_over_request" && typeof msg.requestId === "string" && typeof msg.content === "string") {
10022
+ if (
10023
+ msg.type === "save_over_request"
10024
+ && typeof msg.requestId === "string"
10025
+ && typeof msg.path === "string"
10026
+ && typeof msg.content === "string"
10027
+ && (msg.expectedRevision === undefined || typeof msg.expectedRevision === "string")
10028
+ && (msg.force === undefined || typeof msg.force === "boolean")
10029
+ ) {
9970
10030
  return {
9971
10031
  type: "save_over_request",
9972
10032
  requestId: msg.requestId,
10033
+ path: msg.path,
9973
10034
  content: msg.content,
10035
+ expectedRevision: typeof msg.expectedRevision === "string" ? msg.expectedRevision : undefined,
10036
+ force: msg.force === true,
9974
10037
  };
9975
10038
  }
9976
10039
 
@@ -9986,6 +10049,19 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
9986
10049
  };
9987
10050
  }
9988
10051
 
10052
+ if (
10053
+ msg.type === "watch_file_subscribe"
10054
+ && typeof msg.path === "string"
10055
+ && msg.path.length <= 16_384
10056
+ && (msg.revision === undefined || typeof msg.revision === "string")
10057
+ ) {
10058
+ return {
10059
+ type: "watch_file_subscribe",
10060
+ path: msg.path,
10061
+ revision: typeof msg.revision === "string" ? msg.revision : undefined,
10062
+ };
10063
+ }
10064
+
9989
10065
  if (msg.type === "send_to_editor_request" && typeof msg.requestId === "string" && typeof msg.content === "string") {
9990
10066
  return {
9991
10067
  type: "send_to_editor_request",
@@ -11322,6 +11398,7 @@ function buildStudioRelativeUrl(
11322
11398
  if (doc?.path) params.set("docPath", doc.path);
11323
11399
  if (doc?.draftId) params.set("draftId", doc.draftId);
11324
11400
  if (doc?.resourceDir) params.set("resourceDir", doc.resourceDir);
11401
+ if (doc?.watchFile) params.set("watchFile", "1");
11325
11402
  if (options?.skipWorkspaceRestore) params.set("skipWorkspaceRestore", "1");
11326
11403
  if (options?.paneFocus) params.set("paneFocus", options.paneFocus);
11327
11404
  return `/?${params.toString()}`;
@@ -11411,16 +11488,36 @@ function resolveRequestedStudioDocumentFromUrl(
11411
11488
  const requestedLabel = (requestUrl.searchParams.get("docLabel") ?? "").trim();
11412
11489
  const requestedDraftId = (requestUrl.searchParams.get("draftId") ?? "").trim();
11413
11490
  const requestedResourceDir = (requestUrl.searchParams.get("resourceDir") ?? "").trim();
11491
+ const requestedWatchFile = requestUrl.searchParams.get("watchFile") === "1";
11414
11492
 
11415
11493
  if (requestedPath) {
11416
11494
  const file = readStudioFile(requestedPath, studioCwd);
11417
- if (file.ok !== false) {
11495
+ const requestedCanonicalTextPath = resolve(requestedPath);
11496
+ const resolvedFilePath = file.ok !== false ? resolve(file.resolvedPath) : "";
11497
+ const watchedPathStayedCanonical = !requestedWatchFile || (
11498
+ process.platform === "win32"
11499
+ ? requestedCanonicalTextPath.toLowerCase() === resolvedFilePath.toLowerCase()
11500
+ : requestedCanonicalTextPath === resolvedFilePath
11501
+ );
11502
+ if (file.ok !== false && watchedPathStayedCanonical) {
11418
11503
  return {
11419
11504
  text: file.text,
11420
11505
  label: requestedLabel || file.label,
11421
11506
  source: "file",
11422
11507
  path: file.resolvedPath,
11423
11508
  resourceDir: requestedResourceDir || fallback?.resourceDir || studioCwd,
11509
+ diskRevision: file.diskRevision,
11510
+ watchFile: requestedWatchFile,
11511
+ };
11512
+ }
11513
+ if (requestedWatchFile && isAbsolute(requestedPath)) {
11514
+ return {
11515
+ text: fallback?.watchFile && fallback.path === requestedPath ? fallback.text : "",
11516
+ label: requestedLabel || basename(requestedPath),
11517
+ source: "file",
11518
+ path: resolve(requestedPath),
11519
+ resourceDir: requestedResourceDir || dirname(resolve(requestedPath)),
11520
+ watchFile: true,
11424
11521
  };
11425
11522
  }
11426
11523
  }
@@ -11705,6 +11802,8 @@ function buildStudioHtml(
11705
11802
  const initialPath = escapeHtmlForInline(initialDocument?.path ?? "");
11706
11803
  const initialDraftId = escapeHtmlForInline(initialDocument?.draftId ?? "");
11707
11804
  const initialResourceDir = escapeHtmlForInline(initialDocument?.resourceDir ?? "");
11805
+ const initialDiskRevision = escapeHtmlForInline(initialDocument?.diskRevision ?? "");
11806
+ const initialWatchFile = initialDocument?.watchFile === true ? "1" : "0";
11708
11807
  const initialModel = escapeHtmlForInline(initialModelLabel ?? "none");
11709
11808
  const initialTerminal = escapeHtmlForInline(initialTerminalLabel ?? "unknown");
11710
11809
  const initialTerminalDetailAttr = escapeHtmlForInline(initialTerminalDetail ?? initialTerminalLabel ?? "unknown");
@@ -11781,12 +11880,12 @@ ${cssVarsBlock}
11781
11880
  </style>
11782
11881
  <link rel="stylesheet" href="${stylesheetHref}" />
11783
11882
  </head>
11784
- <body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-initial-draft-id="${initialDraftId}" data-initial-resource-dir="${initialResourceDir}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}" data-terminal-detail="${initialTerminalDetailAttr}" data-theme-name="${initialTheme}" data-context-tokens="${initialContextTokens}" data-context-window="${initialContextWindow}" data-context-percent="${initialContextPercent}" data-studio-mode="${studioMode}" data-ssh-session="${initialSshSession}">
11883
+ <body data-initial-source="${initialSource}" data-initial-label="${initialLabel}" data-initial-path="${initialPath}" data-initial-draft-id="${initialDraftId}" data-initial-resource-dir="${initialResourceDir}" data-initial-disk-revision="${initialDiskRevision}" data-watched-file-preview="${initialWatchFile}" data-model-label="${initialModel}" data-terminal-label="${initialTerminal}" data-terminal-detail="${initialTerminalDetailAttr}" data-theme-name="${initialTheme}" data-context-tokens="${initialContextTokens}" data-context-window="${initialContextWindow}" data-context-percent="${initialContextPercent}" data-studio-mode="${studioMode}" data-ssh-session="${initialSshSession}">
11785
11884
  <header id="studioHeader">
11786
11885
  <h1><span class="app-logo" aria-hidden="true">π</span> Studio <span class="app-subtitle">${appSubtitle}</span></h1>
11787
11886
  <div class="controls">
11788
- <button id="saveAsBtn" type="button" title="Save editor content to a new file path. Cmd/Ctrl+S falls back here when no direct save path is available.">Save editor as…</button>
11789
- <button id="saveOverBtn" type="button" title="Overwrite current file with editor content. Shortcut: Cmd/Ctrl+S.">Save editor</button>
11887
+ <button id="saveAsBtn" type="button" title="Save editor content to a new file path. Shortcut: Cmd/Ctrl+Shift+S; Cmd/Ctrl+S falls back here for new drafts.">Save editor as…</button>
11888
+ <button id="saveOverBtn" type="button" title="Save only when the current file still matches Studio's disk revision. Shortcut: Cmd/Ctrl+S.">Save editor</button>
11790
11889
  <button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
11791
11890
  <button id="clearWorkspaceBtn" type="button" title="Clear editor text and reset this tab to a fresh blank draft. Saved files and responses are not changed.">Reset editor</button>
11792
11891
  <button id="importFileBtn" type="button" title="Import a file as an editable copy.">Import file copy…</button>
@@ -12020,6 +12119,7 @@ ${cssVarsBlock}
12020
12119
  </div>
12021
12120
  <div class="section-header-actions">
12022
12121
  <button id="rightFocusBtn" class="pane-focus-btn" type="button" title="Show only the response pane. Shortcut: F10 or Cmd/Ctrl+Esc.">Focus pane</button>
12122
+ <button id="watchedOpenEditableBtn" type="button" hidden title="Open this watched file in a separate editable Studio tab with conflict-safe saving.">Open editable tab</button>
12023
12123
  <span id="exportPreviewControls" class="export-preview-controls">
12024
12124
  <button id="exportPdfBtn" class="export-preview-trigger" type="button" aria-haspopup="menu" aria-expanded="false" title="Choose a format and export the current right-pane preview.">Export right preview</button>
12025
12125
  <div id="exportPreviewMenu" class="export-preview-menu" role="menu" hidden>
@@ -12137,7 +12237,8 @@ ${cssVarsBlock}
12137
12237
  <section class="shortcuts-group">
12138
12238
  <h3>Editor</h3>
12139
12239
  <dl>
12140
- <div><dt>Cmd/Ctrl+S</dt><dd>Save editor</dd></div>
12240
+ <div><dt>Cmd/Ctrl+S</dt><dd>Save editor when the disk revision still matches</dd></div>
12241
+ <div><dt>Cmd/Ctrl+Shift+S</dt><dd>Save editor as a new file</dd></div>
12141
12242
  <div class="shortcuts-full-only"><dt>Cmd/Ctrl+Enter</dt><dd>Run editor text, or queue steering during an active run</dd></div>
12142
12243
  <div><dt>Option/Alt+Tab or Cmd/Ctrl+Shift+Space</dt><dd>Suggest a completion at the editor cursor</dd></div>
12143
12244
  <div><dt>Tab</dt><dd>Insert a visible completion suggestion; otherwise indent selected editor text</dd></div>
@@ -12213,6 +12314,9 @@ export default function (pi: ExtensionAPI) {
12213
12314
  let serverState: StudioServerState | null = null;
12214
12315
  const studioWorkspaceStateStore = createStudioWorkspaceStateStore();
12215
12316
  const studioResourceGrantRegistry = createStudioResourceGrantRegistry();
12317
+ const studioFileWatchers = new Map<WebSocket, ReturnType<typeof createStudioFileWatcher>>();
12318
+ const studioWatchedClientPaths = new Map<WebSocket, string>();
12319
+ const studioFileWatchSubscriptionGenerations = new Map<WebSocket, number>();
12216
12320
  let activeRequest: ActiveStudioRequest | null = null;
12217
12321
  let studioDirectRunChain: StudioDirectRunChain | null = null;
12218
12322
  let queuedStudioDirectRequests: QueuedStudioDirectRequest[] = [];
@@ -12273,6 +12377,21 @@ export default function (pi: ExtensionAPI) {
12273
12377
  actionRequestId: null,
12274
12378
  };
12275
12379
 
12380
+ const closeStudioFileWatcher = async (client: WebSocket): Promise<void> => {
12381
+ const watcher = studioFileWatchers.get(client);
12382
+ if (!watcher) return;
12383
+ studioFileWatchers.delete(client);
12384
+ await watcher.close().catch(() => undefined);
12385
+ };
12386
+
12387
+ const closeAllStudioFileWatchers = async (): Promise<void> => {
12388
+ const clients = Array.from(studioFileWatchers.keys());
12389
+ await Promise.all(clients.map((client) => closeStudioFileWatcher(client)));
12390
+ // Keep socket capability metadata until each socket is closed. Clearing it
12391
+ // while an authenticated socket is still open would briefly make a watched
12392
+ // preview look writable to the save handlers.
12393
+ };
12394
+
12276
12395
  const recordStudioDocumentResourceGrants = (document: InitialStudioDocument, cwd = studioCwd) => {
12277
12396
  if (document.path) {
12278
12397
  try {
@@ -14393,6 +14512,92 @@ export default function (pi: ExtensionAPI) {
14393
14512
  }
14394
14513
  };
14395
14514
 
14515
+ const subscribeStudioWatchedFile = async (client: WebSocket, msg: WatchFileSubscribeMessage): Promise<void> => {
14516
+ const subscriptionGeneration = (studioFileWatchSubscriptionGenerations.get(client) ?? 0) + 1;
14517
+ studioFileWatchSubscriptionGenerations.set(client, subscriptionGeneration);
14518
+ await closeStudioFileWatcher(client);
14519
+ if (studioFileWatchSubscriptionGenerations.get(client) !== subscriptionGeneration || client.readyState !== WebSocket.OPEN) return;
14520
+ if (studioFileWatchers.size >= STUDIO_WATCHED_FILE_MAX_CLIENTS) {
14521
+ sendToClient(client, {
14522
+ type: "watched_file_error",
14523
+ path: msg.path,
14524
+ message: `Studio watched-file client limit reached (${STUDIO_WATCHED_FILE_MAX_CLIENTS}).`,
14525
+ });
14526
+ return;
14527
+ }
14528
+ const canonicalPath = resolve(msg.path);
14529
+ const clientWatchedPath = studioWatchedClientPaths.get(client) ?? "";
14530
+ if (clientWatchedPath !== canonicalPath) {
14531
+ sendToClient(client, {
14532
+ type: "watched_file_error",
14533
+ path: canonicalPath,
14534
+ message: "This socket is not authorized for that server-created watched preview.",
14535
+ });
14536
+ return;
14537
+ }
14538
+
14539
+ const watcher = createStudioFileWatcher({
14540
+ filePath: canonicalPath,
14541
+ initialRevision: msg.revision,
14542
+ readSnapshot: (filePath: string) => {
14543
+ const file = readStudioFile(filePath, dirname(filePath), { requireCanonicalPath: true });
14544
+ if (file.ok === false) throw new Error(file.message);
14545
+ if (file.text.length > PREVIEW_RENDER_MAX_CHARS) {
14546
+ throw new Error(`Watched file exceeds the ${PREVIEW_RENDER_MAX_CHARS.toLocaleString()}-character preview limit.`);
14547
+ }
14548
+ return { path: file.resolvedPath, text: file.text, revision: file.diskRevision };
14549
+ },
14550
+ onUpdate: (snapshot: { path: string; text: string; revision: string }, details: { generation: number; recovered: boolean }) => {
14551
+ if (studioFileWatchers.get(client) !== watcher || client.readyState !== WebSocket.OPEN) return;
14552
+ sendToClient(client, {
14553
+ type: "watched_file_update",
14554
+ path: snapshot.path,
14555
+ text: snapshot.text,
14556
+ diskRevision: snapshot.revision,
14557
+ generation: details.generation,
14558
+ recovered: details.recovered,
14559
+ message: `Updated ${basename(snapshot.path)} from disk.`,
14560
+ });
14561
+ },
14562
+ onError: (error: unknown, details: { generation: number; lastRevision: string | null }) => {
14563
+ if (studioFileWatchers.get(client) !== watcher || client.readyState !== WebSocket.OPEN) return;
14564
+ sendToClient(client, {
14565
+ type: "watched_file_error",
14566
+ path: canonicalPath,
14567
+ diskRevision: details.lastRevision,
14568
+ generation: details.generation,
14569
+ message: `Could not refresh ${basename(canonicalPath)}; keeping the last good preview: ${error instanceof Error ? error.message : String(error)}`,
14570
+ });
14571
+ },
14572
+ onRecovered: (snapshot: { path: string; revision: string }, details: { generation: number; changed: boolean }) => {
14573
+ if (studioFileWatchers.get(client) !== watcher || client.readyState !== WebSocket.OPEN || details.changed) return;
14574
+ sendToClient(client, {
14575
+ type: "watched_file_ready",
14576
+ path: snapshot.path,
14577
+ diskRevision: snapshot.revision,
14578
+ generation: details.generation,
14579
+ recovered: true,
14580
+ message: `Watching ${basename(snapshot.path)} for disk changes.`,
14581
+ });
14582
+ },
14583
+ });
14584
+ studioFileWatchers.set(client, watcher);
14585
+ await watcher.refresh();
14586
+ if (
14587
+ studioFileWatchers.get(client) === watcher
14588
+ && client.readyState === WebSocket.OPEN
14589
+ && !watcher.error
14590
+ ) {
14591
+ sendToClient(client, {
14592
+ type: "watched_file_ready",
14593
+ path: canonicalPath,
14594
+ diskRevision: watcher.revision,
14595
+ generation: watcher.generation,
14596
+ message: `Watching ${basename(canonicalPath)} for disk changes.`,
14597
+ });
14598
+ }
14599
+ };
14600
+
14396
14601
  const handleStudioMessage = (client: WebSocket, msg: IncomingStudioMessage) => {
14397
14602
  if (msg.type === "ping") {
14398
14603
  sendToClient(client, { type: "pong", timestamp: Date.now() });
@@ -14402,6 +14607,16 @@ export default function (pi: ExtensionAPI) {
14402
14607
  studioWorkspaceStateStore.set(msg.tabStateId, msg.state);
14403
14608
  return;
14404
14609
  }
14610
+ if (msg.type === "watch_file_subscribe") {
14611
+ void subscribeStudioWatchedFile(client, msg).catch((error) => {
14612
+ sendToClient(client, {
14613
+ type: "watched_file_error",
14614
+ path: msg.path,
14615
+ message: `Could not start watched preview: ${error instanceof Error ? error.message : String(error)}`,
14616
+ });
14617
+ });
14618
+ return;
14619
+ }
14405
14620
 
14406
14621
  emitDebugEvent("studio_message", {
14407
14622
  type: msg.type,
@@ -15372,6 +15587,10 @@ export default function (pi: ExtensionAPI) {
15372
15587
  }
15373
15588
 
15374
15589
  if (msg.type === "save_as_request") {
15590
+ if (studioWatchedClientPaths.has(client)) {
15591
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Watched previews are read-only. Open an editable Studio tab before saving." });
15592
+ return;
15593
+ }
15375
15594
  if (!isValidRequestId(msg.requestId)) {
15376
15595
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
15377
15596
  return;
@@ -15380,14 +15599,21 @@ export default function (pi: ExtensionAPI) {
15380
15599
  sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
15381
15600
  return;
15382
15601
  }
15383
- if (!msg.content.trim()) {
15384
- sendToClient(client, { type: "error", requestId: msg.requestId, message: "Nothing to save." });
15385
- return;
15386
- }
15387
15602
 
15388
- const result = writeStudioFile(msg.path, studioCwd, msg.content);
15603
+ const result = writeStudioFile(msg.path, studioCwd, msg.content, msg.overwrite === true, msg.expectedRevision);
15389
15604
  if (result.ok === false) {
15390
- sendToClient(client, { type: "error", requestId: msg.requestId, message: result.message });
15605
+ if (result.conflict) {
15606
+ sendToClient(client, {
15607
+ type: "save_as_conflict",
15608
+ requestId: msg.requestId,
15609
+ path: result.path || msg.path,
15610
+ currentRevision: result.currentRevision || null,
15611
+ reason: result.reason || "target-exists",
15612
+ message: result.message,
15613
+ });
15614
+ } else {
15615
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: result.message });
15616
+ }
15391
15617
  return;
15392
15618
  }
15393
15619
 
@@ -15397,6 +15623,7 @@ export default function (pi: ExtensionAPI) {
15397
15623
  source: "file",
15398
15624
  path: result.resolvedPath,
15399
15625
  resourceDir: dirname(result.resolvedPath),
15626
+ diskRevision: result.diskRevision,
15400
15627
  };
15401
15628
  recordStudioDocumentResourceGrants(initialStudioDocument);
15402
15629
 
@@ -15406,12 +15633,17 @@ export default function (pi: ExtensionAPI) {
15406
15633
  path: result.resolvedPath,
15407
15634
  label: result.label,
15408
15635
  resourceDir: dirname(result.resolvedPath),
15636
+ diskRevision: result.diskRevision,
15409
15637
  message: `Saved editor text to ${result.label}`,
15410
15638
  });
15411
15639
  return;
15412
15640
  }
15413
15641
 
15414
15642
  if (msg.type === "save_over_request") {
15643
+ if (studioWatchedClientPaths.has(client)) {
15644
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Watched previews are read-only. Open an editable Studio tab before saving." });
15645
+ return;
15646
+ }
15415
15647
  if (!isValidRequestId(msg.requestId)) {
15416
15648
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
15417
15649
  return;
@@ -15420,35 +15652,52 @@ export default function (pi: ExtensionAPI) {
15420
15652
  sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
15421
15653
  return;
15422
15654
  }
15423
- if (!initialStudioDocument || initialStudioDocument.source !== "file" || !initialStudioDocument.path) {
15424
- sendToClient(client, {
15425
- type: "error",
15426
- requestId: msg.requestId,
15427
- message: "Save file is only available for file-backed documents.",
15428
- });
15655
+
15656
+ const result = saveStudioDiskFileIfRevision({
15657
+ path: msg.path,
15658
+ content: msg.content,
15659
+ expectedRevision: msg.expectedRevision,
15660
+ force: msg.force === true,
15661
+ }) as StudioDiskWriteResult;
15662
+ if (result.ok === false) {
15663
+ if (result.conflict) {
15664
+ sendToClient(client, {
15665
+ type: "save_conflict",
15666
+ requestId: msg.requestId,
15667
+ path: result.path || msg.path,
15668
+ currentRevision: result.currentRevision || null,
15669
+ reason: result.reason || "disk-changed",
15670
+ canOverwrite: result.reason !== "location-changed" && result.reason !== "hard-linked-file",
15671
+ message: result.message,
15672
+ });
15673
+ } else {
15674
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: result.message });
15675
+ }
15429
15676
  return;
15430
15677
  }
15431
15678
 
15432
- try {
15433
- writeFileSync(initialStudioDocument.path, msg.content, "utf-8");
15434
- initialStudioDocument = {
15435
- ...initialStudioDocument,
15436
- text: msg.content,
15437
- };
15438
- sendToClient(client, {
15439
- type: "saved",
15440
- requestId: msg.requestId,
15441
- path: initialStudioDocument.path,
15442
- label: initialStudioDocument.label,
15443
- message: `Saved over ${initialStudioDocument.label}`,
15444
- });
15445
- } catch (error) {
15446
- sendToClient(client, {
15447
- type: "error",
15448
- requestId: msg.requestId,
15449
- message: `Failed to save over file: ${error instanceof Error ? error.message : String(error)}`,
15450
- });
15451
- }
15679
+ const previousDocument = initialStudioDocument;
15680
+ const existingLabel = previousDocument?.path === result.path
15681
+ ? previousDocument.label
15682
+ : basename(result.path);
15683
+ initialStudioDocument = {
15684
+ text: msg.content,
15685
+ label: existingLabel,
15686
+ source: "file",
15687
+ path: result.path,
15688
+ resourceDir: dirname(result.path),
15689
+ diskRevision: result.revision,
15690
+ };
15691
+ recordStudioDocumentResourceGrants(initialStudioDocument);
15692
+ sendToClient(client, {
15693
+ type: "saved",
15694
+ requestId: msg.requestId,
15695
+ path: result.path,
15696
+ label: existingLabel,
15697
+ resourceDir: dirname(result.path),
15698
+ diskRevision: result.revision,
15699
+ message: `Saved over ${existingLabel}`,
15700
+ });
15452
15701
  return;
15453
15702
  }
15454
15703
 
@@ -15472,7 +15721,7 @@ export default function (pi: ExtensionAPI) {
15472
15721
  return;
15473
15722
  }
15474
15723
 
15475
- const refreshed = readStudioFile(refreshPath, studioCwd);
15724
+ const refreshed = readStudioFile(refreshPath, studioCwd, { requireCanonicalPath: true });
15476
15725
  if (refreshed.ok === false) {
15477
15726
  sendToClient(client, {
15478
15727
  type: "error",
@@ -15488,6 +15737,7 @@ export default function (pi: ExtensionAPI) {
15488
15737
  source: "file",
15489
15738
  path: refreshed.resolvedPath,
15490
15739
  resourceDir: dirname(refreshed.resolvedPath),
15740
+ diskRevision: refreshed.diskRevision,
15491
15741
  };
15492
15742
  if (!requestedPath || initialStudioDocument?.path === refreshed.resolvedPath) {
15493
15743
  initialStudioDocument = refreshedDocument;
@@ -16855,7 +17105,14 @@ export default function (pi: ExtensionAPI) {
16855
17105
  studioCwd,
16856
17106
  studioResourceGrantRegistry,
16857
17107
  );
16858
- await respondLocalPreviewLinkJson(req, res, requestUrl, resource, serverState, studioResourceGrantRegistry);
17108
+ await respondLocalPreviewLinkJson(
17109
+ req,
17110
+ res,
17111
+ requestUrl,
17112
+ resource,
17113
+ serverState,
17114
+ studioResourceGrantRegistry,
17115
+ );
16859
17116
  } catch (error) {
16860
17117
  if (error instanceof StudioResourceGrantRequiredError) {
16861
17118
  respondStudioResourceGrantRequiredJson(res, error);
@@ -17019,6 +17276,32 @@ export default function (pi: ExtensionAPI) {
17019
17276
  const host = req.headers.host ?? `127.0.0.1:${state.port}`;
17020
17277
  const requestUrl = new URL(req.url ?? "/ws", `http://${host}`);
17021
17278
  const clientMode = normalizeStudioUiMode(requestUrl.searchParams.get("mode"));
17279
+ const requestedWatchedPath = (requestUrl.searchParams.get("watchPath") ?? "").trim();
17280
+ const canonicalWatchedPath = requestedWatchedPath ? resolve(requestedWatchedPath) : "";
17281
+ const requestedDocId = (requestUrl.searchParams.get("docId") ?? "").trim();
17282
+ const capabilityDocument = requestedDocId ? readTransientStudioDocument(requestedDocId) : null;
17283
+ const capabilityWatchedPath = capabilityDocument?.watchFile && capabilityDocument.path
17284
+ ? resolve(capabilityDocument.path)
17285
+ : "";
17286
+ const comparableWatchedPath = process.platform === "win32" ? canonicalWatchedPath.toLowerCase() : canonicalWatchedPath;
17287
+ const comparableCapabilityPath = process.platform === "win32" ? capabilityWatchedPath.toLowerCase() : capabilityWatchedPath;
17288
+ const requestsWatchedCapability = Boolean(canonicalWatchedPath || capabilityWatchedPath);
17289
+ if (
17290
+ requestsWatchedCapability
17291
+ && (
17292
+ clientMode !== "editor-only"
17293
+ || !canonicalWatchedPath
17294
+ || !capabilityWatchedPath
17295
+ || comparableWatchedPath !== comparableCapabilityPath
17296
+ )
17297
+ ) {
17298
+ try {
17299
+ ws.close(4003, "Watched preview authorization unavailable");
17300
+ } catch {
17301
+ // Ignore close errors.
17302
+ }
17303
+ return;
17304
+ }
17022
17305
  if (clientMode === "full") {
17023
17306
  for (const client of clients) {
17024
17307
  if (client.readyState !== WebSocket.OPEN) continue;
@@ -17034,6 +17317,7 @@ export default function (pi: ExtensionAPI) {
17034
17317
  }
17035
17318
  clients.add(ws);
17036
17319
  clientModes.set(ws, clientMode);
17320
+ if (canonicalWatchedPath) studioWatchedClientPaths.set(ws, canonicalWatchedPath);
17037
17321
  emitDebugEvent("studio_ws_connected", { clients: clients.size, mode: clientMode });
17038
17322
  broadcastState();
17039
17323
 
@@ -17046,15 +17330,33 @@ export default function (pi: ExtensionAPI) {
17046
17330
  handleStudioMessage(ws, parsed);
17047
17331
  });
17048
17332
 
17049
- ws.on("close", () => {
17333
+ const cleanupClient = () => {
17050
17334
  clients.delete(ws);
17051
17335
  clientModes.delete(ws);
17336
+ studioWatchedClientPaths.delete(ws);
17337
+ const cleanupGeneration = (studioFileWatchSubscriptionGenerations.get(ws) ?? 0) + 1;
17338
+ studioFileWatchSubscriptionGenerations.set(ws, cleanupGeneration);
17339
+ void closeStudioFileWatcher(ws).finally(() => {
17340
+ if (studioFileWatchSubscriptionGenerations.get(ws) === cleanupGeneration) {
17341
+ studioFileWatchSubscriptionGenerations.delete(ws);
17342
+ }
17343
+ });
17344
+ };
17345
+
17346
+ ws.on("close", () => {
17347
+ cleanupClient();
17052
17348
  emitDebugEvent("studio_ws_disconnected", { clients: clients.size });
17053
17349
  });
17054
17350
 
17055
17351
  ws.on("error", () => {
17056
- clients.delete(ws);
17057
- clientModes.delete(ws);
17352
+ // Make the socket unusable before removing its watched-path marker;
17353
+ // otherwise an error event could briefly weaken read-only enforcement.
17354
+ try {
17355
+ ws.terminate();
17356
+ } catch {
17357
+ try { ws.close(); } catch {}
17358
+ }
17359
+ cleanupClient();
17058
17360
  });
17059
17361
  });
17060
17362
 
@@ -17122,7 +17424,12 @@ export default function (pi: ExtensionAPI) {
17122
17424
  const stopServer = async () => {
17123
17425
  studioSideQuestionStartRequestId = null;
17124
17426
  await disposeStudioSideQuestionRuntime();
17125
- if (!serverState) return;
17427
+ await closeAllStudioFileWatchers();
17428
+ if (!serverState) {
17429
+ studioWatchedClientPaths.clear();
17430
+ studioFileWatchSubscriptionGenerations.clear();
17431
+ return;
17432
+ }
17126
17433
  clearStudioDirectRunState();
17127
17434
  clearActiveRequest();
17128
17435
  clearPendingStudioCompletion();
@@ -17148,6 +17455,8 @@ export default function (pi: ExtensionAPI) {
17148
17455
  await new Promise<void>((resolve) => {
17149
17456
  state.server.close(() => resolve());
17150
17457
  });
17458
+ studioWatchedClientPaths.clear();
17459
+ studioFileWatchSubscriptionGenerations.clear();
17151
17460
  studioWorkspaceStateStore.clear();
17152
17461
  studioResourceGrantRegistry.clear();
17153
17462
  };
@@ -17166,6 +17475,13 @@ export default function (pi: ExtensionAPI) {
17166
17475
  await disposeStudioSideQuestionRuntime();
17167
17476
  studioTraceHistory.clear();
17168
17477
  lastCommandCtx = null;
17478
+ for (const client of studioWatchedClientPaths.keys()) {
17479
+ try { client.close(4001, "Watched preview session changed"); } catch {}
17480
+ }
17481
+ await closeAllStudioFileWatchers();
17482
+ transientStudioDocuments.clear();
17483
+ studioResourceGrantRegistry.clear();
17484
+ initialStudioDocument = null;
17169
17485
  }
17170
17486
  latestModelRequestCtx = ctx;
17171
17487
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
@@ -17517,7 +17833,7 @@ export default function (pi: ExtensionAPI) {
17517
17833
  const resolveStudioLaunchDocument = (
17518
17834
  trimmed: string,
17519
17835
  ctx: ExtensionCommandContext,
17520
- options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; allowPdfPreview?: boolean; watchPdf?: boolean },
17836
+ options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; allowPdfPreview?: boolean; watchPdf?: boolean; watchText?: boolean },
17521
17837
  ): StudioLaunchSelection | null => {
17522
17838
  const defaultSource = options?.defaultSource === "blank" ? "blank" : "last-response";
17523
17839
  const commandLabel = options?.commandLabel ?? "/studio";
@@ -17624,6 +17940,30 @@ export default function (pi: ExtensionAPI) {
17624
17940
  return null;
17625
17941
  }
17626
17942
 
17943
+ if (options?.watchText) {
17944
+ if (file.text.length > PREVIEW_RENDER_MAX_CHARS) {
17945
+ ctx.ui.notify(`Watched preview source exceeds ${PREVIEW_RENDER_MAX_CHARS.toLocaleString()} characters.`, "error");
17946
+ return null;
17947
+ }
17948
+ return {
17949
+ document: {
17950
+ text: file.text,
17951
+ label: file.label,
17952
+ source: "file",
17953
+ path: file.resolvedPath,
17954
+ resourceDir: dirname(file.resolvedPath),
17955
+ diskRevision: file.diskRevision,
17956
+ watchFile: true,
17957
+ },
17958
+ kind: "watched-preview",
17959
+ mode: "editor-only",
17960
+ transient: true,
17961
+ skipWorkspaceRestore: true,
17962
+ paneFocus: "right",
17963
+ resourcePath: file.resolvedPath,
17964
+ };
17965
+ }
17966
+
17627
17967
  if (file.text.length > 200_000) {
17628
17968
  ctx.ui.notify(
17629
17969
  "Loaded a large file. Studio critique requests currently reject documents over 200k characters.",
@@ -17637,6 +17977,7 @@ export default function (pi: ExtensionAPI) {
17637
17977
  source: "file",
17638
17978
  path: file.resolvedPath,
17639
17979
  resourceDir: ctx.cwd,
17980
+ diskRevision: file.diskRevision,
17640
17981
  });
17641
17982
  };
17642
17983
 
@@ -17894,7 +18235,7 @@ export default function (pi: ExtensionAPI) {
17894
18235
  trimmed: string,
17895
18236
  ctx: ExtensionCommandContext,
17896
18237
  mode: StudioUiMode,
17897
- options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; replaceExistingFull?: boolean; allowPdfPreview?: boolean; watchPdf?: boolean },
18238
+ options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; replaceExistingFull?: boolean; allowPdfPreview?: boolean; watchPdf?: boolean; watchText?: boolean },
17898
18239
  ) => {
17899
18240
  const launchOpenFlags = parseStudioLaunchOpenFlags(trimmed);
17900
18241
  if (launchOpenFlags.error) {
@@ -17910,11 +18251,12 @@ export default function (pi: ExtensionAPI) {
17910
18251
  const launchesPdfPreview = parsedLaunchPath
17911
18252
  ? Boolean(parseStudioPdfLaunchTarget(normalizePathInput(parsedLaunchPath)))
17912
18253
  : false;
17913
- if (launchOpenFlags.watchPdf && !launchesPdfPreview) {
17914
- ctx.ui.notify("--watch requires a local PDF path, for example: /studio --watch main.pdf", "error");
18254
+ if (launchOpenFlags.watchPdf && !parsedLaunchPath) {
18255
+ ctx.ui.notify("--watch requires a local text or PDF path, for example: /studio --watch notes.md", "error");
17915
18256
  return;
17916
18257
  }
17917
- const requestedLaunchMode: StudioUiMode = launchesPdfPreview ? "editor-only" : mode;
18258
+ const launchesWatchedTextPreview = Boolean(launchOpenFlags.watchPdf && parsedLaunchPath && !launchesPdfPreview);
18259
+ const requestedLaunchMode: StudioUiMode = launchesPdfPreview || launchesWatchedTextPreview ? "editor-only" : mode;
17918
18260
  if (requestedLaunchMode === "full" && hasConnectedFullStudioView()) {
17919
18261
  if (options?.replaceExistingFull) {
17920
18262
  closeStudioClientsByMode("full", 4001, "Full Studio replaced");
@@ -17948,7 +18290,8 @@ export default function (pi: ExtensionAPI) {
17948
18290
 
17949
18291
  const selection = resolveStudioLaunchDocument(launchArgs, ctx, {
17950
18292
  ...options,
17951
- watchPdf: launchOpenFlags.watchPdf,
18293
+ watchPdf: launchOpenFlags.watchPdf && launchesPdfPreview,
18294
+ watchText: launchesWatchedTextPreview,
17952
18295
  });
17953
18296
  if (!selection) return;
17954
18297
  const selected = selection.document;
@@ -17974,7 +18317,9 @@ export default function (pi: ExtensionAPI) {
17974
18317
  ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
17975
18318
  const openedLabel = selection.kind === "pdf-preview"
17976
18319
  ? "pi Studio PDF preview"
17977
- : (launchMode === "editor-only" ? "pi Studio editor-only view" : "pi Studio");
18320
+ : (selection.kind === "watched-preview"
18321
+ ? "pi Studio watched file preview"
18322
+ : (launchMode === "editor-only" ? "pi Studio editor-only view" : "pi Studio"));
17978
18323
 
17979
18324
  const shouldOpenBrowser = shouldAutoOpenStudioBrowser({
17980
18325
  openRemoteBrowser: launchOpenFlags.openRemoteBrowser,
@@ -17989,6 +18334,8 @@ export default function (pi: ExtensionAPI) {
17989
18334
  if (selection.kind === "pdf-preview") {
17990
18335
  const watchLabel = launchOpenFlags.watchPdf ? " (auto-refresh on)" : "";
17991
18336
  ctx.ui.notify(`Opened ${openedLabel}${watchLabel}: ${selection.resourcePath ?? selected.label}`, "info");
18337
+ } else if (selection.kind === "watched-preview") {
18338
+ ctx.ui.notify(`Opened ${openedLabel}: ${selection.resourcePath ?? selected.label}`, "info");
17992
18339
  } else if (selected.source === "file") {
17993
18340
  ctx.ui.notify(`Opened ${openedLabel} with file loaded: ${selected.label}`, "info");
17994
18341
  } else if (selected.source === "last-response") {
@@ -18011,7 +18358,7 @@ export default function (pi: ExtensionAPI) {
18011
18358
  };
18012
18359
 
18013
18360
  pi.registerCommand("studio", {
18014
- description: "Open pi Studio browser UI or a PDF preview (/studio, /studio <file>, /studio --watch <pdf>, /studio --blank, /studio --last, /studio --no-browser)",
18361
+ description: "Open pi Studio browser UI or a read-only watched preview (/studio, /studio <file>, /studio --watch <file>, /studio --blank, /studio --last, /studio --no-browser)",
18015
18362
  handler: async (args: string, ctx: ExtensionCommandContext) => {
18016
18363
  const trimmed = args.trim();
18017
18364
 
@@ -18042,7 +18389,7 @@ export default function (pi: ExtensionAPI) {
18042
18389
  "Usage: /studio [path|--blank|--last]\n"
18043
18390
  + " /studio Open studio with last model response (fallback: blank)\n"
18044
18391
  + " /studio <path> Open a text file in Studio, or a PDF in a read-only companion preview\n"
18045
- + " /studio --watch <pdf> Open a PDF with auto-refresh enabled\n"
18392
+ + " /studio --watch <file> Open a read-only text preview that follows disk changes, or a PDF with auto-refresh\n"
18046
18393
  + " /studio --blank Open with blank editor\n"
18047
18394
  + " /studio --last Open with last model response\n"
18048
18395
  + " /studio --no-browser Print the Studio URL without opening a browser\n"
@@ -18093,7 +18440,7 @@ export default function (pi: ExtensionAPI) {
18093
18440
  });
18094
18441
 
18095
18442
  pi.registerCommand("studio-editor-only", {
18096
- description: "Open pi Studio in editor-only mode or preview a PDF (/studio-editor-only, /studio-editor-only <file>, /studio-editor-only --watch <pdf>)",
18443
+ description: "Open pi Studio in editor-only mode or a read-only watched preview (/studio-editor-only, /studio-editor-only <file>, /studio-editor-only --watch <file>)",
18097
18444
  handler: async (args: string, ctx: ExtensionCommandContext) => {
18098
18445
  const trimmed = args.trim();
18099
18446
  if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
@@ -18101,7 +18448,7 @@ export default function (pi: ExtensionAPI) {
18101
18448
  "Usage: /studio-editor-only [path|--blank|--last]\n"
18102
18449
  + " /studio-editor-only Open an editor-only Studio view (default: blank editor)\n"
18103
18450
  + " /studio-editor-only <path> Open a text file for editing, or a PDF in a read-only preview\n"
18104
- + " /studio-editor-only --watch <pdf> Open a PDF with auto-refresh enabled\n"
18451
+ + " /studio-editor-only --watch <file> Open a read-only text preview that follows disk changes, or a PDF with auto-refresh\n"
18105
18452
  + " /studio-editor-only --blank Open with blank editor\n"
18106
18453
  + " /studio-editor-only --last Open with last model response loaded into the editor\n"
18107
18454
  + " /studio-editor-only --no-browser Print URL without opening a browser\n"
@@ -18457,6 +18804,7 @@ export default function (pi: ExtensionAPI) {
18457
18804
  label: file.label,
18458
18805
  source: "file",
18459
18806
  path: file.resolvedPath,
18807
+ diskRevision: file.diskRevision,
18460
18808
  };
18461
18809
  initialStudioDocument = nextDoc;
18462
18810
  recordStudioDocumentResourceGrants(nextDoc, ctx.cwd);