@firstpick/pi-package-webui 0.1.2 → 0.1.4
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/README.md +18 -9
- package/bin/pi-webui.mjs +1039 -33
- package/index.ts +136 -29
- package/package.json +3 -2
- package/public/app.js +3025 -137
- package/public/index.html +43 -3
- package/public/service-worker.js +13 -1
- package/public/styles.css +1031 -131
- package/tests/mobile-static.test.mjs +260 -2
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 >
|
|
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 >
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 = {
|
|
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:
|
|
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:
|
|
2334
|
+
tabs: statusTabs,
|
|
2335
|
+
restorableTabs: mergeRestorableTabDescriptors(statusTabs, closedRestorableTabs),
|
|
1398
2336
|
};
|
|
1399
2337
|
|
|
1400
2338
|
if (detailed) {
|
|
1401
|
-
|
|
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:
|
|
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
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|