pi-studio 0.9.16 → 0.9.18

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
@@ -162,6 +162,7 @@ interface PreparedStudioPdfExport {
162
162
  createdAt: number;
163
163
  filePath?: string;
164
164
  tempDirPath?: string;
165
+ persistent?: boolean;
165
166
  }
166
167
 
167
168
  interface PreparedStudioHtmlExport {
@@ -171,6 +172,7 @@ interface PreparedStudioHtmlExport {
171
172
  createdAt: number;
172
173
  filePath?: string;
173
174
  tempDirPath?: string;
175
+ persistent?: boolean;
174
176
  }
175
177
 
176
178
  interface StudioHtmlAnnotationPlaceholder {
@@ -320,6 +322,13 @@ interface CompletionSuggestionRequestMessage {
320
322
  language?: string;
321
323
  label?: string;
322
324
  path?: string;
325
+ contextMode?: "cursor" | "session";
326
+ contextText?: string;
327
+ }
328
+
329
+ interface CompletionSuggestionCancelRequestMessage {
330
+ type: "completion_suggestion_cancel_request";
331
+ requestId: string;
323
332
  }
324
333
 
325
334
  interface QuizGenerateRequestMessage {
@@ -463,6 +472,7 @@ type IncomingStudioMessage =
463
472
  | AnnotationRequestMessage
464
473
  | SendRunRequestMessage
465
474
  | CompletionSuggestionRequestMessage
475
+ | CompletionSuggestionCancelRequestMessage
466
476
  | QuizGenerateRequestMessage
467
477
  | QuizAnswerRequestMessage
468
478
  | QuizDiscussRequestMessage
@@ -485,6 +495,7 @@ type IncomingStudioMessage =
485
495
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
486
496
  const PREVIEW_RENDER_MAX_CHARS = 400_000;
487
497
  const STUDIO_COMPLETION_MAX_TEXT_CHARS = 250_000;
498
+ const STUDIO_COMPLETION_MAX_CONTEXT_CHARS = 12_000;
488
499
  const STUDIO_COMPLETION_PREFIX_CHARS = 12_000;
489
500
  const STUDIO_COMPLETION_SUFFIX_CHARS = 6_000;
490
501
  const PDF_EXPORT_MAX_CHARS = 400_000;
@@ -2248,6 +2259,8 @@ function inferStudioPdfLanguageFromPath(pathInput: string): string | undefined {
2248
2259
  ".yml": "yaml",
2249
2260
  ".toml": "toml",
2250
2261
  ".lua": "lua",
2262
+ ".csv": "csv",
2263
+ ".tsv": "tsv",
2251
2264
  ".txt": "text",
2252
2265
  ".rst": "text",
2253
2266
  ".adoc": "text",
@@ -2290,6 +2303,33 @@ function buildStudioResponseExportOutputPath(cwd: string, extension: "pdf" | "ht
2290
2303
  return join(cwd || process.cwd(), `studio-response-${formatStudioExportTimestamp()}.studio.${extension}`);
2291
2304
  }
2292
2305
 
2306
+ function buildStudioPreviewExportPath(sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string, filename: string): string | null {
2307
+ const cleanFilename = String(filename || "").trim();
2308
+ if (!cleanFilename) return null;
2309
+ const source = typeof sourcePath === "string" ? sourcePath.trim() : "";
2310
+ if (source) {
2311
+ const expanded = recoverLikelyDroppedLeadingSlashPath(expandHome(source));
2312
+ return join(dirname(isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded)), cleanFilename);
2313
+ }
2314
+ const resource = normalizeStudioResourceDirectoryInput(typeof resourceDir === "string" ? resourceDir : "");
2315
+ if (resource) {
2316
+ const expanded = recoverLikelyDroppedLeadingSlashPath(expandHome(resource));
2317
+ return join(isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded), cleanFilename);
2318
+ }
2319
+ return join(fallbackCwd || process.cwd(), cleanFilename);
2320
+ }
2321
+
2322
+ function writeStudioPreviewExportFile(path: string | null, data: Buffer): { filePath: string | null; error: string | null } {
2323
+ if (!path) return { filePath: null, error: "No export path was resolved." };
2324
+ try {
2325
+ mkdirSync(dirname(path), { recursive: true });
2326
+ writeFileSync(path, data);
2327
+ return { filePath: path, error: null };
2328
+ } catch (error) {
2329
+ return { filePath: null, error: error instanceof Error ? error.message : String(error) };
2330
+ }
2331
+ }
2332
+
2293
2333
  function writeStudioFile(pathArg: string, cwd: string, content: string):
2294
2334
  | { ok: true; label: string; resolvedPath: string }
2295
2335
  | { ok: false; message: string } {
@@ -2457,6 +2497,7 @@ const STUDIO_LOCAL_LINK_TEXT_EXTENSIONS = new Set([
2457
2497
  ".diff", ".patch",
2458
2498
  ]);
2459
2499
  const STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
2500
+ const STUDIO_LOCAL_LINK_OFFICE_EXTENSIONS = new Set([".docx", ".odt"]);
2460
2501
  const STUDIO_LOCAL_LINK_TEXT_FILENAMES = new Set([
2461
2502
  ".dockerignore", ".editorconfig", ".env", ".env.example", ".eslintignore", ".gitattributes",
2462
2503
  ".gitignore", ".gitmodules", ".npmignore", ".prettierignore", "dockerfile", "gemfile",
@@ -2468,7 +2509,7 @@ const STUDIO_FILE_BROWSER_IGNORED_DIRS = new Set([
2468
2509
  "__pycache__", ".venv", "venv", ".mypy_cache", ".pytest_cache", ".ruff_cache",
2469
2510
  ]);
2470
2511
 
2471
- type StudioLocalPreviewResourceKind = "pdf" | "text" | "image" | "other";
2512
+ type StudioLocalPreviewResourceKind = "pdf" | "text" | "image" | "office" | "other";
2472
2513
 
2473
2514
  interface StudioLocalPreviewResource {
2474
2515
  filePath: string;
@@ -2564,6 +2605,7 @@ function getStudioLocalPreviewResourceKind(extension: string, filePathOrName?: s
2564
2605
  if (ext === ".pdf") return "pdf";
2565
2606
  if (STUDIO_LOCAL_LINK_TEXT_EXTENSIONS.has(ext) || STUDIO_LOCAL_LINK_TEXT_FILENAMES.has(name)) return "text";
2566
2607
  if (STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS.has(ext)) return "image";
2608
+ if (STUDIO_LOCAL_LINK_OFFICE_EXTENSIONS.has(ext)) return "office";
2567
2609
  return "other";
2568
2610
  }
2569
2611
 
@@ -4295,6 +4337,125 @@ function wrapStudioCodeAsMarkdown(code: string, language?: string): string {
4295
4337
  return `${marker}${lang}\n${source}\n${marker}`;
4296
4338
  }
4297
4339
 
4340
+ const STUDIO_DELIMITED_PREVIEW_MAX_DATA_ROWS = 200;
4341
+ const STUDIO_DELIMITED_PREVIEW_MAX_COLUMNS = 50;
4342
+ const STUDIO_DELIMITED_PREVIEW_MAX_CELL_CHARS = 500;
4343
+
4344
+ function getStudioDelimitedTextConfig(language?: string): { label: string; delimiter: string } | null {
4345
+ const normalized = normalizeStudioEditorLanguage(language);
4346
+ if (normalized === "csv") return { label: "CSV", delimiter: "," };
4347
+ if (normalized === "tsv") return { label: "TSV", delimiter: "\t" };
4348
+ return null;
4349
+ }
4350
+
4351
+ function parseStudioDelimitedTextRows(text: string, delimiter: string, maxRows: number): { rows: string[][]; truncatedRows: boolean } {
4352
+ const source = String(text ?? "").replace(/^\uFEFF/, "");
4353
+ const limit = Math.max(1, Math.floor(maxRows));
4354
+ const rows: string[][] = [];
4355
+ let row: string[] = [];
4356
+ let cell = "";
4357
+ let inQuotes = false;
4358
+ let truncatedRows = false;
4359
+
4360
+ const pushCell = () => {
4361
+ row.push(cell);
4362
+ cell = "";
4363
+ };
4364
+ const pushRow = (index: number): boolean => {
4365
+ pushCell();
4366
+ rows.push(row);
4367
+ row = [];
4368
+ if (rows.length >= limit) {
4369
+ truncatedRows = index < source.length - 1;
4370
+ return true;
4371
+ }
4372
+ return false;
4373
+ };
4374
+
4375
+ for (let i = 0; i < source.length; i += 1) {
4376
+ if (rows.length >= limit) {
4377
+ truncatedRows = true;
4378
+ break;
4379
+ }
4380
+ const ch = source[i];
4381
+ if (inQuotes) {
4382
+ if (ch === '"') {
4383
+ if (source[i + 1] === '"') {
4384
+ cell += '"';
4385
+ i += 1;
4386
+ } else {
4387
+ inQuotes = false;
4388
+ }
4389
+ } else {
4390
+ cell += ch;
4391
+ }
4392
+ continue;
4393
+ }
4394
+ if (ch === '"' && cell === "") {
4395
+ inQuotes = true;
4396
+ continue;
4397
+ }
4398
+ if (ch === delimiter) {
4399
+ pushCell();
4400
+ continue;
4401
+ }
4402
+ if (ch === "\n") {
4403
+ if (pushRow(i)) break;
4404
+ continue;
4405
+ }
4406
+ if (ch === "\r") {
4407
+ if (source[i + 1] === "\n") i += 1;
4408
+ if (pushRow(i)) break;
4409
+ continue;
4410
+ }
4411
+ cell += ch;
4412
+ }
4413
+
4414
+ if (!truncatedRows && rows.length < limit && (cell.length > 0 || row.length > 0)) {
4415
+ pushCell();
4416
+ rows.push(row);
4417
+ }
4418
+
4419
+ return { rows, truncatedRows };
4420
+ }
4421
+
4422
+ function formatStudioDelimitedMarkdownCell(value: string | undefined): string {
4423
+ const raw = String(value ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
4424
+ const shortened = raw.length > STUDIO_DELIMITED_PREVIEW_MAX_CELL_CHARS
4425
+ ? `${raw.slice(0, STUDIO_DELIMITED_PREVIEW_MAX_CELL_CHARS)}…`
4426
+ : raw;
4427
+ return shortened.replace(/\n/g, "<br>").replace(/\|/g, "\\|").trim() || " ";
4428
+ }
4429
+
4430
+ function formatStudioDelimitedTextAsMarkdown(text: string, language?: string): string | null {
4431
+ const config = getStudioDelimitedTextConfig(language);
4432
+ if (!config) return null;
4433
+ const parsed = parseStudioDelimitedTextRows(text, config.delimiter, STUDIO_DELIMITED_PREVIEW_MAX_DATA_ROWS + 1);
4434
+ const rows = parsed.rows;
4435
+ if (!rows.length) return `_${config.label} file has no tabular data to preview._`;
4436
+ const rawColumnCount = rows.reduce((max, row) => Math.max(max, row.length), 0);
4437
+ const columnCount = Math.min(rawColumnCount, STUDIO_DELIMITED_PREVIEW_MAX_COLUMNS);
4438
+ if (columnCount <= 0) return `_${config.label} file has no tabular data to preview._`;
4439
+ const header = rows[0] ?? [];
4440
+ const dataRows = rows.slice(1);
4441
+ const columnIndexes = Array.from({ length: columnCount }, (_value, index) => index);
4442
+ const lines: string[] = [`**${config.label} preview**`, ""];
4443
+ const notices: string[] = [];
4444
+ if (parsed.truncatedRows) notices.push(`showing first ${Math.max(0, dataRows.length)} data rows`);
4445
+ if (rawColumnCount > columnCount) notices.push(`showing first ${columnCount} of ${rawColumnCount} columns`);
4446
+ if (notices.length) lines.push(`_${notices.join("; ")}._`, "");
4447
+ lines.push(`| ${columnIndexes.map((index) => formatStudioDelimitedMarkdownCell(header[index] || `Column ${index + 1}`)).join(" | ")} |`);
4448
+ lines.push(`| ${columnIndexes.map(() => "---").join(" | ")} |`);
4449
+ if (dataRows.length) {
4450
+ dataRows.forEach((row) => {
4451
+ lines.push(`| ${columnIndexes.map((index) => formatStudioDelimitedMarkdownCell(row[index])).join(" | ")} |`);
4452
+ });
4453
+ } else {
4454
+ lines.push(`| ${columnIndexes.map(() => " ").join(" | ")} |`);
4455
+ }
4456
+ return lines.join("\n");
4457
+ }
4458
+
4298
4459
  function extractStudioFenceInfoLanguage(info: string): string | undefined {
4299
4460
  const firstToken = String(info ?? "").trim().split(/\s+/)[0]?.replace(/^\./, "") ?? "";
4300
4461
  return normalizeStudioEditorLanguage(firstToken || undefined);
@@ -5245,11 +5406,13 @@ function buildStudioLiteralTextPdfTexConfig(options?: StudioPdfRenderOptions): {
5245
5406
 
5246
5407
  function prepareStudioPdfMarkdown(markdown: string, isLatex?: boolean, editorLanguage?: string): string {
5247
5408
  if (isLatex) return markdown;
5248
- const effectiveEditorLanguage = inferStudioPdfLanguage(markdown, editorLanguage);
5409
+ const delimitedMarkdown = formatStudioDelimitedTextAsMarkdown(markdown, editorLanguage);
5410
+ const input = delimitedMarkdown ?? markdown;
5411
+ const effectiveEditorLanguage = delimitedMarkdown ? "markdown" : inferStudioPdfLanguage(input, editorLanguage);
5249
5412
  const source = effectiveEditorLanguage && effectiveEditorLanguage !== "markdown" && effectiveEditorLanguage !== "latex"
5250
- && !isStudioSingleFencedCodeBlock(markdown)
5251
- ? wrapStudioCodeAsMarkdown(markdown, effectiveEditorLanguage)
5252
- : markdown;
5413
+ && !isStudioSingleFencedCodeBlock(input)
5414
+ ? wrapStudioCodeAsMarkdown(input, effectiveEditorLanguage)
5415
+ : input;
5253
5416
  const annotationReadySource = !effectiveEditorLanguage || effectiveEditorLanguage === "markdown" || effectiveEditorLanguage === "latex"
5254
5417
  ? replaceStudioAnnotationMarkersForPdf(source)
5255
5418
  : source;
@@ -5970,17 +6133,19 @@ async function renderStudioStandaloneHtmlWithPandoc(
5970
6133
  sourcePath?: string,
5971
6134
  options?: StudioHtmlRenderOptions,
5972
6135
  ): Promise<{ html: Buffer; warning?: string }> {
5973
- const effectiveEditorLanguage = inferStudioPdfLanguage(markdown, editorLanguage);
5974
- if (!isLatex && isLikelyStandaloneStudioHtml(markdown, effectiveEditorLanguage)) {
5975
- return { html: Buffer.from(String(markdown ?? ""), "utf-8") };
6136
+ const delimitedMarkdown = isLatex ? null : formatStudioDelimitedTextAsMarkdown(markdown, editorLanguage);
6137
+ const input = delimitedMarkdown ?? markdown;
6138
+ const effectiveEditorLanguage = delimitedMarkdown ? "markdown" : inferStudioPdfLanguage(input, editorLanguage);
6139
+ if (!isLatex && isLikelyStandaloneStudioHtml(input, effectiveEditorLanguage)) {
6140
+ return { html: Buffer.from(String(input ?? ""), "utf-8") };
5976
6141
  }
5977
6142
  const source = !isLatex
5978
6143
  && effectiveEditorLanguage
5979
6144
  && effectiveEditorLanguage !== "markdown"
5980
6145
  && effectiveEditorLanguage !== "latex"
5981
- && !isStudioSingleFencedCodeBlock(markdown)
5982
- ? wrapStudioCodeAsMarkdown(markdown, effectiveEditorLanguage)
5983
- : markdown;
6146
+ && !isStudioSingleFencedCodeBlock(input)
6147
+ ? wrapStudioCodeAsMarkdown(input, effectiveEditorLanguage)
6148
+ : input;
5984
6149
  const annotationPrepared = prepareStudioAnnotationMarkersForHtml(source);
5985
6150
  const pdfPrepared = prepareStudioPdfBlocksForHtml(annotationPrepared.markdown);
5986
6151
  let renderedHtml = await renderStudioMarkdownWithPandoc(pdfPrepared.markdown, isLatex, resourcePath, sourcePath);
@@ -6651,7 +6816,74 @@ function respondHtmlPreviewResourceJson(req: IncomingMessage, res: ServerRespons
6651
6816
  });
6652
6817
  }
6653
6818
 
6654
- function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResponse, requestUrl: URL, resource: StudioLocalPreviewResource, serverState: StudioServerState): void {
6819
+ function formatStudioMarkdownAngleTarget(pathText: string): string {
6820
+ return `<${String(pathText || "").replace(/>/g, "%3E")}>`;
6821
+ }
6822
+
6823
+ function sanitizeStudioPreviewBlockLine(value: string): string {
6824
+ return String(value || "").replace(/[\r\n]+/g, " ").trim();
6825
+ }
6826
+
6827
+ function buildStudioLocalResourcePreviewDocument(resource: StudioLocalPreviewResource): InitialStudioDocument {
6828
+ const label = basename(resource.filePath) || resource.label || "local preview";
6829
+ const resourcePath = resource.label || basename(resource.filePath) || resource.filePath;
6830
+ const title = sanitizeStudioPreviewBlockLine(label);
6831
+ let text = "";
6832
+ if (resource.kind === "pdf") {
6833
+ text = "```studio-pdf\n"
6834
+ + `path: ${sanitizeStudioPreviewBlockLine(resourcePath)}\n`
6835
+ + `title: ${title || "PDF preview"}\n`
6836
+ + "height: 820\n"
6837
+ + "```\n";
6838
+ } else if (resource.kind === "image") {
6839
+ text = `![${title || "Image preview"}](${formatStudioMarkdownAngleTarget(resourcePath)})\n`;
6840
+ } else {
6841
+ throw new Error("This local resource cannot be opened as a preview document.");
6842
+ }
6843
+ return {
6844
+ text,
6845
+ label: `${label} preview`,
6846
+ source: "blank",
6847
+ resourceDir: resource.resourceDir,
6848
+ };
6849
+ }
6850
+
6851
+ function getStudioOfficePandocInputFormat(extension: string): string {
6852
+ const ext = String(extension || "").toLowerCase();
6853
+ if (ext === ".docx") return "docx";
6854
+ if (ext === ".odt") return "odt";
6855
+ return ext.replace(/^\./, "") || "docx";
6856
+ }
6857
+
6858
+ async function convertStudioOfficeDocumentToMarkdown(resource: StudioLocalPreviewResource): Promise<{ text: string; label: string }> {
6859
+ if (resource.kind !== "office") throw new Error("This local resource is not a supported convertible document.");
6860
+ const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
6861
+ const inputFormat = getStudioOfficePandocInputFormat(resource.extension);
6862
+ const result = await runStudioSubprocess(pandocCommand, [
6863
+ "-f", inputFormat,
6864
+ "-t", "markdown",
6865
+ "--wrap=none",
6866
+ resource.filePath,
6867
+ ], {
6868
+ cwd: dirname(resource.filePath),
6869
+ timeoutMs: STUDIO_PANDOC_TIMEOUT_MS,
6870
+ stdoutMaxBytes: STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES,
6871
+ label: "pandoc document conversion",
6872
+ notFoundMessage: "pandoc was not found. Install pandoc or set PANDOC_PATH to convert DOCX/ODT documents in Studio.",
6873
+ });
6874
+ if (result.code !== 0) {
6875
+ throw new Error(`pandoc failed with exit code ${result.code}${result.stderr ? `: ${result.stderr}` : ""}`);
6876
+ }
6877
+ if (result.stdoutTruncated) {
6878
+ throw new Error("Converted document exceeded Studio's import size limit.");
6879
+ }
6880
+ const label = `converted: ${resource.label || basename(resource.filePath) || "document"}`;
6881
+ const note = `<!-- ${label} from ${resource.filePath}. This is a Markdown conversion; saving will not update the original ${resource.extension || "document"} file. -->`;
6882
+ const body = result.stdout.trim();
6883
+ return { text: `${note}\n\n${body}\n`, label };
6884
+ }
6885
+
6886
+ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResponse, requestUrl: URL, resource: StudioLocalPreviewResource, serverState: StudioServerState): Promise<void> {
6655
6887
  const method = (req.method ?? "GET").toUpperCase();
6656
6888
  if (method !== "GET" && method !== "HEAD") {
6657
6889
  res.setHeader("Allow", "GET, HEAD");
@@ -6675,32 +6907,72 @@ function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResponse,
6675
6907
  return;
6676
6908
  }
6677
6909
 
6910
+ if (action === "preview-url") {
6911
+ if (resource.kind !== "pdf" && resource.kind !== "image") {
6912
+ respondJson(res, 400, { ok: false, error: "This local resource cannot be opened in a Studio preview tab." });
6913
+ return;
6914
+ }
6915
+ const document = buildStudioLocalResourcePreviewDocument(resource);
6916
+ const docId = storeTransientStudioDocument(document);
6917
+ const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId);
6918
+ const parsedUrl = new URL(url);
6919
+ respondJson(res, 200, {
6920
+ ...basePayload,
6921
+ url,
6922
+ relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
6923
+ });
6924
+ return;
6925
+ }
6926
+
6678
6927
  if (action !== "document" && action !== "editor-url") {
6679
6928
  respondJson(res, 400, { ok: false, error: "Unsupported local link action." });
6680
6929
  return;
6681
6930
  }
6682
- if (resource.kind !== "text") {
6683
- respondJson(res, 400, { ok: false, error: "This local resource is not a text document Studio can load into the editor." });
6931
+ if (resource.kind !== "text" && resource.kind !== "office") {
6932
+ respondJson(res, 400, { ok: false, error: "This local resource is not a document Studio can load into the editor." });
6684
6933
  return;
6685
6934
  }
6686
6935
 
6687
- const file = readStudioFile(resource.filePath, dirname(resource.filePath));
6688
- if (file.ok === false) {
6689
- respondJson(res, 400, { ok: false, error: file.message });
6690
- return;
6936
+ let document: InitialStudioDocument;
6937
+ let responseText = "";
6938
+ let converted = false;
6939
+ if (resource.kind === "office") {
6940
+ let conversion: { text: string; label: string };
6941
+ try {
6942
+ conversion = await convertStudioOfficeDocumentToMarkdown(resource);
6943
+ } catch (error) {
6944
+ respondJson(res, 400, { ok: false, error: `Document conversion failed: ${error instanceof Error ? error.message : String(error)}` });
6945
+ return;
6946
+ }
6947
+ converted = true;
6948
+ responseText = conversion.text;
6949
+ document = {
6950
+ text: conversion.text,
6951
+ label: conversion.label,
6952
+ source: "blank",
6953
+ resourceDir: resource.resourceDir,
6954
+ };
6955
+ } else {
6956
+ const file = readStudioFile(resource.filePath, dirname(resource.filePath));
6957
+ if (file.ok === false) {
6958
+ respondJson(res, 400, { ok: false, error: file.message });
6959
+ return;
6960
+ }
6961
+ responseText = file.text;
6962
+ document = {
6963
+ text: file.text,
6964
+ label: resource.label || file.label,
6965
+ source: "file",
6966
+ path: file.resolvedPath,
6967
+ resourceDir: resource.resourceDir,
6968
+ };
6691
6969
  }
6692
-
6693
- const document: InitialStudioDocument = {
6694
- text: file.text,
6695
- label: resource.label || file.label,
6696
- source: "file",
6697
- path: file.resolvedPath,
6698
- resourceDir: resource.resourceDir,
6699
- };
6700
6970
  if (action === "document") {
6701
6971
  respondJson(res, 200, {
6702
6972
  ...basePayload,
6703
- text: file.text,
6973
+ text: responseText,
6974
+ label: document.label,
6975
+ converted,
6704
6976
  resourceDir: resource.resourceDir,
6705
6977
  });
6706
6978
  return;
@@ -6711,6 +6983,7 @@ function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResponse,
6711
6983
  const parsedUrl = new URL(url);
6712
6984
  respondJson(res, 200, {
6713
6985
  ...basePayload,
6986
+ converted,
6714
6987
  url,
6715
6988
  relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
6716
6989
  });
@@ -7217,6 +7490,8 @@ function buildStudioCompletionSuggestionPrompt(options: {
7217
7490
  language?: string;
7218
7491
  label?: string;
7219
7492
  path?: string;
7493
+ contextMode?: "cursor" | "session";
7494
+ contextText?: string;
7220
7495
  }): string {
7221
7496
  const text = String(options.text || "");
7222
7497
  const start = Math.max(0, Math.min(Math.floor(options.selectionStart || 0), text.length));
@@ -7226,17 +7501,23 @@ function buildStudioCompletionSuggestionPrompt(options: {
7226
7501
  const suffix = text.slice(end, Math.min(text.length, end + STUDIO_COMPLETION_SUFFIX_CHARS));
7227
7502
  const language = String(options.language || "").trim() || "unknown";
7228
7503
  const label = String(options.label || options.path || "Studio editor").trim();
7504
+ const contextText = String(options.contextText || "").trim().slice(-STUDIO_COMPLETION_MAX_CONTEXT_CHARS);
7229
7505
  return [
7230
7506
  "Generate an inline completion for the current editor cursor position.",
7231
7507
  "Return only the exact text to insert. Do not wrap it in Markdown fences. Do not explain.",
7232
7508
  "Match the surrounding language, style, indentation, and register.",
7233
7509
  "Keep the suggestion short unless the context clearly asks for a longer continuation.",
7510
+ contextText
7511
+ ? "Use the extra session context only as background. Do not continue the extra context directly unless the editor cursor calls for it."
7512
+ : "Use only the cursor-local editor context below.",
7234
7513
  selected
7235
7514
  ? "The selected text will be replaced by the completion."
7236
7515
  : "The completion will be inserted at the cursor.",
7237
7516
  "",
7238
7517
  `File/context label: ${label}`,
7239
7518
  `Language mode: ${language}`,
7519
+ `Suggestion context mode: ${contextText ? "editor plus latest response" : "editor only"}`,
7520
+ contextText ? ["", "<extra_context>", contextText, "</extra_context>"].join("\n") : "",
7240
7521
  "",
7241
7522
  "<prefix>",
7242
7523
  prefix,
@@ -7262,6 +7543,9 @@ async function runStudioCompletionSuggestion(ctx: StudioModelRequestContext, opt
7262
7543
  language?: string;
7263
7544
  label?: string;
7264
7545
  path?: string;
7546
+ contextMode?: "cursor" | "session";
7547
+ contextText?: string;
7548
+ signal?: AbortSignal;
7265
7549
  }): Promise<string> {
7266
7550
  const prompt = buildStudioCompletionSuggestionPrompt(options);
7267
7551
  // Intentionally omit `reasoning`: pi-ai treats absent reasoning as off/disabled
@@ -7271,6 +7555,7 @@ async function runStudioCompletionSuggestion(ctx: StudioModelRequestContext, opt
7271
7555
  maxTokens: 650,
7272
7556
  timeoutMs: 60_000,
7273
7557
  trim: false,
7558
+ signal: options.signal,
7274
7559
  }));
7275
7560
  if (!suggestion.trim()) throw new Error("Model returned an empty completion suggestion.");
7276
7561
  return suggestion;
@@ -7685,12 +7970,20 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
7685
7970
  };
7686
7971
  }
7687
7972
 
7973
+ if (msg.type === "completion_suggestion_cancel_request" && typeof msg.requestId === "string") {
7974
+ return {
7975
+ type: "completion_suggestion_cancel_request",
7976
+ requestId: msg.requestId,
7977
+ };
7978
+ }
7979
+
7688
7980
  if (msg.type === "completion_suggestion_request" && typeof msg.requestId === "string" && typeof msg.text === "string") {
7689
7981
  const textLength = msg.text.length;
7690
7982
  const rawStart = typeof msg.selectionStart === "number" && Number.isFinite(msg.selectionStart) ? msg.selectionStart : textLength;
7691
7983
  const rawEnd = typeof msg.selectionEnd === "number" && Number.isFinite(msg.selectionEnd) ? msg.selectionEnd : rawStart;
7692
7984
  const selectionStart = Math.max(0, Math.min(Math.floor(rawStart), textLength));
7693
7985
  const selectionEnd = Math.max(selectionStart, Math.min(Math.floor(rawEnd), textLength));
7986
+ const contextMode = msg.contextMode === "session" ? "session" : "cursor";
7694
7987
  return {
7695
7988
  type: "completion_suggestion_request",
7696
7989
  requestId: msg.requestId,
@@ -7700,6 +7993,8 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
7700
7993
  language: typeof msg.language === "string" ? msg.language : undefined,
7701
7994
  label: typeof msg.label === "string" ? msg.label : undefined,
7702
7995
  path: typeof msg.path === "string" ? msg.path : undefined,
7996
+ contextMode,
7997
+ contextText: contextMode === "session" && typeof msg.contextText === "string" ? msg.contextText.slice(-STUDIO_COMPLETION_MAX_CONTEXT_CHARS) : undefined,
7703
7998
  };
7704
7999
  }
7705
8000
 
@@ -9582,6 +9877,11 @@ ${cssVarsBlock}
9582
9877
  <div class="source-actions-row">
9583
9878
  <button id="copyDraftBtn" type="button" title="Copy the current editor text to the clipboard.">Copy</button>
9584
9879
  <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>
9880
+ <button id="suggestCompletionOptionsBtn" type="button" hidden title="Suggestion context options">▾</button>
9881
+ <select id="completionContextSelect" hidden aria-label="Suggestion context mode" title="Choose how much context Suggest includes.">
9882
+ <option value="cursor" selected>Context: editor only</option>
9883
+ <option value="session">Context: editor + latest response</option>
9884
+ </select>
9585
9885
  <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>
9586
9886
  <button id="sendEditorBtn" type="button">Send to pi editor</button>
9587
9887
  </div>
@@ -9608,6 +9908,7 @@ ${cssVarsBlock}
9608
9908
  <option value="c">Syntax highlight: C</option>
9609
9909
  <option value="cpp">Syntax highlight: C++</option>
9610
9910
  <option value="css">Syntax highlight: CSS</option>
9911
+ <option value="csv">Syntax highlight: CSV</option>
9611
9912
  <option value="diff">Syntax highlight: Diff</option>
9612
9913
  <option value="fortran">Syntax highlight: Fortran</option>
9613
9914
  <option value="go">Syntax highlight: Go</option>
@@ -9626,6 +9927,7 @@ ${cssVarsBlock}
9626
9927
  <option value="rust">Syntax highlight: Rust</option>
9627
9928
  <option value="swift">Syntax highlight: Swift</option>
9628
9929
  <option value="toml">Syntax highlight: TOML</option>
9930
+ <option value="tsv">Syntax highlight: TSV</option>
9629
9931
  <option value="typescript">Syntax highlight: TypeScript</option>
9630
9932
  <option value="xml">Syntax highlight: XML</option>
9631
9933
  <option value="yaml">Syntax highlight: YAML</option>
@@ -9811,11 +10113,11 @@ ${cssVarsBlock}
9811
10113
  <div class="shortcuts-header">
9812
10114
  <div>
9813
10115
  <h2 id="shortcutsTitle">Keyboard shortcuts</h2>
9814
- <p class="shortcuts-description">Studio navigation and high-frequency actions.</p>
10116
+ <p class="shortcuts-description">Studio navigation and high-frequency actions. Use arrow keys, Page Up/Down, Home/End, or mouse/trackpad to scroll.</p>
9815
10117
  </div>
9816
10118
  <button id="shortcutsCloseBtn" class="shortcuts-close-btn" type="button" aria-label="Close keyboard shortcuts">Close</button>
9817
10119
  </div>
9818
- <div class="shortcuts-body">
10120
+ <div id="shortcutsBody" class="shortcuts-body" tabindex="0" aria-label="Keyboard shortcuts list">
9819
10121
  <section class="shortcuts-group">
9820
10122
  <h3>Navigation</h3>
9821
10123
  <dl>
@@ -9844,6 +10146,7 @@ ${cssVarsBlock}
9844
10146
  <div><dt>Cmd/Ctrl+Enter</dt><dd>Run editor text, or queue steering during an active run</dd></div>
9845
10147
  <div><dt>Option/Alt+Tab or Cmd/Ctrl+Shift+Space</dt><dd>Suggest a completion at the editor cursor</dd></div>
9846
10148
  <div><dt>Tab</dt><dd>Insert a visible completion suggestion; otherwise indent selected editor text</dd></div>
10149
+ <div><dt>Esc</dt><dd>Dismiss a visible completion suggestion, close overlays, exit pane focus, or stop an active request</dd></div>
9847
10150
  <div><dt>Shift+Tab</dt><dd>Unindent selected editor text</dd></div>
9848
10151
  </dl>
9849
10152
  </section>
@@ -9938,6 +10241,7 @@ export default function (pi: ExtensionAPI) {
9938
10241
  let studioReplActiveSessionName: string | null = null;
9939
10242
  let compactInProgress = false;
9940
10243
  let compactRequestId: string | null = null;
10244
+ const activeCompletionSuggestions = new Map<string, AbortController>();
9941
10245
 
9942
10246
  const selectStudioReplSessionForTool = (params: { sessionName?: string; target?: string }): { session: StudioReplSessionInfo | null; error?: string; sessions: StudioReplSessionInfo[] } => {
9943
10247
  const state = listStudioReplSessions();
@@ -11424,6 +11728,21 @@ export default function (pi: ExtensionAPI) {
11424
11728
  return;
11425
11729
  }
11426
11730
 
11731
+ if (msg.type === "completion_suggestion_cancel_request") {
11732
+ if (!isValidRequestId(msg.requestId)) {
11733
+ sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: "Invalid request ID." });
11734
+ return;
11735
+ }
11736
+ const controller = activeCompletionSuggestions.get(msg.requestId);
11737
+ if (!controller) {
11738
+ sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: "No matching suggestion request is running." });
11739
+ return;
11740
+ }
11741
+ controller.abort();
11742
+ sendToClient(client, { type: "completion_suggestion_progress", requestId: msg.requestId, message: "Stopping suggestion…" });
11743
+ return;
11744
+ }
11745
+
11427
11746
  if (msg.type === "completion_suggestion_request") {
11428
11747
  if (!isValidRequestId(msg.requestId)) {
11429
11748
  sendToClient(client, { type: "completion_suggestion_error", requestId: msg.requestId, message: "Invalid request ID." });
@@ -11443,6 +11762,8 @@ export default function (pi: ExtensionAPI) {
11443
11762
  return;
11444
11763
  }
11445
11764
  sendToClient(client, { type: "completion_suggestion_progress", requestId: msg.requestId, message: "Generating suggestion…" });
11765
+ const completionController = new AbortController();
11766
+ activeCompletionSuggestions.set(msg.requestId, completionController);
11446
11767
  void (async () => {
11447
11768
  try {
11448
11769
  const suggestion = await runStudioCompletionSuggestion(ctx, {
@@ -11452,6 +11773,9 @@ export default function (pi: ExtensionAPI) {
11452
11773
  language: msg.language,
11453
11774
  label: msg.label,
11454
11775
  path: msg.path,
11776
+ contextMode: msg.contextMode,
11777
+ contextText: msg.contextText,
11778
+ signal: completionController.signal,
11455
11779
  });
11456
11780
  sendToClient(client, {
11457
11781
  type: "completion_suggestion_result",
@@ -11464,8 +11788,12 @@ export default function (pi: ExtensionAPI) {
11464
11788
  sendToClient(client, {
11465
11789
  type: "completion_suggestion_error",
11466
11790
  requestId: msg.requestId,
11467
- message: `Suggestion failed: ${error instanceof Error ? error.message : String(error)}`,
11791
+ message: completionController.signal.aborted
11792
+ ? "Suggestion stopped."
11793
+ : `Suggestion failed: ${error instanceof Error ? error.message : String(error)}`,
11468
11794
  });
11795
+ } finally {
11796
+ activeCompletionSuggestions.delete(msg.requestId);
11469
11797
  }
11470
11798
  })();
11471
11799
  return;
@@ -12011,6 +12339,7 @@ export default function (pi: ExtensionAPI) {
12011
12339
  };
12012
12340
 
12013
12341
  const disposePreparedPdfExport = (entry: PreparedStudioPdfExport | null | undefined) => {
12342
+ if (entry?.persistent) return;
12014
12343
  if (!entry?.tempDirPath) return;
12015
12344
  void rm(entry.tempDirPath, { recursive: true, force: true }).catch(() => undefined);
12016
12345
  };
@@ -12039,7 +12368,7 @@ export default function (pi: ExtensionAPI) {
12039
12368
  }
12040
12369
  };
12041
12370
 
12042
- const storePreparedPdfExport = (pdf: Buffer, filename: string, warning?: string): string => {
12371
+ const storePreparedPdfExport = (pdf: Buffer, filename: string, warning?: string, filePath?: string): string => {
12043
12372
  prunePreparedPdfExports();
12044
12373
  const exportId = randomUUID();
12045
12374
  preparedPdfExports.set(exportId, {
@@ -12047,6 +12376,8 @@ export default function (pi: ExtensionAPI) {
12047
12376
  filename,
12048
12377
  warning,
12049
12378
  createdAt: Date.now(),
12379
+ filePath,
12380
+ persistent: Boolean(filePath),
12050
12381
  });
12051
12382
  return exportId;
12052
12383
  };
@@ -12055,7 +12386,7 @@ export default function (pi: ExtensionAPI) {
12055
12386
  prunePreparedPdfExports();
12056
12387
  const entry = preparedPdfExports.get(exportId);
12057
12388
  if (!entry) return null;
12058
- if (entry.filePath && entry.tempDirPath) return entry;
12389
+ if (entry.filePath && (entry.tempDirPath || entry.persistent)) return entry;
12059
12390
 
12060
12391
  const tempDirPath = join(tmpdir(), `pi-studio-prepared-pdf-${Date.now()}-${randomUUID()}`);
12061
12392
  const filePath = join(tempDirPath, sanitizePdfFilename(entry.filename));
@@ -12105,6 +12436,7 @@ export default function (pi: ExtensionAPI) {
12105
12436
  };
12106
12437
 
12107
12438
  const disposePreparedHtmlExport = (entry: PreparedStudioHtmlExport | null | undefined) => {
12439
+ if (entry?.persistent) return;
12108
12440
  if (!entry?.tempDirPath) return;
12109
12441
  void rm(entry.tempDirPath, { recursive: true, force: true }).catch(() => undefined);
12110
12442
  };
@@ -12133,7 +12465,7 @@ export default function (pi: ExtensionAPI) {
12133
12465
  }
12134
12466
  };
12135
12467
 
12136
- const storePreparedHtmlExport = (html: Buffer, filename: string, warning?: string): string => {
12468
+ const storePreparedHtmlExport = (html: Buffer, filename: string, warning?: string, filePath?: string): string => {
12137
12469
  prunePreparedHtmlExports();
12138
12470
  const exportId = randomUUID();
12139
12471
  preparedHtmlExports.set(exportId, {
@@ -12141,6 +12473,8 @@ export default function (pi: ExtensionAPI) {
12141
12473
  filename,
12142
12474
  warning,
12143
12475
  createdAt: Date.now(),
12476
+ filePath,
12477
+ persistent: Boolean(filePath),
12144
12478
  });
12145
12479
  return exportId;
12146
12480
  };
@@ -12149,7 +12483,7 @@ export default function (pi: ExtensionAPI) {
12149
12483
  prunePreparedHtmlExports();
12150
12484
  const entry = preparedHtmlExports.get(exportId);
12151
12485
  if (!entry) return null;
12152
- if (entry.filePath && entry.tempDirPath) return entry;
12486
+ if (entry.filePath && (entry.tempDirPath || entry.persistent)) return entry;
12153
12487
 
12154
12488
  const tempDirPath = join(tmpdir(), `pi-studio-prepared-html-${Date.now()}-${randomUUID()}`);
12155
12489
  const filePath = join(tempDirPath, sanitizeHtmlFilename(entry.filename));
@@ -12570,7 +12904,8 @@ export default function (pi: ExtensionAPI) {
12570
12904
 
12571
12905
  try {
12572
12906
  const { pdf, warning } = await renderStudioPdfWithPandoc(markdown, isLatex, resourcePath, editorPdfLanguage, sourcePath || undefined);
12573
- const exportId = storePreparedPdfExport(pdf, filename, warning);
12907
+ const writeResult = writeStudioPreviewExportFile(buildStudioPreviewExportPath(sourcePath || undefined, userResourceDir || undefined, studioCwd, filename), pdf);
12908
+ const exportId = storePreparedPdfExport(pdf, filename, warning, writeResult.filePath ?? undefined);
12574
12909
  const token = serverState?.token ?? "";
12575
12910
  let openedExternal = false;
12576
12911
  let openError: string | null = null;
@@ -12587,6 +12922,8 @@ export default function (pi: ExtensionAPI) {
12587
12922
  respondJson(res, 200, {
12588
12923
  ok: true,
12589
12924
  filename,
12925
+ path: writeResult.filePath,
12926
+ writeError: writeResult.error,
12590
12927
  warning: warning ?? null,
12591
12928
  openedExternal,
12592
12929
  openError,
@@ -12681,7 +13018,8 @@ export default function (pi: ExtensionAPI) {
12681
13018
  themeVars,
12682
13019
  },
12683
13020
  );
12684
- const exportId = storePreparedHtmlExport(html, filename, warning);
13021
+ const writeResult = writeStudioPreviewExportFile(buildStudioPreviewExportPath(sourcePath || undefined, userResourceDir || undefined, studioCwd, filename), html);
13022
+ const exportId = storePreparedHtmlExport(html, filename, warning, writeResult.filePath ?? undefined);
12685
13023
  const token = serverState?.token ?? "";
12686
13024
  let openedExternal = false;
12687
13025
  let openError: string | null = null;
@@ -12698,6 +13036,8 @@ export default function (pi: ExtensionAPI) {
12698
13036
  respondJson(res, 200, {
12699
13037
  ok: true,
12700
13038
  filename,
13039
+ path: writeResult.filePath,
13040
+ writeError: writeResult.error,
12701
13041
  warning: warning ?? null,
12702
13042
  openedExternal,
12703
13043
  openError,
@@ -12990,17 +13330,19 @@ export default function (pi: ExtensionAPI) {
12990
13330
  return;
12991
13331
  }
12992
13332
 
12993
- try {
12994
- const resource = resolveStudioLocalPreviewResourcePath(
12995
- requestUrl.searchParams.get("path") ?? "",
12996
- requestUrl.searchParams.get("sourcePath") ?? undefined,
12997
- requestUrl.searchParams.get("resourceDir") ?? undefined,
12998
- studioCwd,
12999
- );
13000
- respondLocalPreviewLinkJson(req, res, requestUrl, resource, serverState);
13001
- } catch (error) {
13002
- respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
13003
- }
13333
+ void (async () => {
13334
+ try {
13335
+ const resource = resolveStudioLocalPreviewResourcePath(
13336
+ requestUrl.searchParams.get("path") ?? "",
13337
+ requestUrl.searchParams.get("sourcePath") ?? undefined,
13338
+ requestUrl.searchParams.get("resourceDir") ?? undefined,
13339
+ studioCwd,
13340
+ );
13341
+ await respondLocalPreviewLinkJson(req, res, requestUrl, resource, serverState);
13342
+ } catch (error) {
13343
+ respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
13344
+ }
13345
+ })();
13004
13346
  return;
13005
13347
  }
13006
13348