pi-studio 0.9.14 → 0.9.16

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
@@ -311,6 +311,17 @@ interface SendRunRequestMessage {
311
311
  text: string;
312
312
  }
313
313
 
314
+ interface CompletionSuggestionRequestMessage {
315
+ type: "completion_suggestion_request";
316
+ requestId: string;
317
+ text: string;
318
+ selectionStart: number;
319
+ selectionEnd: number;
320
+ language?: string;
321
+ label?: string;
322
+ path?: string;
323
+ }
324
+
314
325
  interface QuizGenerateRequestMessage {
315
326
  type: "quiz_generate_request";
316
327
  requestId: string;
@@ -451,6 +462,7 @@ type IncomingStudioMessage =
451
462
  | CritiqueRequestMessage
452
463
  | AnnotationRequestMessage
453
464
  | SendRunRequestMessage
465
+ | CompletionSuggestionRequestMessage
454
466
  | QuizGenerateRequestMessage
455
467
  | QuizAnswerRequestMessage
456
468
  | QuizDiscussRequestMessage
@@ -472,6 +484,9 @@ type IncomingStudioMessage =
472
484
 
473
485
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
474
486
  const PREVIEW_RENDER_MAX_CHARS = 400_000;
487
+ const STUDIO_COMPLETION_MAX_TEXT_CHARS = 250_000;
488
+ const STUDIO_COMPLETION_PREFIX_CHARS = 12_000;
489
+ const STUDIO_COMPLETION_SUFFIX_CHARS = 6_000;
475
490
  const PDF_EXPORT_MAX_CHARS = 400_000;
476
491
  const HTML_EXPORT_MAX_CHARS = 400_000;
477
492
  const HTML_PREVIEW_MATH_RENDER_MAX_ITEMS = 250;
@@ -2442,6 +2457,16 @@ const STUDIO_LOCAL_LINK_TEXT_EXTENSIONS = new Set([
2442
2457
  ".diff", ".patch",
2443
2458
  ]);
2444
2459
  const STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
2460
+ const STUDIO_LOCAL_LINK_TEXT_FILENAMES = new Set([
2461
+ ".dockerignore", ".editorconfig", ".env", ".env.example", ".eslintignore", ".gitattributes",
2462
+ ".gitignore", ".gitmodules", ".npmignore", ".prettierignore", "dockerfile", "gemfile",
2463
+ "justfile", "license", "makefile", "rakefile", "readme",
2464
+ ]);
2465
+ const STUDIO_FILE_BROWSER_MAX_ENTRIES = 500;
2466
+ const STUDIO_FILE_BROWSER_IGNORED_DIRS = new Set([
2467
+ ".git", "node_modules", ".next", ".cache", "dist", "build", "coverage", "target",
2468
+ "__pycache__", ".venv", "venv", ".mypy_cache", ".pytest_cache", ".ruff_cache",
2469
+ ]);
2445
2470
 
2446
2471
  type StudioLocalPreviewResourceKind = "pdf" | "text" | "image" | "other";
2447
2472
 
@@ -2454,6 +2479,17 @@ interface StudioLocalPreviewResource {
2454
2479
  resourceDir: string;
2455
2480
  }
2456
2481
 
2482
+ interface StudioFileBrowserEntry {
2483
+ name: string;
2484
+ path: string;
2485
+ type: "directory" | "file";
2486
+ extension: string;
2487
+ kind: StudioLocalPreviewResourceKind | "directory";
2488
+ size: number;
2489
+ mtimeMs: number;
2490
+ hidden: boolean;
2491
+ }
2492
+
2457
2493
  function resolveStudioPdfResourcePath(pdfPath: string | undefined, sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
2458
2494
  const rawPath = typeof pdfPath === "string" ? pdfPath.trim() : "";
2459
2495
  if (!rawPath) throw new Error("Missing PDF path.");
@@ -2522,10 +2558,11 @@ function parseStudioLocalPreviewResourcePage(resourcePath: string): number | nul
2522
2558
  return null;
2523
2559
  }
2524
2560
 
2525
- function getStudioLocalPreviewResourceKind(extension: string): StudioLocalPreviewResourceKind {
2561
+ function getStudioLocalPreviewResourceKind(extension: string, filePathOrName?: string): StudioLocalPreviewResourceKind {
2526
2562
  const ext = extension.toLowerCase();
2563
+ const name = basename(String(filePathOrName || "")).toLowerCase();
2527
2564
  if (ext === ".pdf") return "pdf";
2528
- if (STUDIO_LOCAL_LINK_TEXT_EXTENSIONS.has(ext)) return "text";
2565
+ if (STUDIO_LOCAL_LINK_TEXT_EXTENSIONS.has(ext) || STUDIO_LOCAL_LINK_TEXT_FILENAMES.has(name)) return "text";
2529
2566
  if (STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS.has(ext)) return "image";
2530
2567
  return "other";
2531
2568
  }
@@ -2563,12 +2600,94 @@ function resolveStudioLocalPreviewResourcePath(
2563
2600
  filePath: candidateReal,
2564
2601
  label: rel && rel !== "" ? rel : basename(candidateReal),
2565
2602
  extension,
2566
- kind: getStudioLocalPreviewResourceKind(extension),
2603
+ kind: getStudioLocalPreviewResourceKind(extension, candidateReal),
2567
2604
  page: parseStudioLocalPreviewResourcePage(rawPath),
2568
2605
  resourceDir: boundaryReal,
2569
2606
  };
2570
2607
  }
2571
2608
 
2609
+ function resolveStudioFileBrowserDirectory(
2610
+ dirPath: string | undefined,
2611
+ sourcePath: string | undefined,
2612
+ resourceDir: string | undefined,
2613
+ fallbackCwd: string,
2614
+ ): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null } {
2615
+ const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
2616
+ const rootReal = realpathSync(context.boundaryDir);
2617
+ const rawDir = typeof dirPath === "string" ? dirPath.trim() : "";
2618
+ const baseDir = context.baseDir;
2619
+ const requested = rawDir
2620
+ ? (isAbsolute(recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir)))
2621
+ ? recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir))
2622
+ : resolve(baseDir, recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir))))
2623
+ : baseDir;
2624
+ const currentReal = realpathSync(requested);
2625
+ const currentStat = statSync(currentReal);
2626
+ if (!currentStat.isDirectory()) throw new Error("File browser path does not refer to a directory.");
2627
+ if (!isPathInsideOrEqualDirectory(currentReal, rootReal)) {
2628
+ throw new Error("File browser path must stay within the current Studio resource directory.");
2629
+ }
2630
+ const parent = dirname(currentReal);
2631
+ const parentDir = parent !== currentReal && isPathInsideOrEqualDirectory(parent, rootReal) ? parent : null;
2632
+ const relativeDir = relative(rootReal, currentReal) || ".";
2633
+ return { rootDir: rootReal, currentDir: currentReal, relativeDir, parentDir };
2634
+ }
2635
+
2636
+ function listStudioFileBrowserDirectory(
2637
+ dirPath: string | undefined,
2638
+ sourcePath: string | undefined,
2639
+ resourceDir: string | undefined,
2640
+ fallbackCwd: string,
2641
+ ): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null; entries: StudioFileBrowserEntry[]; omitted: number; omittedIgnored: number } {
2642
+ const context = resolveStudioFileBrowserDirectory(dirPath, sourcePath, resourceDir, fallbackCwd);
2643
+ const entries: StudioFileBrowserEntry[] = [];
2644
+ let omitted = 0;
2645
+ let omittedIgnored = 0;
2646
+ const dirents = readdirSync(context.currentDir, { withFileTypes: true });
2647
+ for (const dirent of dirents) {
2648
+ const name = dirent.name;
2649
+ if (STUDIO_FILE_BROWSER_IGNORED_DIRS.has(name)) {
2650
+ omittedIgnored += 1;
2651
+ continue;
2652
+ }
2653
+ const candidate = join(context.currentDir, name);
2654
+ try {
2655
+ const real = realpathSync(candidate);
2656
+ if (!isPathInsideOrEqualDirectory(real, context.rootDir)) {
2657
+ omitted += 1;
2658
+ continue;
2659
+ }
2660
+ const stat = statSync(real);
2661
+ if (!stat.isDirectory() && !stat.isFile()) {
2662
+ omitted += 1;
2663
+ continue;
2664
+ }
2665
+ const type = stat.isDirectory() ? "directory" : "file";
2666
+ const extension = type === "file" ? extname(real).toLowerCase() : "";
2667
+ entries.push({
2668
+ name,
2669
+ path: real,
2670
+ type,
2671
+ extension,
2672
+ kind: type === "directory" ? "directory" : getStudioLocalPreviewResourceKind(extension, real),
2673
+ size: stat.size,
2674
+ mtimeMs: stat.mtimeMs,
2675
+ hidden: name.startsWith("."),
2676
+ });
2677
+ } catch {
2678
+ omitted += 1;
2679
+ }
2680
+ }
2681
+ entries.sort((a, b) => {
2682
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
2683
+ if (a.hidden !== b.hidden) return a.hidden ? 1 : -1;
2684
+ return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true });
2685
+ });
2686
+ const limitedEntries = entries.slice(0, STUDIO_FILE_BROWSER_MAX_ENTRIES);
2687
+ omitted += Math.max(0, entries.length - limitedEntries.length);
2688
+ return { ...context, entries: limitedEntries, omitted, omittedIgnored };
2689
+ }
2690
+
2572
2691
  function resolveStudioHtmlPreviewResourcePath(
2573
2692
  resourcePath: string | undefined,
2574
2693
  sourcePath: string | undefined,
@@ -7014,7 +7133,7 @@ async function resolveStudioModelRequestAuth(ctx: StudioModelRequestContext, mod
7014
7133
  if (typeof registry.getApiKey === "function") {
7015
7134
  return { apiKey: await registry.getApiKey(model) };
7016
7135
  }
7017
- throw new Error("Current pi model registry does not expose model credentials for Studio quiz.");
7136
+ throw new Error("Current pi model registry does not expose model credentials for Studio model requests.");
7018
7137
  }
7019
7138
 
7020
7139
  function getStudioQuizReasoning(model: NonNullable<ExtensionContext["model"]>, thinking: StudioQuizThinking | undefined): ThinkingLevel | undefined {
@@ -7023,33 +7142,47 @@ function getStudioQuizReasoning(model: NonNullable<ExtensionContext["model"]>, t
7023
7142
  return normalized === "off" ? undefined : normalized;
7024
7143
  }
7025
7144
 
7026
- async function runStudioQuizModelText(ctx: StudioModelRequestContext, prompt: string, options?: { maxTokens?: number; signal?: AbortSignal; thinking?: StudioQuizThinking }): Promise<string> {
7145
+ async function runStudioModelText(
7146
+ ctx: StudioModelRequestContext,
7147
+ prompt: string,
7148
+ options?: { systemPrompt?: string; maxTokens?: number; signal?: AbortSignal; reasoning?: ThinkingLevel; timeoutMs?: number; trim?: boolean },
7149
+ ): Promise<string> {
7027
7150
  if (!ctx.model) throw new Error("No active model selected.");
7028
7151
  const auth = await resolveStudioModelRequestAuth(ctx, ctx.model);
7029
7152
  const response = await completeSimple(
7030
7153
  ctx.model,
7031
7154
  {
7032
- systemPrompt: "You are an active tutor inside pi Studio. Ask and mark concise, probing quiz questions. Return exactly the requested format.",
7155
+ systemPrompt: options?.systemPrompt ?? "You are a concise assistant inside pi Studio. Return exactly the requested format.",
7033
7156
  messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }],
7034
7157
  },
7035
7158
  {
7036
7159
  apiKey: auth.apiKey,
7037
7160
  headers: auth.headers,
7038
- reasoning: getStudioQuizReasoning(ctx.model, options?.thinking),
7161
+ reasoning: options?.reasoning,
7039
7162
  maxTokens: options?.maxTokens ?? 2500,
7040
7163
  signal: options?.signal,
7041
- timeoutMs: 120_000,
7164
+ timeoutMs: options?.timeoutMs ?? 120_000,
7042
7165
  },
7043
7166
  );
7044
- const text = response.content
7167
+ const rawText = response.content
7045
7168
  .filter((part): part is { type: "text"; text: string } => part.type === "text")
7046
7169
  .map((part) => part.text)
7047
- .join("\n")
7048
- .trim();
7049
- if (!text) throw new Error("Model returned no text response.");
7170
+ .join("\n");
7171
+ const text = options?.trim === false ? rawText : rawText.trim();
7172
+ if (!text.trim()) throw new Error("Model returned no text response.");
7050
7173
  return text;
7051
7174
  }
7052
7175
 
7176
+ async function runStudioQuizModelText(ctx: StudioModelRequestContext, prompt: string, options?: { maxTokens?: number; signal?: AbortSignal; thinking?: StudioQuizThinking }): Promise<string> {
7177
+ return runStudioModelText(ctx, prompt, {
7178
+ systemPrompt: "You are an active tutor inside pi Studio. Ask and mark concise, probing quiz questions. Return exactly the requested format.",
7179
+ reasoning: ctx.model ? getStudioQuizReasoning(ctx.model, options?.thinking) : undefined,
7180
+ maxTokens: options?.maxTokens ?? 2500,
7181
+ signal: options?.signal,
7182
+ timeoutMs: 120_000,
7183
+ });
7184
+ }
7185
+
7053
7186
  async function runStudioQuizModelJson(
7054
7187
  ctx: StudioModelRequestContext,
7055
7188
  prompt: string,
@@ -7077,6 +7210,72 @@ async function runStudioQuizModelJson(
7077
7210
  throw lastError ?? new Error("Model did not return valid JSON.");
7078
7211
  }
7079
7212
 
7213
+ function buildStudioCompletionSuggestionPrompt(options: {
7214
+ text: string;
7215
+ selectionStart: number;
7216
+ selectionEnd: number;
7217
+ language?: string;
7218
+ label?: string;
7219
+ path?: string;
7220
+ }): string {
7221
+ const text = String(options.text || "");
7222
+ const start = Math.max(0, Math.min(Math.floor(options.selectionStart || 0), text.length));
7223
+ const end = Math.max(start, Math.min(Math.floor(options.selectionEnd || start), text.length));
7224
+ const prefix = text.slice(Math.max(0, start - STUDIO_COMPLETION_PREFIX_CHARS), start);
7225
+ const selected = text.slice(start, end);
7226
+ const suffix = text.slice(end, Math.min(text.length, end + STUDIO_COMPLETION_SUFFIX_CHARS));
7227
+ const language = String(options.language || "").trim() || "unknown";
7228
+ const label = String(options.label || options.path || "Studio editor").trim();
7229
+ return [
7230
+ "Generate an inline completion for the current editor cursor position.",
7231
+ "Return only the exact text to insert. Do not wrap it in Markdown fences. Do not explain.",
7232
+ "Match the surrounding language, style, indentation, and register.",
7233
+ "Keep the suggestion short unless the context clearly asks for a longer continuation.",
7234
+ selected
7235
+ ? "The selected text will be replaced by the completion."
7236
+ : "The completion will be inserted at the cursor.",
7237
+ "",
7238
+ `File/context label: ${label}`,
7239
+ `Language mode: ${language}`,
7240
+ "",
7241
+ "<prefix>",
7242
+ prefix,
7243
+ "</prefix>",
7244
+ selected ? ["", "<selected>", selected, "</selected>"].join("\n") : "",
7245
+ "",
7246
+ "<suffix>",
7247
+ suffix,
7248
+ "</suffix>",
7249
+ ].filter((part) => part !== "").join("\n");
7250
+ }
7251
+
7252
+ function cleanStudioCompletionSuggestion(text: string): string {
7253
+ let value = String(text || "").replace(/\r\n/g, "\n");
7254
+ value = value.replace(/^\s*(?:Here(?:'s| is) (?:the )?(?:completion|suggestion):|Completion:|Suggestion:)\s*/i, "");
7255
+ return value;
7256
+ }
7257
+
7258
+ async function runStudioCompletionSuggestion(ctx: StudioModelRequestContext, options: {
7259
+ text: string;
7260
+ selectionStart: number;
7261
+ selectionEnd: number;
7262
+ language?: string;
7263
+ label?: string;
7264
+ path?: string;
7265
+ }): Promise<string> {
7266
+ const prompt = buildStudioCompletionSuggestionPrompt(options);
7267
+ // Intentionally omit `reasoning`: pi-ai treats absent reasoning as off/disabled
7268
+ // where supported. Passing "minimal" would still enable a reasoning path and slow completions.
7269
+ const suggestion = cleanStudioCompletionSuggestion(await runStudioModelText(ctx, prompt, {
7270
+ systemPrompt: "You are an inline autocomplete engine inside pi Studio. Return only text to insert at the cursor. Never explain. Never include Markdown fences unless literal fences are the intended insertion.",
7271
+ maxTokens: 650,
7272
+ timeoutMs: 60_000,
7273
+ trim: false,
7274
+ }));
7275
+ if (!suggestion.trim()) throw new Error("Model returned an empty completion suggestion.");
7276
+ return suggestion;
7277
+ }
7278
+
7080
7279
  function inferStudioResponseKind(markdown: string): StudioRequestKind {
7081
7280
  const lower = markdown.toLowerCase();
7082
7281
  if (lower.includes("## critiques") && lower.includes("## document")) return "critique";
@@ -7486,6 +7685,24 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
7486
7685
  };
7487
7686
  }
7488
7687
 
7688
+ if (msg.type === "completion_suggestion_request" && typeof msg.requestId === "string" && typeof msg.text === "string") {
7689
+ const textLength = msg.text.length;
7690
+ const rawStart = typeof msg.selectionStart === "number" && Number.isFinite(msg.selectionStart) ? msg.selectionStart : textLength;
7691
+ const rawEnd = typeof msg.selectionEnd === "number" && Number.isFinite(msg.selectionEnd) ? msg.selectionEnd : rawStart;
7692
+ const selectionStart = Math.max(0, Math.min(Math.floor(rawStart), textLength));
7693
+ const selectionEnd = Math.max(selectionStart, Math.min(Math.floor(rawEnd), textLength));
7694
+ return {
7695
+ type: "completion_suggestion_request",
7696
+ requestId: msg.requestId,
7697
+ text: msg.text,
7698
+ selectionStart,
7699
+ selectionEnd,
7700
+ language: typeof msg.language === "string" ? msg.language : undefined,
7701
+ label: typeof msg.label === "string" ? msg.label : undefined,
7702
+ path: typeof msg.path === "string" ? msg.path : undefined,
7703
+ };
7704
+ }
7705
+
7489
7706
  if (msg.type === "quiz_generate_request" && typeof msg.requestId === "string" && typeof msg.sourceText === "string") {
7490
7707
  const rawCount = typeof msg.questionCount === "number" && Number.isFinite(msg.questionCount) ? msg.questionCount : 5;
7491
7708
  return {
@@ -8958,7 +9175,7 @@ function resolveRequestedStudioDocumentFromUrl(
8958
9175
  label: requestedLabel || file.label,
8959
9176
  source: "file",
8960
9177
  path: file.resolvedPath,
8961
- resourceDir: requestedResourceDir || dirname(file.resolvedPath),
9178
+ resourceDir: requestedResourceDir || fallback?.resourceDir || studioCwd,
8962
9179
  };
8963
9180
  }
8964
9181
  }
@@ -9314,7 +9531,7 @@ ${cssVarsBlock}
9314
9531
  <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>
9315
9532
  <button id="saveOverBtn" type="button" title="Overwrite current file with editor content. Shortcut: Cmd/Ctrl+S.">Save editor</button>
9316
9533
  <button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
9317
- <button id="clearWorkspaceBtn" type="button" title="Clear the current editor draft in this browser tab. Saved files and responses are not changed.">Clear editor</button>
9534
+ <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>
9318
9535
  <label class="file-label" title="Load a local file into editor text.">Load file content<input id="fileInput" type="file" accept=".md,.markdown,.mdx,.qmd,.js,.mjs,.cjs,.jsx,.ts,.mts,.cts,.tsx,.py,.pyw,.sh,.bash,.zsh,.json,.jsonc,.json5,.rs,.c,.h,.cpp,.cxx,.cc,.hpp,.hxx,.jl,.f90,.f95,.f03,.f,.for,.r,.R,.m,.tex,.latex,.diff,.patch,.java,.go,.rb,.swift,.html,.htm,.css,.xml,.yaml,.yml,.toml,.lua,.txt,.rst,.adoc" /></label>
9319
9536
  <button id="loadGitDiffBtn" type="button" title="Load the current git diff from the Studio context into the editor.">Load git diff</button>
9320
9537
  <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
@@ -9363,8 +9580,9 @@ ${cssVarsBlock}
9363
9580
  </select>
9364
9581
  </div>
9365
9582
  <div class="source-actions-row">
9366
- <button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy text</button>
9367
- <button id="openCompanionBtn" type="button" title="Open a detached copy of the current editor text in a new editor-only Studio tab.">Open new editor</button>
9583
+ <button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy</button>
9584
+ <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>
9585
+ <button id="openCompanionBtn" type="button" title="Open a detached copy of the current editor text in a new editor-only Studio tab.">New editor</button>
9368
9586
  <button id="sendEditorBtn" type="button">Send to pi editor</button>
9369
9587
  </div>
9370
9588
  <div class="source-actions-row">
@@ -9446,6 +9664,16 @@ ${cssVarsBlock}
9446
9664
  <button id="editorSelectionJumpBtn" type="button" class="editor-selection-action-btn" hidden title="Jump to the current editor selection in the preview.">Jump</button>
9447
9665
  </div>
9448
9666
  </div>
9667
+ <div id="completionSuggestionPanel" class="completion-suggestion-panel" hidden>
9668
+ <div class="completion-suggestion-header">
9669
+ <strong>Suggested completion</strong>
9670
+ <button id="completionSuggestionDismissBtn" type="button" title="Dismiss this suggestion">Dismiss</button>
9671
+ </div>
9672
+ <pre id="completionSuggestionText" class="completion-suggestion-text"></pre>
9673
+ <div class="completion-suggestion-actions">
9674
+ <button id="completionSuggestionInsertBtn" type="button" title="Insert this suggestion at the cursor or original selection. You can also press Tab while the editor is focused.">Insert suggestion (Tab)</button>
9675
+ </div>
9676
+ </div>
9449
9677
  <div id="sourcePreview" class="panel-scroll rendered-markdown" hidden><pre class="plain-markdown"></pre></div>
9450
9678
  </div>
9451
9679
  <aside id="outlineOverlay" class="outline-dock-wrap" hidden>
@@ -9508,6 +9736,7 @@ ${cssVarsBlock}
9508
9736
  <option value="preview" selected>Response (Preview)</option>
9509
9737
  <option value="editor-preview">Editor (Preview)</option>
9510
9738
  <option value="trace">Working</option>
9739
+ <option value="files">Files</option>
9511
9740
  <option value="repl">REPL</option>
9512
9741
  </select>
9513
9742
  </div>
@@ -9613,7 +9842,9 @@ ${cssVarsBlock}
9613
9842
  <dl>
9614
9843
  <div><dt>Cmd/Ctrl+S</dt><dd>Save editor</dd></div>
9615
9844
  <div><dt>Cmd/Ctrl+Enter</dt><dd>Run editor text, or queue steering during an active run</dd></div>
9616
- <div><dt>Tab / Shift+Tab</dt><dd>Indent or unindent selected editor text</dd></div>
9845
+ <div><dt>Option/Alt+Tab or Cmd/Ctrl+Shift+Space</dt><dd>Suggest a completion at the editor cursor</dd></div>
9846
+ <div><dt>Tab</dt><dd>Insert a visible completion suggestion; otherwise indent selected editor text</dd></div>
9847
+ <div><dt>Shift+Tab</dt><dd>Unindent selected editor text</dd></div>
9617
9848
  </dl>
9618
9849
  </section>
9619
9850
  <section class="shortcuts-group">
@@ -11193,6 +11424,53 @@ export default function (pi: ExtensionAPI) {
11193
11424
  return;
11194
11425
  }
11195
11426
 
11427
+ if (msg.type === "completion_suggestion_request") {
11428
+ if (!isValidRequestId(msg.requestId)) {
11429
+ sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: "Invalid request ID." });
11430
+ return;
11431
+ }
11432
+ const ctx = latestModelRequestCtx ?? lastCommandCtx;
11433
+ if (!ctx) {
11434
+ sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: "No active pi model context is available for editor suggestions." });
11435
+ return;
11436
+ }
11437
+ if (!msg.text.trim()) {
11438
+ sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: "Editor is empty." });
11439
+ return;
11440
+ }
11441
+ if (msg.text.length > STUDIO_COMPLETION_MAX_TEXT_CHARS) {
11442
+ sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: `Editor text is too large for suggestions (${STUDIO_COMPLETION_MAX_TEXT_CHARS} character limit).` });
11443
+ return;
11444
+ }
11445
+ sendToClient(client, { type: "completion_suggestion_progress", requestId: msg.requestId, message: "Generating suggestion…" });
11446
+ void (async () => {
11447
+ try {
11448
+ const suggestion = await runStudioCompletionSuggestion(ctx, {
11449
+ text: msg.text,
11450
+ selectionStart: msg.selectionStart,
11451
+ selectionEnd: msg.selectionEnd,
11452
+ language: msg.language,
11453
+ label: msg.label,
11454
+ path: msg.path,
11455
+ });
11456
+ sendToClient(client, {
11457
+ type: "completion_suggestion_result",
11458
+ requestId: msg.requestId,
11459
+ suggestion,
11460
+ selectionStart: msg.selectionStart,
11461
+ selectionEnd: msg.selectionEnd,
11462
+ });
11463
+ } catch (error) {
11464
+ sendToClient(client, {
11465
+ type: "completion_suggestion_error",
11466
+ requestId: msg.requestId,
11467
+ message: `Suggestion failed: ${error instanceof Error ? error.message : String(error)}`,
11468
+ });
11469
+ }
11470
+ })();
11471
+ return;
11472
+ }
11473
+
11196
11474
  if (msg.type === "quiz_generate_request") {
11197
11475
  if (!isValidRequestId(msg.requestId)) {
11198
11476
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
@@ -12677,6 +12955,34 @@ export default function (pi: ExtensionAPI) {
12677
12955
  return;
12678
12956
  }
12679
12957
 
12958
+ if (requestUrl.pathname === "/file-browser") {
12959
+ const token = requestUrl.searchParams.get("token") ?? "";
12960
+ if (token !== serverState.token) {
12961
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
12962
+ return;
12963
+ }
12964
+
12965
+ const method = (req.method ?? "GET").toUpperCase();
12966
+ if (method !== "GET" && method !== "HEAD") {
12967
+ res.setHeader("Allow", "GET, HEAD");
12968
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use GET." });
12969
+ return;
12970
+ }
12971
+
12972
+ try {
12973
+ const listing = listStudioFileBrowserDirectory(
12974
+ requestUrl.searchParams.get("dir") ?? undefined,
12975
+ requestUrl.searchParams.get("sourcePath") ?? undefined,
12976
+ requestUrl.searchParams.get("resourceDir") ?? undefined,
12977
+ studioCwd,
12978
+ );
12979
+ respondJson(res, 200, { ok: true, ...listing, entries: method === "HEAD" ? [] : listing.entries });
12980
+ } catch (error) {
12981
+ respondJson(res, 404, { ok: false, error: `File browser unavailable: ${error instanceof Error ? error.message : String(error)}` });
12982
+ }
12983
+ return;
12984
+ }
12985
+
12680
12986
  if (requestUrl.pathname === "/local-preview-link") {
12681
12987
  const token = requestUrl.searchParams.get("token") ?? "";
12682
12988
  if (token !== serverState.token) {
@@ -13377,7 +13683,7 @@ export default function (pi: ExtensionAPI) {
13377
13683
  label: file.label,
13378
13684
  source: "file",
13379
13685
  path: file.resolvedPath,
13380
- resourceDir: dirname(file.resolvedPath),
13686
+ resourceDir: ctx.cwd,
13381
13687
  };
13382
13688
  };
13383
13689
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.14",
3
+ "version": "0.9.16",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",