pi-studio 0.9.53 → 0.9.55

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,
@@ -68,9 +74,11 @@ import {
68
74
  import {
69
75
  buildStudioSideQuestionFollowUpPrompt,
70
76
  buildStudioSideQuestionPrompt,
77
+ getStudioSideQuestionThinkingLevels,
71
78
  normalizeStudioSideQuestionFocusKind,
72
79
  normalizeStudioSideQuestionGatherScope,
73
80
  normalizeStudioSideQuestionThinking,
81
+ resolveStudioSideQuestionThinking,
74
82
  STUDIO_SIDE_QUESTION_FOCUS_MAX_CHARS,
75
83
  STUDIO_SIDE_QUESTION_QUESTION_MAX_CHARS,
76
84
  } from "./shared/studio-side-question.js";
@@ -114,7 +122,7 @@ type StudioQuartoPreviewStatus = "idle" | "starting" | "running" | "stopping" |
114
122
  type StudioShowMeSourceKind = "selection" | "response" | "editor" | "context";
115
123
  type StudioSideQuestionFocusKind = "selection" | "section" | "editor" | "response" | "none";
116
124
  type StudioSideQuestionGatherScope = "none" | "folder" | "repo" | "custom";
117
- type StudioSideQuestionThinking = "off" | "minimal" | "low" | "medium" | "high";
125
+ type StudioSideQuestionThinking = ModelThinkingLevel;
118
126
  type StudioSideQuestionStatus = "idle" | "running" | "error";
119
127
 
120
128
  interface StudioQuartoPreviewContext {
@@ -314,11 +322,24 @@ interface InitialStudioDocument {
314
322
  path?: string;
315
323
  draftId?: string;
316
324
  resourceDir?: string;
325
+ diskRevision?: string;
326
+ watchFile?: boolean;
317
327
  }
318
328
 
329
+ type StudioDiskWriteResult =
330
+ | { ok: true; path: string; revision: string; size: number }
331
+ | {
332
+ ok: false;
333
+ conflict?: boolean;
334
+ reason?: string;
335
+ path?: string;
336
+ currentRevision?: string | null;
337
+ message: string;
338
+ };
339
+
319
340
  interface StudioLaunchSelection {
320
341
  document: InitialStudioDocument;
321
- kind: "document" | "pdf-preview";
342
+ kind: "document" | "pdf-preview" | "watched-preview";
322
343
  mode?: StudioUiMode;
323
344
  transient?: boolean;
324
345
  skipWorkspaceRestore?: boolean;
@@ -719,12 +740,17 @@ interface SaveAsRequestMessage {
719
740
  requestId: string;
720
741
  path: string;
721
742
  content: string;
743
+ overwrite?: boolean;
744
+ expectedRevision?: string;
722
745
  }
723
746
 
724
747
  interface SaveOverRequestMessage {
725
748
  type: "save_over_request";
726
749
  requestId: string;
750
+ path: string;
727
751
  content: string;
752
+ expectedRevision?: string;
753
+ force?: boolean;
728
754
  }
729
755
 
730
756
  interface RefreshFromDiskRequestMessage {
@@ -733,6 +759,12 @@ interface RefreshFromDiskRequestMessage {
733
759
  path?: string;
734
760
  }
735
761
 
762
+ interface WatchFileSubscribeMessage {
763
+ type: "watch_file_subscribe";
764
+ path: string;
765
+ revision?: string;
766
+ }
767
+
736
768
  interface SendToEditorRequestMessage {
737
769
  type: "send_to_editor_request";
738
770
  requestId: string;
@@ -821,6 +853,7 @@ type IncomingStudioMessage =
821
853
  | SaveAsRequestMessage
822
854
  | SaveOverRequestMessage
823
855
  | RefreshFromDiskRequestMessage
856
+ | WatchFileSubscribeMessage
824
857
  | SendToEditorRequestMessage
825
858
  | GetFromEditorRequestMessage
826
859
  | QuartoPreviewCheckRequestMessage
@@ -833,6 +866,7 @@ type IncomingStudioMessage =
833
866
 
834
867
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
835
868
  const PREVIEW_RENDER_MAX_CHARS = 400_000;
869
+ const STUDIO_WATCHED_FILE_MAX_CLIENTS = 16;
836
870
  const STUDIO_COMPLETION_MAX_TEXT_CHARS = 250_000;
837
871
  const STUDIO_COMPLETION_MAX_CONTEXT_CHARS = 12_000;
838
872
  const STUDIO_COMPLETION_PREFIX_CHARS = 12_000;
@@ -2463,8 +2497,8 @@ function resolveStudioPath(pathArg: string, cwd: string): { ok: true; resolved:
2463
2497
  return { ok: true, resolved, label: normalized };
2464
2498
  }
2465
2499
 
2466
- function readStudioFile(pathArg: string, cwd: string):
2467
- | { ok: true; text: string; label: string; resolvedPath: string }
2500
+ function readStudioFile(pathArg: string, cwd: string, options?: { requireCanonicalPath?: boolean }):
2501
+ | { ok: true; text: string; label: string; resolvedPath: string; diskRevision: string }
2468
2502
  | { ok: false; message: string } {
2469
2503
  const resolved = resolveStudioPath(pathArg, cwd);
2470
2504
  if (resolved.ok === false) {
@@ -2472,39 +2506,41 @@ function readStudioFile(pathArg: string, cwd: string):
2472
2506
  }
2473
2507
 
2474
2508
  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);
2509
+ const snapshot = readStudioDiskFileSnapshot(resolved.resolved);
2510
+ if (options?.requireCanonicalPath) {
2511
+ const requestedPath = resolve(resolved.resolved);
2512
+ const canonicalPath = resolve(snapshot.path);
2513
+ const pathsMatch = process.platform === "win32"
2514
+ ? requestedPath.toLowerCase() === canonicalPath.toLowerCase()
2515
+ : requestedPath === canonicalPath;
2516
+ if (!pathsMatch) {
2517
+ return { ok: false, message: `Could not read file: ${resolved.label} (the file location now resolves somewhere else)` };
2518
+ }
2519
+ }
2520
+ // Heuristic: check the first 8KB for binary indicators.
2521
+ const sample = snapshot.buffer.subarray(0, 8192);
2491
2522
  let nulCount = 0;
2492
2523
  let controlCount = 0;
2493
2524
  for (let i = 0; i < sample.length; i++) {
2494
2525
  const b = sample[i];
2495
2526
  if (b === 0x00) nulCount++;
2496
- // Control chars excluding tab (0x09), newline (0x0A), carriage return (0x0D)
2527
+ // Control chars excluding tab (0x09), newline (0x0A), carriage return (0x0D).
2497
2528
  else if (b < 0x08 || (b > 0x0D && b < 0x20 && b !== 0x1B)) controlCount++;
2498
2529
  }
2499
2530
  if (nulCount > 0 || (sample.length > 0 && controlCount / sample.length > 0.1)) {
2500
2531
  return { ok: false, message: `File appears to be binary: ${resolved.label}` };
2501
2532
  }
2502
- const text = buf.toString("utf-8");
2503
- return { ok: true, text, label: resolved.label, resolvedPath: resolved.resolved };
2533
+ return {
2534
+ ok: true,
2535
+ text: snapshot.buffer.toString("utf-8"),
2536
+ label: resolved.label,
2537
+ resolvedPath: snapshot.path,
2538
+ diskRevision: snapshot.revision,
2539
+ };
2504
2540
  } catch (error) {
2505
2541
  return {
2506
2542
  ok: false,
2507
- message: `Failed to read file: ${resolved.label} (${error instanceof Error ? error.message : String(error)})`,
2543
+ message: `Could not read file: ${resolved.label} (${error instanceof Error ? error.message : String(error)})`,
2508
2544
  };
2509
2545
  }
2510
2546
  }
@@ -2864,23 +2900,18 @@ function writeStudioPreviewExportFile(path: string | null, data: Buffer): { file
2864
2900
  }
2865
2901
  }
2866
2902
 
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
- }
2903
+ function writeStudioFile(pathArg: string, cwd: string, content: string, overwrite = false, expectedRevision?: string):
2904
+ | { ok: true; label: string; resolvedPath: string; diskRevision: string }
2905
+ | { ok: false; conflict?: boolean; reason?: string; path?: string; currentRevision?: string | null; message: string } {
2906
+ const normalizedLabel = normalizePathInput(pathArg);
2907
+ const result = saveStudioDiskFileAs({ path: pathArg, cwd, content, overwrite, expectedRevision }) as StudioDiskWriteResult;
2908
+ if (result.ok === false) return result;
2909
+ return {
2910
+ ok: true,
2911
+ label: normalizedLabel || basename(result.path),
2912
+ resolvedPath: result.path,
2913
+ diskRevision: result.revision,
2914
+ };
2884
2915
  }
2885
2916
 
2886
2917
  function writeStudioSideQuestionMarkdownFile(
@@ -7961,6 +7992,13 @@ async function respondLocalPreviewLinkJson(
7961
7992
  }
7962
7993
 
7963
7994
  const action = (requestUrl.searchParams.get("action") ?? "resolve").trim().toLowerCase();
7995
+ const originDocId = (requestUrl.searchParams.get("docId") ?? "").trim();
7996
+ const originDocument = originDocId ? readTransientStudioDocument(originDocId) : null;
7997
+ const originatesFromWatchedPreview = requestUrl.searchParams.get("watchedFile") === "1" || originDocument?.watchFile === true;
7998
+ if (action === "document" && originatesFromWatchedPreview) {
7999
+ respondJson(res, 409, { ok: false, error: "Open here is unavailable in a watched preview. Open a separate editable file tab instead." });
8000
+ return;
8001
+ }
7964
8002
  const basePayload = {
7965
8003
  ok: true,
7966
8004
  kind: resource.kind,
@@ -7990,10 +8028,14 @@ async function respondLocalPreviewLinkJson(
7990
8028
  return;
7991
8029
  }
7992
8030
 
7993
- if (action !== "document" && action !== "editor-url") {
8031
+ if (action !== "document" && action !== "editor-url" && action !== "watch-url") {
7994
8032
  respondJson(res, 400, { ok: false, error: "Unsupported local link action." });
7995
8033
  return;
7996
8034
  }
8035
+ if (action === "watch-url" && resource.kind !== "text") {
8036
+ respondJson(res, 400, { ok: false, error: "Follow-changes preview is available only for readable text files." });
8037
+ return;
8038
+ }
7997
8039
  if (resource.kind !== "text" && resource.kind !== "office") {
7998
8040
  respondJson(res, 400, { ok: false, error: "This local resource is not a document Studio can load into the editor." });
7999
8041
  return;
@@ -8038,6 +8080,7 @@ async function respondLocalPreviewLinkJson(
8038
8080
  source: "file",
8039
8081
  path: file.resolvedPath,
8040
8082
  resourceDir: documentResourceDir,
8083
+ diskRevision: file.diskRevision,
8041
8084
  };
8042
8085
  }
8043
8086
  if (action === "document") {
@@ -8047,16 +8090,27 @@ async function respondLocalPreviewLinkJson(
8047
8090
  label: document.label,
8048
8091
  converted,
8049
8092
  resourceDir: documentResourceDir,
8093
+ diskRevision: document.diskRevision || null,
8050
8094
  });
8051
8095
  return;
8052
8096
  }
8053
8097
 
8098
+ if (action === "watch-url") {
8099
+ if (responseText.length > PREVIEW_RENDER_MAX_CHARS) {
8100
+ respondJson(res, 413, { ok: false, error: `Watched preview source exceeds ${PREVIEW_RENDER_MAX_CHARS.toLocaleString()} characters.` });
8101
+ return;
8102
+ }
8103
+ document = { ...document, watchFile: true };
8104
+ }
8054
8105
  const docId = storeTransientStudioDocument(document);
8055
8106
  respondJson(res, 200, {
8056
8107
  ...basePayload,
8057
8108
  converted,
8058
8109
  resourceDir: documentResourceDir,
8059
- relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, { skipWorkspaceRestore: true }),
8110
+ relativeUrl: buildStudioRelativeUrl(serverState.token, "editor-only", document, docId, {
8111
+ skipWorkspaceRestore: true,
8112
+ paneFocus: action === "watch-url" ? "right" : undefined,
8113
+ }),
8060
8114
  });
8061
8115
  }
8062
8116
 
@@ -9956,21 +10010,35 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
9956
10010
  msg.type === "save_as_request" &&
9957
10011
  typeof msg.requestId === "string" &&
9958
10012
  typeof msg.path === "string" &&
9959
- typeof msg.content === "string"
10013
+ typeof msg.content === "string" &&
10014
+ (msg.overwrite === undefined || typeof msg.overwrite === "boolean") &&
10015
+ (msg.expectedRevision === undefined || typeof msg.expectedRevision === "string")
9960
10016
  ) {
9961
10017
  return {
9962
10018
  type: "save_as_request",
9963
10019
  requestId: msg.requestId,
9964
10020
  path: msg.path,
9965
10021
  content: msg.content,
10022
+ overwrite: msg.overwrite === true,
10023
+ expectedRevision: typeof msg.expectedRevision === "string" ? msg.expectedRevision : undefined,
9966
10024
  };
9967
10025
  }
9968
10026
 
9969
- if (msg.type === "save_over_request" && typeof msg.requestId === "string" && typeof msg.content === "string") {
10027
+ if (
10028
+ msg.type === "save_over_request"
10029
+ && typeof msg.requestId === "string"
10030
+ && typeof msg.path === "string"
10031
+ && typeof msg.content === "string"
10032
+ && (msg.expectedRevision === undefined || typeof msg.expectedRevision === "string")
10033
+ && (msg.force === undefined || typeof msg.force === "boolean")
10034
+ ) {
9970
10035
  return {
9971
10036
  type: "save_over_request",
9972
10037
  requestId: msg.requestId,
10038
+ path: msg.path,
9973
10039
  content: msg.content,
10040
+ expectedRevision: typeof msg.expectedRevision === "string" ? msg.expectedRevision : undefined,
10041
+ force: msg.force === true,
9974
10042
  };
9975
10043
  }
9976
10044
 
@@ -9986,6 +10054,19 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
9986
10054
  };
9987
10055
  }
9988
10056
 
10057
+ if (
10058
+ msg.type === "watch_file_subscribe"
10059
+ && typeof msg.path === "string"
10060
+ && msg.path.length <= 16_384
10061
+ && (msg.revision === undefined || typeof msg.revision === "string")
10062
+ ) {
10063
+ return {
10064
+ type: "watch_file_subscribe",
10065
+ path: msg.path,
10066
+ revision: typeof msg.revision === "string" ? msg.revision : undefined,
10067
+ };
10068
+ }
10069
+
9989
10070
  if (msg.type === "send_to_editor_request" && typeof msg.requestId === "string" && typeof msg.content === "string") {
9990
10071
  return {
9991
10072
  type: "send_to_editor_request",
@@ -11322,6 +11403,7 @@ function buildStudioRelativeUrl(
11322
11403
  if (doc?.path) params.set("docPath", doc.path);
11323
11404
  if (doc?.draftId) params.set("draftId", doc.draftId);
11324
11405
  if (doc?.resourceDir) params.set("resourceDir", doc.resourceDir);
11406
+ if (doc?.watchFile) params.set("watchFile", "1");
11325
11407
  if (options?.skipWorkspaceRestore) params.set("skipWorkspaceRestore", "1");
11326
11408
  if (options?.paneFocus) params.set("paneFocus", options.paneFocus);
11327
11409
  return `/?${params.toString()}`;
@@ -11411,16 +11493,36 @@ function resolveRequestedStudioDocumentFromUrl(
11411
11493
  const requestedLabel = (requestUrl.searchParams.get("docLabel") ?? "").trim();
11412
11494
  const requestedDraftId = (requestUrl.searchParams.get("draftId") ?? "").trim();
11413
11495
  const requestedResourceDir = (requestUrl.searchParams.get("resourceDir") ?? "").trim();
11496
+ const requestedWatchFile = requestUrl.searchParams.get("watchFile") === "1";
11414
11497
 
11415
11498
  if (requestedPath) {
11416
11499
  const file = readStudioFile(requestedPath, studioCwd);
11417
- if (file.ok !== false) {
11500
+ const requestedCanonicalTextPath = resolve(requestedPath);
11501
+ const resolvedFilePath = file.ok !== false ? resolve(file.resolvedPath) : "";
11502
+ const watchedPathStayedCanonical = !requestedWatchFile || (
11503
+ process.platform === "win32"
11504
+ ? requestedCanonicalTextPath.toLowerCase() === resolvedFilePath.toLowerCase()
11505
+ : requestedCanonicalTextPath === resolvedFilePath
11506
+ );
11507
+ if (file.ok !== false && watchedPathStayedCanonical) {
11418
11508
  return {
11419
11509
  text: file.text,
11420
11510
  label: requestedLabel || file.label,
11421
11511
  source: "file",
11422
11512
  path: file.resolvedPath,
11423
11513
  resourceDir: requestedResourceDir || fallback?.resourceDir || studioCwd,
11514
+ diskRevision: file.diskRevision,
11515
+ watchFile: requestedWatchFile,
11516
+ };
11517
+ }
11518
+ if (requestedWatchFile && isAbsolute(requestedPath)) {
11519
+ return {
11520
+ text: fallback?.watchFile && fallback.path === requestedPath ? fallback.text : "",
11521
+ label: requestedLabel || basename(requestedPath),
11522
+ source: "file",
11523
+ path: resolve(requestedPath),
11524
+ resourceDir: requestedResourceDir || dirname(resolve(requestedPath)),
11525
+ watchFile: true,
11424
11526
  };
11425
11527
  }
11426
11528
  }
@@ -11705,6 +11807,8 @@ function buildStudioHtml(
11705
11807
  const initialPath = escapeHtmlForInline(initialDocument?.path ?? "");
11706
11808
  const initialDraftId = escapeHtmlForInline(initialDocument?.draftId ?? "");
11707
11809
  const initialResourceDir = escapeHtmlForInline(initialDocument?.resourceDir ?? "");
11810
+ const initialDiskRevision = escapeHtmlForInline(initialDocument?.diskRevision ?? "");
11811
+ const initialWatchFile = initialDocument?.watchFile === true ? "1" : "0";
11708
11812
  const initialModel = escapeHtmlForInline(initialModelLabel ?? "none");
11709
11813
  const initialTerminal = escapeHtmlForInline(initialTerminalLabel ?? "unknown");
11710
11814
  const initialTerminalDetailAttr = escapeHtmlForInline(initialTerminalDetail ?? initialTerminalLabel ?? "unknown");
@@ -11781,12 +11885,12 @@ ${cssVarsBlock}
11781
11885
  </style>
11782
11886
  <link rel="stylesheet" href="${stylesheetHref}" />
11783
11887
  </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}">
11888
+ <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
11889
  <header id="studioHeader">
11786
11890
  <h1><span class="app-logo" aria-hidden="true">π</span> Studio <span class="app-subtitle">${appSubtitle}</span></h1>
11787
11891
  <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>
11892
+ <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>
11893
+ <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
11894
  <button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
11791
11895
  <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
11896
  <button id="importFileBtn" type="button" title="Import a file as an editable copy.">Import file copy…</button>
@@ -11804,10 +11908,12 @@ ${cssVarsBlock}
11804
11908
  <section id="leftPane">
11805
11909
  <div id="leftSectionHeader" class="section-header">
11806
11910
  <div class="section-header-main">
11807
- <select id="editorViewSelect" aria-label="Editor view mode" title="Editor view mode. Shortcut: F7 when the editor pane is active; F6 switches panes.">
11808
- <option value="markdown" selected>Editor (Raw)</option>
11809
- <option value="preview">Editor (Preview)</option>
11810
- </select>
11911
+ <span id="editorViewSelectWrap" class="studio-header-select-wrap">
11912
+ <select id="editorViewSelect" aria-label="Editor view mode" title="Editor view mode. Shortcut: F7 when the editor pane is active; F6 switches panes.">
11913
+ <option value="markdown" selected>Editor (Raw)</option>
11914
+ <option value="preview">Editor (Preview)</option>
11915
+ </select>
11916
+ </span>
11811
11917
  </div>
11812
11918
  <div class="section-header-actions">
11813
11919
  <button id="leftFocusBtn" class="pane-focus-btn" type="button" title="Show only the editor pane. Shortcut: F10 or Cmd/Ctrl+Esc.">Focus pane</button>
@@ -11835,7 +11941,7 @@ ${cssVarsBlock}
11835
11941
  </div>
11836
11942
  <div class="source-actions-row repl-action-line" hidden>
11837
11943
  <button id="sendReplBtn" type="button" hidden title="Send the current selection, or the full editor text, to the active REPL session shown in the right pane.">Send to REPL</button>
11838
- <select id="replSendModeSelect" hidden aria-label="REPL send mode" title="Choose how Send to REPL interprets the editor text.">
11944
+ <select id="replSendModeSelect" class="studio-flat-select" hidden aria-label="REPL send mode" title="Choose how Send to REPL interprets the editor text.">
11839
11945
  <option value="raw" selected>Send mode: Raw</option>
11840
11946
  <option value="literate">Send mode: Literate</option>
11841
11947
  </select>
@@ -11844,11 +11950,11 @@ ${cssVarsBlock}
11844
11950
  <button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy</button>
11845
11951
  <button id="suggestCompletionBtn" type="button" title="Ask the current model for a short completion at the editor cursor. Shortcut: Option/Alt+Tab where available, or Cmd/Ctrl+Shift+Space from the editor.">Suggest</button>
11846
11952
  <button id="suggestCompletionOptionsBtn" type="button" hidden title="Suggestion context options">▾</button>
11847
- <select id="completionContextSelect" hidden aria-label="Suggestion context mode" title="Choose how much context Suggest includes.">
11953
+ <select id="completionContextSelect" class="studio-flat-select" hidden aria-label="Suggestion context mode" title="Choose how much context Suggest includes.">
11848
11954
  <option value="cursor" selected>Context: editor only</option>
11849
11955
  <option value="session">Context: editor + latest response</option>
11850
11956
  </select>
11851
- <select id="completionModelSelect" hidden aria-label="Suggestion model" title="Choose the model used for Suggest. Suggestions use direct completion with thinking off and do not change the main Pi model.">
11957
+ <select id="completionModelSelect" class="studio-flat-select" hidden aria-label="Suggestion model" title="Choose the model used for Suggest. Suggestions use direct completion with thinking off and do not change the main Pi model.">
11852
11958
  <option value="current" selected>Suggestion model: current Pi model</option>
11853
11959
  </select>
11854
11960
  <button id="openCompanionBtn" type="button" title="Open a blank editor-only Studio tab.">New editor tab</button>
@@ -11856,7 +11962,7 @@ ${cssVarsBlock}
11856
11962
  </div>
11857
11963
  <div class="source-actions-row">
11858
11964
  <button id="insertHeaderBtn" type="button" title="Insert annotated-reply protocol header (source metadata, [an: ...] syntax hint, precedence note, and end marker).">Annotation header</button>
11859
- <select id="annotationModeSelect" aria-label="Inline annotation visibility mode" title="On: keep and send [an: ...] markers. Hide: keep markers in the editor, hide them in preview, and strip before Run/Critique.">
11965
+ <select id="annotationModeSelect" class="studio-flat-select" aria-label="Inline annotation visibility mode" title="On: keep and send [an: ...] markers. Hide: keep markers in the editor, hide them in preview, and strip before Run/Critique.">
11860
11966
  <option value="on" selected>Inline annotations: On</option>
11861
11967
  <option value="off">Inline annotations: Hide</option>
11862
11968
  </select>
@@ -11864,7 +11970,7 @@ ${cssVarsBlock}
11864
11970
  <button id="saveAnnotatedBtn" type="button" title="Save full editor content (including [an: ...] markers) as a .annotated.md file.">Save .annotated.md</button>
11865
11971
  </div>
11866
11972
  <div class="source-actions-row">
11867
- <select id="lensSelect" aria-label="Critique focus">
11973
+ <select id="lensSelect" class="studio-flat-select" aria-label="Critique focus">
11868
11974
  <option value="auto" selected>Critique: Auto</option>
11869
11975
  <option value="writing">Critique: Writing</option>
11870
11976
  <option value="code">Critique: Code</option>
@@ -11874,7 +11980,7 @@ ${cssVarsBlock}
11874
11980
  <button id="showMeResponseBtn" type="button" hidden title="Explain the response displayed in the right pane using the smallest useful visual or structural representation.">Explain displayed response</button>
11875
11981
  <button id="askAsideBtn" type="button" title="Open a separate side-question thread without adding it to the main Pi conversation.">Side question</button>
11876
11982
  <button id="quizBtn" type="button" title="Open an active quiz for the current editor selection or document.">Quiz me</button>
11877
- <select id="highlightSelect" aria-label="Editor syntax highlighting">
11983
+ <select id="highlightSelect" class="studio-flat-select" aria-label="Editor syntax highlighting">
11878
11984
  <option value="off">Syntax highlight: Off</option>
11879
11985
  <option value="bash">Syntax highlight: Bash</option>
11880
11986
  <option value="c">Syntax highlight: C</option>
@@ -11904,11 +12010,11 @@ ${cssVarsBlock}
11904
12010
  <option value="xml">Syntax highlight: XML</option>
11905
12011
  <option value="yaml">Syntax highlight: YAML</option>
11906
12012
  </select>
11907
- <select id="lineNumbersSelect" aria-label="Editor line numbers">
12013
+ <select id="lineNumbersSelect" class="studio-flat-select" aria-label="Editor line numbers">
11908
12014
  <option value="off">Line numbers: Off</option>
11909
12015
  <option value="on" selected>Line numbers: On</option>
11910
12016
  </select>
11911
- <select id="editorFontSizeSelect" aria-label="Editor text size" title="Adjust raw editor text size.">
12017
+ <select id="editorFontSizeSelect" class="studio-flat-select" aria-label="Editor text size" title="Adjust raw editor text size.">
11912
12018
  <option value="10">Editor text: 10px</option>
11913
12019
  <option value="11">Editor text: 11px</option>
11914
12020
  <option value="12" selected>Editor text: 12px</option>
@@ -12006,20 +12112,23 @@ ${cssVarsBlock}
12006
12112
  <section id="rightPane">
12007
12113
  <div id="rightSectionHeader" class="section-header">
12008
12114
  <div class="section-header-main">
12009
- <select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+1–8 switches directly between all right-pane views. Cmd/Ctrl+Alt+P/E/W/F/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, and Side questions.">
12010
- <option value="markdown">Response (Raw)</option>
12011
- <option value="preview" selected>Response (Preview)</option>
12012
- <option value="editor-preview">Editor (Preview)</option>
12013
- <option value="editor-quarto-preview" hidden>Editor (Quarto Preview)</option>
12014
- <option value="trace">Working</option>
12015
- <option value="changes">Changes</option>
12016
- <option value="files">Files</option>
12017
- <option value="repl">REPL</option>
12018
- <option value="side-questions">Side questions</option>
12019
- </select>
12115
+ <span id="rightViewSelectWrap" class="studio-header-select-wrap">
12116
+ <select id="rightViewSelect" aria-label="Response view mode" title="Right pane view mode. F7 cycles when the right pane is active; Cmd/Ctrl+Alt+1–8 switches directly between all right-pane views. Cmd/Ctrl+Alt+P/E/W/F/Q keep mnemonic shortcuts for Preview, Editor Preview, Working, Files, and Side questions.">
12117
+ <option value="markdown">Response (Raw)</option>
12118
+ <option value="preview" selected>Response (Preview)</option>
12119
+ <option value="editor-preview">Editor (Preview)</option>
12120
+ <option value="editor-quarto-preview" hidden>Editor (Quarto Preview)</option>
12121
+ <option value="trace">Working</option>
12122
+ <option value="changes">Changes</option>
12123
+ <option value="files">Files</option>
12124
+ <option value="repl">REPL</option>
12125
+ <option value="side-questions">Side questions</option>
12126
+ </select>
12127
+ </span>
12020
12128
  </div>
12021
12129
  <div class="section-header-actions">
12022
12130
  <button id="rightFocusBtn" class="pane-focus-btn" type="button" title="Show only the response pane. Shortcut: F10 or Cmd/Ctrl+Esc.">Focus pane</button>
12131
+ <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
12132
  <span id="exportPreviewControls" class="export-preview-controls">
12024
12133
  <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
12134
  <div id="exportPreviewMenu" class="export-preview-menu" role="menu" hidden>
@@ -12042,28 +12151,34 @@ ${cssVarsBlock}
12042
12151
  <div class="response-wrap">
12043
12152
  <div id="responseActions" class="response-actions">
12044
12153
  <div class="response-actions-row response-options-row">
12045
- <select id="followSelect" aria-label="Auto-update response">
12046
- <option value="on" selected>Auto-update response: On</option>
12047
- <option value="off">Auto-update response: Off</option>
12048
- </select>
12049
- <select id="responseHighlightSelect" aria-label="Response markdown highlighting">
12050
- <option value="off">Syntax highlight: Off</option>
12051
- <option value="on" selected>Syntax highlight: On</option>
12052
- </select>
12053
- <select id="responseFontSizeSelect" aria-label="Response text size" title="Adjust right-pane response, preview, and working text size.">
12054
- <option value="11">Response text: 11px</option>
12055
- <option value="12">Response text: 12px</option>
12056
- <option value="12.5">Response text: 12.5px</option>
12057
- <option value="13">Response text: 13px</option>
12058
- <option value="13.5" selected>Response text: 13.5px</option>
12059
- <option value="14">Response text: 14px</option>
12060
- <option value="14.5">Response text: 14.5px</option>
12061
- <option value="15">Response text: 15px</option>
12062
- <option value="15.5">Response text: 15.5px</option>
12063
- <option value="16">Response text: 16px</option>
12064
- <option value="18">Response text: 18px</option>
12065
- <option value="20">Response text: 20px</option>
12066
- </select>
12154
+ <span id="followSelectWrap" class="studio-menu-select-wrap response-option-select-wrap">
12155
+ <select id="followSelect" aria-label="Auto-update response">
12156
+ <option value="on" selected>Auto-update response: On</option>
12157
+ <option value="off">Auto-update response: Off</option>
12158
+ </select>
12159
+ </span>
12160
+ <span id="responseHighlightSelectWrap" class="studio-menu-select-wrap response-option-select-wrap">
12161
+ <select id="responseHighlightSelect" aria-label="Response markdown highlighting">
12162
+ <option value="off">Syntax highlight: Off</option>
12163
+ <option value="on" selected>Syntax highlight: On</option>
12164
+ </select>
12165
+ </span>
12166
+ <span id="responseFontSizeSelectWrap" class="studio-menu-select-wrap response-option-select-wrap">
12167
+ <select id="responseFontSizeSelect" aria-label="Response text size" title="Adjust right-pane response, preview, and working text size.">
12168
+ <option value="11">Response text: 11px</option>
12169
+ <option value="12">Response text: 12px</option>
12170
+ <option value="12.5">Response text: 12.5px</option>
12171
+ <option value="13">Response text: 13px</option>
12172
+ <option value="13.5" selected>Response text: 13.5px</option>
12173
+ <option value="14">Response text: 14px</option>
12174
+ <option value="14.5">Response text: 14.5px</option>
12175
+ <option value="15">Response text: 15px</option>
12176
+ <option value="15.5">Response text: 15.5px</option>
12177
+ <option value="16">Response text: 16px</option>
12178
+ <option value="18">Response text: 18px</option>
12179
+ <option value="20">Response text: 20px</option>
12180
+ </select>
12181
+ </span>
12067
12182
  </div>
12068
12183
  <div class="response-actions-row history-row">
12069
12184
  <button id="pullLatestBtn" type="button" title="Fetch the latest assistant response when auto-update is off.">Fetch latest response</button>
@@ -12137,7 +12252,8 @@ ${cssVarsBlock}
12137
12252
  <section class="shortcuts-group">
12138
12253
  <h3>Editor</h3>
12139
12254
  <dl>
12140
- <div><dt>Cmd/Ctrl+S</dt><dd>Save editor</dd></div>
12255
+ <div><dt>Cmd/Ctrl+S</dt><dd>Save editor when the disk revision still matches</dd></div>
12256
+ <div><dt>Cmd/Ctrl+Shift+S</dt><dd>Save editor as a new file</dd></div>
12141
12257
  <div class="shortcuts-full-only"><dt>Cmd/Ctrl+Enter</dt><dd>Run editor text, or queue steering during an active run</dd></div>
12142
12258
  <div><dt>Option/Alt+Tab or Cmd/Ctrl+Shift+Space</dt><dd>Suggest a completion at the editor cursor</dd></div>
12143
12259
  <div><dt>Tab</dt><dd>Insert a visible completion suggestion; otherwise indent selected editor text</dd></div>
@@ -12213,6 +12329,9 @@ export default function (pi: ExtensionAPI) {
12213
12329
  let serverState: StudioServerState | null = null;
12214
12330
  const studioWorkspaceStateStore = createStudioWorkspaceStateStore();
12215
12331
  const studioResourceGrantRegistry = createStudioResourceGrantRegistry();
12332
+ const studioFileWatchers = new Map<WebSocket, ReturnType<typeof createStudioFileWatcher>>();
12333
+ const studioWatchedClientPaths = new Map<WebSocket, string>();
12334
+ const studioFileWatchSubscriptionGenerations = new Map<WebSocket, number>();
12216
12335
  let activeRequest: ActiveStudioRequest | null = null;
12217
12336
  let studioDirectRunChain: StudioDirectRunChain | null = null;
12218
12337
  let queuedStudioDirectRequests: QueuedStudioDirectRequest[] = [];
@@ -12273,6 +12392,21 @@ export default function (pi: ExtensionAPI) {
12273
12392
  actionRequestId: null,
12274
12393
  };
12275
12394
 
12395
+ const closeStudioFileWatcher = async (client: WebSocket): Promise<void> => {
12396
+ const watcher = studioFileWatchers.get(client);
12397
+ if (!watcher) return;
12398
+ studioFileWatchers.delete(client);
12399
+ await watcher.close().catch(() => undefined);
12400
+ };
12401
+
12402
+ const closeAllStudioFileWatchers = async (): Promise<void> => {
12403
+ const clients = Array.from(studioFileWatchers.keys());
12404
+ await Promise.all(clients.map((client) => closeStudioFileWatcher(client)));
12405
+ // Keep socket capability metadata until each socket is closed. Clearing it
12406
+ // while an authenticated socket is still open would briefly make a watched
12407
+ // preview look writable to the save handlers.
12408
+ };
12409
+
12276
12410
  const recordStudioDocumentResourceGrants = (document: InitialStudioDocument, cwd = studioCwd) => {
12277
12411
  if (document.path) {
12278
12412
  try {
@@ -13573,6 +13707,9 @@ export default function (pi: ExtensionAPI) {
13573
13707
  }
13574
13708
  : null;
13575
13709
 
13710
+ const getCurrentStudioSideQuestionThinkingLevels = (): StudioSideQuestionThinking[] =>
13711
+ getStudioSideQuestionThinkingLevels(latestModelRequestCtx?.model ?? lastCommandCtx?.model) as StudioSideQuestionThinking[];
13712
+
13576
13713
  const getStudioThemeOptions = () => {
13577
13714
  try {
13578
13715
  const themes = lastCommandCtx?.ui?.getAllThemes?.() ?? [];
@@ -13608,6 +13745,7 @@ export default function (pi: ExtensionAPI) {
13608
13745
  modelLabel: currentModelLabel,
13609
13746
  currentModel: getCurrentStudioModelDescriptor(),
13610
13747
  thinkingLevel: getThinkingLevelSafe() ?? "off",
13748
+ sideQuestionThinkingLevels: getCurrentStudioSideQuestionThinkingLevels(),
13611
13749
  piModels: modelOptions,
13612
13750
  suggestionModels: modelOptions,
13613
13751
  currentTheme: getCurrentStudioThemeDescriptor(),
@@ -13914,6 +14052,7 @@ export default function (pi: ExtensionAPI) {
13914
14052
  const payload: Record<string, unknown> = {
13915
14053
  type: "side_question_state",
13916
14054
  state: getStudioSideQuestionPublicState(),
14055
+ sideQuestionThinkingLevels: getCurrentStudioSideQuestionThinkingLevels(),
13917
14056
  webSearchAvailable: Boolean(String(process.env.BRAVE_API_KEY || "").trim()),
13918
14057
  };
13919
14058
  if (client) payload.availablePiTools = getPublicStudioSideQuestionToolCatalog();
@@ -14135,6 +14274,7 @@ export default function (pi: ExtensionAPI) {
14135
14274
  const model = latestModelRequestCtx?.model ?? ctx.model;
14136
14275
  if (!model) throw new Error("No active Pi model is available for side questions.");
14137
14276
  await resolveStudioModelRequestAuth({ model, modelRegistry: ctx.modelRegistry }, model);
14277
+ const thinking = resolveStudioSideQuestionThinking(model, context.thinking) as StudioSideQuestionThinking;
14138
14278
  const gitSnapshot = context.gitContext
14139
14279
  ? await captureStudioSideQuestionGitSnapshot(contextRoot, { runGit: runStudioSideQuestionGitCommand }) as StudioSideQuestionGitSnapshot
14140
14280
  : null;
@@ -14173,7 +14313,7 @@ export default function (pi: ExtensionAPI) {
14173
14313
  customTools: tools,
14174
14314
  sessionManager: sideSessionManager,
14175
14315
  model,
14176
- thinking: context.thinking,
14316
+ thinking,
14177
14317
  inheritedPrompt: stripStudioDynamicSystemPromptFooter(ctx.getSystemPrompt()),
14178
14318
  });
14179
14319
  session = agentRuntime.session;
@@ -14182,7 +14322,7 @@ export default function (pi: ExtensionAPI) {
14182
14322
  cwd: workingDirectory,
14183
14323
  sessionManager: sideSessionManager,
14184
14324
  model,
14185
- thinkingLevel: context.thinking,
14325
+ thinkingLevel: thinking,
14186
14326
  tools: activeToolNames,
14187
14327
  customTools: tools,
14188
14328
  resourceLoader: createStudioSideQuestionResourceLoader(ctx),
@@ -14215,7 +14355,7 @@ export default function (pi: ExtensionAPI) {
14215
14355
  tools: selectedPiTools,
14216
14356
  },
14217
14357
  modelLabel: formatStudioModelOptionLabel(model),
14218
- thinking: context.thinking,
14358
+ thinking,
14219
14359
  messages: [],
14220
14360
  activity: [],
14221
14361
  error: context.webSearch && !webAvailable ? "Web search was requested but BRAVE_API_KEY is not configured; this thread will use local context only." : "",
@@ -14393,6 +14533,92 @@ export default function (pi: ExtensionAPI) {
14393
14533
  }
14394
14534
  };
14395
14535
 
14536
+ const subscribeStudioWatchedFile = async (client: WebSocket, msg: WatchFileSubscribeMessage): Promise<void> => {
14537
+ const subscriptionGeneration = (studioFileWatchSubscriptionGenerations.get(client) ?? 0) + 1;
14538
+ studioFileWatchSubscriptionGenerations.set(client, subscriptionGeneration);
14539
+ await closeStudioFileWatcher(client);
14540
+ if (studioFileWatchSubscriptionGenerations.get(client) !== subscriptionGeneration || client.readyState !== WebSocket.OPEN) return;
14541
+ if (studioFileWatchers.size >= STUDIO_WATCHED_FILE_MAX_CLIENTS) {
14542
+ sendToClient(client, {
14543
+ type: "watched_file_error",
14544
+ path: msg.path,
14545
+ message: `Studio watched-file client limit reached (${STUDIO_WATCHED_FILE_MAX_CLIENTS}).`,
14546
+ });
14547
+ return;
14548
+ }
14549
+ const canonicalPath = resolve(msg.path);
14550
+ const clientWatchedPath = studioWatchedClientPaths.get(client) ?? "";
14551
+ if (clientWatchedPath !== canonicalPath) {
14552
+ sendToClient(client, {
14553
+ type: "watched_file_error",
14554
+ path: canonicalPath,
14555
+ message: "This socket is not authorized for that server-created watched preview.",
14556
+ });
14557
+ return;
14558
+ }
14559
+
14560
+ const watcher = createStudioFileWatcher({
14561
+ filePath: canonicalPath,
14562
+ initialRevision: msg.revision,
14563
+ readSnapshot: (filePath: string) => {
14564
+ const file = readStudioFile(filePath, dirname(filePath), { requireCanonicalPath: true });
14565
+ if (file.ok === false) throw new Error(file.message);
14566
+ if (file.text.length > PREVIEW_RENDER_MAX_CHARS) {
14567
+ throw new Error(`Watched file exceeds the ${PREVIEW_RENDER_MAX_CHARS.toLocaleString()}-character preview limit.`);
14568
+ }
14569
+ return { path: file.resolvedPath, text: file.text, revision: file.diskRevision };
14570
+ },
14571
+ onUpdate: (snapshot: { path: string; text: string; revision: string }, details: { generation: number; recovered: boolean }) => {
14572
+ if (studioFileWatchers.get(client) !== watcher || client.readyState !== WebSocket.OPEN) return;
14573
+ sendToClient(client, {
14574
+ type: "watched_file_update",
14575
+ path: snapshot.path,
14576
+ text: snapshot.text,
14577
+ diskRevision: snapshot.revision,
14578
+ generation: details.generation,
14579
+ recovered: details.recovered,
14580
+ message: `Updated ${basename(snapshot.path)} from disk.`,
14581
+ });
14582
+ },
14583
+ onError: (error: unknown, details: { generation: number; lastRevision: string | null }) => {
14584
+ if (studioFileWatchers.get(client) !== watcher || client.readyState !== WebSocket.OPEN) return;
14585
+ sendToClient(client, {
14586
+ type: "watched_file_error",
14587
+ path: canonicalPath,
14588
+ diskRevision: details.lastRevision,
14589
+ generation: details.generation,
14590
+ message: `Could not refresh ${basename(canonicalPath)}; keeping the last good preview: ${error instanceof Error ? error.message : String(error)}`,
14591
+ });
14592
+ },
14593
+ onRecovered: (snapshot: { path: string; revision: string }, details: { generation: number; changed: boolean }) => {
14594
+ if (studioFileWatchers.get(client) !== watcher || client.readyState !== WebSocket.OPEN || details.changed) return;
14595
+ sendToClient(client, {
14596
+ type: "watched_file_ready",
14597
+ path: snapshot.path,
14598
+ diskRevision: snapshot.revision,
14599
+ generation: details.generation,
14600
+ recovered: true,
14601
+ message: `Watching ${basename(snapshot.path)} for disk changes.`,
14602
+ });
14603
+ },
14604
+ });
14605
+ studioFileWatchers.set(client, watcher);
14606
+ await watcher.refresh();
14607
+ if (
14608
+ studioFileWatchers.get(client) === watcher
14609
+ && client.readyState === WebSocket.OPEN
14610
+ && !watcher.error
14611
+ ) {
14612
+ sendToClient(client, {
14613
+ type: "watched_file_ready",
14614
+ path: canonicalPath,
14615
+ diskRevision: watcher.revision,
14616
+ generation: watcher.generation,
14617
+ message: `Watching ${basename(canonicalPath)} for disk changes.`,
14618
+ });
14619
+ }
14620
+ };
14621
+
14396
14622
  const handleStudioMessage = (client: WebSocket, msg: IncomingStudioMessage) => {
14397
14623
  if (msg.type === "ping") {
14398
14624
  sendToClient(client, { type: "pong", timestamp: Date.now() });
@@ -14402,6 +14628,16 @@ export default function (pi: ExtensionAPI) {
14402
14628
  studioWorkspaceStateStore.set(msg.tabStateId, msg.state);
14403
14629
  return;
14404
14630
  }
14631
+ if (msg.type === "watch_file_subscribe") {
14632
+ void subscribeStudioWatchedFile(client, msg).catch((error) => {
14633
+ sendToClient(client, {
14634
+ type: "watched_file_error",
14635
+ path: msg.path,
14636
+ message: `Could not start watched preview: ${error instanceof Error ? error.message : String(error)}`,
14637
+ });
14638
+ });
14639
+ return;
14640
+ }
14405
14641
 
14406
14642
  emitDebugEvent("studio_message", {
14407
14643
  type: msg.type,
@@ -14423,6 +14659,7 @@ export default function (pi: ExtensionAPI) {
14423
14659
  modelLabel: currentModelLabel,
14424
14660
  currentModel: getCurrentStudioModelDescriptor(),
14425
14661
  thinkingLevel: getThinkingLevelSafe() ?? "off",
14662
+ sideQuestionThinkingLevels: getCurrentStudioSideQuestionThinkingLevels(),
14426
14663
  piModels: getStudioModelOptions(),
14427
14664
  suggestionModels: getStudioModelOptions(),
14428
14665
  currentTheme: getCurrentStudioThemeDescriptor(),
@@ -15372,6 +15609,10 @@ export default function (pi: ExtensionAPI) {
15372
15609
  }
15373
15610
 
15374
15611
  if (msg.type === "save_as_request") {
15612
+ if (studioWatchedClientPaths.has(client)) {
15613
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Watched previews are read-only. Open an editable Studio tab before saving." });
15614
+ return;
15615
+ }
15375
15616
  if (!isValidRequestId(msg.requestId)) {
15376
15617
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
15377
15618
  return;
@@ -15380,14 +15621,21 @@ export default function (pi: ExtensionAPI) {
15380
15621
  sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
15381
15622
  return;
15382
15623
  }
15383
- if (!msg.content.trim()) {
15384
- sendToClient(client, { type: "error", requestId: msg.requestId, message: "Nothing to save." });
15385
- return;
15386
- }
15387
15624
 
15388
- const result = writeStudioFile(msg.path, studioCwd, msg.content);
15625
+ const result = writeStudioFile(msg.path, studioCwd, msg.content, msg.overwrite === true, msg.expectedRevision);
15389
15626
  if (result.ok === false) {
15390
- sendToClient(client, { type: "error", requestId: msg.requestId, message: result.message });
15627
+ if (result.conflict) {
15628
+ sendToClient(client, {
15629
+ type: "save_as_conflict",
15630
+ requestId: msg.requestId,
15631
+ path: result.path || msg.path,
15632
+ currentRevision: result.currentRevision || null,
15633
+ reason: result.reason || "target-exists",
15634
+ message: result.message,
15635
+ });
15636
+ } else {
15637
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: result.message });
15638
+ }
15391
15639
  return;
15392
15640
  }
15393
15641
 
@@ -15397,6 +15645,7 @@ export default function (pi: ExtensionAPI) {
15397
15645
  source: "file",
15398
15646
  path: result.resolvedPath,
15399
15647
  resourceDir: dirname(result.resolvedPath),
15648
+ diskRevision: result.diskRevision,
15400
15649
  };
15401
15650
  recordStudioDocumentResourceGrants(initialStudioDocument);
15402
15651
 
@@ -15406,12 +15655,17 @@ export default function (pi: ExtensionAPI) {
15406
15655
  path: result.resolvedPath,
15407
15656
  label: result.label,
15408
15657
  resourceDir: dirname(result.resolvedPath),
15658
+ diskRevision: result.diskRevision,
15409
15659
  message: `Saved editor text to ${result.label}`,
15410
15660
  });
15411
15661
  return;
15412
15662
  }
15413
15663
 
15414
15664
  if (msg.type === "save_over_request") {
15665
+ if (studioWatchedClientPaths.has(client)) {
15666
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: "Watched previews are read-only. Open an editable Studio tab before saving." });
15667
+ return;
15668
+ }
15415
15669
  if (!isValidRequestId(msg.requestId)) {
15416
15670
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
15417
15671
  return;
@@ -15420,35 +15674,52 @@ export default function (pi: ExtensionAPI) {
15420
15674
  sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
15421
15675
  return;
15422
15676
  }
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
- });
15677
+
15678
+ const result = saveStudioDiskFileIfRevision({
15679
+ path: msg.path,
15680
+ content: msg.content,
15681
+ expectedRevision: msg.expectedRevision,
15682
+ force: msg.force === true,
15683
+ }) as StudioDiskWriteResult;
15684
+ if (result.ok === false) {
15685
+ if (result.conflict) {
15686
+ sendToClient(client, {
15687
+ type: "save_conflict",
15688
+ requestId: msg.requestId,
15689
+ path: result.path || msg.path,
15690
+ currentRevision: result.currentRevision || null,
15691
+ reason: result.reason || "disk-changed",
15692
+ canOverwrite: result.reason !== "location-changed" && result.reason !== "hard-linked-file",
15693
+ message: result.message,
15694
+ });
15695
+ } else {
15696
+ sendToClient(client, { type: "error", requestId: msg.requestId, message: result.message });
15697
+ }
15429
15698
  return;
15430
15699
  }
15431
15700
 
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
- }
15701
+ const previousDocument = initialStudioDocument;
15702
+ const existingLabel = previousDocument?.path === result.path
15703
+ ? previousDocument.label
15704
+ : basename(result.path);
15705
+ initialStudioDocument = {
15706
+ text: msg.content,
15707
+ label: existingLabel,
15708
+ source: "file",
15709
+ path: result.path,
15710
+ resourceDir: dirname(result.path),
15711
+ diskRevision: result.revision,
15712
+ };
15713
+ recordStudioDocumentResourceGrants(initialStudioDocument);
15714
+ sendToClient(client, {
15715
+ type: "saved",
15716
+ requestId: msg.requestId,
15717
+ path: result.path,
15718
+ label: existingLabel,
15719
+ resourceDir: dirname(result.path),
15720
+ diskRevision: result.revision,
15721
+ message: `Saved over ${existingLabel}`,
15722
+ });
15452
15723
  return;
15453
15724
  }
15454
15725
 
@@ -15472,7 +15743,7 @@ export default function (pi: ExtensionAPI) {
15472
15743
  return;
15473
15744
  }
15474
15745
 
15475
- const refreshed = readStudioFile(refreshPath, studioCwd);
15746
+ const refreshed = readStudioFile(refreshPath, studioCwd, { requireCanonicalPath: true });
15476
15747
  if (refreshed.ok === false) {
15477
15748
  sendToClient(client, {
15478
15749
  type: "error",
@@ -15488,6 +15759,7 @@ export default function (pi: ExtensionAPI) {
15488
15759
  source: "file",
15489
15760
  path: refreshed.resolvedPath,
15490
15761
  resourceDir: dirname(refreshed.resolvedPath),
15762
+ diskRevision: refreshed.diskRevision,
15491
15763
  };
15492
15764
  if (!requestedPath || initialStudioDocument?.path === refreshed.resolvedPath) {
15493
15765
  initialStudioDocument = refreshedDocument;
@@ -16855,7 +17127,14 @@ export default function (pi: ExtensionAPI) {
16855
17127
  studioCwd,
16856
17128
  studioResourceGrantRegistry,
16857
17129
  );
16858
- await respondLocalPreviewLinkJson(req, res, requestUrl, resource, serverState, studioResourceGrantRegistry);
17130
+ await respondLocalPreviewLinkJson(
17131
+ req,
17132
+ res,
17133
+ requestUrl,
17134
+ resource,
17135
+ serverState,
17136
+ studioResourceGrantRegistry,
17137
+ );
16859
17138
  } catch (error) {
16860
17139
  if (error instanceof StudioResourceGrantRequiredError) {
16861
17140
  respondStudioResourceGrantRequiredJson(res, error);
@@ -17019,6 +17298,32 @@ export default function (pi: ExtensionAPI) {
17019
17298
  const host = req.headers.host ?? `127.0.0.1:${state.port}`;
17020
17299
  const requestUrl = new URL(req.url ?? "/ws", `http://${host}`);
17021
17300
  const clientMode = normalizeStudioUiMode(requestUrl.searchParams.get("mode"));
17301
+ const requestedWatchedPath = (requestUrl.searchParams.get("watchPath") ?? "").trim();
17302
+ const canonicalWatchedPath = requestedWatchedPath ? resolve(requestedWatchedPath) : "";
17303
+ const requestedDocId = (requestUrl.searchParams.get("docId") ?? "").trim();
17304
+ const capabilityDocument = requestedDocId ? readTransientStudioDocument(requestedDocId) : null;
17305
+ const capabilityWatchedPath = capabilityDocument?.watchFile && capabilityDocument.path
17306
+ ? resolve(capabilityDocument.path)
17307
+ : "";
17308
+ const comparableWatchedPath = process.platform === "win32" ? canonicalWatchedPath.toLowerCase() : canonicalWatchedPath;
17309
+ const comparableCapabilityPath = process.platform === "win32" ? capabilityWatchedPath.toLowerCase() : capabilityWatchedPath;
17310
+ const requestsWatchedCapability = Boolean(canonicalWatchedPath || capabilityWatchedPath);
17311
+ if (
17312
+ requestsWatchedCapability
17313
+ && (
17314
+ clientMode !== "editor-only"
17315
+ || !canonicalWatchedPath
17316
+ || !capabilityWatchedPath
17317
+ || comparableWatchedPath !== comparableCapabilityPath
17318
+ )
17319
+ ) {
17320
+ try {
17321
+ ws.close(4003, "Watched preview authorization unavailable");
17322
+ } catch {
17323
+ // Ignore close errors.
17324
+ }
17325
+ return;
17326
+ }
17022
17327
  if (clientMode === "full") {
17023
17328
  for (const client of clients) {
17024
17329
  if (client.readyState !== WebSocket.OPEN) continue;
@@ -17034,6 +17339,7 @@ export default function (pi: ExtensionAPI) {
17034
17339
  }
17035
17340
  clients.add(ws);
17036
17341
  clientModes.set(ws, clientMode);
17342
+ if (canonicalWatchedPath) studioWatchedClientPaths.set(ws, canonicalWatchedPath);
17037
17343
  emitDebugEvent("studio_ws_connected", { clients: clients.size, mode: clientMode });
17038
17344
  broadcastState();
17039
17345
 
@@ -17046,15 +17352,33 @@ export default function (pi: ExtensionAPI) {
17046
17352
  handleStudioMessage(ws, parsed);
17047
17353
  });
17048
17354
 
17049
- ws.on("close", () => {
17355
+ const cleanupClient = () => {
17050
17356
  clients.delete(ws);
17051
17357
  clientModes.delete(ws);
17358
+ studioWatchedClientPaths.delete(ws);
17359
+ const cleanupGeneration = (studioFileWatchSubscriptionGenerations.get(ws) ?? 0) + 1;
17360
+ studioFileWatchSubscriptionGenerations.set(ws, cleanupGeneration);
17361
+ void closeStudioFileWatcher(ws).finally(() => {
17362
+ if (studioFileWatchSubscriptionGenerations.get(ws) === cleanupGeneration) {
17363
+ studioFileWatchSubscriptionGenerations.delete(ws);
17364
+ }
17365
+ });
17366
+ };
17367
+
17368
+ ws.on("close", () => {
17369
+ cleanupClient();
17052
17370
  emitDebugEvent("studio_ws_disconnected", { clients: clients.size });
17053
17371
  });
17054
17372
 
17055
17373
  ws.on("error", () => {
17056
- clients.delete(ws);
17057
- clientModes.delete(ws);
17374
+ // Make the socket unusable before removing its watched-path marker;
17375
+ // otherwise an error event could briefly weaken read-only enforcement.
17376
+ try {
17377
+ ws.terminate();
17378
+ } catch {
17379
+ try { ws.close(); } catch {}
17380
+ }
17381
+ cleanupClient();
17058
17382
  });
17059
17383
  });
17060
17384
 
@@ -17122,7 +17446,12 @@ export default function (pi: ExtensionAPI) {
17122
17446
  const stopServer = async () => {
17123
17447
  studioSideQuestionStartRequestId = null;
17124
17448
  await disposeStudioSideQuestionRuntime();
17125
- if (!serverState) return;
17449
+ await closeAllStudioFileWatchers();
17450
+ if (!serverState) {
17451
+ studioWatchedClientPaths.clear();
17452
+ studioFileWatchSubscriptionGenerations.clear();
17453
+ return;
17454
+ }
17126
17455
  clearStudioDirectRunState();
17127
17456
  clearActiveRequest();
17128
17457
  clearPendingStudioCompletion();
@@ -17148,6 +17477,8 @@ export default function (pi: ExtensionAPI) {
17148
17477
  await new Promise<void>((resolve) => {
17149
17478
  state.server.close(() => resolve());
17150
17479
  });
17480
+ studioWatchedClientPaths.clear();
17481
+ studioFileWatchSubscriptionGenerations.clear();
17151
17482
  studioWorkspaceStateStore.clear();
17152
17483
  studioResourceGrantRegistry.clear();
17153
17484
  };
@@ -17166,6 +17497,13 @@ export default function (pi: ExtensionAPI) {
17166
17497
  await disposeStudioSideQuestionRuntime();
17167
17498
  studioTraceHistory.clear();
17168
17499
  lastCommandCtx = null;
17500
+ for (const client of studioWatchedClientPaths.keys()) {
17501
+ try { client.close(4001, "Watched preview session changed"); } catch {}
17502
+ }
17503
+ await closeAllStudioFileWatchers();
17504
+ transientStudioDocuments.clear();
17505
+ studioResourceGrantRegistry.clear();
17506
+ initialStudioDocument = null;
17169
17507
  }
17170
17508
  latestModelRequestCtx = ctx;
17171
17509
  hydrateLatestAssistant(ctx.sessionManager.getBranch());
@@ -17517,7 +17855,7 @@ export default function (pi: ExtensionAPI) {
17517
17855
  const resolveStudioLaunchDocument = (
17518
17856
  trimmed: string,
17519
17857
  ctx: ExtensionCommandContext,
17520
- options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; allowPdfPreview?: boolean; watchPdf?: boolean },
17858
+ options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; allowPdfPreview?: boolean; watchPdf?: boolean; watchText?: boolean },
17521
17859
  ): StudioLaunchSelection | null => {
17522
17860
  const defaultSource = options?.defaultSource === "blank" ? "blank" : "last-response";
17523
17861
  const commandLabel = options?.commandLabel ?? "/studio";
@@ -17624,6 +17962,30 @@ export default function (pi: ExtensionAPI) {
17624
17962
  return null;
17625
17963
  }
17626
17964
 
17965
+ if (options?.watchText) {
17966
+ if (file.text.length > PREVIEW_RENDER_MAX_CHARS) {
17967
+ ctx.ui.notify(`Watched preview source exceeds ${PREVIEW_RENDER_MAX_CHARS.toLocaleString()} characters.`, "error");
17968
+ return null;
17969
+ }
17970
+ return {
17971
+ document: {
17972
+ text: file.text,
17973
+ label: file.label,
17974
+ source: "file",
17975
+ path: file.resolvedPath,
17976
+ resourceDir: dirname(file.resolvedPath),
17977
+ diskRevision: file.diskRevision,
17978
+ watchFile: true,
17979
+ },
17980
+ kind: "watched-preview",
17981
+ mode: "editor-only",
17982
+ transient: true,
17983
+ skipWorkspaceRestore: true,
17984
+ paneFocus: "right",
17985
+ resourcePath: file.resolvedPath,
17986
+ };
17987
+ }
17988
+
17627
17989
  if (file.text.length > 200_000) {
17628
17990
  ctx.ui.notify(
17629
17991
  "Loaded a large file. Studio critique requests currently reject documents over 200k characters.",
@@ -17637,6 +17999,7 @@ export default function (pi: ExtensionAPI) {
17637
17999
  source: "file",
17638
18000
  path: file.resolvedPath,
17639
18001
  resourceDir: ctx.cwd,
18002
+ diskRevision: file.diskRevision,
17640
18003
  });
17641
18004
  };
17642
18005
 
@@ -17894,7 +18257,7 @@ export default function (pi: ExtensionAPI) {
17894
18257
  trimmed: string,
17895
18258
  ctx: ExtensionCommandContext,
17896
18259
  mode: StudioUiMode,
17897
- options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; replaceExistingFull?: boolean; allowPdfPreview?: boolean; watchPdf?: boolean },
18260
+ options?: { defaultSource?: "blank" | "last-response"; commandLabel?: string; replaceExistingFull?: boolean; allowPdfPreview?: boolean; watchPdf?: boolean; watchText?: boolean },
17898
18261
  ) => {
17899
18262
  const launchOpenFlags = parseStudioLaunchOpenFlags(trimmed);
17900
18263
  if (launchOpenFlags.error) {
@@ -17910,11 +18273,12 @@ export default function (pi: ExtensionAPI) {
17910
18273
  const launchesPdfPreview = parsedLaunchPath
17911
18274
  ? Boolean(parseStudioPdfLaunchTarget(normalizePathInput(parsedLaunchPath)))
17912
18275
  : false;
17913
- if (launchOpenFlags.watchPdf && !launchesPdfPreview) {
17914
- ctx.ui.notify("--watch requires a local PDF path, for example: /studio --watch main.pdf", "error");
18276
+ if (launchOpenFlags.watchPdf && !parsedLaunchPath) {
18277
+ ctx.ui.notify("--watch requires a local text or PDF path, for example: /studio --watch notes.md", "error");
17915
18278
  return;
17916
18279
  }
17917
- const requestedLaunchMode: StudioUiMode = launchesPdfPreview ? "editor-only" : mode;
18280
+ const launchesWatchedTextPreview = Boolean(launchOpenFlags.watchPdf && parsedLaunchPath && !launchesPdfPreview);
18281
+ const requestedLaunchMode: StudioUiMode = launchesPdfPreview || launchesWatchedTextPreview ? "editor-only" : mode;
17918
18282
  if (requestedLaunchMode === "full" && hasConnectedFullStudioView()) {
17919
18283
  if (options?.replaceExistingFull) {
17920
18284
  closeStudioClientsByMode("full", 4001, "Full Studio replaced");
@@ -17948,7 +18312,8 @@ export default function (pi: ExtensionAPI) {
17948
18312
 
17949
18313
  const selection = resolveStudioLaunchDocument(launchArgs, ctx, {
17950
18314
  ...options,
17951
- watchPdf: launchOpenFlags.watchPdf,
18315
+ watchPdf: launchOpenFlags.watchPdf && launchesPdfPreview,
18316
+ watchText: launchesWatchedTextPreview,
17952
18317
  });
17953
18318
  if (!selection) return;
17954
18319
  const selected = selection.document;
@@ -17974,7 +18339,9 @@ export default function (pi: ExtensionAPI) {
17974
18339
  ?? (launchOpenFlags.noBrowser ? buildStudioForwardingHint(state.port, url, { prefix: "Browser auto-open was skipped because --no-browser was used." }) : null);
17975
18340
  const openedLabel = selection.kind === "pdf-preview"
17976
18341
  ? "pi Studio PDF preview"
17977
- : (launchMode === "editor-only" ? "pi Studio editor-only view" : "pi Studio");
18342
+ : (selection.kind === "watched-preview"
18343
+ ? "pi Studio watched file preview"
18344
+ : (launchMode === "editor-only" ? "pi Studio editor-only view" : "pi Studio"));
17978
18345
 
17979
18346
  const shouldOpenBrowser = shouldAutoOpenStudioBrowser({
17980
18347
  openRemoteBrowser: launchOpenFlags.openRemoteBrowser,
@@ -17989,6 +18356,8 @@ export default function (pi: ExtensionAPI) {
17989
18356
  if (selection.kind === "pdf-preview") {
17990
18357
  const watchLabel = launchOpenFlags.watchPdf ? " (auto-refresh on)" : "";
17991
18358
  ctx.ui.notify(`Opened ${openedLabel}${watchLabel}: ${selection.resourcePath ?? selected.label}`, "info");
18359
+ } else if (selection.kind === "watched-preview") {
18360
+ ctx.ui.notify(`Opened ${openedLabel}: ${selection.resourcePath ?? selected.label}`, "info");
17992
18361
  } else if (selected.source === "file") {
17993
18362
  ctx.ui.notify(`Opened ${openedLabel} with file loaded: ${selected.label}`, "info");
17994
18363
  } else if (selected.source === "last-response") {
@@ -18011,7 +18380,7 @@ export default function (pi: ExtensionAPI) {
18011
18380
  };
18012
18381
 
18013
18382
  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)",
18383
+ 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
18384
  handler: async (args: string, ctx: ExtensionCommandContext) => {
18016
18385
  const trimmed = args.trim();
18017
18386
 
@@ -18042,7 +18411,7 @@ export default function (pi: ExtensionAPI) {
18042
18411
  "Usage: /studio [path|--blank|--last]\n"
18043
18412
  + " /studio Open studio with last model response (fallback: blank)\n"
18044
18413
  + " /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"
18414
+ + " /studio --watch <file> Open a read-only text preview that follows disk changes, or a PDF with auto-refresh\n"
18046
18415
  + " /studio --blank Open with blank editor\n"
18047
18416
  + " /studio --last Open with last model response\n"
18048
18417
  + " /studio --no-browser Print the Studio URL without opening a browser\n"
@@ -18093,7 +18462,7 @@ export default function (pi: ExtensionAPI) {
18093
18462
  });
18094
18463
 
18095
18464
  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>)",
18465
+ 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
18466
  handler: async (args: string, ctx: ExtensionCommandContext) => {
18098
18467
  const trimmed = args.trim();
18099
18468
  if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") {
@@ -18101,7 +18470,7 @@ export default function (pi: ExtensionAPI) {
18101
18470
  "Usage: /studio-editor-only [path|--blank|--last]\n"
18102
18471
  + " /studio-editor-only Open an editor-only Studio view (default: blank editor)\n"
18103
18472
  + " /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"
18473
+ + " /studio-editor-only --watch <file> Open a read-only text preview that follows disk changes, or a PDF with auto-refresh\n"
18105
18474
  + " /studio-editor-only --blank Open with blank editor\n"
18106
18475
  + " /studio-editor-only --last Open with last model response loaded into the editor\n"
18107
18476
  + " /studio-editor-only --no-browser Print URL without opening a browser\n"
@@ -18457,6 +18826,7 @@ export default function (pi: ExtensionAPI) {
18457
18826
  label: file.label,
18458
18827
  source: "file",
18459
18828
  path: file.resolvedPath,
18829
+ diskRevision: file.diskRevision,
18460
18830
  };
18461
18831
  initialStudioDocument = nextDoc;
18462
18832
  recordStudioDocumentResourceGrants(nextDoc, ctx.cwd);