@firstpick/pi-package-webui 0.1.2 → 0.1.3

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/bin/pi-webui.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import { spawn } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { createServer } from "node:http";
5
+ import { createRequire } from "node:module";
5
6
  import { access, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
6
7
  import { homedir, networkInterfaces } from "node:os";
7
8
  import path from "node:path";
@@ -9,6 +10,7 @@ import { StringDecoder } from "node:string_decoder";
9
10
  import { fileURLToPath } from "node:url";
10
11
 
11
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const require = createRequire(import.meta.url);
12
14
  const packageRoot = path.resolve(__dirname, "..");
13
15
  const publicDir = path.join(packageRoot, "public");
14
16
  const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
@@ -18,8 +20,56 @@ const DEFAULT_PORT = 31415;
18
20
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
19
21
  const BODY_LIMIT_BYTES = 1024 * 1024;
20
22
  const EVENT_HISTORY_LIMIT = 200;
23
+ const EXTENSION_UI_BLOCKING_METHODS = new Set(["select", "confirm", "input", "editor"]);
21
24
  const STATUS_RPC_TIMEOUT_MS = 1_800;
22
25
  const FAST_PICK_LIMIT = 30;
26
+ const PATH_SUGGESTION_LIMIT = 20;
27
+ const PATH_SUGGESTION_QUERY_LIMIT = 512;
28
+ const PATH_SUGGESTION_SCAN_LIMIT = 5000;
29
+ const PATH_SUGGESTION_MAX_OUTPUT_LENGTH = 300000;
30
+ const PATH_SUGGESTION_EXCLUDED_DIRS = new Set([".git", "node_modules"]);
31
+ const RESTORE_TAB_LIMIT = 30;
32
+ const NETWORK_REBIND_DELAY_MS = 100;
33
+ const NETWORK_REBIND_FORCE_CLOSE_MS = 750;
34
+ const AUTO_TAB_TITLE_MAX_LENGTH = 44;
35
+ const AUTO_TAB_TITLE_WORD_LIMIT = 8;
36
+ const AUTO_TAB_TITLE_STOP_WORDS = new Set([
37
+ "a",
38
+ "an",
39
+ "and",
40
+ "are",
41
+ "as",
42
+ "at",
43
+ "be",
44
+ "best",
45
+ "by",
46
+ "for",
47
+ "from",
48
+ "how",
49
+ "in",
50
+ "is",
51
+ "it",
52
+ "its",
53
+ "me",
54
+ "my",
55
+ "of",
56
+ "on",
57
+ "or",
58
+ "please",
59
+ "s",
60
+ "should",
61
+ "that",
62
+ "the",
63
+ "this",
64
+ "to",
65
+ "way",
66
+ "what",
67
+ "whats",
68
+ "when",
69
+ "with",
70
+ "you",
71
+ "your",
72
+ ]);
23
73
 
24
74
  const MIME_TYPES = new Map([
25
75
  [".html", "text/html; charset=utf-8"],
@@ -344,12 +394,13 @@ class PiRpcProcess {
344
394
  }
345
395
  }
346
396
 
347
- function sendJson(res, statusCode, payload) {
397
+ function sendJson(res, statusCode, payload, headers = {}) {
348
398
  const body = JSON.stringify(payload, null, 2);
349
399
  res.writeHead(statusCode, {
350
400
  "content-type": "application/json; charset=utf-8",
351
401
  "cache-control": "no-store",
352
402
  "x-content-type-options": "nosniff",
403
+ ...headers,
353
404
  });
354
405
  res.end(body);
355
406
  }
@@ -386,6 +437,81 @@ function rpcSuccess(command, data = {}) {
386
437
  return { type: "response", command, success: true, data };
387
438
  }
388
439
 
440
+ const ACTION_FEEDBACK_REACTIONS = new Set(["up", "down", "question"]);
441
+
442
+ function trimFeedbackField(value, maxLength) {
443
+ const text = String(value || "").trim();
444
+ return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
445
+ }
446
+
447
+ function normalizeActionFeedbackItems(body) {
448
+ const rawItems = Array.isArray(body?.feedback) ? body.feedback : Array.isArray(body?.items) ? body.items : [];
449
+ if (rawItems.length === 0) throw new Error("feedback is required");
450
+ if (rawItems.length > 20) throw new Error("feedback is limited to 20 reactions per submission");
451
+ return rawItems.map((item, index) => {
452
+ const reaction = String(item?.reaction || "").trim();
453
+ if (!ACTION_FEEDBACK_REACTIONS.has(reaction)) throw new Error(`Invalid feedback reaction at item ${index + 1}`);
454
+ return {
455
+ reaction,
456
+ comment: trimFeedbackField(item?.comment, 800),
457
+ kind: trimFeedbackField(item?.kind || "action", 80),
458
+ title: trimFeedbackField(item?.title || `item ${index + 1}`, 240),
459
+ snippet: trimFeedbackField(item?.snippet, 2000),
460
+ messageIndex: Number.isFinite(Number(item?.messageIndex)) ? Number(item.messageIndex) : index,
461
+ createdAt: trimFeedbackField(item?.createdAt, 80),
462
+ };
463
+ });
464
+ }
465
+
466
+ function actionFeedbackReactionLabel(reaction) {
467
+ if (reaction === "up") return "👍 thumbs up — Good job; repeat this pattern when appropriate.";
468
+ if (reaction === "down") return "👎 thumbs down — avoid or reconsider this target/pattern; prioritize the user comment.";
469
+ return "? question mark — explain this target in detail in the final output.";
470
+ }
471
+
472
+ function formatActionFeedbackLearningPrompt(items) {
473
+ const lines = [
474
+ "The user submitted direct feedback on specific Web UI action or final-output cards from your last run.",
475
+ "Use it to steer future behavior and create or update a concise LEARNING note from this feedback.",
476
+ "Reaction semantics:",
477
+ "- 👍 thumbs up: treat as 'Good job!' and reinforce the action/pattern.",
478
+ "- 👎 thumbs down: avoid or reconsider this target/pattern; include any user comment.",
479
+ "- ? question mark: explain the target in detail in your final output.",
480
+ "",
481
+ "Feedback items:",
482
+ ];
483
+
484
+ items.forEach((item, index) => {
485
+ lines.push(
486
+ `${index + 1}. ${actionFeedbackReactionLabel(item.reaction)}`,
487
+ ` Target (${item.kind}): ${item.title}`,
488
+ item.comment ? ` User comment: ${item.comment}` : undefined,
489
+ item.snippet ? ` Action excerpt:\n${item.snippet.split(/\r?\n/).map((line) => ` ${line}`).join("\n")}` : undefined,
490
+ );
491
+ });
492
+
493
+ lines.push(
494
+ "",
495
+ "After processing this feedback, report which LEARNING was created or updated. If any item used '?', include the requested detailed explanation in the final response.",
496
+ );
497
+ return lines.filter((line) => line !== undefined).join("\n");
498
+ }
499
+
500
+ async function handleActionFeedback(tab, body) {
501
+ const feedbackItems = normalizeActionFeedbackItems(body);
502
+ const state = await tab.rpc.send({ type: "get_state" });
503
+ if (state.success === false) return state;
504
+ if (state.data?.isStreaming || state.data?.isCompacting) {
505
+ throw makeHttpError(409, "Wait for the current agent run or compaction to finish before sending feedback.");
506
+ }
507
+
508
+ const command = { type: "prompt", message: formatActionFeedbackLearningPrompt(feedbackItems) };
509
+ markTabWorking(tab);
510
+ const response = await tab.rpc.send(command);
511
+ if (response.success === false) markTabIdle(tab);
512
+ return response;
513
+ }
514
+
389
515
  function parseSlashCommand(message) {
390
516
  const text = String(message || "").trim();
391
517
  if (!text.startsWith("/") || text.includes("\n")) return undefined;
@@ -396,6 +522,54 @@ function parseSlashCommand(message) {
396
522
  return { name, args: (match[2] || "").trim(), text };
397
523
  }
398
524
 
525
+ function truncateTabTitle(title, maxLength = AUTO_TAB_TITLE_MAX_LENGTH) {
526
+ const text = String(title || "").replace(/\s+/g, " ").trim();
527
+ if (!maxLength || text.length <= maxLength) return text;
528
+ return `${text.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
529
+ }
530
+
531
+ function titleCaseTabTitle(title) {
532
+ return title ? `${title.charAt(0).toUpperCase()}${title.slice(1)}` : "";
533
+ }
534
+
535
+ function generatedTabTitleFromPrompt(message) {
536
+ const line = String(message || "")
537
+ .split(/\r?\n/)
538
+ .map((item) => item.trim())
539
+ .find((item) => item && !item.startsWith("```"));
540
+ if (!line) return "";
541
+
542
+ const cleaned = line
543
+ .replace(/https?:\/\/\S+/gi, "link")
544
+ .replace(/^\/+/, "")
545
+ .replace(/[-_]+/g, " ")
546
+ .replace(/[`*_~#>{}\[\]()<>'"“”‘’,;:!?]+/g, " ")
547
+ .replace(/\s+/g, " ")
548
+ .trim()
549
+ .replace(/^(?:please\s+)?(?:can|could|would)\s+you\s+/i, "")
550
+ .replace(/^(?:please\s+)?(?:help\s+me\s+|i\s+(?:need|want)\s+(?:you\s+to\s+)?)/i, "")
551
+ .replace(/^(?:for|in|on)\s+the\s+/i, "");
552
+ if (!cleaned) return "";
553
+
554
+ const words = cleaned.split(/\s+/).map((word) => word.replace(/^[^\w]+|[^\w]+$/g, "")).filter(Boolean);
555
+ const meaningfulWords = words.filter((word) => !AUTO_TAB_TITLE_STOP_WORDS.has(word.toLowerCase()));
556
+ const selectedWords = (meaningfulWords.length >= 3 ? meaningfulWords : words).slice(0, AUTO_TAB_TITLE_WORD_LIMIT);
557
+ return truncateTabTitle(titleCaseTabTitle(selectedWords.join(" ")));
558
+ }
559
+
560
+ function uniqueTabTitle(title, currentTab, maxLength = AUTO_TAB_TITLE_MAX_LENGTH) {
561
+ const base = truncateTabTitle(title, maxLength);
562
+ if (!base) return "";
563
+ const existing = new Set([...tabs.values()].filter((tab) => tab.id !== currentTab?.id).map((tab) => tab.title));
564
+ if (!existing.has(base)) return base;
565
+ for (let suffix = 2; suffix < 100; suffix++) {
566
+ const suffixText = ` ${suffix}`;
567
+ const candidate = `${truncateTabTitle(base, Math.max(1, maxLength - suffixText.length))}${suffixText}`;
568
+ if (!existing.has(candidate)) return candidate;
569
+ }
570
+ return `${truncateTabTitle(base, Math.max(1, maxLength - 4))} ${currentTab?.index || 1}`;
571
+ }
572
+
399
573
  const eventHistory = [];
400
574
 
401
575
  function truncateStatusText(value, maxLength = 240) {
@@ -408,7 +582,7 @@ function statusEventSummary(event) {
408
582
  timestamp: new Date().toISOString(),
409
583
  type: String(event?.type || "event"),
410
584
  };
411
- for (const key of ["tabId", "tabTitle", "pid", "cwd", "code", "signal", "command", "queueLength", "pendingMessageCount"]) {
585
+ for (const key of ["id", "tabId", "tabTitle", "previousTabTitle", "titleSource", "pid", "cwd", "code", "signal", "command", "method", "replayed", "queueLength", "pendingMessageCount", "pendingExtensionUiRequestCount"]) {
412
586
  if (event?.[key] !== undefined) summary[key] = event[key];
413
587
  }
414
588
  if (event?.assistantMessageEvent?.type) summary.updateType = event.assistantMessageEvent.type;
@@ -427,7 +601,7 @@ function latestEvents(limit = 40) {
427
601
  return eventHistory.slice(-Math.max(0, Math.min(EVENT_HISTORY_LIMIT, limit)));
428
602
  }
429
603
 
430
- function runCommand(command, args, { cwd, timeoutMs = 2000 } = {}) {
604
+ function runCommand(command, args, { cwd, timeoutMs = 2000, maxOutputLength = 20000 } = {}) {
431
605
  return new Promise((resolve) => {
432
606
  const child = spawn(command, args, {
433
607
  cwd,
@@ -449,11 +623,11 @@ function runCommand(command, args, { cwd, timeoutMs = 2000 } = {}) {
449
623
  }, timeoutMs);
450
624
  child.stdout.on("data", (chunk) => {
451
625
  stdout += String(chunk);
452
- if (stdout.length > 20000) stdout = stdout.slice(-20000);
626
+ if (stdout.length > maxOutputLength) stdout = stdout.slice(-maxOutputLength);
453
627
  });
454
628
  child.stderr.on("data", (chunk) => {
455
629
  stderr += String(chunk);
456
- if (stderr.length > 20000) stderr = stderr.slice(-20000);
630
+ if (stderr.length > maxOutputLength) stderr = stderr.slice(-maxOutputLength);
457
631
  });
458
632
  child.on("error", (error) => finish({ exitCode: undefined, stdout, stderr: sanitizeError(error), error: sanitizeError(error) }));
459
633
  child.on("exit", (exitCode) => finish({ exitCode, stdout, stderr, timedOut: false }));
@@ -678,6 +852,199 @@ async function getDirectoryPickerData(viewPath, activeCwd) {
678
852
  };
679
853
  }
680
854
 
855
+ function normalizeSuggestionPath(value) {
856
+ return String(value || "").replace(/\\/g, "/");
857
+ }
858
+
859
+ function cleanPathSuggestionQuery(value) {
860
+ return normalizeSuggestionPath(value).replace(/\0/g, "").slice(0, PATH_SUGGESTION_QUERY_LIMIT);
861
+ }
862
+
863
+ function splitSuggestionPathQuery(query) {
864
+ const normalized = normalizeSuggestionPath(query);
865
+ if (normalized === "~") return { displayBase: "~", prefix: "" };
866
+ if (!normalized || normalized.endsWith("/")) return { displayBase: normalized, prefix: "" };
867
+ const slashIndex = normalized.lastIndexOf("/");
868
+ if (slashIndex === -1) return { displayBase: "", prefix: normalized };
869
+ return { displayBase: normalized.slice(0, slashIndex + 1), prefix: normalized.slice(slashIndex + 1) };
870
+ }
871
+
872
+ function resolveSuggestionBase(displayBase, cwd) {
873
+ const base = displayBase || ".";
874
+ if (base === "~" || base.startsWith("~/")) return path.resolve(expandUserPath(base));
875
+ if (base.startsWith("/")) return path.resolve(base);
876
+ return path.resolve(cwd, base);
877
+ }
878
+
879
+ function joinSuggestionDisplayPath(displayBase, name) {
880
+ const base = normalizeSuggestionPath(displayBase);
881
+ if (!base || base === ".") return name;
882
+ if (base === "/") return `/${name}`;
883
+ return `${base.replace(/\/+$/, "")}/${name}`;
884
+ }
885
+
886
+ function pathSuggestionLabel(pathText) {
887
+ const normalized = normalizeSuggestionPath(pathText).replace(/\/+$/, "");
888
+ const name = normalized ? path.posix.basename(normalized) : pathText;
889
+ return `${name || pathText}${pathText.endsWith("/") ? "/" : ""}`;
890
+ }
891
+
892
+ function sortPathSuggestions(items) {
893
+ return items.sort((a, b) => {
894
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
895
+ return a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: "base" });
896
+ });
897
+ }
898
+
899
+ async function getDirectPathSuggestions(query, cwd) {
900
+ const { displayBase, prefix } = splitSuggestionPathQuery(query);
901
+ const searchDir = resolveSuggestionBase(displayBase, cwd);
902
+ let entries;
903
+ try {
904
+ entries = await readdir(searchDir, { withFileTypes: true });
905
+ } catch {
906
+ return [];
907
+ }
908
+
909
+ const normalizedPrefix = prefix.toLowerCase();
910
+ const suggestions = [];
911
+ for (const entry of entries) {
912
+ if (entry.name === ".git" || (!normalizedPrefix && PATH_SUGGESTION_EXCLUDED_DIRS.has(entry.name))) continue;
913
+ if (normalizedPrefix && !entry.name.toLowerCase().startsWith(normalizedPrefix)) continue;
914
+ let isDirectory = entry.isDirectory();
915
+ if (!isDirectory && entry.isSymbolicLink()) {
916
+ try {
917
+ isDirectory = (await stat(path.join(searchDir, entry.name))).isDirectory();
918
+ } catch {
919
+ isDirectory = false;
920
+ }
921
+ }
922
+ const pathText = normalizeSuggestionPath(`${joinSuggestionDisplayPath(displayBase, entry.name)}${isDirectory ? "/" : ""}`);
923
+ suggestions.push({
924
+ path: pathText,
925
+ label: `${entry.name}${isDirectory ? "/" : ""}`,
926
+ type: isDirectory ? "directory" : "file",
927
+ description: pathText,
928
+ });
929
+ }
930
+ return sortPathSuggestions(suggestions).slice(0, PATH_SUGGESTION_LIMIT);
931
+ }
932
+
933
+ function addSuggestionEntry(entries, pathText, isDirectory) {
934
+ const normalized = normalizeSuggestionPath(pathText).replace(/^\.\//, "");
935
+ if (!normalized || normalized === ".git" || normalized.startsWith(".git/")) return;
936
+ const value = isDirectory && !normalized.endsWith("/") ? `${normalized}/` : normalized;
937
+ if (!entries.has(value)) entries.set(value, { path: value, isDirectory });
938
+ }
939
+
940
+ function addSuggestionPathWithParents(entries, pathText) {
941
+ const normalized = normalizeSuggestionPath(pathText).replace(/^\.\//, "");
942
+ if (!normalized || normalized.startsWith(".git/")) return;
943
+ const parts = normalized.split("/").filter(Boolean);
944
+ let parent = "";
945
+ for (let index = 0; index < parts.length - 1; index++) {
946
+ parent = parent ? `${parent}/${parts[index]}` : parts[index];
947
+ addSuggestionEntry(entries, `${parent}/`, true);
948
+ }
949
+ addSuggestionEntry(entries, normalized, false);
950
+ }
951
+
952
+ async function getGitPathSuggestionEntries(cwd) {
953
+ const result = await runCommand("git", ["-C", cwd, "ls-files", "-co", "--exclude-standard"], {
954
+ timeoutMs: 1200,
955
+ maxOutputLength: PATH_SUGGESTION_MAX_OUTPUT_LENGTH,
956
+ });
957
+ if (result.exitCode !== 0 || !result.stdout.trim()) return null;
958
+ const entries = new Map();
959
+ for (const line of result.stdout.split("\n")) addSuggestionPathWithParents(entries, line.trim());
960
+ return [...entries.values()];
961
+ }
962
+
963
+ async function getFilesystemPathSuggestionEntries(cwd) {
964
+ const entries = new Map();
965
+ async function walk(dir, relativeDir = "", depth = 0) {
966
+ if (entries.size >= PATH_SUGGESTION_SCAN_LIMIT || depth > 6) return;
967
+ let dirEntries;
968
+ try {
969
+ dirEntries = await readdir(dir, { withFileTypes: true });
970
+ } catch {
971
+ return;
972
+ }
973
+ dirEntries.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
974
+ for (const entry of dirEntries) {
975
+ if (entries.size >= PATH_SUGGESTION_SCAN_LIMIT) return;
976
+ const relativePath = normalizeSuggestionPath(relativeDir ? `${relativeDir}/${entry.name}` : entry.name);
977
+ let isDirectory = entry.isDirectory();
978
+ if (!isDirectory && entry.isSymbolicLink()) {
979
+ try {
980
+ isDirectory = (await stat(path.join(dir, entry.name))).isDirectory();
981
+ } catch {
982
+ isDirectory = false;
983
+ }
984
+ }
985
+ if (isDirectory) {
986
+ addSuggestionEntry(entries, `${relativePath}/`, true);
987
+ if (!PATH_SUGGESTION_EXCLUDED_DIRS.has(entry.name)) await walk(path.join(dir, entry.name), relativePath, depth + 1);
988
+ } else {
989
+ addSuggestionEntry(entries, relativePath, false);
990
+ }
991
+ }
992
+ }
993
+ await walk(cwd);
994
+ return [...entries.values()];
995
+ }
996
+
997
+ function isSubsequence(needle, haystack) {
998
+ let index = 0;
999
+ for (const char of haystack) {
1000
+ if (char === needle[index]) index++;
1001
+ if (index >= needle.length) return true;
1002
+ }
1003
+ return needle.length === 0;
1004
+ }
1005
+
1006
+ function scorePathSuggestion(entry, query) {
1007
+ const q = normalizeSuggestionPath(query).replace(/^\.\//, "").replace(/\/+$/, "").toLowerCase();
1008
+ if (!q) return entry.isDirectory ? 2 : 1;
1009
+ const entryPath = entry.path.replace(/\/+$/, "").toLowerCase();
1010
+ const name = path.posix.basename(entryPath);
1011
+ let score = 0;
1012
+ if (name === q) score = 100;
1013
+ else if (name.startsWith(q)) score = 90;
1014
+ else if (entryPath.startsWith(q)) score = 80;
1015
+ else if (name.includes(q)) score = 70;
1016
+ else if (entryPath.includes(q)) score = 55;
1017
+ else if (isSubsequence(q, name)) score = 40;
1018
+ else if (isSubsequence(q, entryPath)) score = 25;
1019
+ if (entry.isDirectory && score > 0) score += 5;
1020
+ return score;
1021
+ }
1022
+
1023
+ function formatRankedPathSuggestions(entries, query) {
1024
+ return entries
1025
+ .map((entry) => ({ ...entry, score: scorePathSuggestion(entry, query) }))
1026
+ .filter((entry) => entry.score > 0)
1027
+ .sort((a, b) => b.score - a.score || a.path.length - b.path.length || a.path.localeCompare(b.path))
1028
+ .slice(0, PATH_SUGGESTION_LIMIT)
1029
+ .map((entry) => ({
1030
+ path: entry.path,
1031
+ label: pathSuggestionLabel(entry.path),
1032
+ type: entry.isDirectory ? "directory" : "file",
1033
+ description: entry.path,
1034
+ }));
1035
+ }
1036
+
1037
+ async function getPathSuggestionData(tab, rawQuery) {
1038
+ const query = cleanPathSuggestionQuery(rawQuery);
1039
+ const shouldUseDirect = !query || query.includes("/") || query.startsWith(".") || query.startsWith("~");
1040
+ let suggestions = shouldUseDirect ? await getDirectPathSuggestions(query, tab.cwd) : [];
1041
+ if (suggestions.length === 0 && query) {
1042
+ const entries = (await getGitPathSuggestionEntries(tab.cwd)) ?? (await getFilesystemPathSuggestionEntries(tab.cwd));
1043
+ suggestions = formatRankedPathSuggestions(entries, query);
1044
+ }
1045
+ return { cwd: tab.cwd, displayCwd: displayPath(tab.cwd), query, suggestions };
1046
+ }
1047
+
681
1048
  async function getWorkspaceInfo(cwd, startedAt) {
682
1049
  const info = {
683
1050
  cwd,
@@ -853,6 +1220,82 @@ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd)
853
1220
  }
854
1221
  }
855
1222
 
1223
+ function themeLabel(name) {
1224
+ return String(name || "")
1225
+ .split(/[-_]+/)
1226
+ .filter(Boolean)
1227
+ .map((part) => part.length <= 3 ? part.toUpperCase() : `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
1228
+ .join(" ");
1229
+ }
1230
+
1231
+ function stringRecord(value) {
1232
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
1233
+ const record = {};
1234
+ for (const [key, item] of Object.entries(value)) {
1235
+ if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") record[key] = String(item);
1236
+ }
1237
+ return record;
1238
+ }
1239
+
1240
+ async function directoryExists(dir) {
1241
+ try {
1242
+ const info = await stat(dir);
1243
+ return info.isDirectory();
1244
+ } catch {
1245
+ return false;
1246
+ }
1247
+ }
1248
+
1249
+ async function resolveBundledThemesDir() {
1250
+ const candidates = [];
1251
+ try {
1252
+ const manifestPath = require.resolve("@firstpick/pi-themes-bundle/package.json");
1253
+ const root = path.dirname(manifestPath);
1254
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
1255
+ const declaredThemes = Array.isArray(manifest.pi?.themes) ? manifest.pi.themes : ["./themes"];
1256
+ for (const entry of declaredThemes) {
1257
+ if (typeof entry === "string" && entry.trim()) candidates.push(path.resolve(root, entry));
1258
+ }
1259
+ } catch {
1260
+ // In repo development the bundle may be a sibling package rather than an installed dependency.
1261
+ }
1262
+ candidates.push(path.resolve(packageRoot, "..", "pi-package-themes-bundle", "themes"));
1263
+
1264
+ for (const candidate of candidates) {
1265
+ if (await directoryExists(candidate)) return candidate;
1266
+ }
1267
+ return null;
1268
+ }
1269
+
1270
+ function sanitizeBundledTheme(theme, fileName) {
1271
+ const name = typeof theme?.name === "string" && theme.name.trim() ? theme.name.trim() : path.basename(fileName, ".json");
1272
+ return {
1273
+ name,
1274
+ label: themeLabel(name),
1275
+ vars: stringRecord(theme?.vars),
1276
+ colors: stringRecord(theme?.colors),
1277
+ export: stringRecord(theme?.export),
1278
+ };
1279
+ }
1280
+
1281
+ async function readBundledThemes() {
1282
+ const dir = await resolveBundledThemesDir();
1283
+ if (!dir) return { source: "@firstpick/pi-themes-bundle", themes: [] };
1284
+
1285
+ const files = (await readdir(dir)).filter((file) => file.endsWith(".json")).sort((a, b) => a.localeCompare(b));
1286
+ const themes = [];
1287
+ for (const file of files) {
1288
+ try {
1289
+ const raw = await readFile(path.join(dir, file), "utf8");
1290
+ themes.push(sanitizeBundledTheme(JSON.parse(raw), file));
1291
+ } catch (error) {
1292
+ console.error(`Skipping invalid theme ${file}: ${sanitizeError(error)}`);
1293
+ }
1294
+ }
1295
+ themes.sort((a, b) => a.label.localeCompare(b.label));
1296
+ return { source: "@firstpick/pi-themes-bundle", themes };
1297
+ }
1298
+
856
1299
  function normalizeStaticPath(urlPath) {
857
1300
  if (urlPath === "/") return "index.html";
858
1301
  const name = urlPath.startsWith("/") ? urlPath.slice(1) : urlPath;
@@ -959,6 +1402,49 @@ if (options.version) {
959
1402
  process.exit(0);
960
1403
  }
961
1404
 
1405
+ const restoreTabs = readRestoreTabsFromEnv();
1406
+
1407
+ function normalizedRestoreString(value, maxLength) {
1408
+ const text = typeof value === "string" ? value.trim() : "";
1409
+ return text ? text.slice(0, maxLength) : undefined;
1410
+ }
1411
+
1412
+ function normalizeRestoreTabDescriptor(item, seenIds) {
1413
+ if (!item || typeof item !== "object") return null;
1414
+ const state = item.state && typeof item.state === "object" ? item.state : {};
1415
+ const rawId = normalizedRestoreString(item.id, 128);
1416
+ const id = rawId && /^[A-Za-z0-9._:-]+$/.test(rawId) && !seenIds.has(rawId) ? rawId : undefined;
1417
+ if (id) seenIds.add(id);
1418
+
1419
+ const descriptor = {
1420
+ id,
1421
+ title: normalizedRestoreString(item.title, 160),
1422
+ titleSource: ["explicit", "auto", "default"].includes(item.titleSource) ? item.titleSource : undefined,
1423
+ cwd: normalizedRestoreString(item.cwd || item.workspace?.cwd, 4096),
1424
+ conversationStarted: item.conversationStarted === true,
1425
+ sessionFile: normalizedRestoreString(item.sessionFile || state.sessionFile, 4096),
1426
+ };
1427
+
1428
+ if (Number.isInteger(item.index) && item.index > 0) descriptor.index = item.index;
1429
+ return descriptor;
1430
+ }
1431
+
1432
+ function readRestoreTabsFromEnv() {
1433
+ const raw = process.env.PI_WEBUI_RESTORE_TABS;
1434
+ delete process.env.PI_WEBUI_RESTORE_TABS;
1435
+ if (!raw) return [];
1436
+
1437
+ try {
1438
+ const parsed = JSON.parse(raw);
1439
+ const items = Array.isArray(parsed) ? parsed : [];
1440
+ const seenIds = new Set();
1441
+ return items.map((item) => normalizeRestoreTabDescriptor(item, seenIds)).filter(Boolean).slice(0, RESTORE_TAB_LIMIT);
1442
+ } catch (error) {
1443
+ console.warn(`failed to parse PI_WEBUI_RESTORE_TABS: ${sanitizeError(error)}`);
1444
+ return [];
1445
+ }
1446
+ }
1447
+
962
1448
  function buildPiArgsForTab(tabIndex, title) {
963
1449
  const args = ["--mode", "rpc"];
964
1450
  if (options.noSession) args.push("--no-session");
@@ -989,7 +1475,254 @@ async function resolvePiCommand(piArgs) {
989
1475
  }
990
1476
 
991
1477
  const tabs = new Map();
1478
+ const closedRestorableTabs = [];
992
1479
  let nextTabIndex = 1;
1480
+ const TAB_ACTIVITY_IDLE_RECONCILE_GRACE_MS = 1200;
1481
+ const TAB_ACTIVITY_STATE_RECONCILE_INTERVAL_MS = 2500;
1482
+ const TAB_ACTIVITY_STATE_RECONCILE_TIMEOUT_MS = 1200;
1483
+
1484
+ function sessionFileFromState(state) {
1485
+ return state && typeof state === "object" ? normalizedRestoreString(state.sessionFile, 4096) : undefined;
1486
+ }
1487
+
1488
+ function rememberTabState(tab, state) {
1489
+ if (!tab || !state || typeof state !== "object") return;
1490
+ tab.lastState = state;
1491
+ if (!options.noSession && Object.prototype.hasOwnProperty.call(state, "sessionFile")) tab.sessionFile = sessionFileFromState(state);
1492
+ }
1493
+
1494
+ function forgetTabState(tab) {
1495
+ if (!tab) return;
1496
+ tab.lastState = null;
1497
+ tab.sessionFile = undefined;
1498
+ }
1499
+
1500
+ function tabRestorableSessionFile(tab) {
1501
+ if (options.noSession) return undefined;
1502
+ return normalizedRestoreString(tab?.sessionFile || tab?.lastState?.sessionFile, 4096);
1503
+ }
1504
+
1505
+ function createTabActivity(now = new Date().toISOString()) {
1506
+ return {
1507
+ status: "idle",
1508
+ isWorking: false,
1509
+ completionSerial: 0,
1510
+ lastChangedAt: now,
1511
+ lastStartedAt: null,
1512
+ lastCompletedAt: null,
1513
+ };
1514
+ }
1515
+
1516
+ function resetTabActivity(tab) {
1517
+ tab.activity = createTabActivity();
1518
+ }
1519
+
1520
+ function tabActivitySnapshot(tab) {
1521
+ return { ...(tab.activity || createTabActivity(tab.createdAt)) };
1522
+ }
1523
+
1524
+ function pendingExtensionUiMap(tab) {
1525
+ if (!tab.pendingExtensionUiRequests) tab.pendingExtensionUiRequests = new Map();
1526
+ return tab.pendingExtensionUiRequests;
1527
+ }
1528
+
1529
+ function isPendingExtensionUiRequest(event) {
1530
+ return event?.type === "extension_ui_request" && EXTENSION_UI_BLOCKING_METHODS.has(event.method) && event.id;
1531
+ }
1532
+
1533
+ function pruneExpiredPendingExtensionUiRequests(tab, nowMs = Date.now()) {
1534
+ const pending = tab?.pendingExtensionUiRequests;
1535
+ if (!pending) return;
1536
+ for (const [id, request] of pending) {
1537
+ const expiresAtMs = Date.parse(request.expiresAt || "");
1538
+ if (Number.isFinite(expiresAtMs) && expiresAtMs <= nowMs) pending.delete(id);
1539
+ }
1540
+ }
1541
+
1542
+ function pendingExtensionUiRequests(tab) {
1543
+ pruneExpiredPendingExtensionUiRequests(tab);
1544
+ return [...(tab?.pendingExtensionUiRequests?.values() || [])];
1545
+ }
1546
+
1547
+ function pendingExtensionUiRequestSummaries(tab) {
1548
+ return pendingExtensionUiRequests(tab).map((request) => ({
1549
+ id: request.id,
1550
+ method: request.method,
1551
+ title: truncateStatusText(request.title || request.placeholder || "", 120),
1552
+ message: request.message ? truncateStatusText(request.message, 180) : undefined,
1553
+ receivedAt: request.receivedAt,
1554
+ expiresAt: request.expiresAt,
1555
+ }));
1556
+ }
1557
+
1558
+ function trackPendingExtensionUiRequest(tab, event) {
1559
+ if (!isPendingExtensionUiRequest(event)) return;
1560
+ const receivedAt = new Date().toISOString();
1561
+ const timeoutMs = Number(event.timeout);
1562
+ const expiresAt = Number.isFinite(timeoutMs) && timeoutMs > 0 ? new Date(Date.parse(receivedAt) + timeoutMs + 1000).toISOString() : undefined;
1563
+ pendingExtensionUiMap(tab).set(String(event.id), { ...event, receivedAt, expiresAt });
1564
+ if (!tab.activity?.isWorking) markTabWorking(tab, receivedAt);
1565
+ }
1566
+
1567
+ function resolvePendingExtensionUiRequest(tab, id) {
1568
+ if (!id) return false;
1569
+ return !!tab?.pendingExtensionUiRequests?.delete(String(id));
1570
+ }
1571
+
1572
+ function clearPendingExtensionUiRequests(tab) {
1573
+ tab?.pendingExtensionUiRequests?.clear();
1574
+ }
1575
+
1576
+ function replayPendingExtensionUiRequests(tab, res) {
1577
+ const pending = pendingExtensionUiRequests(tab);
1578
+ for (const request of pending) {
1579
+ sendSse(res, {
1580
+ ...request,
1581
+ type: "extension_ui_request",
1582
+ replayed: true,
1583
+ tabId: tab.id,
1584
+ tabTitle: tab.title,
1585
+ pendingExtensionUiRequestCount: pending.length,
1586
+ tabActivity: tabActivitySnapshot(tab),
1587
+ });
1588
+ }
1589
+ }
1590
+
1591
+ async function cancelPendingExtensionUiRequests(tab) {
1592
+ const pending = pendingExtensionUiRequests(tab);
1593
+ if (!pending.length) return 0;
1594
+ const ids = [];
1595
+ for (const request of pending) {
1596
+ ids.push(String(request.id));
1597
+ try {
1598
+ await tab.rpc.writeRaw({ type: "extension_ui_response", id: request.id, cancelled: true });
1599
+ } catch {
1600
+ // Abort should remain best-effort even if the RPC process already exited.
1601
+ }
1602
+ resolvePendingExtensionUiRequest(tab, request.id);
1603
+ }
1604
+ broadcastTabEvent(tab, {
1605
+ type: "webui_extension_ui_cancelled",
1606
+ tabId: tab.id,
1607
+ tabTitle: tab.title,
1608
+ ids,
1609
+ pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
1610
+ tabActivity: tabActivitySnapshot(tab),
1611
+ });
1612
+ return ids.length;
1613
+ }
1614
+
1615
+ function markTabWorking(tab, timestamp = new Date().toISOString()) {
1616
+ const activity = tab.activity || createTabActivity(timestamp);
1617
+ activity.status = "working";
1618
+ activity.isWorking = true;
1619
+ activity.lastStartedAt = timestamp;
1620
+ activity.lastChangedAt = timestamp;
1621
+ tab.activity = activity;
1622
+ }
1623
+
1624
+ function markTabDone(tab, timestamp = new Date().toISOString()) {
1625
+ const activity = tab.activity || createTabActivity(timestamp);
1626
+ activity.status = "done";
1627
+ activity.isWorking = false;
1628
+ activity.completionSerial = (Number(activity.completionSerial) || 0) + 1;
1629
+ activity.lastCompletedAt = timestamp;
1630
+ activity.lastChangedAt = timestamp;
1631
+ tab.activity = activity;
1632
+ }
1633
+
1634
+ function markTabIdle(tab, timestamp = new Date().toISOString()) {
1635
+ const activity = tab.activity || createTabActivity(timestamp);
1636
+ activity.status = "idle";
1637
+ activity.isWorking = false;
1638
+ activity.lastChangedAt = timestamp;
1639
+ tab.activity = activity;
1640
+ }
1641
+
1642
+ function commandStartsVisibleWork(command) {
1643
+ return command?.type === "compact" || (command?.type === "prompt" && !command.streamingBehavior);
1644
+ }
1645
+
1646
+ function commandStartsConversation(command) {
1647
+ return command?.type === "prompt" && !command.streamingBehavior;
1648
+ }
1649
+
1650
+ function stateHasVisibleWork(state) {
1651
+ return !!state?.isStreaming || !!state?.isCompacting || Number(state?.pendingMessageCount || 0) > 0;
1652
+ }
1653
+
1654
+ function activityRecentlyStarted(activity, nowMs = Date.now()) {
1655
+ const startedMs = Date.parse(activity?.lastStartedAt || activity?.lastChangedAt || "");
1656
+ return Number.isFinite(startedMs) && nowMs - startedMs < TAB_ACTIVITY_IDLE_RECONCILE_GRACE_MS;
1657
+ }
1658
+
1659
+ function reconcileTabActivityFromState(tab, state, timestamp = new Date().toISOString()) {
1660
+ if (!tab) return createTabActivity(timestamp);
1661
+ if (!state || typeof state !== "object") return tabActivitySnapshot(tab);
1662
+ if (pendingExtensionUiRequests(tab).length > 0) {
1663
+ if (!tab.activity?.isWorking) markTabWorking(tab, timestamp);
1664
+ return tabActivitySnapshot(tab);
1665
+ }
1666
+ if (stateHasVisibleWork(state)) {
1667
+ if (!tab.activity?.isWorking) markTabWorking(tab, timestamp);
1668
+ return tabActivitySnapshot(tab);
1669
+ }
1670
+ if (tab.activity?.isWorking && !activityRecentlyStarted(tab.activity)) {
1671
+ markTabDone(tab, timestamp);
1672
+ }
1673
+ return tabActivitySnapshot(tab);
1674
+ }
1675
+
1676
+ async function reconcileWorkingTabActivity(tab) {
1677
+ if (!tab?.activity?.isWorking) return;
1678
+ if (activityRecentlyStarted(tab.activity)) return;
1679
+ const now = Date.now();
1680
+ if (now - (tab.activityStateReconcileAt || 0) < TAB_ACTIVITY_STATE_RECONCILE_INTERVAL_MS) return;
1681
+ tab.activityStateReconcileAt = now;
1682
+ try {
1683
+ const response = await tab.rpc.send({ type: "get_state" }, TAB_ACTIVITY_STATE_RECONCILE_TIMEOUT_MS);
1684
+ if (response?.success !== false) {
1685
+ rememberTabState(tab, response.data);
1686
+ reconcileTabActivityFromState(tab, response.data);
1687
+ }
1688
+ } catch {
1689
+ // Ignore reconciliation failures; normal RPC events will still update activity.
1690
+ }
1691
+ }
1692
+
1693
+ async function listTabsWithReconciledActivity() {
1694
+ await Promise.all([...tabs.values()].map(reconcileWorkingTabActivity));
1695
+ return listTabs();
1696
+ }
1697
+
1698
+ function updateTabActivityFromEvent(tab, event) {
1699
+ const timestamp = new Date().toISOString();
1700
+ switch (event?.type) {
1701
+ case "agent_start":
1702
+ case "compaction_start":
1703
+ markTabWorking(tab, timestamp);
1704
+ break;
1705
+ case "agent_end":
1706
+ case "compaction_end":
1707
+ markTabDone(tab, timestamp);
1708
+ break;
1709
+ case "pi_process_exit":
1710
+ case "pi_process_error":
1711
+ if (tab.activity?.isWorking) markTabDone(tab, timestamp);
1712
+ else markTabIdle(tab, timestamp);
1713
+ break;
1714
+ case "response":
1715
+ if (event.command === "get_state" && event.success !== false) {
1716
+ rememberTabState(tab, event.data);
1717
+ reconcileTabActivityFromState(tab, event.data, timestamp);
1718
+ } else if (!tab.activity) tab.activity = createTabActivity(timestamp);
1719
+ break;
1720
+ default:
1721
+ if (!tab.activity) tab.activity = createTabActivity(timestamp);
1722
+ break;
1723
+ }
1724
+ return tabActivitySnapshot(tab);
1725
+ }
993
1726
 
994
1727
  function defaultTabTitle(tabIndex) {
995
1728
  if (options.name) return tabIndex === 1 ? options.name : `${options.name} ${tabIndex}`;
@@ -1000,26 +1733,42 @@ function attachRpcToTab(tab, rpc) {
1000
1733
  tab.rpcUnsubscribe?.();
1001
1734
  tab.rpc = rpc;
1002
1735
  tab.rpcUnsubscribe = rpc.onEvent((event) => {
1003
- const scopedEvent = { ...event, tabId: tab.id, tabTitle: tab.title };
1736
+ updateTabActivityFromEvent(tab, event);
1737
+ let scopedEvent = { ...event, tabId: tab.id, tabTitle: tab.title, tabActivity: tabActivitySnapshot(tab) };
1738
+ if (event?.type === "pi_process_exit" || event?.type === "pi_process_error") clearPendingExtensionUiRequests(tab);
1739
+ else trackPendingExtensionUiRequest(tab, scopedEvent);
1740
+ scopedEvent = { ...scopedEvent, tabActivity: tabActivitySnapshot(tab), pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length };
1004
1741
  recordEvent(scopedEvent);
1005
1742
  for (const client of tab.sseClients) sendSse(client, scopedEvent);
1006
1743
  });
1007
1744
  }
1008
1745
 
1009
- async function createTab({ title, cwd } = {}) {
1010
- const tabIndex = nextTabIndex++;
1011
- const tabTitle = String(title || "").trim() || defaultTabTitle(tabIndex);
1746
+ async function createTab({ id: requestedId, index, title, titleSource, conversationStarted, cwd, sessionFile } = {}) {
1747
+ const tabIndex = Number.isInteger(index) && index > 0 ? index : nextTabIndex;
1748
+ nextTabIndex = Math.max(nextTabIndex, tabIndex + 1);
1749
+ const explicitTitle = String(title || "").trim();
1750
+ const tabTitle = explicitTitle || defaultTabTitle(tabIndex);
1751
+ const titleIsExplicit = Boolean(explicitTitle || (options.name && tabIndex === 1));
1752
+ const resolvedTitleSource = ["explicit", "auto", "default"].includes(titleSource) ? titleSource : titleIsExplicit ? "explicit" : "default";
1012
1753
  const tabCwd = cwd ? await resolveCwd(cwd, options.cwd) : options.cwd;
1013
- const id = randomUUID();
1754
+ const id = requestedId && !tabs.has(requestedId) ? requestedId : randomUUID();
1014
1755
  const piArgs = buildPiArgsForTab(tabIndex, tabTitle);
1756
+ if (sessionFile && !options.noSession) piArgs.push("--session", sessionFile);
1015
1757
  const piCommand = await resolvePiCommand(piArgs);
1016
1758
  const rpc = new PiRpcProcess({ ...piCommand, cwd: tabCwd });
1759
+ const createdAt = new Date().toISOString();
1017
1760
  const tab = {
1018
1761
  id,
1019
1762
  index: tabIndex,
1020
1763
  title: tabTitle,
1764
+ titleSource: resolvedTitleSource,
1765
+ conversationStarted: conversationStarted === true,
1021
1766
  cwd: tabCwd,
1022
- createdAt: new Date().toISOString(),
1767
+ createdAt,
1768
+ sessionFile: options.noSession ? undefined : normalizedRestoreString(sessionFile, 4096),
1769
+ lastState: null,
1770
+ activity: createTabActivity(createdAt),
1771
+ pendingExtensionUiRequests: new Map(),
1023
1772
  rpc: undefined,
1024
1773
  rpcUnsubscribe: undefined,
1025
1774
  sseClients: new Set(),
@@ -1028,6 +1777,9 @@ async function createTab({ title, cwd } = {}) {
1028
1777
  attachRpcToTab(tab, rpc);
1029
1778
  tabs.set(id, tab);
1030
1779
  rpc.start();
1780
+ if (sessionFile && !options.noSession) {
1781
+ recordEvent({ type: "webui_tab_restored", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd });
1782
+ }
1031
1783
  return tab;
1032
1784
  }
1033
1785
 
@@ -1040,13 +1792,18 @@ function tabMeta(tab) {
1040
1792
  id: tab.id,
1041
1793
  index: tab.index,
1042
1794
  title: tab.title,
1795
+ titleSource: tab.titleSource || "default",
1796
+ conversationStarted: !!tab.conversationStarted,
1043
1797
  cwd: tab.cwd,
1798
+ sessionFile: tabRestorableSessionFile(tab),
1044
1799
  createdAt: tab.createdAt,
1045
1800
  startedAt: tab.rpc.startedAt,
1046
1801
  pid: tab.rpc.child?.pid,
1047
1802
  running: !!tab.rpc.child && tab.rpc.child.exitCode === null,
1048
1803
  command: tab.rpc.displayCommand,
1049
1804
  clientCount: tab.sseClients.size,
1805
+ pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
1806
+ activity: tabActivitySnapshot(tab),
1050
1807
  };
1051
1808
  }
1052
1809
 
@@ -1054,6 +1811,96 @@ function listTabs() {
1054
1811
  return [...tabs.values()].map(tabMeta);
1055
1812
  }
1056
1813
 
1814
+ function restorableTabDescriptor(tab, state = null) {
1815
+ return normalizeRestoreTabDescriptor({
1816
+ id: tab.id,
1817
+ index: tab.index,
1818
+ title: tab.title,
1819
+ titleSource: tab.titleSource,
1820
+ conversationStarted: tab.conversationStarted,
1821
+ cwd: tab.cwd,
1822
+ sessionFile: sessionFileFromState(state) || tabRestorableSessionFile(tab),
1823
+ }, new Set());
1824
+ }
1825
+
1826
+ function restorableTabKey(tab) {
1827
+ if (tab.id) return `id:${tab.id}`;
1828
+ if (tab.sessionFile) return `session:${tab.sessionFile}`;
1829
+ return `tab:${tab.index || "?"}:${tab.title || ""}:${tab.cwd || ""}`;
1830
+ }
1831
+
1832
+ function restorableTabSortIndex(tab) {
1833
+ return Number.isInteger(tab.index) && tab.index > 0 ? tab.index : Number.MAX_SAFE_INTEGER;
1834
+ }
1835
+
1836
+ function mergeRestorableTabDescriptors(...sources) {
1837
+ const merged = [];
1838
+ const seen = new Set();
1839
+ for (const source of sources) {
1840
+ for (const item of Array.isArray(source) ? source : []) {
1841
+ const descriptor = normalizeRestoreTabDescriptor(item, new Set());
1842
+ if (!descriptor) continue;
1843
+ const key = restorableTabKey(descriptor);
1844
+ if (seen.has(key)) continue;
1845
+ seen.add(key);
1846
+ merged.push(descriptor);
1847
+ }
1848
+ }
1849
+ return merged
1850
+ .sort((a, b) => restorableTabSortIndex(a) - restorableTabSortIndex(b) || String(a.title || "").localeCompare(String(b.title || "")))
1851
+ .slice(0, RESTORE_TAB_LIMIT);
1852
+ }
1853
+
1854
+ function rememberClosedRestorableTab(tab, state = null) {
1855
+ const descriptor = restorableTabDescriptor(tab, state);
1856
+ if (!descriptor) return;
1857
+ const key = restorableTabKey(descriptor);
1858
+ const existingIndex = closedRestorableTabs.findIndex((item) => restorableTabKey(item) === key);
1859
+ if (existingIndex !== -1) closedRestorableTabs.splice(existingIndex, 1);
1860
+ closedRestorableTabs.push(descriptor);
1861
+ while (closedRestorableTabs.length > RESTORE_TAB_LIMIT) closedRestorableTabs.shift();
1862
+ }
1863
+
1864
+ function broadcastTabEvent(tab, event) {
1865
+ recordEvent(event);
1866
+ for (const client of tab.sseClients) sendSse(client, event);
1867
+ }
1868
+
1869
+ function renameTab(tab, title, { source = "explicit", maxLength, unique = source === "auto" } = {}) {
1870
+ if (!tab) return false;
1871
+ const rawTitle = maxLength ? truncateTabTitle(title, maxLength) : String(title || "").replace(/\s+/g, " ").trim();
1872
+ const nextTitle = unique ? uniqueTabTitle(rawTitle, tab, maxLength || AUTO_TAB_TITLE_MAX_LENGTH) : rawTitle;
1873
+ if (!nextTitle) return false;
1874
+
1875
+ const previousTitle = tab.title;
1876
+ tab.title = nextTitle;
1877
+ tab.titleSource = source;
1878
+ if (previousTitle === nextTitle) return false;
1879
+
1880
+ broadcastTabEvent(tab, {
1881
+ type: "webui_tab_renamed",
1882
+ tabId: tab.id,
1883
+ tabTitle: tab.title,
1884
+ previousTabTitle: previousTitle,
1885
+ titleSource: source,
1886
+ tab: tabMeta(tab),
1887
+ tabActivity: tabActivitySnapshot(tab),
1888
+ });
1889
+ return true;
1890
+ }
1891
+
1892
+ function maybeNameTabForConversation(tab, command) {
1893
+ if (!tab || !commandStartsConversation(command) || tab.conversationStarted || tab.titleSource === "explicit") return false;
1894
+ tab.conversationStarted = true;
1895
+ const title = generatedTabTitleFromPrompt(command.message) || `Conversation ${tab.index}`;
1896
+ return renameTab(tab, title, { source: "auto", maxLength: AUTO_TAB_TITLE_MAX_LENGTH });
1897
+ }
1898
+
1899
+ function responseWithTab(response, tab) {
1900
+ if (!response || typeof response !== "object") return response;
1901
+ return { ...response, tab: tabMeta(tab) };
1902
+ }
1903
+
1057
1904
  async function updateTabCwd(id, cwd) {
1058
1905
  const tab = tabs.get(id);
1059
1906
  if (!tab) throw makeHttpError(404, `Unknown Pi tab: ${id}`);
@@ -1075,11 +1922,14 @@ async function updateTabCwd(id, cwd) {
1075
1922
  oldRpc.stop();
1076
1923
 
1077
1924
  tab.cwd = nextCwd;
1925
+ forgetTabState(tab);
1926
+ resetTabActivity(tab);
1927
+ clearPendingExtensionUiRequests(tab);
1078
1928
  const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
1079
1929
  attachRpcToTab(tab, rpc);
1080
1930
  rpc.start();
1081
1931
 
1082
- const changedEvent = { type: "webui_cwd_changed", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid };
1932
+ const changedEvent = { type: "webui_cwd_changed", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid, tabActivity: tabActivitySnapshot(tab) };
1083
1933
  recordEvent(changedEvent);
1084
1934
  for (const client of tab.sseClients) {
1085
1935
  sendSse(client, changedEvent);
@@ -1090,6 +1940,7 @@ async function updateTabCwd(id, cwd) {
1090
1940
  async function restartTabRpc(tab, reason = "reload") {
1091
1941
  const state = await tab.rpc.send({ type: "get_state" });
1092
1942
  if (state.success === false) throw makeHttpError(400, state.error || "Unable to read Pi state before reload");
1943
+ rememberTabState(tab, state.data);
1093
1944
  if (state.data?.isStreaming) throw makeHttpError(409, "Wait for the current response to finish before reloading.");
1094
1945
  if (state.data?.isCompacting) throw makeHttpError(409, "Wait for compaction to finish before reloading.");
1095
1946
 
@@ -1105,11 +1956,13 @@ async function restartTabRpc(tab, reason = "reload") {
1105
1956
  tab.rpcUnsubscribe = undefined;
1106
1957
  oldRpc.stop();
1107
1958
 
1959
+ resetTabActivity(tab);
1960
+ clearPendingExtensionUiRequests(tab);
1108
1961
  const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
1109
1962
  attachRpcToTab(tab, rpc);
1110
1963
  rpc.start();
1111
1964
 
1112
- const reloadedEvent = { type: "webui_tab_reloaded", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid, reason, sessionFile: state.data?.sessionFile };
1965
+ const reloadedEvent = { type: "webui_tab_reloaded", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid, reason, sessionFile: state.data?.sessionFile, tabActivity: tabActivitySnapshot(tab) };
1113
1966
  recordEvent(reloadedEvent);
1114
1967
  for (const client of tab.sseClients) sendSse(client, reloadedEvent);
1115
1968
  return tab;
@@ -1142,8 +1995,8 @@ function webuiHotkeysOutput() {
1142
1995
  "Web UI hotkeys:",
1143
1996
  "Enter: send on desktop; newline on mobile",
1144
1997
  "Ctrl/Cmd+Enter: send from textarea",
1145
- "Tab: accept slash-command suggestion",
1146
- "Arrow up/down: move through slash-command suggestions",
1998
+ "Tab: accept slash-command or @path suggestion",
1999
+ "Arrow up/down: move through slash-command or @path suggestions",
1147
2000
  "Escape: close actions, tabs, model picker, or mobile drawer",
1148
2001
  "Mobile: Send button submits; Return inserts a newline",
1149
2002
  ].join("\n");
@@ -1160,7 +2013,9 @@ async function handleNativeSlashCommand(tab, body) {
1160
2013
  }
1161
2014
  case "new": {
1162
2015
  const response = await tab.rpc.send({ type: "new_session" });
1163
- return response.success === false ? response : rpcSuccess("native_slash_command", { command: "new", message: "Started a new session.", result: response.data });
2016
+ if (response.success === false) return response;
2017
+ tab.conversationStarted = false;
2018
+ return rpcSuccess("native_slash_command", { command: "new", tab: tabMeta(tab), message: "Started a new session.", result: response.data });
1164
2019
  }
1165
2020
  case "compact": {
1166
2021
  const response = await tab.rpc.send(parsed.args ? { type: "compact", customInstructions: parsed.args } : { type: "compact" });
@@ -1169,7 +2024,9 @@ async function handleNativeSlashCommand(tab, body) {
1169
2024
  case "name": {
1170
2025
  if (!parsed.args) throw makeHttpError(400, "Usage: /name <session name>");
1171
2026
  const response = await tab.rpc.send({ type: "set_session_name", name: parsed.args });
1172
- return response.success === false ? response : rpcSuccess("native_slash_command", { command: "name", message: `Session name set to: ${parsed.args}` });
2027
+ if (response.success === false) return response;
2028
+ renameTab(tab, parsed.args, { source: "explicit" });
2029
+ return rpcSuccess("native_slash_command", { command: "name", tab: tabMeta(tab), message: `Session and tab name set to: ${tab.title}` });
1173
2030
  }
1174
2031
  case "session": {
1175
2032
  const [state, stats] = await Promise.all([
@@ -1198,11 +2055,18 @@ async function handleNativeSlashCommand(tab, body) {
1198
2055
  }
1199
2056
  }
1200
2057
 
1201
- function closeTab(id) {
2058
+ async function closeTab(id) {
1202
2059
  const tab = tabs.get(id);
1203
2060
  if (!tab) throw makeHttpError(404, `Unknown Pi tab: ${id}`);
1204
2061
  if (tabs.size <= 1) throw makeHttpError(400, "Cannot close the last Pi tab");
1205
2062
 
2063
+ let restorableState = null;
2064
+ if (!options.noSession) {
2065
+ const stateResult = await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
2066
+ if (stateResult.ok) restorableState = stateResult.data;
2067
+ }
2068
+ rememberClosedRestorableTab(tab, restorableState);
2069
+
1206
2070
  const closingEvent = { type: "webui_tab_closing", tabId: tab.id, tabTitle: tab.title };
1207
2071
  recordEvent(closingEvent);
1208
2072
  for (const client of tab.sseClients) {
@@ -1234,10 +2098,27 @@ function getRequestedTab(req, url, body = {}) {
1234
2098
  return tab;
1235
2099
  }
1236
2100
 
2101
+ async function createInitialTabs() {
2102
+ if (!restoreTabs.length) return [await createTab()];
2103
+
2104
+ const created = [];
2105
+ for (const descriptor of restoreTabs) {
2106
+ try {
2107
+ created.push(await createTab(descriptor));
2108
+ } catch (error) {
2109
+ console.warn(`failed to restore Web UI tab ${descriptor.title || descriptor.id || "unknown"}: ${sanitizeError(error)}`);
2110
+ }
2111
+ }
2112
+
2113
+ return created.length ? created : [await createTab()];
2114
+ }
2115
+
1237
2116
  const serverStartedAt = new Date().toISOString();
1238
- const initialTab = await createTab();
2117
+ const initialTabs = await createInitialTabs();
2118
+ const initialTab = initialTabs[0];
1239
2119
  let currentHost = options.host;
1240
2120
  let networkRebindInProgress = false;
2121
+ let networkRebindTargetHost = null;
1241
2122
 
1242
2123
  function localNetworkAddresses() {
1243
2124
  const addresses = [];
@@ -1252,10 +2133,14 @@ function localNetworkAddresses() {
1252
2133
 
1253
2134
  function networkStatus() {
1254
2135
  const open = !isLocalHost(currentHost);
2136
+ const targetHost = networkRebindTargetHost || currentHost;
2137
+ const opening = networkRebindInProgress && !isLocalHost(targetHost);
2138
+ const closing = networkRebindInProgress && isLocalHost(targetHost);
1255
2139
  const networkUrls = open ? localNetworkAddresses().map((address) => `http://${address}:${options.port}/`) : [];
1256
2140
  return {
1257
2141
  open,
1258
- opening: networkRebindInProgress,
2142
+ opening,
2143
+ closing,
1259
2144
  host: currentHost,
1260
2145
  port: options.port,
1261
2146
  localUrl: `http://127.0.0.1:${options.port}/`,
@@ -1265,7 +2150,15 @@ function networkStatus() {
1265
2150
 
1266
2151
  function closeSseClientsForRebind(nextHost) {
1267
2152
  for (const tab of tabs.values()) {
1268
- const rebindEvent = { type: "webui_network_rebinding", tabId: tab.id, tabTitle: tab.title, host: nextHost, port: options.port };
2153
+ const rebindEvent = {
2154
+ type: "webui_network_rebinding",
2155
+ tabId: tab.id,
2156
+ tabTitle: tab.title,
2157
+ host: nextHost,
2158
+ port: options.port,
2159
+ opening: !isLocalHost(nextHost),
2160
+ closing: isLocalHost(nextHost),
2161
+ };
1269
2162
  recordEvent(rebindEvent);
1270
2163
  for (const client of tab.sseClients) {
1271
2164
  sendSse(client, rebindEvent);
@@ -1281,10 +2174,18 @@ function closeServerListener() {
1281
2174
  resolve();
1282
2175
  return;
1283
2176
  }
2177
+ const forceCloseTimer = setTimeout(() => {
2178
+ // Rebinding is intentionally disruptive. Long-poll/SSE/keep-alive clients can
2179
+ // otherwise keep server.close() pending and leave currentHost stuck on 0.0.0.0.
2180
+ server.closeAllConnections?.();
2181
+ }, NETWORK_REBIND_FORCE_CLOSE_MS);
2182
+ forceCloseTimer.unref?.();
1284
2183
  server.close((error) => {
2184
+ clearTimeout(forceCloseTimer);
1285
2185
  if (error) reject(error);
1286
2186
  else resolve();
1287
2187
  });
2188
+ server.closeIdleConnections?.();
1288
2189
  });
1289
2190
  }
1290
2191
 
@@ -1313,6 +2214,7 @@ async function openToLocalNetwork() {
1313
2214
  if (!isLocalHost(currentHost) || networkRebindInProgress) return networkStatus();
1314
2215
 
1315
2216
  networkRebindInProgress = true;
2217
+ networkRebindTargetHost = nextHost;
1316
2218
  closeSseClientsForRebind(nextHost);
1317
2219
  const previousHost = currentHost;
1318
2220
  try {
@@ -1333,6 +2235,37 @@ async function openToLocalNetwork() {
1333
2235
  throw error;
1334
2236
  } finally {
1335
2237
  networkRebindInProgress = false;
2238
+ networkRebindTargetHost = null;
2239
+ }
2240
+ }
2241
+
2242
+ async function closeNetworkAccess() {
2243
+ const nextHost = "127.0.0.1";
2244
+ if (isLocalHost(currentHost) || networkRebindInProgress) return networkStatus();
2245
+
2246
+ networkRebindInProgress = true;
2247
+ networkRebindTargetHost = nextHost;
2248
+ closeSseClientsForRebind(nextHost);
2249
+ const previousHost = currentHost;
2250
+ try {
2251
+ await closeServerListener();
2252
+ await listenOn(nextHost);
2253
+ currentHost = nextHost;
2254
+ console.warn("Web UI network access closed; listening on localhost only.");
2255
+ return networkStatus();
2256
+ } catch (error) {
2257
+ console.error("Failed to close Web UI network access:", sanitizeError(error));
2258
+ if (!server.listening) {
2259
+ try {
2260
+ await listenOn(previousHost);
2261
+ } catch (restoreError) {
2262
+ console.error("Failed to restore Web UI listener:", sanitizeError(restoreError));
2263
+ }
2264
+ }
2265
+ throw error;
2266
+ } finally {
2267
+ networkRebindInProgress = false;
2268
+ networkRebindTargetHost = null;
1336
2269
  }
1337
2270
  }
1338
2271
 
@@ -1340,6 +2273,7 @@ async function safeRpcData(tab, command, timeoutMs = STATUS_RPC_TIMEOUT_MS) {
1340
2273
  try {
1341
2274
  const response = await tab.rpc.send(command, timeoutMs);
1342
2275
  if (response?.success === false) return { ok: false, error: response.error || `${command.type} failed` };
2276
+ if (command?.type === "get_state") rememberTabState(tab, response?.data);
1343
2277
  return { ok: true, data: response?.data ?? null };
1344
2278
  } catch (error) {
1345
2279
  return { ok: false, error: sanitizeError(error) };
@@ -1362,14 +2296,16 @@ async function tabStatusDetails(tab) {
1362
2296
  getWorkspaceInfo(tab.cwd, tab.rpc.startedAt).then((data) => ({ ok: true, data })).catch((error) => ({ ok: false, error: sanitizeError(error) })),
1363
2297
  ]);
1364
2298
  const models = modelsResult.ok ? modelsResult.data?.models || [] : [];
2299
+ const stateData = stateResult.ok ? stateResult.data : tab.lastState || null;
1365
2300
  return {
1366
2301
  ...tabMeta(tab),
1367
- state: stateResult.ok ? stateResult.data : null,
2302
+ state: stateData,
1368
2303
  stateError: stateResult.ok ? undefined : stateResult.error,
1369
2304
  stats: statsResult.ok ? statsResult.data : null,
1370
2305
  statsError: statsResult.ok ? undefined : statsResult.error,
1371
2306
  workspace: workspaceResult.ok ? workspaceResult.data : null,
1372
2307
  workspaceError: workspaceResult.ok ? undefined : workspaceResult.error,
2308
+ pendingExtensionUiRequests: pendingExtensionUiRequestSummaries(tab),
1373
2309
  models: {
1374
2310
  count: models.length,
1375
2311
  providers: providerList(models),
@@ -1381,6 +2317,7 @@ async function tabStatusDetails(tab) {
1381
2317
  async function webuiStatus({ detailed = false, eventLimit = 40 } = {}) {
1382
2318
  const tab = firstTab();
1383
2319
  const network = networkStatus();
2320
+ const statusTabs = listTabs();
1384
2321
  const data = {
1385
2322
  online: true,
1386
2323
  webuiVersion: packageJson.version,
@@ -1394,11 +2331,15 @@ async function webuiStatus({ detailed = false, eventLimit = 40 } = {}) {
1394
2331
  network,
1395
2332
  piPid: tab?.rpc.child?.pid,
1396
2333
  piRunning: !!tab?.rpc.child && tab.rpc.child.exitCode === null,
1397
- tabs: listTabs(),
2334
+ tabs: statusTabs,
2335
+ restorableTabs: mergeRestorableTabDescriptors(statusTabs, closedRestorableTabs),
1398
2336
  };
1399
2337
 
1400
2338
  if (detailed) {
1401
- data.tabs = await Promise.all([...tabs.values()].map((item) => tabStatusDetails(item)));
2339
+ const detailedTabs = await Promise.all([...tabs.values()].map((item) => tabStatusDetails(item)));
2340
+ data.tabs = detailedTabs;
2341
+ data.restorableTabs = mergeRestorableTabDescriptors(detailedTabs, closedRestorableTabs);
2342
+ data.closedTabs = closedRestorableTabs.slice();
1402
2343
  data.events = latestEvents(eventLimit);
1403
2344
  }
1404
2345
 
@@ -1410,7 +2351,7 @@ const server = createServer(async (req, res) => {
1410
2351
  const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
1411
2352
 
1412
2353
  if (url.pathname === "/api/tabs" && req.method === "GET") {
1413
- sendJson(res, 200, { ok: true, data: { tabs: listTabs() } });
2354
+ sendJson(res, 200, { ok: true, data: { tabs: await listTabsWithReconciledActivity() } });
1414
2355
  return;
1415
2356
  }
1416
2357
 
@@ -1431,7 +2372,7 @@ const server = createServer(async (req, res) => {
1431
2372
 
1432
2373
  if (url.pathname.startsWith("/api/tabs/") && req.method === "DELETE") {
1433
2374
  const id = decodeURIComponent(url.pathname.slice("/api/tabs/".length));
1434
- closeTab(id);
2375
+ await closeTab(id);
1435
2376
  sendJson(res, 200, { ok: true, data: { tabs: listTabs(), activeTabId: firstTab()?.id || null } });
1436
2377
  return;
1437
2378
  }
@@ -1454,7 +2395,10 @@ const server = createServer(async (req, res) => {
1454
2395
  pid: tab.rpc.child?.pid,
1455
2396
  cwd: tab.cwd,
1456
2397
  startedAt: tab.rpc.startedAt,
2398
+ tabActivity: tabActivitySnapshot(tab),
2399
+ pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
1457
2400
  });
2401
+ replayPendingExtensionUiRequests(tab, res);
1458
2402
  const keepAlive = setInterval(() => res.write(": keepalive\n\n"), 15000);
1459
2403
  req.on("close", () => {
1460
2404
  clearInterval(keepAlive);
@@ -1474,6 +2418,7 @@ const server = createServer(async (req, res) => {
1474
2418
  cwd: status.cwd,
1475
2419
  network: status.network,
1476
2420
  tabs: status.tabs,
2421
+ restorableTabs: status.restorableTabs,
1477
2422
  });
1478
2423
  return;
1479
2424
  }
@@ -1486,6 +2431,11 @@ const server = createServer(async (req, res) => {
1486
2431
  return;
1487
2432
  }
1488
2433
 
2434
+ if (url.pathname === "/api/themes" && req.method === "GET") {
2435
+ sendJson(res, 200, { ok: true, data: await readBundledThemes() });
2436
+ return;
2437
+ }
2438
+
1489
2439
  if (url.pathname === "/api/network" && req.method === "GET") {
1490
2440
  sendJson(res, 200, { ok: true, data: networkStatus() });
1491
2441
  return;
@@ -1494,9 +2444,20 @@ const server = createServer(async (req, res) => {
1494
2444
  if (url.pathname === "/api/network/open" && req.method === "POST") {
1495
2445
  if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Opening to the network is only allowed from localhost");
1496
2446
  const before = networkStatus();
1497
- sendJson(res, 202, { ok: true, data: { ...before, opening: true } });
1498
- if (!before.open && !networkRebindInProgress) {
1499
- setTimeout(() => openToLocalNetwork().catch((error) => console.error("network open failed:", sanitizeError(error))), 20).unref();
2447
+ const shouldOpen = !before.open && !networkRebindInProgress;
2448
+ sendJson(res, 202, { ok: true, data: { ...before, opening: shouldOpen || before.opening, closing: before.closing } }, { connection: "close" });
2449
+ if (shouldOpen) {
2450
+ setTimeout(() => openToLocalNetwork().catch((error) => console.error("network open failed:", sanitizeError(error))), NETWORK_REBIND_DELAY_MS).unref();
2451
+ }
2452
+ return;
2453
+ }
2454
+
2455
+ if (url.pathname === "/api/network/close" && req.method === "POST") {
2456
+ const before = networkStatus();
2457
+ const shouldClose = before.open && !networkRebindInProgress;
2458
+ sendJson(res, 202, { ok: true, data: { ...before, opening: before.opening, closing: shouldClose || before.closing } }, { connection: "close" });
2459
+ if (shouldClose) {
2460
+ setTimeout(() => closeNetworkAccess().catch((error) => console.error("network close failed:", sanitizeError(error))), NETWORK_REBIND_DELAY_MS).unref();
1500
2461
  }
1501
2462
  return;
1502
2463
  }
@@ -1526,6 +2487,12 @@ const server = createServer(async (req, res) => {
1526
2487
  return;
1527
2488
  }
1528
2489
 
2490
+ if (url.pathname === "/api/path-suggestions" && req.method === "GET") {
2491
+ const tab = getRequestedTab(req, url);
2492
+ sendJson(res, 200, { ok: true, data: await getPathSuggestionData(tab, url.searchParams.get("query")) });
2493
+ return;
2494
+ }
2495
+
1529
2496
  if (url.pathname === "/api/path-fast-picks" && req.method === "GET") {
1530
2497
  sendJson(res, 200, { ok: true, data: { picks: await readPathFastPicks() } });
1531
2498
  return;
@@ -1550,17 +2517,31 @@ const server = createServer(async (req, res) => {
1550
2517
  return;
1551
2518
  }
1552
2519
 
2520
+ if (url.pathname === "/api/action-feedback" && req.method === "POST") {
2521
+ const body = await readJsonBody(req);
2522
+ const tab = getRequestedTab(req, url, body);
2523
+ const response = await handleActionFeedback(tab, body);
2524
+ sendJson(res, response.success === false ? 400 : 200, response);
2525
+ return;
2526
+ }
2527
+
1553
2528
  if (url.pathname === "/api/prompt" && req.method === "POST") {
1554
2529
  const body = await readJsonBody(req);
1555
2530
  const tab = getRequestedTab(req, url, body);
1556
2531
  const nativeResponse = await handleNativeSlashCommand(tab, body);
1557
2532
  if (nativeResponse) {
1558
- sendJson(res, nativeResponse.success === false ? 400 : 200, nativeResponse);
2533
+ sendJson(res, nativeResponse.success === false ? 400 : 200, responseWithTab(nativeResponse, tab));
1559
2534
  return;
1560
2535
  }
1561
2536
  const command = commandFromPost(url.pathname, body);
2537
+ const startsVisibleWork = commandStartsVisibleWork(command);
2538
+ if (startsVisibleWork) {
2539
+ maybeNameTabForConversation(tab, command);
2540
+ markTabWorking(tab);
2541
+ }
1562
2542
  const response = await tab.rpc.send(command);
1563
- sendJson(res, response.success === false ? 400 : 200, response);
2543
+ if (response.success === false && startsVisibleWork) markTabIdle(tab);
2544
+ sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
1564
2545
  return;
1565
2546
  }
1566
2547
 
@@ -1589,7 +2570,18 @@ const server = createServer(async (req, res) => {
1589
2570
  if (payload.type !== "extension_ui_response") payload.type = "extension_ui_response";
1590
2571
  if (!payload.id) throw new Error("id is required");
1591
2572
  await tab.rpc.writeRaw(payload);
1592
- sendJson(res, 200, { ok: true });
2573
+ const resolved = resolvePendingExtensionUiRequest(tab, payload.id);
2574
+ if (resolved) {
2575
+ broadcastTabEvent(tab, {
2576
+ type: "webui_extension_ui_resolved",
2577
+ tabId: tab.id,
2578
+ tabTitle: tab.title,
2579
+ id: String(payload.id),
2580
+ pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
2581
+ tabActivity: tabActivitySnapshot(tab),
2582
+ });
2583
+ }
2584
+ sendJson(res, 200, { ok: true, tab: tabMeta(tab) });
1593
2585
  return;
1594
2586
  }
1595
2587
 
@@ -1598,8 +2590,21 @@ const server = createServer(async (req, res) => {
1598
2590
  const command = commandFromPost(url.pathname, body);
1599
2591
  if (command) {
1600
2592
  const tab = getRequestedTab(req, url, body);
2593
+ if (command.type === "abort") await cancelPendingExtensionUiRequests(tab);
2594
+ const startsVisibleWork = commandStartsVisibleWork(command);
2595
+ if (startsVisibleWork) {
2596
+ maybeNameTabForConversation(tab, command);
2597
+ markTabWorking(tab);
2598
+ }
1601
2599
  const response = await tab.rpc.send(command);
1602
- sendJson(res, response.success === false ? 400 : 200, response);
2600
+ if (response.success === false && startsVisibleWork) markTabIdle(tab);
2601
+ if (response.success !== false && command.type === "new_session") {
2602
+ tab.conversationStarted = false;
2603
+ forgetTabState(tab);
2604
+ rememberTabState(tab, response.data);
2605
+ clearPendingExtensionUiRequests(tab);
2606
+ }
2607
+ sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
1603
2608
  return;
1604
2609
  }
1605
2610
  }
@@ -1627,6 +2632,7 @@ server.listen(options.port, currentHost, () => {
1627
2632
  console.log(`Pi Web UI: http://${urlHost}:${options.port}/`);
1628
2633
  console.log(`Working directory: ${options.cwd}`);
1629
2634
  console.log(`Pi RPC: ${initialTab.rpc.displayCommand}`);
2635
+ if (restoreTabs.length) console.log(`Restored Web UI tabs: ${initialTabs.length}`);
1630
2636
  if (!isLocalHost(currentHost)) {
1631
2637
  console.warn("WARNING: Web UI has no authentication. Only expose it on trusted networks.");
1632
2638
  }