pi-studio 0.9.17 → 0.9.19

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 {
@@ -255,6 +257,7 @@ interface StudioTraceToolEntry {
255
257
  toolName: string;
256
258
  label: string | null;
257
259
  argsSummary: string | null;
260
+ args: string | null;
258
261
  output: string;
259
262
  images: StudioTraceImage[];
260
263
  startedAt: number;
@@ -427,6 +430,7 @@ interface SaveOverRequestMessage {
427
430
  interface RefreshFromDiskRequestMessage {
428
431
  type: "refresh_from_disk_request";
429
432
  requestId: string;
433
+ path?: string;
430
434
  }
431
435
 
432
436
  interface SendToEditorRequestMessage {
@@ -515,6 +519,7 @@ const MAX_PREPARED_PDF_EXPORTS = 8;
515
519
  const MAX_PREPARED_HTML_EXPORTS = 8;
516
520
  const STUDIO_TRACE_SNAPSHOT_MAX_ENTRIES = 80;
517
521
  const STUDIO_TRACE_SNAPSHOT_MAX_FIELD_CHARS = 20_000;
522
+ const STUDIO_TRACE_TOOL_ARGS_MAX_CHARS = 20_000;
518
523
  const STUDIO_TRACE_IMAGE_MAX_COUNT = 8;
519
524
  const STUDIO_TRACE_IMAGE_MAX_BASE64_CHARS = 2_500_000;
520
525
  const STUDIO_TRACE_SNAPSHOT_MAX_IMAGES = 12;
@@ -2257,6 +2262,8 @@ function inferStudioPdfLanguageFromPath(pathInput: string): string | undefined {
2257
2262
  ".yml": "yaml",
2258
2263
  ".toml": "toml",
2259
2264
  ".lua": "lua",
2265
+ ".csv": "csv",
2266
+ ".tsv": "tsv",
2260
2267
  ".txt": "text",
2261
2268
  ".rst": "text",
2262
2269
  ".adoc": "text",
@@ -2299,6 +2306,33 @@ function buildStudioResponseExportOutputPath(cwd: string, extension: "pdf" | "ht
2299
2306
  return join(cwd || process.cwd(), `studio-response-${formatStudioExportTimestamp()}.studio.${extension}`);
2300
2307
  }
2301
2308
 
2309
+ function buildStudioPreviewExportPath(sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string, filename: string): string | null {
2310
+ const cleanFilename = String(filename || "").trim();
2311
+ if (!cleanFilename) return null;
2312
+ const source = typeof sourcePath === "string" ? sourcePath.trim() : "";
2313
+ if (source) {
2314
+ const expanded = recoverLikelyDroppedLeadingSlashPath(expandHome(source));
2315
+ return join(dirname(isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded)), cleanFilename);
2316
+ }
2317
+ const resource = normalizeStudioResourceDirectoryInput(typeof resourceDir === "string" ? resourceDir : "");
2318
+ if (resource) {
2319
+ const expanded = recoverLikelyDroppedLeadingSlashPath(expandHome(resource));
2320
+ return join(isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded), cleanFilename);
2321
+ }
2322
+ return join(fallbackCwd || process.cwd(), cleanFilename);
2323
+ }
2324
+
2325
+ function writeStudioPreviewExportFile(path: string | null, data: Buffer): { filePath: string | null; error: string | null } {
2326
+ if (!path) return { filePath: null, error: "No export path was resolved." };
2327
+ try {
2328
+ mkdirSync(dirname(path), { recursive: true });
2329
+ writeFileSync(path, data);
2330
+ return { filePath: path, error: null };
2331
+ } catch (error) {
2332
+ return { filePath: null, error: error instanceof Error ? error.message : String(error) };
2333
+ }
2334
+ }
2335
+
2302
2336
  function writeStudioFile(pathArg: string, cwd: string, content: string):
2303
2337
  | { ok: true; label: string; resolvedPath: string }
2304
2338
  | { ok: false; message: string } {
@@ -2466,6 +2500,7 @@ const STUDIO_LOCAL_LINK_TEXT_EXTENSIONS = new Set([
2466
2500
  ".diff", ".patch",
2467
2501
  ]);
2468
2502
  const STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
2503
+ const STUDIO_LOCAL_LINK_OFFICE_EXTENSIONS = new Set([".docx", ".odt"]);
2469
2504
  const STUDIO_LOCAL_LINK_TEXT_FILENAMES = new Set([
2470
2505
  ".dockerignore", ".editorconfig", ".env", ".env.example", ".eslintignore", ".gitattributes",
2471
2506
  ".gitignore", ".gitmodules", ".npmignore", ".prettierignore", "dockerfile", "gemfile",
@@ -2477,7 +2512,7 @@ const STUDIO_FILE_BROWSER_IGNORED_DIRS = new Set([
2477
2512
  "__pycache__", ".venv", "venv", ".mypy_cache", ".pytest_cache", ".ruff_cache",
2478
2513
  ]);
2479
2514
 
2480
- type StudioLocalPreviewResourceKind = "pdf" | "text" | "image" | "other";
2515
+ type StudioLocalPreviewResourceKind = "pdf" | "text" | "image" | "office" | "other";
2481
2516
 
2482
2517
  interface StudioLocalPreviewResource {
2483
2518
  filePath: string;
@@ -2573,6 +2608,7 @@ function getStudioLocalPreviewResourceKind(extension: string, filePathOrName?: s
2573
2608
  if (ext === ".pdf") return "pdf";
2574
2609
  if (STUDIO_LOCAL_LINK_TEXT_EXTENSIONS.has(ext) || STUDIO_LOCAL_LINK_TEXT_FILENAMES.has(name)) return "text";
2575
2610
  if (STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS.has(ext)) return "image";
2611
+ if (STUDIO_LOCAL_LINK_OFFICE_EXTENSIONS.has(ext)) return "office";
2576
2612
  return "other";
2577
2613
  }
2578
2614
 
@@ -4304,6 +4340,125 @@ function wrapStudioCodeAsMarkdown(code: string, language?: string): string {
4304
4340
  return `${marker}${lang}\n${source}\n${marker}`;
4305
4341
  }
4306
4342
 
4343
+ const STUDIO_DELIMITED_PREVIEW_MAX_DATA_ROWS = 200;
4344
+ const STUDIO_DELIMITED_PREVIEW_MAX_COLUMNS = 50;
4345
+ const STUDIO_DELIMITED_PREVIEW_MAX_CELL_CHARS = 500;
4346
+
4347
+ function getStudioDelimitedTextConfig(language?: string): { label: string; delimiter: string } | null {
4348
+ const normalized = normalizeStudioEditorLanguage(language);
4349
+ if (normalized === "csv") return { label: "CSV", delimiter: "," };
4350
+ if (normalized === "tsv") return { label: "TSV", delimiter: "\t" };
4351
+ return null;
4352
+ }
4353
+
4354
+ function parseStudioDelimitedTextRows(text: string, delimiter: string, maxRows: number): { rows: string[][]; truncatedRows: boolean } {
4355
+ const source = String(text ?? "").replace(/^\uFEFF/, "");
4356
+ const limit = Math.max(1, Math.floor(maxRows));
4357
+ const rows: string[][] = [];
4358
+ let row: string[] = [];
4359
+ let cell = "";
4360
+ let inQuotes = false;
4361
+ let truncatedRows = false;
4362
+
4363
+ const pushCell = () => {
4364
+ row.push(cell);
4365
+ cell = "";
4366
+ };
4367
+ const pushRow = (index: number): boolean => {
4368
+ pushCell();
4369
+ rows.push(row);
4370
+ row = [];
4371
+ if (rows.length >= limit) {
4372
+ truncatedRows = index < source.length - 1;
4373
+ return true;
4374
+ }
4375
+ return false;
4376
+ };
4377
+
4378
+ for (let i = 0; i < source.length; i += 1) {
4379
+ if (rows.length >= limit) {
4380
+ truncatedRows = true;
4381
+ break;
4382
+ }
4383
+ const ch = source[i];
4384
+ if (inQuotes) {
4385
+ if (ch === '"') {
4386
+ if (source[i + 1] === '"') {
4387
+ cell += '"';
4388
+ i += 1;
4389
+ } else {
4390
+ inQuotes = false;
4391
+ }
4392
+ } else {
4393
+ cell += ch;
4394
+ }
4395
+ continue;
4396
+ }
4397
+ if (ch === '"' && cell === "") {
4398
+ inQuotes = true;
4399
+ continue;
4400
+ }
4401
+ if (ch === delimiter) {
4402
+ pushCell();
4403
+ continue;
4404
+ }
4405
+ if (ch === "\n") {
4406
+ if (pushRow(i)) break;
4407
+ continue;
4408
+ }
4409
+ if (ch === "\r") {
4410
+ if (source[i + 1] === "\n") i += 1;
4411
+ if (pushRow(i)) break;
4412
+ continue;
4413
+ }
4414
+ cell += ch;
4415
+ }
4416
+
4417
+ if (!truncatedRows && rows.length < limit && (cell.length > 0 || row.length > 0)) {
4418
+ pushCell();
4419
+ rows.push(row);
4420
+ }
4421
+
4422
+ return { rows, truncatedRows };
4423
+ }
4424
+
4425
+ function formatStudioDelimitedMarkdownCell(value: string | undefined): string {
4426
+ const raw = String(value ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
4427
+ const shortened = raw.length > STUDIO_DELIMITED_PREVIEW_MAX_CELL_CHARS
4428
+ ? `${raw.slice(0, STUDIO_DELIMITED_PREVIEW_MAX_CELL_CHARS)}…`
4429
+ : raw;
4430
+ return shortened.replace(/\n/g, "<br>").replace(/\|/g, "\\|").trim() || " ";
4431
+ }
4432
+
4433
+ function formatStudioDelimitedTextAsMarkdown(text: string, language?: string): string | null {
4434
+ const config = getStudioDelimitedTextConfig(language);
4435
+ if (!config) return null;
4436
+ const parsed = parseStudioDelimitedTextRows(text, config.delimiter, STUDIO_DELIMITED_PREVIEW_MAX_DATA_ROWS + 1);
4437
+ const rows = parsed.rows;
4438
+ if (!rows.length) return `_${config.label} file has no tabular data to preview._`;
4439
+ const rawColumnCount = rows.reduce((max, row) => Math.max(max, row.length), 0);
4440
+ const columnCount = Math.min(rawColumnCount, STUDIO_DELIMITED_PREVIEW_MAX_COLUMNS);
4441
+ if (columnCount <= 0) return `_${config.label} file has no tabular data to preview._`;
4442
+ const header = rows[0] ?? [];
4443
+ const dataRows = rows.slice(1);
4444
+ const columnIndexes = Array.from({ length: columnCount }, (_value, index) => index);
4445
+ const lines: string[] = [`**${config.label} preview**`, ""];
4446
+ const notices: string[] = [];
4447
+ if (parsed.truncatedRows) notices.push(`showing first ${Math.max(0, dataRows.length)} data rows`);
4448
+ if (rawColumnCount > columnCount) notices.push(`showing first ${columnCount} of ${rawColumnCount} columns`);
4449
+ if (notices.length) lines.push(`_${notices.join("; ")}._`, "");
4450
+ lines.push(`| ${columnIndexes.map((index) => formatStudioDelimitedMarkdownCell(header[index] || `Column ${index + 1}`)).join(" | ")} |`);
4451
+ lines.push(`| ${columnIndexes.map(() => "---").join(" | ")} |`);
4452
+ if (dataRows.length) {
4453
+ dataRows.forEach((row) => {
4454
+ lines.push(`| ${columnIndexes.map((index) => formatStudioDelimitedMarkdownCell(row[index])).join(" | ")} |`);
4455
+ });
4456
+ } else {
4457
+ lines.push(`| ${columnIndexes.map(() => " ").join(" | ")} |`);
4458
+ }
4459
+ return lines.join("\n");
4460
+ }
4461
+
4307
4462
  function extractStudioFenceInfoLanguage(info: string): string | undefined {
4308
4463
  const firstToken = String(info ?? "").trim().split(/\s+/)[0]?.replace(/^\./, "") ?? "";
4309
4464
  return normalizeStudioEditorLanguage(firstToken || undefined);
@@ -5254,11 +5409,13 @@ function buildStudioLiteralTextPdfTexConfig(options?: StudioPdfRenderOptions): {
5254
5409
 
5255
5410
  function prepareStudioPdfMarkdown(markdown: string, isLatex?: boolean, editorLanguage?: string): string {
5256
5411
  if (isLatex) return markdown;
5257
- const effectiveEditorLanguage = inferStudioPdfLanguage(markdown, editorLanguage);
5412
+ const delimitedMarkdown = formatStudioDelimitedTextAsMarkdown(markdown, editorLanguage);
5413
+ const input = delimitedMarkdown ?? markdown;
5414
+ const effectiveEditorLanguage = delimitedMarkdown ? "markdown" : inferStudioPdfLanguage(input, editorLanguage);
5258
5415
  const source = effectiveEditorLanguage && effectiveEditorLanguage !== "markdown" && effectiveEditorLanguage !== "latex"
5259
- && !isStudioSingleFencedCodeBlock(markdown)
5260
- ? wrapStudioCodeAsMarkdown(markdown, effectiveEditorLanguage)
5261
- : markdown;
5416
+ && !isStudioSingleFencedCodeBlock(input)
5417
+ ? wrapStudioCodeAsMarkdown(input, effectiveEditorLanguage)
5418
+ : input;
5262
5419
  const annotationReadySource = !effectiveEditorLanguage || effectiveEditorLanguage === "markdown" || effectiveEditorLanguage === "latex"
5263
5420
  ? replaceStudioAnnotationMarkersForPdf(source)
5264
5421
  : source;
@@ -5979,17 +6136,19 @@ async function renderStudioStandaloneHtmlWithPandoc(
5979
6136
  sourcePath?: string,
5980
6137
  options?: StudioHtmlRenderOptions,
5981
6138
  ): Promise<{ html: Buffer; warning?: string }> {
5982
- const effectiveEditorLanguage = inferStudioPdfLanguage(markdown, editorLanguage);
5983
- if (!isLatex && isLikelyStandaloneStudioHtml(markdown, effectiveEditorLanguage)) {
5984
- return { html: Buffer.from(String(markdown ?? ""), "utf-8") };
6139
+ const delimitedMarkdown = isLatex ? null : formatStudioDelimitedTextAsMarkdown(markdown, editorLanguage);
6140
+ const input = delimitedMarkdown ?? markdown;
6141
+ const effectiveEditorLanguage = delimitedMarkdown ? "markdown" : inferStudioPdfLanguage(input, editorLanguage);
6142
+ if (!isLatex && isLikelyStandaloneStudioHtml(input, effectiveEditorLanguage)) {
6143
+ return { html: Buffer.from(String(input ?? ""), "utf-8") };
5985
6144
  }
5986
6145
  const source = !isLatex
5987
6146
  && effectiveEditorLanguage
5988
6147
  && effectiveEditorLanguage !== "markdown"
5989
6148
  && effectiveEditorLanguage !== "latex"
5990
- && !isStudioSingleFencedCodeBlock(markdown)
5991
- ? wrapStudioCodeAsMarkdown(markdown, effectiveEditorLanguage)
5992
- : markdown;
6149
+ && !isStudioSingleFencedCodeBlock(input)
6150
+ ? wrapStudioCodeAsMarkdown(input, effectiveEditorLanguage)
6151
+ : input;
5993
6152
  const annotationPrepared = prepareStudioAnnotationMarkersForHtml(source);
5994
6153
  const pdfPrepared = prepareStudioPdfBlocksForHtml(annotationPrepared.markdown);
5995
6154
  let renderedHtml = await renderStudioMarkdownWithPandoc(pdfPrepared.markdown, isLatex, resourcePath, sourcePath);
@@ -6660,7 +6819,74 @@ function respondHtmlPreviewResourceJson(req: IncomingMessage, res: ServerRespons
6660
6819
  });
6661
6820
  }
6662
6821
 
6663
- function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResponse, requestUrl: URL, resource: StudioLocalPreviewResource, serverState: StudioServerState): void {
6822
+ function formatStudioMarkdownAngleTarget(pathText: string): string {
6823
+ return `<${String(pathText || "").replace(/>/g, "%3E")}>`;
6824
+ }
6825
+
6826
+ function sanitizeStudioPreviewBlockLine(value: string): string {
6827
+ return String(value || "").replace(/[\r\n]+/g, " ").trim();
6828
+ }
6829
+
6830
+ function buildStudioLocalResourcePreviewDocument(resource: StudioLocalPreviewResource): InitialStudioDocument {
6831
+ const label = basename(resource.filePath) || resource.label || "local preview";
6832
+ const resourcePath = resource.label || basename(resource.filePath) || resource.filePath;
6833
+ const title = sanitizeStudioPreviewBlockLine(label);
6834
+ let text = "";
6835
+ if (resource.kind === "pdf") {
6836
+ text = "```studio-pdf\n"
6837
+ + `path: ${sanitizeStudioPreviewBlockLine(resourcePath)}\n`
6838
+ + `title: ${title || "PDF preview"}\n`
6839
+ + "height: 820\n"
6840
+ + "```\n";
6841
+ } else if (resource.kind === "image") {
6842
+ text = `![${title || "Image preview"}](${formatStudioMarkdownAngleTarget(resourcePath)})\n`;
6843
+ } else {
6844
+ throw new Error("This local resource cannot be opened as a preview document.");
6845
+ }
6846
+ return {
6847
+ text,
6848
+ label: `${label} preview`,
6849
+ source: "blank",
6850
+ resourceDir: resource.resourceDir,
6851
+ };
6852
+ }
6853
+
6854
+ function getStudioOfficePandocInputFormat(extension: string): string {
6855
+ const ext = String(extension || "").toLowerCase();
6856
+ if (ext === ".docx") return "docx";
6857
+ if (ext === ".odt") return "odt";
6858
+ return ext.replace(/^\./, "") || "docx";
6859
+ }
6860
+
6861
+ async function convertStudioOfficeDocumentToMarkdown(resource: StudioLocalPreviewResource): Promise<{ text: string; label: string }> {
6862
+ if (resource.kind !== "office") throw new Error("This local resource is not a supported convertible document.");
6863
+ const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
6864
+ const inputFormat = getStudioOfficePandocInputFormat(resource.extension);
6865
+ const result = await runStudioSubprocess(pandocCommand, [
6866
+ "-f", inputFormat,
6867
+ "-t", "markdown",
6868
+ "--wrap=none",
6869
+ resource.filePath,
6870
+ ], {
6871
+ cwd: dirname(resource.filePath),
6872
+ timeoutMs: STUDIO_PANDOC_TIMEOUT_MS,
6873
+ stdoutMaxBytes: STUDIO_SUBPROCESS_OUTPUT_MAX_BYTES,
6874
+ label: "pandoc document conversion",
6875
+ notFoundMessage: "pandoc was not found. Install pandoc or set PANDOC_PATH to convert DOCX/ODT documents in Studio.",
6876
+ });
6877
+ if (result.code !== 0) {
6878
+ throw new Error(`pandoc failed with exit code ${result.code}${result.stderr ? `: ${result.stderr}` : ""}`);
6879
+ }
6880
+ if (result.stdoutTruncated) {
6881
+ throw new Error("Converted document exceeded Studio's import size limit.");
6882
+ }
6883
+ const label = `converted: ${resource.label || basename(resource.filePath) || "document"}`;
6884
+ const note = `<!-- ${label} from ${resource.filePath}. This is a Markdown conversion; saving will not update the original ${resource.extension || "document"} file. -->`;
6885
+ const body = result.stdout.trim();
6886
+ return { text: `${note}\n\n${body}\n`, label };
6887
+ }
6888
+
6889
+ async function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResponse, requestUrl: URL, resource: StudioLocalPreviewResource, serverState: StudioServerState): Promise<void> {
6664
6890
  const method = (req.method ?? "GET").toUpperCase();
6665
6891
  if (method !== "GET" && method !== "HEAD") {
6666
6892
  res.setHeader("Allow", "GET, HEAD");
@@ -6684,32 +6910,72 @@ function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResponse,
6684
6910
  return;
6685
6911
  }
6686
6912
 
6913
+ if (action === "preview-url") {
6914
+ if (resource.kind !== "pdf" && resource.kind !== "image") {
6915
+ respondJson(res, 400, { ok: false, error: "This local resource cannot be opened in a Studio preview tab." });
6916
+ return;
6917
+ }
6918
+ const document = buildStudioLocalResourcePreviewDocument(resource);
6919
+ const docId = storeTransientStudioDocument(document);
6920
+ const url = buildStudioUrl(serverState.port, serverState.token, "editor-only", document, docId);
6921
+ const parsedUrl = new URL(url);
6922
+ respondJson(res, 200, {
6923
+ ...basePayload,
6924
+ url,
6925
+ relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
6926
+ });
6927
+ return;
6928
+ }
6929
+
6687
6930
  if (action !== "document" && action !== "editor-url") {
6688
6931
  respondJson(res, 400, { ok: false, error: "Unsupported local link action." });
6689
6932
  return;
6690
6933
  }
6691
- if (resource.kind !== "text") {
6692
- respondJson(res, 400, { ok: false, error: "This local resource is not a text document Studio can load into the editor." });
6934
+ if (resource.kind !== "text" && resource.kind !== "office") {
6935
+ respondJson(res, 400, { ok: false, error: "This local resource is not a document Studio can load into the editor." });
6693
6936
  return;
6694
6937
  }
6695
6938
 
6696
- const file = readStudioFile(resource.filePath, dirname(resource.filePath));
6697
- if (file.ok === false) {
6698
- respondJson(res, 400, { ok: false, error: file.message });
6699
- return;
6939
+ let document: InitialStudioDocument;
6940
+ let responseText = "";
6941
+ let converted = false;
6942
+ if (resource.kind === "office") {
6943
+ let conversion: { text: string; label: string };
6944
+ try {
6945
+ conversion = await convertStudioOfficeDocumentToMarkdown(resource);
6946
+ } catch (error) {
6947
+ respondJson(res, 400, { ok: false, error: `Document conversion failed: ${error instanceof Error ? error.message : String(error)}` });
6948
+ return;
6949
+ }
6950
+ converted = true;
6951
+ responseText = conversion.text;
6952
+ document = {
6953
+ text: conversion.text,
6954
+ label: conversion.label,
6955
+ source: "blank",
6956
+ resourceDir: resource.resourceDir,
6957
+ };
6958
+ } else {
6959
+ const file = readStudioFile(resource.filePath, dirname(resource.filePath));
6960
+ if (file.ok === false) {
6961
+ respondJson(res, 400, { ok: false, error: file.message });
6962
+ return;
6963
+ }
6964
+ responseText = file.text;
6965
+ document = {
6966
+ text: file.text,
6967
+ label: resource.label || file.label,
6968
+ source: "file",
6969
+ path: file.resolvedPath,
6970
+ resourceDir: resource.resourceDir,
6971
+ };
6700
6972
  }
6701
-
6702
- const document: InitialStudioDocument = {
6703
- text: file.text,
6704
- label: resource.label || file.label,
6705
- source: "file",
6706
- path: file.resolvedPath,
6707
- resourceDir: resource.resourceDir,
6708
- };
6709
6973
  if (action === "document") {
6710
6974
  respondJson(res, 200, {
6711
6975
  ...basePayload,
6712
- text: file.text,
6976
+ text: responseText,
6977
+ label: document.label,
6978
+ converted,
6713
6979
  resourceDir: resource.resourceDir,
6714
6980
  });
6715
6981
  return;
@@ -6720,6 +6986,7 @@ function respondLocalPreviewLinkJson(req: IncomingMessage, res: ServerResponse,
6720
6986
  const parsedUrl = new URL(url);
6721
6987
  respondJson(res, 200, {
6722
6988
  ...basePayload,
6989
+ converted,
6723
6990
  url,
6724
6991
  relativeUrl: `${parsedUrl.pathname}${parsedUrl.search}`,
6725
6992
  });
@@ -7876,10 +8143,15 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
7876
8143
  };
7877
8144
  }
7878
8145
 
7879
- if (msg.type === "refresh_from_disk_request" && typeof msg.requestId === "string") {
8146
+ if (
8147
+ msg.type === "refresh_from_disk_request"
8148
+ && typeof msg.requestId === "string"
8149
+ && (msg.path === undefined || typeof msg.path === "string")
8150
+ ) {
7880
8151
  return {
7881
8152
  type: "refresh_from_disk_request",
7882
8153
  requestId: msg.requestId,
8154
+ path: typeof msg.path === "string" ? msg.path : undefined,
7883
8155
  };
7884
8156
  }
7885
8157
 
@@ -8303,15 +8575,17 @@ function createStudioTraceSnapshot(source: StudioTraceState): { traceState: Stud
8303
8575
  };
8304
8576
  }
8305
8577
  const argsSummary = truncateStudioTraceSnapshotText(entry.argsSummary ?? "");
8578
+ const args = truncateStudioTraceSnapshotText(entry.args ?? entry.argsSummary ?? "");
8306
8579
  const output = truncateStudioTraceSnapshotText(entry.output);
8307
8580
  const snapshotImages = copyStudioTraceImagesForSnapshot(entry.images, imageBudget);
8308
- truncated = truncated || argsSummary.truncated || output.truncated || snapshotImages.omitted > 0;
8581
+ truncated = truncated || argsSummary.truncated || args.truncated || output.truncated || snapshotImages.omitted > 0;
8309
8582
  const omittedImageNote = snapshotImages.omitted > 0
8310
8583
  ? `[${snapshotImages.omitted} image preview${snapshotImages.omitted === 1 ? "" : "s"} omitted from saved Working view to keep history bounded.]`
8311
8584
  : "";
8312
8585
  return {
8313
8586
  ...entry,
8314
8587
  argsSummary: argsSummary.text || null,
8588
+ args: args.text || null,
8315
8589
  output: [output.text, omittedImageNote].filter(Boolean).join("\n"),
8316
8590
  images: snapshotImages.images,
8317
8591
  };
@@ -8522,6 +8796,34 @@ function summarizeStudioTraceToolArgs(toolName: string, args: unknown): string |
8522
8796
  }
8523
8797
  }
8524
8798
 
8799
+ function truncateStudioTraceToolArgs(text: string): string {
8800
+ const value = sanitizeStudioTraceOutputText(String(text || "").trim());
8801
+ if (!value || value.length <= STUDIO_TRACE_TOOL_ARGS_MAX_CHARS) return value;
8802
+ const keepHead = Math.max(1_000, Math.floor(STUDIO_TRACE_TOOL_ARGS_MAX_CHARS * 0.65));
8803
+ const keepTail = Math.max(1_000, STUDIO_TRACE_TOOL_ARGS_MAX_CHARS - keepHead - 160);
8804
+ const omitted = value.length - keepHead - keepTail;
8805
+ return `${value.slice(0, keepHead)}\n\n… ${omitted} chars omitted from tool input …\n\n${value.slice(value.length - keepTail)}`;
8806
+ }
8807
+
8808
+ function formatStudioTraceToolArgs(toolName: string, args: unknown): string | null {
8809
+ const normalizedTool = String(toolName || "").trim().toLowerCase();
8810
+ const payload = (args && typeof args === "object") ? (args as Record<string, unknown>) : {};
8811
+ let raw = "";
8812
+ if (normalizedTool === "bash" && typeof payload.command === "string") {
8813
+ raw = payload.command;
8814
+ } else if ((normalizedTool === "repl_send" || normalizedTool === "studio_repl_send") && typeof payload.code === "string") {
8815
+ raw = payload.code;
8816
+ } else {
8817
+ try {
8818
+ raw = JSON.stringify(args, null, 2);
8819
+ } catch {
8820
+ raw = String(args ?? "");
8821
+ }
8822
+ }
8823
+ const truncated = truncateStudioTraceToolArgs(raw);
8824
+ return truncated ? truncated : null;
8825
+ }
8826
+
8525
8827
  function isStudioReplRuntime(value: unknown): value is StudioReplRuntime {
8526
8828
  return value === "shell"
8527
8829
  || value === "python"
@@ -9563,7 +9865,7 @@ ${cssVarsBlock}
9563
9865
  <button id="saveOverBtn" type="button" title="Overwrite current file with editor content. Shortcut: Cmd/Ctrl+S.">Save editor</button>
9564
9866
  <button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
9565
9867
  <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>
9566
- <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>
9868
+ <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>
9567
9869
  <button id="loadGitDiffBtn" type="button" title="Load the current git diff from the Studio context into the editor.">Load git diff</button>
9568
9870
  <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
9569
9871
  <button id="zenModeBtn" class="zen-mode-btn" type="button" title="Hide secondary Studio controls. Shortcut: F9.">Zen</button>
@@ -9644,6 +9946,7 @@ ${cssVarsBlock}
9644
9946
  <option value="c">Syntax highlight: C</option>
9645
9947
  <option value="cpp">Syntax highlight: C++</option>
9646
9948
  <option value="css">Syntax highlight: CSS</option>
9949
+ <option value="csv">Syntax highlight: CSV</option>
9647
9950
  <option value="diff">Syntax highlight: Diff</option>
9648
9951
  <option value="fortran">Syntax highlight: Fortran</option>
9649
9952
  <option value="go">Syntax highlight: Go</option>
@@ -9662,6 +9965,7 @@ ${cssVarsBlock}
9662
9965
  <option value="rust">Syntax highlight: Rust</option>
9663
9966
  <option value="swift">Syntax highlight: Swift</option>
9664
9967
  <option value="toml">Syntax highlight: TOML</option>
9968
+ <option value="tsv">Syntax highlight: TSV</option>
9665
9969
  <option value="typescript">Syntax highlight: TypeScript</option>
9666
9970
  <option value="xml">Syntax highlight: XML</option>
9667
9971
  <option value="yaml">Syntax highlight: YAML</option>
@@ -9847,11 +10151,11 @@ ${cssVarsBlock}
9847
10151
  <div class="shortcuts-header">
9848
10152
  <div>
9849
10153
  <h2 id="shortcutsTitle">Keyboard shortcuts</h2>
9850
- <p class="shortcuts-description">Studio navigation and high-frequency actions.</p>
10154
+ <p class="shortcuts-description">Studio navigation and high-frequency actions. Use arrow keys, Page Up/Down, Home/End, or mouse/trackpad to scroll.</p>
9851
10155
  </div>
9852
10156
  <button id="shortcutsCloseBtn" class="shortcuts-close-btn" type="button" aria-label="Close keyboard shortcuts">Close</button>
9853
10157
  </div>
9854
- <div class="shortcuts-body">
10158
+ <div id="shortcutsBody" class="shortcuts-body" tabindex="0" aria-label="Keyboard shortcuts list">
9855
10159
  <section class="shortcuts-group">
9856
10160
  <h3>Navigation</h3>
9857
10161
  <dl>
@@ -10774,7 +11078,17 @@ export default function (pi: ExtensionAPI) {
10774
11078
  const existingId = studioTraceToolEntryIds.get(toolCallId);
10775
11079
  if (existingId) {
10776
11080
  const existing = studioTraceState.entries.find((entry) => entry.id === existingId);
10777
- if (existing && existing.type === "tool") return existing;
11081
+ if (existing && existing.type === "tool") {
11082
+ if (args !== undefined) {
11083
+ existing.toolName = toolName;
11084
+ existing.label = deriveToolActivityLabel(toolName, args);
11085
+ existing.argsSummary = summarizeStudioTraceToolArgs(toolName, args);
11086
+ existing.args = formatStudioTraceToolArgs(toolName, args);
11087
+ existing.updatedAt = Date.now();
11088
+ upsertStudioTraceEntry(existing);
11089
+ }
11090
+ return existing;
11091
+ }
10778
11092
  }
10779
11093
  if (studioTraceState.runId == null || studioTraceState.status === "idle") {
10780
11094
  resetStudioTraceForRun();
@@ -10787,6 +11101,7 @@ export default function (pi: ExtensionAPI) {
10787
11101
  toolName,
10788
11102
  label: deriveToolActivityLabel(toolName, args),
10789
11103
  argsSummary: summarizeStudioTraceToolArgs(toolName, args),
11104
+ args: formatStudioTraceToolArgs(toolName, args),
10790
11105
  output: "",
10791
11106
  images: [],
10792
11107
  startedAt: now,
@@ -10809,6 +11124,8 @@ export default function (pi: ExtensionAPI) {
10809
11124
  images?: StudioTraceImage[],
10810
11125
  ) => {
10811
11126
  const entry = ensureStudioTraceToolEntry(toolCallId, toolName, args);
11127
+ if (!entry.argsSummary) entry.argsSummary = summarizeStudioTraceToolArgs(toolName, args);
11128
+ if (!entry.args) entry.args = formatStudioTraceToolArgs(toolName, args);
10812
11129
  entry.output = output;
10813
11130
  if (Array.isArray(images)) entry.images = images;
10814
11131
  entry.status = status;
@@ -11959,16 +12276,18 @@ export default function (pi: ExtensionAPI) {
11959
12276
  sendToClient(client, { type: "busy", requestId: msg.requestId, message: "Studio is busy." });
11960
12277
  return;
11961
12278
  }
11962
- if (!initialStudioDocument || !initialStudioDocument.path) {
12279
+ const requestedPath = typeof msg.path === "string" && msg.path.trim() ? msg.path.trim() : "";
12280
+ const refreshPath = requestedPath || initialStudioDocument?.path || "";
12281
+ if (!refreshPath) {
11963
12282
  sendToClient(client, {
11964
12283
  type: "error",
11965
12284
  requestId: msg.requestId,
11966
- message: "Refresh from disk is only available for file-backed documents.",
12285
+ message: "Refresh from disk needs a file path. Use Files → Open here, Files → Open file tab, or /studio-editor-only <path> for a refreshable editor tab.",
11967
12286
  });
11968
12287
  return;
11969
12288
  }
11970
12289
 
11971
- const refreshed = readStudioFile(initialStudioDocument.path, studioCwd);
12290
+ const refreshed = readStudioFile(refreshPath, studioCwd);
11972
12291
  if (refreshed.ok === false) {
11973
12292
  sendToClient(client, {
11974
12293
  type: "error",
@@ -11978,18 +12297,21 @@ export default function (pi: ExtensionAPI) {
11978
12297
  return;
11979
12298
  }
11980
12299
 
11981
- initialStudioDocument = {
12300
+ const refreshedDocument: InitialStudioDocument = {
11982
12301
  text: refreshed.text,
11983
12302
  label: refreshed.label,
11984
12303
  source: "file",
11985
12304
  path: refreshed.resolvedPath,
11986
12305
  resourceDir: dirname(refreshed.resolvedPath),
11987
12306
  };
12307
+ if (!requestedPath || initialStudioDocument?.path === refreshed.resolvedPath) {
12308
+ initialStudioDocument = refreshedDocument;
12309
+ }
11988
12310
 
11989
- broadcast({
12311
+ sendToClient(client, {
11990
12312
  type: "studio_document",
11991
12313
  requestId: msg.requestId,
11992
- document: initialStudioDocument,
12314
+ document: refreshedDocument,
11993
12315
  message: `Reloaded ${refreshed.label} from disk.`,
11994
12316
  });
11995
12317
  return;
@@ -12073,6 +12395,7 @@ export default function (pi: ExtensionAPI) {
12073
12395
  };
12074
12396
 
12075
12397
  const disposePreparedPdfExport = (entry: PreparedStudioPdfExport | null | undefined) => {
12398
+ if (entry?.persistent) return;
12076
12399
  if (!entry?.tempDirPath) return;
12077
12400
  void rm(entry.tempDirPath, { recursive: true, force: true }).catch(() => undefined);
12078
12401
  };
@@ -12101,7 +12424,7 @@ export default function (pi: ExtensionAPI) {
12101
12424
  }
12102
12425
  };
12103
12426
 
12104
- const storePreparedPdfExport = (pdf: Buffer, filename: string, warning?: string): string => {
12427
+ const storePreparedPdfExport = (pdf: Buffer, filename: string, warning?: string, filePath?: string): string => {
12105
12428
  prunePreparedPdfExports();
12106
12429
  const exportId = randomUUID();
12107
12430
  preparedPdfExports.set(exportId, {
@@ -12109,6 +12432,8 @@ export default function (pi: ExtensionAPI) {
12109
12432
  filename,
12110
12433
  warning,
12111
12434
  createdAt: Date.now(),
12435
+ filePath,
12436
+ persistent: Boolean(filePath),
12112
12437
  });
12113
12438
  return exportId;
12114
12439
  };
@@ -12117,7 +12442,7 @@ export default function (pi: ExtensionAPI) {
12117
12442
  prunePreparedPdfExports();
12118
12443
  const entry = preparedPdfExports.get(exportId);
12119
12444
  if (!entry) return null;
12120
- if (entry.filePath && entry.tempDirPath) return entry;
12445
+ if (entry.filePath && (entry.tempDirPath || entry.persistent)) return entry;
12121
12446
 
12122
12447
  const tempDirPath = join(tmpdir(), `pi-studio-prepared-pdf-${Date.now()}-${randomUUID()}`);
12123
12448
  const filePath = join(tempDirPath, sanitizePdfFilename(entry.filename));
@@ -12167,6 +12492,7 @@ export default function (pi: ExtensionAPI) {
12167
12492
  };
12168
12493
 
12169
12494
  const disposePreparedHtmlExport = (entry: PreparedStudioHtmlExport | null | undefined) => {
12495
+ if (entry?.persistent) return;
12170
12496
  if (!entry?.tempDirPath) return;
12171
12497
  void rm(entry.tempDirPath, { recursive: true, force: true }).catch(() => undefined);
12172
12498
  };
@@ -12195,7 +12521,7 @@ export default function (pi: ExtensionAPI) {
12195
12521
  }
12196
12522
  };
12197
12523
 
12198
- const storePreparedHtmlExport = (html: Buffer, filename: string, warning?: string): string => {
12524
+ const storePreparedHtmlExport = (html: Buffer, filename: string, warning?: string, filePath?: string): string => {
12199
12525
  prunePreparedHtmlExports();
12200
12526
  const exportId = randomUUID();
12201
12527
  preparedHtmlExports.set(exportId, {
@@ -12203,6 +12529,8 @@ export default function (pi: ExtensionAPI) {
12203
12529
  filename,
12204
12530
  warning,
12205
12531
  createdAt: Date.now(),
12532
+ filePath,
12533
+ persistent: Boolean(filePath),
12206
12534
  });
12207
12535
  return exportId;
12208
12536
  };
@@ -12211,7 +12539,7 @@ export default function (pi: ExtensionAPI) {
12211
12539
  prunePreparedHtmlExports();
12212
12540
  const entry = preparedHtmlExports.get(exportId);
12213
12541
  if (!entry) return null;
12214
- if (entry.filePath && entry.tempDirPath) return entry;
12542
+ if (entry.filePath && (entry.tempDirPath || entry.persistent)) return entry;
12215
12543
 
12216
12544
  const tempDirPath = join(tmpdir(), `pi-studio-prepared-html-${Date.now()}-${randomUUID()}`);
12217
12545
  const filePath = join(tempDirPath, sanitizeHtmlFilename(entry.filename));
@@ -12632,7 +12960,8 @@ export default function (pi: ExtensionAPI) {
12632
12960
 
12633
12961
  try {
12634
12962
  const { pdf, warning } = await renderStudioPdfWithPandoc(markdown, isLatex, resourcePath, editorPdfLanguage, sourcePath || undefined);
12635
- const exportId = storePreparedPdfExport(pdf, filename, warning);
12963
+ const writeResult = writeStudioPreviewExportFile(buildStudioPreviewExportPath(sourcePath || undefined, userResourceDir || undefined, studioCwd, filename), pdf);
12964
+ const exportId = storePreparedPdfExport(pdf, filename, warning, writeResult.filePath ?? undefined);
12636
12965
  const token = serverState?.token ?? "";
12637
12966
  let openedExternal = false;
12638
12967
  let openError: string | null = null;
@@ -12649,6 +12978,8 @@ export default function (pi: ExtensionAPI) {
12649
12978
  respondJson(res, 200, {
12650
12979
  ok: true,
12651
12980
  filename,
12981
+ path: writeResult.filePath,
12982
+ writeError: writeResult.error,
12652
12983
  warning: warning ?? null,
12653
12984
  openedExternal,
12654
12985
  openError,
@@ -12743,7 +13074,8 @@ export default function (pi: ExtensionAPI) {
12743
13074
  themeVars,
12744
13075
  },
12745
13076
  );
12746
- const exportId = storePreparedHtmlExport(html, filename, warning);
13077
+ const writeResult = writeStudioPreviewExportFile(buildStudioPreviewExportPath(sourcePath || undefined, userResourceDir || undefined, studioCwd, filename), html);
13078
+ const exportId = storePreparedHtmlExport(html, filename, warning, writeResult.filePath ?? undefined);
12747
13079
  const token = serverState?.token ?? "";
12748
13080
  let openedExternal = false;
12749
13081
  let openError: string | null = null;
@@ -12760,6 +13092,8 @@ export default function (pi: ExtensionAPI) {
12760
13092
  respondJson(res, 200, {
12761
13093
  ok: true,
12762
13094
  filename,
13095
+ path: writeResult.filePath,
13096
+ writeError: writeResult.error,
12763
13097
  warning: warning ?? null,
12764
13098
  openedExternal,
12765
13099
  openError,
@@ -13052,17 +13386,19 @@ export default function (pi: ExtensionAPI) {
13052
13386
  return;
13053
13387
  }
13054
13388
 
13055
- try {
13056
- const resource = resolveStudioLocalPreviewResourcePath(
13057
- requestUrl.searchParams.get("path") ?? "",
13058
- requestUrl.searchParams.get("sourcePath") ?? undefined,
13059
- requestUrl.searchParams.get("resourceDir") ?? undefined,
13060
- studioCwd,
13061
- );
13062
- respondLocalPreviewLinkJson(req, res, requestUrl, resource, serverState);
13063
- } catch (error) {
13064
- respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
13065
- }
13389
+ void (async () => {
13390
+ try {
13391
+ const resource = resolveStudioLocalPreviewResourcePath(
13392
+ requestUrl.searchParams.get("path") ?? "",
13393
+ requestUrl.searchParams.get("sourcePath") ?? undefined,
13394
+ requestUrl.searchParams.get("resourceDir") ?? undefined,
13395
+ studioCwd,
13396
+ );
13397
+ await respondLocalPreviewLinkJson(req, res, requestUrl, resource, serverState);
13398
+ } catch (error) {
13399
+ respondJson(res, 404, { ok: false, error: `Local resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
13400
+ }
13401
+ })();
13066
13402
  return;
13067
13403
  }
13068
13404
 
@@ -13386,7 +13722,9 @@ export default function (pi: ExtensionAPI) {
13386
13722
  if (!agentBusy) return;
13387
13723
  const toolName = typeof event.toolName === "string" ? event.toolName : "";
13388
13724
  const input = (event as { input?: unknown }).input;
13725
+ const toolCallId = typeof event.toolCallId === "string" ? event.toolCallId : "";
13389
13726
  const label = deriveToolActivityLabel(toolName, input);
13727
+ if (toolCallId) ensureStudioTraceToolEntry(toolCallId, toolName, input);
13390
13728
  emitDebugEvent("tool_call", { toolName, label, activeRequestId: activeRequest?.id ?? null, activeRequestKind: activeRequest?.kind ?? null });
13391
13729
  setTerminalActivity("tool", toolName, label);
13392
13730
  });