pi-studio 0.9.11 → 0.9.12

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
@@ -4,7 +4,7 @@ import { completeSimple, 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";
7
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
8
8
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
9
9
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
10
10
  import { homedir, tmpdir } from "node:os";
@@ -365,6 +365,7 @@ interface ReplStartRequestMessage {
365
365
  requestId: string;
366
366
  runtime: StudioReplRuntime;
367
367
  newSession?: boolean;
368
+ command?: string;
368
369
  }
369
370
 
370
371
  interface ReplStopRequestMessage {
@@ -2364,11 +2365,69 @@ function buildStudioCompanionLabel(_label: string | undefined): string {
2364
2365
  return "copy of editor text";
2365
2366
  }
2366
2367
 
2368
+ const STUDIO_HTML_PREVIEW_RESOURCE_MAX_BYTES = 25 * 1024 * 1024;
2369
+ const STUDIO_HTML_PREVIEW_IMAGE_MIME_BY_EXT = new Map<string, string>([
2370
+ [".png", "image/png"],
2371
+ [".jpg", "image/jpeg"],
2372
+ [".jpeg", "image/jpeg"],
2373
+ [".gif", "image/gif"],
2374
+ [".webp", "image/webp"],
2375
+ ]);
2376
+
2367
2377
  function resolveStudioPdfResourcePath(pdfPath: string | undefined, sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
2368
2378
  const baseDir = resolveStudioBaseDir(sourcePath, resourceDir, fallbackCwd);
2369
2379
  return resolveStudioPdfResourceFile(pdfPath, baseDir);
2370
2380
  }
2371
2381
 
2382
+ function stripStudioHtmlPreviewResourceUrlSuffix(resourcePath: string): string {
2383
+ const withoutHash = resourcePath.split("#")[0] ?? resourcePath;
2384
+ return withoutHash.split("?")[0] ?? withoutHash;
2385
+ }
2386
+
2387
+ function decodeStudioHtmlPreviewResourcePath(resourcePath: string): string {
2388
+ try {
2389
+ return decodeURIComponent(resourcePath);
2390
+ } catch {
2391
+ return resourcePath;
2392
+ }
2393
+ }
2394
+
2395
+ function resolveStudioHtmlPreviewResourcePath(
2396
+ resourcePath: string | undefined,
2397
+ sourcePath: string | undefined,
2398
+ resourceDir: string | undefined,
2399
+ fallbackCwd: string,
2400
+ ): { filePath: string; mimeType: string } {
2401
+ const rawPath = typeof resourcePath === "string" ? resourcePath.trim() : "";
2402
+ if (!rawPath) throw new Error("Missing HTML preview resource path.");
2403
+ if (/\0/.test(rawPath)) throw new Error("Invalid HTML preview resource path.");
2404
+ if (/^[a-z][a-z0-9+.-]*:/i.test(rawPath) && !/^[a-z]:[\\/]/i.test(rawPath)) {
2405
+ throw new Error("Only local HTML preview resources are supported.");
2406
+ }
2407
+
2408
+ const baseDir = resolveStudioBaseDir(sourcePath, resourceDir, fallbackCwd);
2409
+ const cleanedPath = decodeStudioHtmlPreviewResourcePath(stripStudioHtmlPreviewResourceUrlSuffix(rawPath));
2410
+ const expandedPath = expandHome(cleanedPath);
2411
+ const candidate = isAbsolute(expandedPath) ? expandedPath : resolve(baseDir, expandedPath);
2412
+ const ext = extname(candidate).toLowerCase();
2413
+ const mimeType = STUDIO_HTML_PREVIEW_IMAGE_MIME_BY_EXT.get(ext);
2414
+ if (!mimeType) throw new Error("Only local PNG, JPEG, GIF, and WebP images can be embedded in HTML previews.");
2415
+
2416
+ const baseReal = realpathSync(baseDir);
2417
+ const candidateReal = realpathSync(candidate);
2418
+ const rel = relative(baseReal, candidateReal);
2419
+ if (rel.startsWith("..") || isAbsolute(rel)) {
2420
+ throw new Error("HTML preview resource path must stay within the current Studio resource directory.");
2421
+ }
2422
+
2423
+ const stat = statSync(candidateReal);
2424
+ if (!stat.isFile()) throw new Error("HTML preview resource path does not refer to a file.");
2425
+ if (stat.size > STUDIO_HTML_PREVIEW_RESOURCE_MAX_BYTES) {
2426
+ throw new Error("HTML preview resource is too large to embed.");
2427
+ }
2428
+ return { filePath: candidateReal, mimeType };
2429
+ }
2430
+
2372
2431
  function resolveStudioPandocWorkingDir(baseDir: string | undefined): string | undefined {
2373
2432
  const normalized = typeof baseDir === "string" ? baseDir.trim() : "";
2374
2433
  if (!normalized) return undefined;
@@ -6279,6 +6338,23 @@ function respondPdfFile(req: IncomingMessage, res: ServerResponse, filePath: str
6279
6338
  res.end(method === "HEAD" ? undefined : pdf);
6280
6339
  }
6281
6340
 
6341
+ function respondHtmlPreviewResourceJson(req: IncomingMessage, res: ServerResponse, filePath: string, mimeType: string): void {
6342
+ const method = (req.method ?? "GET").toUpperCase();
6343
+ if (method !== "GET" && method !== "HEAD") {
6344
+ res.setHeader("Allow", "GET, HEAD");
6345
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use GET." });
6346
+ return;
6347
+ }
6348
+
6349
+ const data = method === "HEAD" ? "" : readFileSync(filePath).toString("base64");
6350
+ respondJson(res, 200, {
6351
+ ok: true,
6352
+ mimeType,
6353
+ filename: basename(filePath),
6354
+ dataUrl: method === "HEAD" ? "" : `data:${mimeType};base64,${data}`,
6355
+ });
6356
+ }
6357
+
6282
6358
  function openUrlInDefaultBrowser(url: string): Promise<void> {
6283
6359
  const openCommand =
6284
6360
  process.platform === "darwin"
@@ -7183,6 +7259,7 @@ function parseIncomingMessage(data: RawData): IncomingStudioMessage | null {
7183
7259
  requestId: msg.requestId,
7184
7260
  runtime,
7185
7261
  newSession: Boolean(msg.newSession),
7262
+ command: typeof msg.command === "string" ? msg.command : undefined,
7186
7263
  };
7187
7264
  }
7188
7265
  }
@@ -7326,37 +7403,184 @@ function isGenericToolActivityLabel(label: string | null | undefined): boolean {
7326
7403
  || normalized === "editing file";
7327
7404
  }
7328
7405
 
7406
+ function splitStudioShellWords(segment: string): string[] {
7407
+ const words: string[] = [];
7408
+ let current = "";
7409
+ let quote: "'" | "\"" | null = null;
7410
+ let escaped = false;
7411
+
7412
+ for (const char of String(segment || "")) {
7413
+ if (escaped) {
7414
+ current += char;
7415
+ escaped = false;
7416
+ continue;
7417
+ }
7418
+ if (char === "\\" && quote !== "'") {
7419
+ escaped = true;
7420
+ continue;
7421
+ }
7422
+ if (quote) {
7423
+ if (char === quote) quote = null;
7424
+ else current += char;
7425
+ continue;
7426
+ }
7427
+ if (char === "'" || char === "\"") {
7428
+ quote = char;
7429
+ continue;
7430
+ }
7431
+ if (/\s/.test(char)) {
7432
+ if (current) {
7433
+ words.push(current);
7434
+ current = "";
7435
+ }
7436
+ continue;
7437
+ }
7438
+ current += char;
7439
+ }
7440
+ if (escaped) current += "\\";
7441
+ if (current) words.push(current);
7442
+ return words;
7443
+ }
7444
+
7445
+ function normalizeStudioShellCommandToken(token: string): string {
7446
+ let value = String(token || "").trim();
7447
+ if (!value) return "";
7448
+ const slashIndex = value.lastIndexOf("/");
7449
+ if (slashIndex >= 0) value = value.slice(slashIndex + 1);
7450
+ return value.toLowerCase();
7451
+ }
7452
+
7453
+ function isStudioShellAssignmentToken(token: string): boolean {
7454
+ return /^[A-Za-z_][A-Za-z0-9_]*=/.test(String(token || ""));
7455
+ }
7456
+
7457
+ function getStudioShellSegmentCommand(segment: string): { name: string; words: string[]; commandIndex: number } | null {
7458
+ const words = splitStudioShellWords(segment);
7459
+ let index = 0;
7460
+ const skipAssignments = () => {
7461
+ while (index < words.length && isStudioShellAssignmentToken(words[index] || "")) index += 1;
7462
+ };
7463
+
7464
+ skipAssignments();
7465
+ let guard = 0;
7466
+ while (index < words.length && guard < 12) {
7467
+ guard += 1;
7468
+ const name = normalizeStudioShellCommandToken(words[index] || "");
7469
+ if (!name) {
7470
+ index += 1;
7471
+ continue;
7472
+ }
7473
+ if (name === "command" || name === "builtin" || name === "exec") {
7474
+ index += 1;
7475
+ skipAssignments();
7476
+ continue;
7477
+ }
7478
+ if (name === "time") {
7479
+ index += 1;
7480
+ while (index < words.length && String(words[index] || "").startsWith("-")) index += 1;
7481
+ skipAssignments();
7482
+ continue;
7483
+ }
7484
+ if (name === "env") {
7485
+ index += 1;
7486
+ while (index < words.length) {
7487
+ const token = String(words[index] || "");
7488
+ const lowerToken = token.toLowerCase();
7489
+ if (isStudioShellAssignmentToken(token)) {
7490
+ index += 1;
7491
+ continue;
7492
+ }
7493
+ if (lowerToken === "-u" || lowerToken === "--unset" || lowerToken === "-s" || lowerToken === "-S") {
7494
+ index += 2;
7495
+ continue;
7496
+ }
7497
+ if (lowerToken.startsWith("-")) {
7498
+ index += 1;
7499
+ continue;
7500
+ }
7501
+ break;
7502
+ }
7503
+ skipAssignments();
7504
+ continue;
7505
+ }
7506
+ if (name === "sudo") {
7507
+ index += 1;
7508
+ while (index < words.length) {
7509
+ const option = String(words[index] || "");
7510
+ if (!option.startsWith("-")) break;
7511
+ const lowerOption = option.toLowerCase();
7512
+ index += 1;
7513
+ if (["-c", "-g", "-h", "-p", "-t", "-u"].includes(lowerOption) && index < words.length) {
7514
+ index += 1;
7515
+ }
7516
+ }
7517
+ skipAssignments();
7518
+ continue;
7519
+ }
7520
+ return { name, words, commandIndex: index };
7521
+ }
7522
+ return null;
7523
+ }
7524
+
7525
+ function getStudioGitSubcommand(args: string[]): string {
7526
+ for (let index = 0; index < args.length; index += 1) {
7527
+ const token = String(args[index] || "").toLowerCase();
7528
+ if (!token) continue;
7529
+ if (["-c", "-C", "--git-dir", "--work-tree", "--namespace", "--exec-path"].map((value) => value.toLowerCase()).includes(token)) {
7530
+ index += 1;
7531
+ continue;
7532
+ }
7533
+ if (/^--(?:git-dir|work-tree|namespace|exec-path)=/.test(token)) continue;
7534
+ if (token.startsWith("-")) continue;
7535
+ return normalizeStudioShellCommandToken(token);
7536
+ }
7537
+ return "";
7538
+ }
7539
+
7329
7540
  function deriveBashActivityLabel(command: string): string | null {
7330
7541
  const normalized = String(command || "").trim();
7331
7542
  if (!normalized) return null;
7332
- const lower = normalized.toLowerCase();
7333
7543
 
7334
- const segments = lower
7544
+ const segments = normalized
7335
7545
  .split(/(?:&&|\|\||;|\n)+/g)
7336
7546
  .map((segment) => segment.trim())
7337
7547
  .filter((segment) => segment.length > 0);
7338
7548
 
7339
7549
  let hasPwd = false;
7550
+ let hasLs = false;
7340
7551
  let hasLsCurrent = false;
7341
7552
  let hasLsParent = false;
7342
7553
  let hasFind = false;
7343
7554
  let hasFindCurrentListing = false;
7344
7555
  let hasFindParentListing = false;
7556
+ let hasTextSearch = false;
7557
+ let hasFileRead = false;
7558
+ let hasGit = false;
7559
+ let hasGitStatus = false;
7560
+ let hasGitDiff = false;
7561
+ let hasNpm = false;
7562
+ let hasPython = false;
7563
+ let hasNode = false;
7345
7564
 
7346
7565
  for (const segment of segments) {
7347
- if (/\bpwd\b/.test(segment)) hasPwd = true;
7566
+ const commandInfo = getStudioShellSegmentCommand(segment);
7567
+ if (!commandInfo) continue;
7568
+ const commandName = commandInfo.name;
7569
+ const args = commandInfo.words.slice(commandInfo.commandIndex + 1);
7348
7570
 
7349
- if (/\bls\b/.test(segment)) {
7350
- if (/\.\./.test(segment)) hasLsParent = true;
7571
+ if (commandName === "pwd") hasPwd = true;
7572
+
7573
+ if (commandName === "ls") {
7574
+ hasLs = true;
7575
+ if (args.some((arg) => arg === ".." || arg === "../" || arg.startsWith("../"))) hasLsParent = true;
7351
7576
  else hasLsCurrent = true;
7352
7577
  }
7353
7578
 
7354
- if (/\bfind\b/.test(segment)) {
7579
+ if (commandName === "find") {
7355
7580
  hasFind = true;
7356
- const pathMatch = segment.match(/\bfind\s+([^\s]+)/);
7357
- const pathToken = pathMatch ? pathMatch[1] : "";
7358
- const hasSelector = /-(?:name|iname|regex|path|ipath|newer|mtime|mmin|size|user|group)\b/.test(segment);
7359
- const listingLike = /-maxdepth\s+\d+\b/.test(segment) && !hasSelector;
7581
+ const pathToken = args.find((arg) => arg && !String(arg).startsWith("-")) || "";
7582
+ const hasSelector = /-(?:name|iname|regex|path|ipath|newer|mtime|mmin|size|user|group)\b/i.test(segment);
7583
+ const listingLike = /-maxdepth\s+\d+\b/i.test(segment) && !hasSelector;
7360
7584
 
7361
7585
  if (listingLike) {
7362
7586
  if (pathToken === ".." || pathToken === "../") {
@@ -7366,6 +7590,18 @@ function deriveBashActivityLabel(command: string): string | null {
7366
7590
  }
7367
7591
  }
7368
7592
  }
7593
+
7594
+ if (commandName === "rg" || commandName === "grep") hasTextSearch = true;
7595
+ if (commandName === "cat" || commandName === "sed" || commandName === "awk") hasFileRead = true;
7596
+ if (commandName === "git") {
7597
+ hasGit = true;
7598
+ const subcommand = getStudioGitSubcommand(args);
7599
+ if (subcommand === "status") hasGitStatus = true;
7600
+ if (subcommand === "diff") hasGitDiff = true;
7601
+ }
7602
+ if (commandName === "npm") hasNpm = true;
7603
+ if (/^python(?:3(?:\.\d+)?)?$/.test(commandName)) hasPython = true;
7604
+ if (commandName === "node") hasNode = true;
7369
7605
  }
7370
7606
 
7371
7607
  const hasCurrentListing = hasLsCurrent || hasFindCurrentListing;
@@ -7380,34 +7616,34 @@ function deriveBashActivityLabel(command: string): string | null {
7380
7616
  if (hasParentListing) {
7381
7617
  return "Listing parent directory files";
7382
7618
  }
7383
- if (hasCurrentListing || /\bls\b/.test(lower)) {
7619
+ if (hasCurrentListing || hasLs) {
7384
7620
  return "Listing directory files";
7385
7621
  }
7386
- if (hasFind || /\bfind\b/.test(lower)) {
7622
+ if (hasFind) {
7387
7623
  return "Searching files";
7388
7624
  }
7389
- if (/\brg\b/.test(lower) || /\bgrep\b/.test(lower)) {
7625
+ if (hasTextSearch) {
7390
7626
  return "Searching text in files";
7391
7627
  }
7392
- if (/\bcat\b/.test(lower) || /\bsed\b/.test(lower) || /\bawk\b/.test(lower)) {
7628
+ if (hasFileRead) {
7393
7629
  return "Reading file content";
7394
7630
  }
7395
- if (/\bgit\s+status\b/.test(lower)) {
7631
+ if (hasGitStatus) {
7396
7632
  return "Checking git status";
7397
7633
  }
7398
- if (/\bgit\s+diff\b/.test(lower)) {
7634
+ if (hasGitDiff) {
7399
7635
  return "Reviewing git changes";
7400
7636
  }
7401
- if (/\bgit\b/.test(lower)) {
7637
+ if (hasGit) {
7402
7638
  return "Running git command";
7403
7639
  }
7404
- if (/\bnpm\b/.test(lower)) {
7640
+ if (hasNpm) {
7405
7641
  return "Running npm command";
7406
7642
  }
7407
- if (/\bpython3?\b/.test(lower)) {
7643
+ if (hasPython) {
7408
7644
  return "Running Python command";
7409
7645
  }
7410
- if (/\bnode\b/.test(lower)) {
7646
+ if (hasNode) {
7411
7647
  return "Running Node.js command";
7412
7648
  }
7413
7649
  return "Running shell command";
@@ -7749,7 +7985,7 @@ function normalizeStudioReplRuntime(value: unknown): StudioReplRuntime | null {
7749
7985
  return isStudioReplRuntime(normalized) ? normalized : null;
7750
7986
  }
7751
7987
 
7752
- function getStudioReplRuntimeCommand(runtime: StudioReplRuntime): string {
7988
+ function getDefaultStudioReplRuntimeCommand(runtime: StudioReplRuntime): string {
7753
7989
  if (runtime === "shell") return String(process.env.SHELL || "bash").trim() || "bash";
7754
7990
  if (runtime === "python") return "python3";
7755
7991
  if (runtime === "ipython") return "ipython";
@@ -7759,13 +7995,31 @@ function getStudioReplRuntimeCommand(runtime: StudioReplRuntime): string {
7759
7995
  return "clojure";
7760
7996
  }
7761
7997
 
7762
- function getStudioReplSessionName(runtime: StudioReplRuntime): string {
7763
- return `pi-studio-repl-${runtime}`;
7998
+ function normalizeStudioReplCommandOverride(runtime: StudioReplRuntime, command: string | undefined): string | undefined {
7999
+ const normalized = String(command ?? "").replace(/\r?\n/g, " ").replace(/\s+/g, " ").trim();
8000
+ if (!normalized) return undefined;
8001
+ if (normalized.length > 240) return undefined;
8002
+ if (normalized === getDefaultStudioReplRuntimeCommand(runtime)) return undefined;
8003
+ return normalized;
8004
+ }
8005
+
8006
+ function getStudioReplRuntimeCommand(runtime: StudioReplRuntime, command?: string): string {
8007
+ return normalizeStudioReplCommandOverride(runtime, command) || getDefaultStudioReplRuntimeCommand(runtime);
8008
+ }
8009
+
8010
+ function getStudioReplCommandSessionSuffix(runtime: StudioReplRuntime, command?: string): string {
8011
+ const normalized = normalizeStudioReplCommandOverride(runtime, command);
8012
+ if (!normalized) return "";
8013
+ return `-${createHash("sha1").update(`${runtime}\n${normalized}`).digest("hex").slice(0, 8)}`;
8014
+ }
8015
+
8016
+ function getStudioReplSessionName(runtime: StudioReplRuntime, command?: string): string {
8017
+ return `pi-studio-repl-${runtime}${getStudioReplCommandSessionSuffix(runtime, command)}`;
7764
8018
  }
7765
8019
 
7766
- function getNewStudioReplSessionName(runtime: StudioReplRuntime): string {
8020
+ function getNewStudioReplSessionName(runtime: StudioReplRuntime, command?: string): string {
7767
8021
  const suffix = `${Date.now().toString(36)}-${randomUUID().slice(0, 6)}`;
7768
- return `pi-studio-repl-${runtime}-${suffix}`;
8022
+ return `pi-studio-repl-${runtime}${getStudioReplCommandSessionSuffix(runtime, command)}-${suffix}`;
7769
8023
  }
7770
8024
 
7771
8025
  function getStudioReplPaneTarget(sessionName: string): string {
@@ -7865,9 +8119,10 @@ function captureStudioReplSession(sessionName: string): { ok: true; transcript:
7865
8119
  return { ok: true, transcript: String(result.stdout || "").replace(/[\t ]+$/gm, "").trimEnd(), session };
7866
8120
  }
7867
8121
 
7868
- function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options?: { newSession?: boolean }): { ok: true; session: StudioReplSessionInfo; message: string } | { ok: false; message: string } {
8122
+ function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options?: { newSession?: boolean; command?: string }): { ok: true; session: StudioReplSessionInfo; message: string } | { ok: false; message: string } {
7869
8123
  if (!isTmuxAvailable()) return { ok: false, message: "tmux is not available. Install tmux to use Studio REPL sessions." };
7870
- const sessionName = options?.newSession ? getNewStudioReplSessionName(runtime) : getStudioReplSessionName(runtime);
8124
+ const commandOverride = normalizeStudioReplCommandOverride(runtime, options?.command);
8125
+ const sessionName = options?.newSession ? getNewStudioReplSessionName(runtime, commandOverride) : getStudioReplSessionName(runtime, commandOverride);
7871
8126
  const existing = runStudioTmux(["has-session", "-t", sessionName], { timeout: 3_000 });
7872
8127
  if (existing.ok) {
7873
8128
  const inferred = inferStudioReplSessionRuntime(sessionName);
@@ -7883,7 +8138,7 @@ function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options
7883
8138
  message: `${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL is already running.`,
7884
8139
  };
7885
8140
  }
7886
- const command = getStudioReplRuntimeCommand(runtime);
8141
+ const command = getStudioReplRuntimeCommand(runtime, commandOverride);
7887
8142
  const result = runStudioTmux(["new-session", "-d", "-s", sessionName, "-c", cwd || process.cwd(), command], { timeout: 5_000 });
7888
8143
  if (!result.ok) return { ok: false, message: result.message || `Failed to start ${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL.` };
7889
8144
  return {
@@ -7895,7 +8150,7 @@ function startStudioReplSession(runtime: StudioReplRuntime, cwd: string, options
7895
8150
  label: formatStudioReplSessionLabel(sessionName, runtime, "studio"),
7896
8151
  source: "studio",
7897
8152
  },
7898
- message: `Started ${options?.newSession ? "new " : ""}${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL.`,
8153
+ message: `Started ${options?.newSession ? "new " : ""}${STUDIO_REPL_RUNTIME_LABELS[runtime]} REPL${commandOverride ? ` with custom command: ${commandOverride}` : ""}.`,
7899
8154
  };
7900
8155
  }
7901
8156
 
@@ -8937,6 +9192,8 @@ ${cssVarsBlock}
8937
9192
  </div>
8938
9193
  </section>
8939
9194
 
9195
+ <div id="paneResizeHandle" class="pane-resize-handle" role="separator" aria-label="Resize editor and response panes" aria-orientation="vertical" tabindex="0" title="Drag to resize panes. Double-click or press Enter/Space to reset; drag very close to the middle to snap to 50/50."></div>
9196
+
8940
9197
  <section id="rightPane">
8941
9198
  <div id="rightSectionHeader" class="section-header">
8942
9199
  <div class="section-header-main">
@@ -9045,6 +9302,14 @@ ${cssVarsBlock}
9045
9302
  <div><dt>Tab / Shift+Tab</dt><dd>Indent or unindent selected editor text</dd></div>
9046
9303
  </dl>
9047
9304
  </section>
9305
+ <section class="shortcuts-group">
9306
+ <h3>Response</h3>
9307
+ <dl>
9308
+ <div><dt>Alt/Option+←</dt><dd>Previous response when not editing text</dd></div>
9309
+ <div><dt>Alt/Option+→</dt><dd>Next response when not editing text</dd></div>
9310
+ <div><dt>Alt/Option+l</dt><dd>Latest response when not editing text</dd></div>
9311
+ </dl>
9312
+ </section>
9048
9313
  <section class="shortcuts-group">
9049
9314
  <h3>REPL</h3>
9050
9315
  <dl>
@@ -9998,7 +10263,11 @@ export default function (pi: ExtensionAPI) {
9998
10263
  }
9999
10264
  lastSpecificToolActivityLabel = baseLabel;
10000
10265
  } else {
10001
- nextLabel = baseLabel;
10266
+ // Generic shell/tool labels such as "Running git command" are often
10267
+ // stale or too broad once the model has moved on. Keep the precise
10268
+ // Working trace entry, but do not promote generic labels into the
10269
+ // live Studio/terminal status line.
10270
+ nextLabel = null;
10002
10271
  }
10003
10272
  } else {
10004
10273
  nextLabel = null;
@@ -10759,7 +11028,7 @@ export default function (pi: ExtensionAPI) {
10759
11028
  sendToClient(client, { type: "error", requestId: msg.requestId, message: "Invalid request ID." });
10760
11029
  return;
10761
11030
  }
10762
- const started = startStudioReplSession(msg.runtime, studioCwd, { newSession: msg.newSession });
11031
+ const started = startStudioReplSession(msg.runtime, studioCwd, { newSession: msg.newSession, command: msg.command });
10763
11032
  if (!started.ok) {
10764
11033
  sendReplStateToClient(client, { requestId: msg.requestId, replError: started.message });
10765
11034
  sendToClient(client, { type: "error", requestId: msg.requestId, message: started.message });
@@ -12112,6 +12381,27 @@ export default function (pi: ExtensionAPI) {
12112
12381
  return;
12113
12382
  }
12114
12383
 
12384
+ if (requestUrl.pathname === "/html-preview-resource") {
12385
+ const token = requestUrl.searchParams.get("token") ?? "";
12386
+ if (token !== serverState.token) {
12387
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
12388
+ return;
12389
+ }
12390
+
12391
+ try {
12392
+ const resource = resolveStudioHtmlPreviewResourcePath(
12393
+ requestUrl.searchParams.get("path") ?? "",
12394
+ requestUrl.searchParams.get("sourcePath") ?? undefined,
12395
+ requestUrl.searchParams.get("resourceDir") ?? undefined,
12396
+ studioCwd,
12397
+ );
12398
+ respondHtmlPreviewResourceJson(req, res, resource.filePath, resource.mimeType);
12399
+ } catch (error) {
12400
+ respondJson(res, 404, { ok: false, error: `HTML preview resource unavailable: ${error instanceof Error ? error.message : String(error)}` });
12401
+ }
12402
+ return;
12403
+ }
12404
+
12115
12405
  if (requestUrl.pathname !== "/") {
12116
12406
  respondText(res, 404, "Not found");
12117
12407
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.11",
3
+ "version": "0.9.12",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",