pi-studio 0.9.26 → 0.9.28

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
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
- import { completeSimple, type ThinkingLevel } from "@earendil-works/pi-ai";
3
+ import { completeSimple, type ModelThinkingLevel, type ThinkingLevel } from "@earendil-works/pi-ai";
4
4
  import { Type } from "@sinclair/typebox";
5
5
  import { spawn, spawnSync } from "node:child_process";
6
6
  import { createHash, randomUUID } from "node:crypto";
@@ -339,6 +339,9 @@ interface CompletionSuggestionRequestMessage {
339
339
  path?: string;
340
340
  contextMode?: "cursor" | "session";
341
341
  contextText?: string;
342
+ previousSuggestion?: string;
343
+ suggestionModelProvider?: string;
344
+ suggestionModelId?: string;
342
345
  }
343
346
 
344
347
  interface CompletionSuggestionCancelRequestMessage {
@@ -346,6 +349,17 @@ interface CompletionSuggestionCancelRequestMessage {
346
349
  requestId: string;
347
350
  }
348
351
 
352
+ interface PiModelSelectRequestMessage {
353
+ type: "pi_model_select_request";
354
+ provider: string;
355
+ id: string;
356
+ }
357
+
358
+ interface PiThinkingLevelRequestMessage {
359
+ type: "pi_thinking_level_request";
360
+ level: ModelThinkingLevel;
361
+ }
362
+
349
363
  interface QuizGenerateRequestMessage {
350
364
  type: "quiz_generate_request";
351
365
  requestId: string;
@@ -489,6 +503,8 @@ type IncomingStudioMessage =
489
503
  | SendRunRequestMessage
490
504
  | CompletionSuggestionRequestMessage
491
505
  | CompletionSuggestionCancelRequestMessage
506
+ | PiModelSelectRequestMessage
507
+ | PiThinkingLevelRequestMessage
492
508
  | QuizGenerateRequestMessage
493
509
  | QuizAnswerRequestMessage
494
510
  | QuizDiscussRequestMessage
@@ -754,6 +770,17 @@ function buildStudioPandocPdfEngineOptArgs(pdfEngine: string): string[] {
754
770
  ];
755
771
  }
756
772
 
773
+ function getStudioMissingLatexEngineHint(stderr: string, pdfEngine: string): string {
774
+ const text = String(stderr || "");
775
+ const lower = text.toLowerCase();
776
+ const engine = basename(String(pdfEngine || "")).toLowerCase();
777
+ const engineMentioned = [engine, "xelatex", "pdflatex", "lualatex", "tectonic"].filter(Boolean).some((name) => lower.includes(name));
778
+ const missingEnginePattern = /(?:command not found|not found|no such file|could not find|cannot find|is not installed|not installed)/i;
779
+ return engineMentioned && missingEnginePattern.test(text)
780
+ ? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
781
+ : "";
782
+ }
783
+
757
784
  const STUDIO_PANDOC_HTML_FRAGMENT_TEMPLATE = `<!doctype html>
758
785
  <html>
759
786
  <head>
@@ -761,6 +788,26 @@ const STUDIO_PANDOC_HTML_FRAGMENT_TEMPLATE = `<!doctype html>
761
788
  <title>pi Studio preview</title>
762
789
  </head>
763
790
  <body>
791
+ $if(title)$
792
+ <header id="title-block-header">
793
+ <h1 class="title">$title$</h1>
794
+ $if(subtitle)$
795
+ <p class="subtitle">$subtitle$</p>
796
+ $endif$
797
+ $for(author)$
798
+ <p class="author">$author$</p>
799
+ $endfor$
800
+ $if(date)$
801
+ <p class="date">$date$</p>
802
+ $endif$
803
+ $if(abstract)$
804
+ <div class="abstract">
805
+ <div class="abstract-title">Abstract</div>
806
+ $abstract$
807
+ </div>
808
+ $endif$
809
+ </header>
810
+ $endif$
764
811
  $body$
765
812
  </body>
766
813
  </html>
@@ -4279,12 +4326,14 @@ function normalizeMathDelimitersInSegment(markdown: string): string {
4279
4326
  });
4280
4327
 
4281
4328
  normalized = normalized.replace(/\$\s*\\\[\s*([\s\S]*?)\s*\\\]\s*\$/g, (match, expr: string) => {
4329
+ if (/\n\s{0,3}>/.test(match)) return match;
4282
4330
  if (!isLikelyMathExpression(expr)) return match;
4283
4331
  const content = collapseDisplayMathContent(expr);
4284
4332
  return content.length > 0 ? `\\[${content}\\]` : "\\[\\]";
4285
4333
  });
4286
4334
 
4287
4335
  normalized = normalized.replace(/\\\[\s*([\s\S]*?)\s*\\\]/g, (match, expr: string) => {
4336
+ if (/\n\s{0,3}>/.test(match)) return match;
4288
4337
  if (!isLikelyMathExpression(expr)) return `[${expr.trim()}]`;
4289
4338
  const content = collapseDisplayMathContent(expr);
4290
4339
  return content.length > 0 ? `\\[${content}\\]` : "\\[\\]";
@@ -5920,9 +5969,17 @@ async function getStudioPandocHtmlResourceFlag(pandocCommand: string): Promise<"
5920
5969
  return cached;
5921
5970
  }
5922
5971
 
5972
+ function preprocessStudioLatexFootnotemarksForPreview(latex: string): string {
5973
+ return String(latex ?? "").replace(/\\footnotemark\s*\[\s*([^\]\r\n]+?)\s*\]/g, (_match, marker: string) => {
5974
+ const value = String(marker || "").trim();
5975
+ return /^\d+$/.test(value) ? `\\href{#fn${value}}{\\textsuperscript{${value}}}` : (value ? `\\textsuperscript{${value}}` : "");
5976
+ });
5977
+ }
5978
+
5923
5979
  async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolean, resourcePath?: string, sourcePath?: string): Promise<string> {
5924
5980
  const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
5925
- const markdownWithNormalizedFences = isLatex ? markdown : normalizeStudioMarkdownSmartFences(markdown);
5981
+ const latexPreviewSource = isLatex ? preprocessStudioLatexFootnotemarksForPreview(markdown) : markdown;
5982
+ const markdownWithNormalizedFences = isLatex ? latexPreviewSource : normalizeStudioMarkdownSmartFences(markdown);
5926
5983
  const markdownWithoutHtmlComments = isLatex ? markdownWithNormalizedFences : stripStudioMarkdownHtmlCommentsPreservingYamlFrontMatter(markdownWithNormalizedFences);
5927
5984
  const markdownWithPreviewPageBreaks = isLatex ? markdownWithoutHtmlComments : replaceStudioPreviewPageBreakCommands(markdownWithoutHtmlComments);
5928
5985
  const latexSubfigurePreviewTransform = isLatex
@@ -5938,16 +5995,20 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
5938
5995
  const bibliographyArgs = buildStudioPandocBibliographyArgs(markdown, isLatex, resourcePath);
5939
5996
  const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none", ...bibliographyArgs];
5940
5997
  let htmlTemplateDir: string | null = null;
5998
+ const useStudioHtmlTemplate = Boolean(resourcePath || isLatex);
5941
5999
  if (resourcePath) {
5942
6000
  args.push(`--resource-path=${resourcePath}`);
5943
- // Embed images as data URIs so browser previews and exported HTML keep local figures.
5944
- // A minimal template prevents Pandoc's standalone default CSS/title block from leaking
5945
- // into Studio's own standalone export wrapper.
6001
+ }
6002
+ if (useStudioHtmlTemplate) {
6003
+ // Use standalone mode for embedded resources and LaTeX metadata. A minimal
6004
+ // Studio template keeps Pandoc's default standalone CSS out of the pane while
6005
+ // still rendering LaTeX title/author/abstract metadata.
5946
6006
  htmlTemplateDir = join(tmpdir(), `pi-studio-pandoc-html-${randomUUID()}`);
5947
6007
  await mkdir(htmlTemplateDir, { recursive: true });
5948
6008
  const htmlTemplatePath = join(htmlTemplateDir, "template.html");
5949
6009
  await writeFile(htmlTemplatePath, STUDIO_PANDOC_HTML_FRAGMENT_TEMPLATE, "utf-8");
5950
- args.push(await getStudioPandocHtmlResourceFlag(pandocCommand), "--standalone", `--template=${htmlTemplatePath}`);
6010
+ if (resourcePath) args.push(await getStudioPandocHtmlResourceFlag(pandocCommand));
6011
+ args.push("--standalone", `--template=${htmlTemplatePath}`);
5951
6012
  }
5952
6013
  const normalizedMarkdown = isLatex
5953
6014
  ? sourceWithResolvedRefs
@@ -5977,8 +6038,8 @@ async function renderStudioMarkdownWithPandoc(markdown: string, isLatex?: boolea
5977
6038
  }
5978
6039
 
5979
6040
  let renderedHtml = pandocResult.stdout;
5980
- // When --standalone is used for embedded resources, extract only the <body> content.
5981
- if (resourcePath) {
6041
+ // When --standalone is used for embedded resources or LaTeX metadata, extract only the <body> content.
6042
+ if (useStudioHtmlTemplate) {
5982
6043
  const bodyMatch = renderedHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
5983
6044
  if (!bodyMatch) {
5984
6045
  throw new Error("pandoc HTML render did not include a complete body element.");
@@ -6787,9 +6848,7 @@ async function renderStudioPdfWithPandoc(
6787
6848
  });
6788
6849
  if (pandocResult.code !== 0) {
6789
6850
  const stderr = pandocResult.stderr;
6790
- const hint = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
6791
- ? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
6792
- : "";
6851
+ const hint = getStudioMissingLatexEngineHint(stderr, pdfEngine);
6793
6852
  throw new Error(`pandoc PDF export failed with exit code ${pandocResult.code}${stderr ? `: ${stderr}` : ""}${hint}`);
6794
6853
  }
6795
6854
 
@@ -6915,9 +6974,7 @@ async function renderStudioPdfWithPandoc(
6915
6974
  });
6916
6975
  if (pandocResult.code !== 0) {
6917
6976
  const stderr = pandocResult.stderr;
6918
- const hint = stderr.includes("not found") || stderr.includes("xelatex") || stderr.includes("pdflatex")
6919
- ? "\nPDF export requires a LaTeX engine. Install TeX Live (e.g. brew install --cask mactex) or set PANDOC_PDF_ENGINE."
6920
- : "";
6977
+ const hint = getStudioMissingLatexEngineHint(stderr, pdfEngine);
6921
6978
  throw new Error(`pandoc PDF export failed with exit code ${pandocResult.code}${stderr ? `: ${stderr}` : ""}${hint}`);
6922
6979
  }
6923
6980
 
@@ -7303,6 +7360,41 @@ function openPathInDefaultViewer(path: string): Promise<void> {
7303
7360
  });
7304
7361
  }
7305
7362
 
7363
+ async function handleOpenStudioFileBrowserDirectoryRequest(req: IncomingMessage, res: ServerResponse, studioCwd: string): Promise<void> {
7364
+ const method = (req.method ?? "GET").toUpperCase();
7365
+ if (method !== "POST") {
7366
+ res.setHeader("Allow", "POST");
7367
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use POST." });
7368
+ return;
7369
+ }
7370
+ if (isSshSession()) {
7371
+ respondJson(res, 409, { ok: false, error: "Cannot open local file manager from an SSH/headless Studio session. Copy the path instead." });
7372
+ return;
7373
+ }
7374
+
7375
+ const rawBody = await readRequestBody(req, REQUEST_BODY_MAX_BYTES);
7376
+ let payload: Record<string, unknown> = {};
7377
+ try {
7378
+ payload = rawBody ? JSON.parse(rawBody) : {};
7379
+ } catch {
7380
+ respondJson(res, 400, { ok: false, error: "Invalid JSON body." });
7381
+ return;
7382
+ }
7383
+
7384
+ try {
7385
+ const directory = resolveStudioFileBrowserDirectory(
7386
+ typeof payload.dir === "string" ? payload.dir : undefined,
7387
+ typeof payload.sourcePath === "string" ? payload.sourcePath : undefined,
7388
+ typeof payload.resourceDir === "string" ? payload.resourceDir : undefined,
7389
+ studioCwd,
7390
+ );
7391
+ await openPathInDefaultViewer(directory.currentDir);
7392
+ respondJson(res, 200, { ok: true, message: "Opened folder in file manager.", path: directory.currentDir, rootDir: directory.rootDir });
7393
+ } catch (error) {
7394
+ respondJson(res, 404, { ok: false, error: `Could not open file-browser folder: ${error instanceof Error ? error.message : String(error)}` });
7395
+ }
7396
+ }
7397
+
7306
7398
  function detectLensFromText(text: string): Lens {
7307
7399
  const lines = text.split("\n");
7308
7400
  const fencedCodeBlocks = (text.match(/```[\w-]*\n[\s\S]*?```/g) ?? []).length;
@@ -7627,12 +7719,13 @@ function getStudioQuizReasoning(model: NonNullable<ExtensionContext["model"]>, t
7627
7719
  async function runStudioModelText(
7628
7720
  ctx: StudioModelRequestContext,
7629
7721
  prompt: string,
7630
- options?: { systemPrompt?: string; maxTokens?: number; signal?: AbortSignal; reasoning?: ThinkingLevel; timeoutMs?: number; trim?: boolean },
7722
+ options?: { systemPrompt?: string; maxTokens?: number; signal?: AbortSignal; reasoning?: ThinkingLevel; timeoutMs?: number; trim?: boolean; model?: NonNullable<ExtensionContext["model"]> },
7631
7723
  ): Promise<string> {
7632
- if (!ctx.model) throw new Error("No active model selected.");
7633
- const auth = await resolveStudioModelRequestAuth(ctx, ctx.model);
7724
+ const model = options?.model ?? ctx.model;
7725
+ if (!model) throw new Error("No active model selected.");
7726
+ const auth = await resolveStudioModelRequestAuth(ctx, model);
7634
7727
  const response = await completeSimple(
7635
- ctx.model,
7728
+ model,
7636
7729
  {
7637
7730
  systemPrompt: options?.systemPrompt ?? "You are a concise assistant inside pi Studio. Return exactly the requested format.",
7638
7731
  messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }],
@@ -7692,6 +7785,37 @@ async function runStudioQuizModelJson(
7692
7785
  throw lastError ?? new Error("Model did not return valid JSON.");
7693
7786
  }
7694
7787
 
7788
+ function isStudioCompletionCodeLanguage(language: string | undefined): boolean {
7789
+ const normalized = String(language || "").trim().toLowerCase();
7790
+ return new Set([
7791
+ "javascript",
7792
+ "typescript",
7793
+ "python",
7794
+ "bash",
7795
+ "json",
7796
+ "rust",
7797
+ "c",
7798
+ "cpp",
7799
+ "julia",
7800
+ "fortran",
7801
+ "r",
7802
+ "matlab",
7803
+ "diff",
7804
+ "csv",
7805
+ "tsv",
7806
+ "java",
7807
+ "go",
7808
+ "ruby",
7809
+ "swift",
7810
+ "html",
7811
+ "css",
7812
+ "xml",
7813
+ "yaml",
7814
+ "toml",
7815
+ "lua",
7816
+ ]).has(normalized);
7817
+ }
7818
+
7695
7819
  function buildStudioCompletionSuggestionPrompt(options: {
7696
7820
  text: string;
7697
7821
  selectionStart: number;
@@ -7701,6 +7825,7 @@ function buildStudioCompletionSuggestionPrompt(options: {
7701
7825
  path?: string;
7702
7826
  contextMode?: "cursor" | "session";
7703
7827
  contextText?: string;
7828
+ previousSuggestion?: string;
7704
7829
  }): string {
7705
7830
  const text = String(options.text || "");
7706
7831
  const start = Math.max(0, Math.min(Math.floor(options.selectionStart || 0), text.length));
@@ -7711,31 +7836,51 @@ function buildStudioCompletionSuggestionPrompt(options: {
7711
7836
  const language = String(options.language || "").trim() || "unknown";
7712
7837
  const label = String(options.label || options.path || "Studio editor").trim();
7713
7838
  const contextText = String(options.contextText || "").trim().slice(-STUDIO_COMPLETION_MAX_CONTEXT_CHARS);
7839
+ const previousSuggestion = String(options.previousSuggestion || "").trim().slice(-4000);
7840
+ const editorExcerpt = selected
7841
+ ? `${prefix}⟦SELECTION_START⟧${selected}⟦SELECTION_END⟧${suffix}`
7842
+ : `${prefix}⟦CURSOR⟧${suffix}`;
7843
+ const isCodeCompletion = isStudioCompletionCodeLanguage(language);
7844
+ const modeInstructions = isCodeCompletion
7845
+ ? [
7846
+ "You are acting as a tab-completion model for a code editor.",
7847
+ "Return only the exact code/text that should be inserted if the user presses Tab. Do not wrap it in Markdown fences. Do not explain.",
7848
+ "Preserve syntax, indentation, delimiters, local names, comments, and the surrounding coding style.",
7849
+ "Partial identifiers, expressions, arguments, statements, or structured-data fragments are allowed when they are syntactically natural at the marker.",
7850
+ "If the marker is inside a string, comment, docstring, or markup text node, continue that local text naturally rather than applying prose sentence rules globally.",
7851
+ "Keep the completion local and short unless the surrounding code clearly calls for a larger block.",
7852
+ ]
7853
+ : [
7854
+ "You are acting as a tab-completion model for a text editor.",
7855
+ "Return only the exact text that should be inserted if the user presses Tab. Do not wrap it in Markdown fences. Do not explain.",
7856
+ "Do not return a sentence fragment, dependent clause, or lowercase noun phrase unless it is grammatically valid immediately at the marker.",
7857
+ "If the marker follows a completed sentence and you continue with prose, begin with any needed whitespace and a complete new sentence using normal capitalization.",
7858
+ "Return a non-empty completion. If the cursor is at the end of a sentence or paragraph, continue with a plausible complete sentence rather than a fragment.",
7859
+ "Match the surrounding language, style, indentation, and register.",
7860
+ "Keep the suggestion short unless the context clearly asks for a longer continuation.",
7861
+ ];
7714
7862
  return [
7715
- "Generate an inline completion for the current editor cursor position.",
7716
- "Return only the exact text to insert. Do not wrap it in Markdown fences. Do not explain.",
7717
- "Match the surrounding language, style, indentation, and register.",
7718
- "Keep the suggestion short unless the context clearly asks for a longer continuation.",
7863
+ ...modeInstructions,
7864
+ selected
7865
+ ? "The text between ⟦SELECTION_START⟧ and ⟦SELECTION_END⟧ is selected. Your answer will replace only that selected text."
7866
+ : "The cursor is marked by ⟦CURSOR⟧. Your answer will replace only that marker.",
7867
+ "The text before the marker is already written. Do not rewrite it, paraphrase it, or continue from an earlier point in the excerpt.",
7868
+ "After replacing the marker or selected range with your answer, the excerpt must read naturally at that exact position.",
7869
+ "Include any needed leading whitespace or punctuation; do not assume the editor will add it.",
7719
7870
  contextText
7720
7871
  ? "Use the extra session context only as background. Do not continue the extra context directly unless the editor cursor calls for it."
7721
7872
  : "Use only the cursor-local editor context below.",
7722
- selected
7723
- ? "The selected text will be replaced by the completion."
7724
- : "The completion will be inserted at the cursor.",
7873
+ previousSuggestion ? "The user asked for another suggestion. Avoid repeating the previous suggestion; offer a materially different continuation that still fits the same cursor context." : "",
7725
7874
  "",
7726
7875
  `File/context label: ${label}`,
7727
7876
  `Language mode: ${language}`,
7728
7877
  `Suggestion context mode: ${contextText ? "editor plus latest response" : "editor only"}`,
7729
7878
  contextText ? ["", "<extra_context>", contextText, "</extra_context>"].join("\n") : "",
7879
+ previousSuggestion ? ["", "<previous_suggestion>", previousSuggestion, "</previous_suggestion>"].join("\n") : "",
7730
7880
  "",
7731
- "<prefix>",
7732
- prefix,
7733
- "</prefix>",
7734
- selected ? ["", "<selected>", selected, "</selected>"].join("\n") : "",
7735
- "",
7736
- "<suffix>",
7737
- suffix,
7738
- "</suffix>",
7881
+ "<editor_excerpt>",
7882
+ editorExcerpt,
7883
+ "</editor_excerpt>",
7739
7884
  ].filter((part) => part !== "").join("\n");
7740
7885
  }
7741
7886
 
@@ -7754,13 +7899,19 @@ async function runStudioCompletionSuggestion(ctx: StudioModelRequestContext, opt
7754
7899
  path?: string;
7755
7900
  contextMode?: "cursor" | "session";
7756
7901
  contextText?: string;
7902
+ previousSuggestion?: string;
7903
+ model?: NonNullable<ExtensionContext["model"]>;
7757
7904
  signal?: AbortSignal;
7758
7905
  }): Promise<string> {
7759
7906
  const prompt = buildStudioCompletionSuggestionPrompt(options);
7907
+ const systemPrompt = isStudioCompletionCodeLanguage(options.language)
7908
+ ? "You are a code tab-completion engine inside pi Studio. Return only the exact code/text that replaces the cursor marker or selected range in the provided editor excerpt. The resulting excerpt must be syntactically natural at that exact position. Include needed leading whitespace. Never explain. Never include Markdown fences unless literal fences are the intended insertion."
7909
+ : "You are a prose tab-completion engine inside pi Studio. Return only the exact text that replaces the cursor marker or selected range in the provided editor excerpt. The resulting excerpt must read naturally at that exact position. Include needed leading whitespace. Never explain. Never include Markdown fences unless literal fences are the intended insertion.";
7760
7910
  // Intentionally omit `reasoning`: pi-ai treats absent reasoning as off/disabled
7761
7911
  // where supported. Passing "minimal" would still enable a reasoning path and slow completions.
7762
7912
  const suggestion = cleanStudioCompletionSuggestion(await runStudioModelText(ctx, prompt, {
7763
- 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.",
7913
+ systemPrompt,
7914
+ model: options.model,
7764
7915
  maxTokens: 650,
7765
7916
  timeoutMs: 60_000,
7766
7917
  trim: false,
@@ -8186,6 +8337,24 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
8186
8337
  };
8187
8338
  }
8188
8339
 
8340
+ if (msg.type === "pi_model_select_request" && typeof msg.provider === "string" && typeof msg.id === "string") {
8341
+ return {
8342
+ type: "pi_model_select_request",
8343
+ provider: msg.provider,
8344
+ id: msg.id,
8345
+ };
8346
+ }
8347
+
8348
+ if (msg.type === "pi_thinking_level_request" && typeof msg.level === "string") {
8349
+ const level = msg.level.trim().toLowerCase();
8350
+ if (level === "off" || level === "minimal" || level === "low" || level === "medium" || level === "high" || level === "xhigh") {
8351
+ return {
8352
+ type: "pi_thinking_level_request",
8353
+ level,
8354
+ };
8355
+ }
8356
+ }
8357
+
8189
8358
  if (msg.type === "completion_suggestion_request" && typeof msg.requestId === "string" && typeof msg.text === "string") {
8190
8359
  const textLength = msg.text.length;
8191
8360
  const rawStart = typeof msg.selectionStart === "number" && Number.isFinite(msg.selectionStart) ? msg.selectionStart : textLength;
@@ -8204,6 +8373,9 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
8204
8373
  path: typeof msg.path === "string" ? msg.path : undefined,
8205
8374
  contextMode,
8206
8375
  contextText: contextMode === "session" && typeof msg.contextText === "string" ? msg.contextText.slice(-STUDIO_COMPLETION_MAX_CONTEXT_CHARS) : undefined,
8376
+ previousSuggestion: typeof msg.previousSuggestion === "string" ? msg.previousSuggestion.slice(-4000) : undefined,
8377
+ suggestionModelProvider: typeof msg.suggestionModelProvider === "string" ? msg.suggestionModelProvider : undefined,
8378
+ suggestionModelId: typeof msg.suggestionModelId === "string" ? msg.suggestionModelId : undefined,
8207
8379
  };
8208
8380
  }
8209
8381
 
@@ -9760,6 +9932,12 @@ function formatModelLabelWithThinking(modelLabel: string, thinkingLevel?: string
9760
9932
  return `${base} (${level})`;
9761
9933
  }
9762
9934
 
9935
+ function formatStudioModelOptionLabel(model: { provider?: string; id?: string; name?: string } | undefined): string {
9936
+ const base = formatModelLabel(model);
9937
+ const name = typeof model?.name === "string" ? model.name.trim() : "";
9938
+ return name && name !== model?.id ? `${name} (${base})` : base;
9939
+ }
9940
+
9763
9941
  function buildTerminalSessionLabel(cwd: string, sessionName?: string): string {
9764
9942
  const cwdBase = basename(cwd || process.cwd() || "") || cwd || "~";
9765
9943
  const termProgram = String(process.env.TERM_PROGRAM ?? "").trim();
@@ -10073,7 +10251,7 @@ ${cssVarsBlock}
10073
10251
  <button id="saveOverBtn" type="button" title="Overwrite current file with editor content. Shortcut: Cmd/Ctrl+S.">Save editor</button>
10074
10252
  <button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
10075
10253
  <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>
10076
- <label class="file-label" title="Import a browser-selected text file into the editor as an unsaved copy. It will not be refreshable from disk until you save it.">Import file copy…<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>
10254
+ <label class="file-label" title="Browser import: load a selected text file as a detached copy. Use Save editor as to attach this copy to a file path and make it file-backed, or use the Files view to open a refreshable file-backed document directly.">Import file copy…<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>
10077
10255
  <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
10078
10256
  <button id="zenModeBtn" class="zen-mode-btn" type="button" title="Hide secondary Studio controls. Shortcut: F9.">Zen</button>
10079
10257
  </div>
@@ -10127,6 +10305,9 @@ ${cssVarsBlock}
10127
10305
  <option value="cursor" selected>Context: editor only</option>
10128
10306
  <option value="session">Context: editor + latest response</option>
10129
10307
  </select>
10308
+ <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.">
10309
+ <option value="current" selected>Suggestion model: current Pi model</option>
10310
+ </select>
10130
10311
  <button id="openCompanionBtn" type="button" title="Open a blank editor-only Studio tab.">New editor tab</button>
10131
10312
  <button id="sendEditorBtn" type="button">Send current text to Pi editor</button>
10132
10313
  </div>
@@ -10213,11 +10394,12 @@ ${cssVarsBlock}
10213
10394
  </div>
10214
10395
  <div id="completionSuggestionPanel" class="completion-suggestion-panel" hidden>
10215
10396
  <div class="completion-suggestion-header">
10216
- <strong>Suggested completion</strong>
10397
+ <div><strong>Suggested completion</strong><span id="completionSuggestionMeta" class="completion-suggestion-meta"></span></div>
10217
10398
  <button id="completionSuggestionDismissBtn" type="button" title="Dismiss this suggestion">Dismiss</button>
10218
10399
  </div>
10219
10400
  <pre id="completionSuggestionText" class="completion-suggestion-text"></pre>
10220
10401
  <div class="completion-suggestion-actions">
10402
+ <button id="completionSuggestionRegenerateBtn" type="button" title="Ask for a different suggestion at the same cursor position.">Try another</button>
10221
10403
  <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>
10222
10404
  </div>
10223
10405
  </div>
@@ -10352,7 +10534,8 @@ ${cssVarsBlock}
10352
10534
 
10353
10535
  <footer>
10354
10536
  <span id="statusLine"><span id="statusSpinner" aria-hidden="true"> </span><span id="status">Booting studio…</span></span>
10355
- <span id="footerMeta" class="footer-meta"><span id="footerMetaText" class="footer-meta-text"><span id="footerMetaModel" class="footer-meta-part footer-meta-model">${initialModel}</span><span class="footer-meta-sep">·</span><span id="footerMetaTerminal" class="footer-meta-part footer-meta-terminal">${initialTerminal}</span><span class="footer-meta-sep">·</span><span id="footerMetaContext" class="footer-meta-part footer-meta-context">unknown</span></span><button id="compactBtn" class="footer-compact-btn" type="button" title="Trigger pi context compaction now.">Compact</button></span>
10537
+ <span id="footerMeta" class="footer-meta"><span id="footerMetaText" class="footer-meta-text"><button id="footerMetaModel" class="footer-meta-part footer-meta-model footer-model-btn" type="button" aria-haspopup="menu" aria-expanded="false">${initialModel}</button><span class="footer-meta-sep">·</span><span id="footerMetaTerminal" class="footer-meta-part footer-meta-terminal">${initialTerminal}</span><span class="footer-meta-sep">·</span><span id="footerMetaContext" class="footer-meta-part footer-meta-context">unknown</span></span><button id="compactBtn" class="footer-compact-btn" type="button" title="Trigger pi context compaction now.">Compact</button></span>
10538
+ <div id="footerModelMenu" class="footer-model-menu" hidden></div>
10356
10539
  <button id="shortcutsBtn" class="shortcut-hint" type="button" title="Show Studio keyboard shortcuts. Press ? when not editing text.">Shortcuts (?)</button>
10357
10540
  </footer>
10358
10541
 
@@ -10473,7 +10656,7 @@ export default function (pi: ExtensionAPI) {
10473
10656
  let terminalActivityToolName: string | null = null;
10474
10657
  let terminalActivityLabel: string | null = null;
10475
10658
  let lastSpecificToolActivityLabel: string | null = null;
10476
- let currentModel: { provider?: string; id?: string } | undefined;
10659
+ let currentModel: { provider?: string; id?: string; name?: string; reasoning?: boolean } | undefined;
10477
10660
  let currentModelLabel = "none";
10478
10661
  let terminalSessionLabel = buildTerminalSessionLabel(studioCwd);
10479
10662
  let terminalSessionDetail = buildTerminalSessionDetail(studioCwd);
@@ -10746,15 +10929,20 @@ export default function (pi: ExtensionAPI) {
10746
10929
  }
10747
10930
  };
10748
10931
 
10749
- const getThinkingLevelSafe = (): string | undefined => {
10932
+ const getThinkingLevelSafe = (): ModelThinkingLevel | undefined => {
10750
10933
  try {
10751
- return pi.getThinkingLevel();
10934
+ return pi.getThinkingLevel() as ModelThinkingLevel;
10752
10935
  } catch {
10753
10936
  return undefined;
10754
10937
  }
10755
10938
  };
10756
10939
 
10757
- const refreshRuntimeMetadata = (ctx?: { cwd?: string; model?: { provider?: string; id?: string } | undefined }) => {
10940
+ const setThinkingLevelSafe = (level: ModelThinkingLevel) => {
10941
+ // Pi's CLI/model config support "off" as a thinking level; some extension API typings still expose the narrower reasoning-only type.
10942
+ (pi.setThinkingLevel as (nextLevel: ModelThinkingLevel) => void)(level);
10943
+ };
10944
+
10945
+ const refreshRuntimeMetadata = (ctx?: { cwd?: string; model?: { provider?: string; id?: string; name?: string; reasoning?: boolean } | undefined }) => {
10758
10946
  if (ctx?.cwd) {
10759
10947
  studioCwd = ctx.cwd;
10760
10948
  }
@@ -10763,6 +10951,8 @@ export default function (pi: ExtensionAPI) {
10763
10951
  currentModel = {
10764
10952
  provider: ctx.model.provider,
10765
10953
  id: ctx.model.id,
10954
+ name: ctx.model.name,
10955
+ reasoning: Boolean(ctx.model.reasoning),
10766
10956
  };
10767
10957
  } else {
10768
10958
  currentModel = undefined;
@@ -10771,6 +10961,8 @@ export default function (pi: ExtensionAPI) {
10771
10961
  currentModel = {
10772
10962
  provider: lastCommandCtx.model.provider,
10773
10963
  id: lastCommandCtx.model.id,
10964
+ name: lastCommandCtx.model.name,
10965
+ reasoning: Boolean(lastCommandCtx.model.reasoning),
10774
10966
  };
10775
10967
  }
10776
10968
  const baseModelLabel = formatModelLabel(currentModel);
@@ -11455,11 +11647,32 @@ export default function (pi: ExtensionAPI) {
11455
11647
  broadcastState();
11456
11648
  };
11457
11649
 
11650
+ const getStudioModelOptions = () => {
11651
+ const registry = lastCommandCtx?.modelRegistry ?? latestModelRequestCtx?.modelRegistry;
11652
+ if (!registry || typeof registry.getAvailable !== "function") return [];
11653
+ return registry.getAvailable().map((model) => ({
11654
+ provider: model.provider,
11655
+ id: model.id,
11656
+ label: formatStudioModelOptionLabel(model),
11657
+ reasoning: Boolean(model.reasoning),
11658
+ }));
11659
+ };
11660
+
11661
+ const getCurrentStudioModelDescriptor = () => currentModel
11662
+ ? {
11663
+ provider: currentModel.provider,
11664
+ id: currentModel.id,
11665
+ label: formatStudioModelOptionLabel(currentModel),
11666
+ reasoning: Boolean(currentModel.reasoning),
11667
+ }
11668
+ : null;
11669
+
11458
11670
  const broadcastState = () => {
11459
11671
  terminalSessionLabel = buildTerminalSessionLabel(studioCwd, getSessionNameSafe());
11460
11672
  terminalSessionDetail = buildTerminalSessionDetail(studioCwd, getSessionNameSafe());
11461
11673
  currentModelLabel = formatModelLabelWithThinking(formatModelLabel(currentModel), getThinkingLevelSafe());
11462
11674
  refreshContextUsage();
11675
+ const modelOptions = getStudioModelOptions();
11463
11676
  broadcast({
11464
11677
  type: "studio_state",
11465
11678
  busy: isStudioBusy(),
@@ -11468,6 +11681,10 @@ export default function (pi: ExtensionAPI) {
11468
11681
  terminalToolName: terminalActivityToolName,
11469
11682
  terminalActivityLabel,
11470
11683
  modelLabel: currentModelLabel,
11684
+ currentModel: getCurrentStudioModelDescriptor(),
11685
+ thinkingLevel: getThinkingLevelSafe() ?? "off",
11686
+ piModels: modelOptions,
11687
+ suggestionModels: modelOptions,
11471
11688
  terminalSessionLabel,
11472
11689
  terminalSessionDetail,
11473
11690
  contextTokens: contextUsageSnapshot.tokens,
@@ -11763,6 +11980,10 @@ export default function (pi: ExtensionAPI) {
11763
11980
  terminalToolName: terminalActivityToolName,
11764
11981
  terminalActivityLabel,
11765
11982
  modelLabel: currentModelLabel,
11983
+ currentModel: getCurrentStudioModelDescriptor(),
11984
+ thinkingLevel: getThinkingLevelSafe() ?? "off",
11985
+ piModels: getStudioModelOptions(),
11986
+ suggestionModels: getStudioModelOptions(),
11766
11987
  terminalSessionLabel,
11767
11988
  terminalSessionDetail,
11768
11989
  contextTokens: contextUsageSnapshot.tokens,
@@ -11781,6 +12002,47 @@ export default function (pi: ExtensionAPI) {
11781
12002
  return;
11782
12003
  }
11783
12004
 
12005
+ if (msg.type === "pi_model_select_request") {
12006
+ void (async () => {
12007
+ const registry = lastCommandCtx?.modelRegistry ?? latestModelRequestCtx?.modelRegistry;
12008
+ if (!registry || typeof registry.find !== "function") {
12009
+ sendToClient(client, { type: "info", level: "warning", message: "Pi model registry is not available yet." });
12010
+ return;
12011
+ }
12012
+ const model = registry.find(msg.provider, msg.id);
12013
+ if (!model) {
12014
+ sendToClient(client, { type: "info", level: "warning", message: `Pi model not found: ${msg.provider}/${msg.id}` });
12015
+ return;
12016
+ }
12017
+ try {
12018
+ const ok = await pi.setModel(model);
12019
+ if (!ok) {
12020
+ sendToClient(client, { type: "info", level: "warning", message: `Could not switch to ${formatStudioModelOptionLabel(model)}; credentials may be unavailable.` });
12021
+ return;
12022
+ }
12023
+ latestModelRequestCtx = { model, modelRegistry: registry };
12024
+ refreshRuntimeMetadata({ model });
12025
+ broadcastState();
12026
+ sendToClient(client, { type: "info", level: "info", message: `Pi model switched to ${formatStudioModelOptionLabel(model)}.` });
12027
+ } catch (error) {
12028
+ sendToClient(client, { type: "info", level: "error", message: `Model switch failed: ${error instanceof Error ? error.message : String(error)}` });
12029
+ }
12030
+ })();
12031
+ return;
12032
+ }
12033
+
12034
+ if (msg.type === "pi_thinking_level_request") {
12035
+ try {
12036
+ setThinkingLevelSafe(msg.level);
12037
+ refreshRuntimeMetadata({ model: lastCommandCtx?.model ?? latestModelRequestCtx?.model });
12038
+ broadcastState();
12039
+ sendToClient(client, { type: "info", level: "info", message: `Pi thinking level set to ${getThinkingLevelSafe() ?? msg.level}.` });
12040
+ } catch (error) {
12041
+ sendToClient(client, { type: "info", level: "error", message: `Thinking level change failed: ${error instanceof Error ? error.message : String(error)}` });
12042
+ }
12043
+ return;
12044
+ }
12045
+
11784
12046
  if (msg.type === "get_latest_response") {
11785
12047
  if (!lastStudioResponse) {
11786
12048
  sendToClient(client, { type: "info", message: "No latest assistant response is available yet." });
@@ -12063,10 +12325,19 @@ export default function (pi: ExtensionAPI) {
12063
12325
  return;
12064
12326
  }
12065
12327
  sendToClient(client, { type: "completion_suggestion_progress", requestId: msg.requestId, message: "Generating suggestion…" });
12328
+ let suggestionModel: NonNullable<ExtensionContext["model"]> | undefined;
12329
+ if (msg.suggestionModelProvider && msg.suggestionModelId) {
12330
+ suggestionModel = ctx.modelRegistry.find(msg.suggestionModelProvider, msg.suggestionModelId);
12331
+ if (!suggestionModel) {
12332
+ sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: `Suggestion model not found: ${msg.suggestionModelProvider}/${msg.suggestionModelId}` });
12333
+ return;
12334
+ }
12335
+ }
12066
12336
  const completionController = new AbortController();
12067
12337
  activeCompletionSuggestions.set(msg.requestId, completionController);
12068
12338
  void (async () => {
12069
12339
  try {
12340
+ const activeSuggestionModel = suggestionModel ?? ctx.model;
12070
12341
  const suggestion = await runStudioCompletionSuggestion(ctx, {
12071
12342
  text: msg.text,
12072
12343
  selectionStart: msg.selectionStart,
@@ -12076,12 +12347,15 @@ export default function (pi: ExtensionAPI) {
12076
12347
  path: msg.path,
12077
12348
  contextMode: msg.contextMode,
12078
12349
  contextText: msg.contextText,
12350
+ previousSuggestion: msg.previousSuggestion,
12351
+ model: suggestionModel,
12079
12352
  signal: completionController.signal,
12080
12353
  });
12081
12354
  sendToClient(client, {
12082
12355
  type: "completion_suggestion_result",
12083
12356
  requestId: msg.requestId,
12084
12357
  suggestion,
12358
+ modelLabel: formatStudioModelOptionLabel(activeSuggestionModel),
12085
12359
  selectionStart: msg.selectionStart,
12086
12360
  selectionEnd: msg.selectionEnd,
12087
12361
  });
@@ -13706,6 +13980,19 @@ export default function (pi: ExtensionAPI) {
13706
13980
  return;
13707
13981
  }
13708
13982
 
13983
+ if (requestUrl.pathname === "/file-browser-open") {
13984
+ const token = requestUrl.searchParams.get("token") ?? "";
13985
+ if (token !== serverState.token) {
13986
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
13987
+ return;
13988
+ }
13989
+
13990
+ void handleOpenStudioFileBrowserDirectoryRequest(req, res, studioCwd).catch((error) => {
13991
+ respondJson(res, 500, { ok: false, error: `Open folder failed: ${error instanceof Error ? error.message : String(error)}` });
13992
+ });
13993
+ return;
13994
+ }
13995
+
13709
13996
  if (requestUrl.pathname === "/local-preview-link") {
13710
13997
  const token = requestUrl.searchParams.get("token") ?? "";
13711
13998
  if (token !== serverState.token) {