@firstpick/pi-package-webui 0.1.1 → 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/README.md +44 -17
- package/bin/pi-webui.mjs +1483 -35
- package/index.ts +430 -23
- package/package.json +9 -3
- package/public/app.js +3067 -176
- package/public/apple-touch-icon.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/index.html +60 -24
- package/public/manifest.webmanifest +40 -0
- package/public/service-worker.js +58 -0
- package/public/styles.css +1348 -125
- package/tests/mobile-static.test.mjs +370 -0
package/bin/pi-webui.mjs
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { access, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { homedir, networkInterfaces } from "node:os";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
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"));
|
|
@@ -17,14 +19,92 @@ const DEFAULT_HOST = "127.0.0.1";
|
|
|
17
19
|
const DEFAULT_PORT = 31415;
|
|
18
20
|
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
19
21
|
const BODY_LIMIT_BYTES = 1024 * 1024;
|
|
22
|
+
const EVENT_HISTORY_LIMIT = 200;
|
|
23
|
+
const EXTENSION_UI_BLOCKING_METHODS = new Set(["select", "confirm", "input", "editor"]);
|
|
24
|
+
const STATUS_RPC_TIMEOUT_MS = 1_800;
|
|
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
|
+
]);
|
|
20
73
|
|
|
21
74
|
const MIME_TYPES = new Map([
|
|
22
75
|
[".html", "text/html; charset=utf-8"],
|
|
23
76
|
[".js", "text/javascript; charset=utf-8"],
|
|
24
77
|
[".css", "text/css; charset=utf-8"],
|
|
25
78
|
[".svg", "image/svg+xml"],
|
|
79
|
+
[".png", "image/png"],
|
|
80
|
+
[".webmanifest", "application/manifest+json; charset=utf-8"],
|
|
26
81
|
]);
|
|
27
82
|
|
|
83
|
+
const NATIVE_SLASH_COMMANDS = [
|
|
84
|
+
{ name: "settings", description: "Open settings menu" },
|
|
85
|
+
{ name: "model", description: "Select model (opens selector UI)" },
|
|
86
|
+
{ name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
|
|
87
|
+
{ name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" },
|
|
88
|
+
{ name: "import", description: "Import and resume a session from a JSONL file" },
|
|
89
|
+
{ name: "share", description: "Share session as a secret GitHub gist" },
|
|
90
|
+
{ name: "copy", description: "Copy last agent message to clipboard" },
|
|
91
|
+
{ name: "name", description: "Set session display name" },
|
|
92
|
+
{ name: "session", description: "Show session info and stats" },
|
|
93
|
+
{ name: "changelog", description: "Show changelog entries" },
|
|
94
|
+
{ name: "hotkeys", description: "Show all keyboard shortcuts" },
|
|
95
|
+
{ name: "fork", description: "Create a new fork from a previous user message" },
|
|
96
|
+
{ name: "clone", description: "Duplicate the current session at the current position" },
|
|
97
|
+
{ name: "tree", description: "Navigate session tree (switch branches)" },
|
|
98
|
+
{ name: "login", description: "Configure provider authentication" },
|
|
99
|
+
{ name: "logout", description: "Remove provider authentication" },
|
|
100
|
+
{ name: "new", description: "Start a new session" },
|
|
101
|
+
{ name: "compact", description: "Manually compact the session context" },
|
|
102
|
+
{ name: "resume", description: "Resume a different session" },
|
|
103
|
+
{ name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes" },
|
|
104
|
+
{ name: "quit", description: "Quit Pi" },
|
|
105
|
+
].map((command) => ({ ...command, source: "native", location: "Pi" }));
|
|
106
|
+
const NATIVE_SLASH_COMMAND_NAMES = new Set(NATIVE_SLASH_COMMANDS.map((command) => command.name));
|
|
107
|
+
|
|
28
108
|
function usage() {
|
|
29
109
|
console.log(`pi-webui ${packageJson.version}
|
|
30
110
|
|
|
@@ -314,12 +394,13 @@ class PiRpcProcess {
|
|
|
314
394
|
}
|
|
315
395
|
}
|
|
316
396
|
|
|
317
|
-
function sendJson(res, statusCode, payload) {
|
|
397
|
+
function sendJson(res, statusCode, payload, headers = {}) {
|
|
318
398
|
const body = JSON.stringify(payload, null, 2);
|
|
319
399
|
res.writeHead(statusCode, {
|
|
320
400
|
"content-type": "application/json; charset=utf-8",
|
|
321
401
|
"cache-control": "no-store",
|
|
322
402
|
"x-content-type-options": "nosniff",
|
|
403
|
+
...headers,
|
|
323
404
|
});
|
|
324
405
|
res.end(body);
|
|
325
406
|
}
|
|
@@ -352,7 +433,175 @@ function sendSse(res, event) {
|
|
|
352
433
|
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
353
434
|
}
|
|
354
435
|
|
|
355
|
-
function
|
|
436
|
+
function rpcSuccess(command, data = {}) {
|
|
437
|
+
return { type: "response", command, success: true, data };
|
|
438
|
+
}
|
|
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
|
+
|
|
515
|
+
function parseSlashCommand(message) {
|
|
516
|
+
const text = String(message || "").trim();
|
|
517
|
+
if (!text.startsWith("/") || text.includes("\n")) return undefined;
|
|
518
|
+
const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
|
|
519
|
+
if (!match) return undefined;
|
|
520
|
+
const name = match[1].toLowerCase();
|
|
521
|
+
if (!NATIVE_SLASH_COMMAND_NAMES.has(name)) return undefined;
|
|
522
|
+
return { name, args: (match[2] || "").trim(), text };
|
|
523
|
+
}
|
|
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
|
+
|
|
573
|
+
const eventHistory = [];
|
|
574
|
+
|
|
575
|
+
function truncateStatusText(value, maxLength = 240) {
|
|
576
|
+
const text = String(value || "").replace(/\s+/g, " ").trim();
|
|
577
|
+
return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function statusEventSummary(event) {
|
|
581
|
+
const summary = {
|
|
582
|
+
timestamp: new Date().toISOString(),
|
|
583
|
+
type: String(event?.type || "event"),
|
|
584
|
+
};
|
|
585
|
+
for (const key of ["id", "tabId", "tabTitle", "previousTabTitle", "titleSource", "pid", "cwd", "code", "signal", "command", "method", "replayed", "queueLength", "pendingMessageCount", "pendingExtensionUiRequestCount"]) {
|
|
586
|
+
if (event?.[key] !== undefined) summary[key] = event[key];
|
|
587
|
+
}
|
|
588
|
+
if (event?.assistantMessageEvent?.type) summary.updateType = event.assistantMessageEvent.type;
|
|
589
|
+
if (event?.message?.role) summary.messageRole = event.message.role;
|
|
590
|
+
if (event?.error) summary.error = truncateStatusText(event.error);
|
|
591
|
+
if (event?.text && summary.type === "pi_stderr") summary.text = truncateStatusText(event.text);
|
|
592
|
+
return summary;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function recordEvent(event) {
|
|
596
|
+
eventHistory.push(statusEventSummary(event));
|
|
597
|
+
if (eventHistory.length > EVENT_HISTORY_LIMIT) eventHistory.splice(0, eventHistory.length - EVENT_HISTORY_LIMIT);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function latestEvents(limit = 40) {
|
|
601
|
+
return eventHistory.slice(-Math.max(0, Math.min(EVENT_HISTORY_LIMIT, limit)));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function runCommand(command, args, { cwd, timeoutMs = 2000, maxOutputLength = 20000 } = {}) {
|
|
356
605
|
return new Promise((resolve) => {
|
|
357
606
|
const child = spawn(command, args, {
|
|
358
607
|
cwd,
|
|
@@ -374,11 +623,11 @@ function runCommand(command, args, { cwd, timeoutMs = 2000 } = {}) {
|
|
|
374
623
|
}, timeoutMs);
|
|
375
624
|
child.stdout.on("data", (chunk) => {
|
|
376
625
|
stdout += String(chunk);
|
|
377
|
-
if (stdout.length >
|
|
626
|
+
if (stdout.length > maxOutputLength) stdout = stdout.slice(-maxOutputLength);
|
|
378
627
|
});
|
|
379
628
|
child.stderr.on("data", (chunk) => {
|
|
380
629
|
stderr += String(chunk);
|
|
381
|
-
if (stderr.length >
|
|
630
|
+
if (stderr.length > maxOutputLength) stderr = stderr.slice(-maxOutputLength);
|
|
382
631
|
});
|
|
383
632
|
child.on("error", (error) => finish({ exitCode: undefined, stdout, stderr: sanitizeError(error), error: sanitizeError(error) }));
|
|
384
633
|
child.on("exit", (exitCode) => finish({ exitCode, stdout, stderr, timedOut: false }));
|
|
@@ -434,6 +683,137 @@ function uniquePathItems(items) {
|
|
|
434
683
|
return result;
|
|
435
684
|
}
|
|
436
685
|
|
|
686
|
+
function normalizePathFastPicks(value) {
|
|
687
|
+
const items = Array.isArray(value) ? value : Array.isArray(value?.picks) ? value.picks : [];
|
|
688
|
+
const seen = new Set();
|
|
689
|
+
const picks = [];
|
|
690
|
+
for (const item of items) {
|
|
691
|
+
const rawCwd = typeof item === "string" ? item : item?.cwd;
|
|
692
|
+
if (!rawCwd) continue;
|
|
693
|
+
let cwd;
|
|
694
|
+
try {
|
|
695
|
+
cwd = path.resolve(options.cwd, expandUserPath(rawCwd));
|
|
696
|
+
} catch {
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
if (!cwd || seen.has(cwd)) continue;
|
|
700
|
+
seen.add(cwd);
|
|
701
|
+
const displayCwd = String(typeof item === "object" && item?.displayCwd ? item.displayCwd : displayPath(cwd)).slice(0, 4096);
|
|
702
|
+
picks.push({ cwd, displayCwd });
|
|
703
|
+
if (picks.length >= FAST_PICK_LIMIT) break;
|
|
704
|
+
}
|
|
705
|
+
return picks;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function fastPicksStorageFile() {
|
|
709
|
+
if (process.env.PI_WEBUI_FAST_PICKS_FILE) return path.resolve(expandUserPath(process.env.PI_WEBUI_FAST_PICKS_FILE));
|
|
710
|
+
const stateRoot = process.env.XDG_STATE_HOME || path.join(homedir(), ".local", "state");
|
|
711
|
+
return path.join(stateRoot, "pi-webui", "fast-picks.json");
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
let pathFastPicksCache = null;
|
|
715
|
+
|
|
716
|
+
async function readPathFastPicks() {
|
|
717
|
+
if (pathFastPicksCache) return pathFastPicksCache;
|
|
718
|
+
try {
|
|
719
|
+
const parsed = JSON.parse(await readFile(fastPicksStorageFile(), "utf8"));
|
|
720
|
+
pathFastPicksCache = normalizePathFastPicks(parsed);
|
|
721
|
+
} catch (error) {
|
|
722
|
+
if (error?.code !== "ENOENT") console.warn(`failed to read path fast picks: ${sanitizeError(error)}`);
|
|
723
|
+
pathFastPicksCache = [];
|
|
724
|
+
}
|
|
725
|
+
return pathFastPicksCache;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function writePathFastPicks(picks) {
|
|
729
|
+
const normalized = normalizePathFastPicks(picks);
|
|
730
|
+
const storageFile = fastPicksStorageFile();
|
|
731
|
+
await mkdir(path.dirname(storageFile), { recursive: true });
|
|
732
|
+
const tmpFile = `${storageFile}.${process.pid}.${Date.now()}.tmp`;
|
|
733
|
+
await writeFile(tmpFile, `${JSON.stringify({ version: 1, picks: normalized }, null, 2)}\n`, { mode: 0o600 });
|
|
734
|
+
await rename(tmpFile, storageFile);
|
|
735
|
+
pathFastPicksCache = normalized;
|
|
736
|
+
return normalized;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function parseCliScopedModelPatterns() {
|
|
740
|
+
for (let index = 0; index < options.piArgs.length; index++) {
|
|
741
|
+
const arg = options.piArgs[index];
|
|
742
|
+
if (arg === "--models" && options.piArgs[index + 1]) return options.piArgs[index + 1].split(",").map((item) => item.trim()).filter(Boolean);
|
|
743
|
+
if (arg.startsWith("--models=")) return arg.slice("--models=".length).split(",").map((item) => item.trim()).filter(Boolean);
|
|
744
|
+
}
|
|
745
|
+
return undefined;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function readJsonFileIfExists(filePath) {
|
|
749
|
+
try {
|
|
750
|
+
return JSON.parse(await readFile(filePath, "utf8"));
|
|
751
|
+
} catch (error) {
|
|
752
|
+
if (error?.code === "ENOENT") return undefined;
|
|
753
|
+
console.warn(`failed to read ${filePath}: ${sanitizeError(error)}`);
|
|
754
|
+
return undefined;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async function configuredScopedModelPatterns(cwd = options.cwd) {
|
|
759
|
+
const cliPatterns = parseCliScopedModelPatterns();
|
|
760
|
+
if (cliPatterns !== undefined) return { patterns: cliPatterns, source: "cli" };
|
|
761
|
+
|
|
762
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR ? path.resolve(expandUserPath(process.env.PI_CODING_AGENT_DIR)) : path.join(homedir(), ".pi", "agent");
|
|
763
|
+
const [globalSettings, projectSettings] = await Promise.all([
|
|
764
|
+
readJsonFileIfExists(path.join(agentDir, "settings.json")),
|
|
765
|
+
readJsonFileIfExists(path.join(cwd, ".pi", "settings.json")),
|
|
766
|
+
]);
|
|
767
|
+
|
|
768
|
+
if (Array.isArray(projectSettings?.enabledModels)) return { patterns: projectSettings.enabledModels, source: "project" };
|
|
769
|
+
if (Array.isArray(globalSettings?.enabledModels)) return { patterns: globalSettings.enabledModels, source: "global" };
|
|
770
|
+
return { patterns: [], source: "none" };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function stripThinkingSuffix(pattern) {
|
|
774
|
+
const text = String(pattern || "").trim();
|
|
775
|
+
const slashIndex = text.indexOf("/");
|
|
776
|
+
const colonIndex = text.lastIndexOf(":");
|
|
777
|
+
if (colonIndex > (slashIndex === -1 ? -1 : slashIndex)) return text.slice(0, colonIndex);
|
|
778
|
+
return text;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function globToRegExp(pattern) {
|
|
782
|
+
const escaped = pattern.replace(/[.+^${}()|\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
783
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function modelMatchesPattern(model, pattern) {
|
|
787
|
+
const clean = stripThinkingSuffix(pattern).toLowerCase();
|
|
788
|
+
if (!clean) return false;
|
|
789
|
+
const full = `${model.provider}/${model.id}`.toLowerCase();
|
|
790
|
+
const id = String(model.id || "").toLowerCase();
|
|
791
|
+
if (/[?*\[]/.test(clean)) return globToRegExp(clean).test(full) || globToRegExp(clean).test(id);
|
|
792
|
+
return full === clean || id === clean || full.includes(clean) || id.includes(clean);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function resolveScopedModelsFromPatterns(patterns, models) {
|
|
796
|
+
const scoped = [];
|
|
797
|
+
const seen = new Set();
|
|
798
|
+
for (const pattern of patterns || []) {
|
|
799
|
+
for (const model of models || []) {
|
|
800
|
+
const key = `${model.provider}/${model.id}`;
|
|
801
|
+
if (seen.has(key) || !modelMatchesPattern(model, pattern)) continue;
|
|
802
|
+
seen.add(key);
|
|
803
|
+
scoped.push(model);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return scoped;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function getScopedModelData(tab) {
|
|
810
|
+
const { patterns, source } = await configuredScopedModelPatterns(tab.cwd);
|
|
811
|
+
if (!patterns.length) return { models: [], patterns, source };
|
|
812
|
+
const response = await tab.rpc.send({ type: "get_available_models" });
|
|
813
|
+
if (response.success === false) throw makeHttpError(400, response.error || "failed to load available models");
|
|
814
|
+
return { models: resolveScopedModelsFromPatterns(patterns, response.data?.models || []), patterns, source };
|
|
815
|
+
}
|
|
816
|
+
|
|
437
817
|
function pathPickerRoots(activeCwd, viewedCwd) {
|
|
438
818
|
const home = process.env.HOME || process.env.USERPROFILE;
|
|
439
819
|
return uniquePathItems([
|
|
@@ -472,6 +852,199 @@ async function getDirectoryPickerData(viewPath, activeCwd) {
|
|
|
472
852
|
};
|
|
473
853
|
}
|
|
474
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
|
+
|
|
475
1048
|
async function getWorkspaceInfo(cwd, startedAt) {
|
|
476
1049
|
const info = {
|
|
477
1050
|
cwd,
|
|
@@ -647,10 +1220,86 @@ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd)
|
|
|
647
1220
|
}
|
|
648
1221
|
}
|
|
649
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
|
+
|
|
650
1299
|
function normalizeStaticPath(urlPath) {
|
|
651
1300
|
if (urlPath === "/") return "index.html";
|
|
652
1301
|
const name = urlPath.startsWith("/") ? urlPath.slice(1) : urlPath;
|
|
653
|
-
if (!["index.html", "app.js", "styles.css"].includes(name)) return undefined;
|
|
1302
|
+
if (!["index.html", "app.js", "styles.css", "favicon.svg", "apple-touch-icon.png", "icon-192.png", "icon-512.png", "manifest.webmanifest", "service-worker.js"].includes(name)) return undefined;
|
|
654
1303
|
return name;
|
|
655
1304
|
}
|
|
656
1305
|
|
|
@@ -753,6 +1402,49 @@ if (options.version) {
|
|
|
753
1402
|
process.exit(0);
|
|
754
1403
|
}
|
|
755
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
|
+
|
|
756
1448
|
function buildPiArgsForTab(tabIndex, title) {
|
|
757
1449
|
const args = ["--mode", "rpc"];
|
|
758
1450
|
if (options.noSession) args.push("--no-session");
|
|
@@ -783,7 +1475,254 @@ async function resolvePiCommand(piArgs) {
|
|
|
783
1475
|
}
|
|
784
1476
|
|
|
785
1477
|
const tabs = new Map();
|
|
1478
|
+
const closedRestorableTabs = [];
|
|
786
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
|
+
}
|
|
787
1726
|
|
|
788
1727
|
function defaultTabTitle(tabIndex) {
|
|
789
1728
|
if (options.name) return tabIndex === 1 ? options.name : `${options.name} ${tabIndex}`;
|
|
@@ -794,25 +1733,42 @@ function attachRpcToTab(tab, rpc) {
|
|
|
794
1733
|
tab.rpcUnsubscribe?.();
|
|
795
1734
|
tab.rpc = rpc;
|
|
796
1735
|
tab.rpcUnsubscribe = rpc.onEvent((event) => {
|
|
797
|
-
|
|
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 };
|
|
1741
|
+
recordEvent(scopedEvent);
|
|
798
1742
|
for (const client of tab.sseClients) sendSse(client, scopedEvent);
|
|
799
1743
|
});
|
|
800
1744
|
}
|
|
801
1745
|
|
|
802
|
-
async function createTab({ title, cwd } = {}) {
|
|
803
|
-
const tabIndex = nextTabIndex
|
|
804
|
-
|
|
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";
|
|
805
1753
|
const tabCwd = cwd ? await resolveCwd(cwd, options.cwd) : options.cwd;
|
|
806
|
-
const id = randomUUID();
|
|
1754
|
+
const id = requestedId && !tabs.has(requestedId) ? requestedId : randomUUID();
|
|
807
1755
|
const piArgs = buildPiArgsForTab(tabIndex, tabTitle);
|
|
1756
|
+
if (sessionFile && !options.noSession) piArgs.push("--session", sessionFile);
|
|
808
1757
|
const piCommand = await resolvePiCommand(piArgs);
|
|
809
1758
|
const rpc = new PiRpcProcess({ ...piCommand, cwd: tabCwd });
|
|
1759
|
+
const createdAt = new Date().toISOString();
|
|
810
1760
|
const tab = {
|
|
811
1761
|
id,
|
|
812
1762
|
index: tabIndex,
|
|
813
1763
|
title: tabTitle,
|
|
1764
|
+
titleSource: resolvedTitleSource,
|
|
1765
|
+
conversationStarted: conversationStarted === true,
|
|
814
1766
|
cwd: tabCwd,
|
|
815
|
-
createdAt
|
|
1767
|
+
createdAt,
|
|
1768
|
+
sessionFile: options.noSession ? undefined : normalizedRestoreString(sessionFile, 4096),
|
|
1769
|
+
lastState: null,
|
|
1770
|
+
activity: createTabActivity(createdAt),
|
|
1771
|
+
pendingExtensionUiRequests: new Map(),
|
|
816
1772
|
rpc: undefined,
|
|
817
1773
|
rpcUnsubscribe: undefined,
|
|
818
1774
|
sseClients: new Set(),
|
|
@@ -821,6 +1777,9 @@ async function createTab({ title, cwd } = {}) {
|
|
|
821
1777
|
attachRpcToTab(tab, rpc);
|
|
822
1778
|
tabs.set(id, tab);
|
|
823
1779
|
rpc.start();
|
|
1780
|
+
if (sessionFile && !options.noSession) {
|
|
1781
|
+
recordEvent({ type: "webui_tab_restored", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd });
|
|
1782
|
+
}
|
|
824
1783
|
return tab;
|
|
825
1784
|
}
|
|
826
1785
|
|
|
@@ -833,13 +1792,18 @@ function tabMeta(tab) {
|
|
|
833
1792
|
id: tab.id,
|
|
834
1793
|
index: tab.index,
|
|
835
1794
|
title: tab.title,
|
|
1795
|
+
titleSource: tab.titleSource || "default",
|
|
1796
|
+
conversationStarted: !!tab.conversationStarted,
|
|
836
1797
|
cwd: tab.cwd,
|
|
1798
|
+
sessionFile: tabRestorableSessionFile(tab),
|
|
837
1799
|
createdAt: tab.createdAt,
|
|
838
1800
|
startedAt: tab.rpc.startedAt,
|
|
839
1801
|
pid: tab.rpc.child?.pid,
|
|
840
1802
|
running: !!tab.rpc.child && tab.rpc.child.exitCode === null,
|
|
841
1803
|
command: tab.rpc.displayCommand,
|
|
842
1804
|
clientCount: tab.sseClients.size,
|
|
1805
|
+
pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
|
|
1806
|
+
activity: tabActivitySnapshot(tab),
|
|
843
1807
|
};
|
|
844
1808
|
}
|
|
845
1809
|
|
|
@@ -847,6 +1811,96 @@ function listTabs() {
|
|
|
847
1811
|
return [...tabs.values()].map(tabMeta);
|
|
848
1812
|
}
|
|
849
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
|
+
|
|
850
1904
|
async function updateTabCwd(id, cwd) {
|
|
851
1905
|
const tab = tabs.get(id);
|
|
852
1906
|
if (!tab) throw makeHttpError(404, `Unknown Pi tab: ${id}`);
|
|
@@ -856,8 +1910,10 @@ async function updateTabCwd(id, cwd) {
|
|
|
856
1910
|
|
|
857
1911
|
const piArgs = buildPiArgsForTab(tab.index, tab.title);
|
|
858
1912
|
const piCommand = await resolvePiCommand(piArgs);
|
|
1913
|
+
const restartingEvent = { type: "webui_tab_restarting", tabId: tab.id, tabTitle: tab.title, cwd: nextCwd };
|
|
1914
|
+
recordEvent(restartingEvent);
|
|
859
1915
|
for (const client of tab.sseClients) {
|
|
860
|
-
sendSse(client,
|
|
1916
|
+
sendSse(client, restartingEvent);
|
|
861
1917
|
}
|
|
862
1918
|
|
|
863
1919
|
const oldRpc = tab.rpc;
|
|
@@ -866,23 +1922,155 @@ async function updateTabCwd(id, cwd) {
|
|
|
866
1922
|
oldRpc.stop();
|
|
867
1923
|
|
|
868
1924
|
tab.cwd = nextCwd;
|
|
1925
|
+
forgetTabState(tab);
|
|
1926
|
+
resetTabActivity(tab);
|
|
1927
|
+
clearPendingExtensionUiRequests(tab);
|
|
869
1928
|
const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
|
|
870
1929
|
attachRpcToTab(tab, rpc);
|
|
871
1930
|
rpc.start();
|
|
872
1931
|
|
|
1932
|
+
const changedEvent = { type: "webui_cwd_changed", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid, tabActivity: tabActivitySnapshot(tab) };
|
|
1933
|
+
recordEvent(changedEvent);
|
|
873
1934
|
for (const client of tab.sseClients) {
|
|
874
|
-
sendSse(client,
|
|
1935
|
+
sendSse(client, changedEvent);
|
|
875
1936
|
}
|
|
876
1937
|
return { tab, changed: true };
|
|
877
1938
|
}
|
|
878
1939
|
|
|
879
|
-
function
|
|
1940
|
+
async function restartTabRpc(tab, reason = "reload") {
|
|
1941
|
+
const state = await tab.rpc.send({ type: "get_state" });
|
|
1942
|
+
if (state.success === false) throw makeHttpError(400, state.error || "Unable to read Pi state before reload");
|
|
1943
|
+
rememberTabState(tab, state.data);
|
|
1944
|
+
if (state.data?.isStreaming) throw makeHttpError(409, "Wait for the current response to finish before reloading.");
|
|
1945
|
+
if (state.data?.isCompacting) throw makeHttpError(409, "Wait for compaction to finish before reloading.");
|
|
1946
|
+
|
|
1947
|
+
const piArgs = buildPiArgsForTab(tab.index, tab.title);
|
|
1948
|
+
if (state.data?.sessionFile && !options.noSession) piArgs.push("--session", state.data.sessionFile);
|
|
1949
|
+
const piCommand = await resolvePiCommand(piArgs);
|
|
1950
|
+
const reloadingEvent = { type: "webui_tab_reloading", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, reason, sessionFile: state.data?.sessionFile };
|
|
1951
|
+
recordEvent(reloadingEvent);
|
|
1952
|
+
for (const client of tab.sseClients) sendSse(client, reloadingEvent);
|
|
1953
|
+
|
|
1954
|
+
const oldRpc = tab.rpc;
|
|
1955
|
+
tab.rpcUnsubscribe?.();
|
|
1956
|
+
tab.rpcUnsubscribe = undefined;
|
|
1957
|
+
oldRpc.stop();
|
|
1958
|
+
|
|
1959
|
+
resetTabActivity(tab);
|
|
1960
|
+
clearPendingExtensionUiRequests(tab);
|
|
1961
|
+
const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
|
|
1962
|
+
attachRpcToTab(tab, rpc);
|
|
1963
|
+
rpc.start();
|
|
1964
|
+
|
|
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) };
|
|
1966
|
+
recordEvent(reloadedEvent);
|
|
1967
|
+
for (const client of tab.sseClients) sendSse(client, reloadedEvent);
|
|
1968
|
+
return tab;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
async function getCommandData(tab) {
|
|
1972
|
+
const response = await tab.rpc.send({ type: "get_commands" });
|
|
1973
|
+
if (response.success === false) throw makeHttpError(400, response.error || "failed to load commands");
|
|
1974
|
+
return { commands: [...NATIVE_SLASH_COMMANDS, ...(response.data?.commands || [])] };
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
function formatSessionOutput(tab, state, stats) {
|
|
1978
|
+
return [
|
|
1979
|
+
`Session: ${state.sessionName || state.sessionId || "unknown"}`,
|
|
1980
|
+
`Tab: ${tab.title}`,
|
|
1981
|
+
`CWD: ${tab.cwd}`,
|
|
1982
|
+
`Model: ${state.model ? `${state.model.provider}/${state.model.id}` : "none"}`,
|
|
1983
|
+
`Thinking: ${state.thinkingLevel || "unknown"}`,
|
|
1984
|
+
`Status: ${state.isStreaming ? "running" : state.isCompacting ? "compacting" : "idle"}`,
|
|
1985
|
+
`Messages: ${state.messageCount ?? "?"}`,
|
|
1986
|
+
`Queue: ${state.pendingMessageCount ?? 0}`,
|
|
1987
|
+
`Session file: ${state.sessionFile || "none"}`,
|
|
1988
|
+
stats ? `Tokens: input ${stats.tokens?.input ?? 0}, output ${stats.tokens?.output ?? 0}, cache read ${stats.tokens?.cacheRead ?? 0}` : undefined,
|
|
1989
|
+
stats?.cost !== undefined ? `Cost: ${stats.cost}` : undefined,
|
|
1990
|
+
].filter(Boolean).join("\n");
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
function webuiHotkeysOutput() {
|
|
1994
|
+
return [
|
|
1995
|
+
"Web UI hotkeys:",
|
|
1996
|
+
"Enter: send on desktop; newline on mobile",
|
|
1997
|
+
"Ctrl/Cmd+Enter: send from textarea",
|
|
1998
|
+
"Tab: accept slash-command or @path suggestion",
|
|
1999
|
+
"Arrow up/down: move through slash-command or @path suggestions",
|
|
2000
|
+
"Escape: close actions, tabs, model picker, or mobile drawer",
|
|
2001
|
+
"Mobile: Send button submits; Return inserts a newline",
|
|
2002
|
+
].join("\n");
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
async function handleNativeSlashCommand(tab, body) {
|
|
2006
|
+
const parsed = parseSlashCommand(body.message);
|
|
2007
|
+
if (!parsed) return undefined;
|
|
2008
|
+
|
|
2009
|
+
switch (parsed.name) {
|
|
2010
|
+
case "reload": {
|
|
2011
|
+
const reloaded = await restartTabRpc(tab, "slash-command");
|
|
2012
|
+
return rpcSuccess("native_slash_command", { command: "reload", tab: tabMeta(reloaded), message: "Reloaded keybindings, extensions, skills, prompts, and themes." });
|
|
2013
|
+
}
|
|
2014
|
+
case "new": {
|
|
2015
|
+
const response = await tab.rpc.send({ type: "new_session" });
|
|
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 });
|
|
2019
|
+
}
|
|
2020
|
+
case "compact": {
|
|
2021
|
+
const response = await tab.rpc.send(parsed.args ? { type: "compact", customInstructions: parsed.args } : { type: "compact" });
|
|
2022
|
+
return response.success === false ? response : rpcSuccess("native_slash_command", { command: "compact", message: "Compaction finished.", result: response.data });
|
|
2023
|
+
}
|
|
2024
|
+
case "name": {
|
|
2025
|
+
if (!parsed.args) throw makeHttpError(400, "Usage: /name <session name>");
|
|
2026
|
+
const response = await tab.rpc.send({ type: "set_session_name", name: 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}` });
|
|
2030
|
+
}
|
|
2031
|
+
case "session": {
|
|
2032
|
+
const [state, stats] = await Promise.all([
|
|
2033
|
+
tab.rpc.send({ type: "get_state" }),
|
|
2034
|
+
tab.rpc.send({ type: "get_session_stats" }).catch((error) => ({ success: false, error: sanitizeError(error) })),
|
|
2035
|
+
]);
|
|
2036
|
+
if (state.success === false) return state;
|
|
2037
|
+
return rpcSuccess("native_slash_command", { command: "session", message: formatSessionOutput(tab, state.data || {}, stats.success === false ? null : stats.data) });
|
|
2038
|
+
}
|
|
2039
|
+
case "copy": {
|
|
2040
|
+
const response = await tab.rpc.send({ type: "get_last_assistant_text" });
|
|
2041
|
+
if (response.success === false) return response;
|
|
2042
|
+
const text = String(response.data?.text || "");
|
|
2043
|
+
if (!text.trim()) throw makeHttpError(400, "No assistant message to copy.");
|
|
2044
|
+
return rpcSuccess("native_slash_command", { command: "copy", message: "Copied the last assistant message.", copyText: text });
|
|
2045
|
+
}
|
|
2046
|
+
case "hotkeys": {
|
|
2047
|
+
return rpcSuccess("native_slash_command", { command: "hotkeys", message: webuiHotkeysOutput() });
|
|
2048
|
+
}
|
|
2049
|
+
case "clone": {
|
|
2050
|
+
const response = await tab.rpc.send({ type: "clone" });
|
|
2051
|
+
return response.success === false ? response : rpcSuccess("native_slash_command", { command: "clone", message: "Cloned the current session.", result: response.data });
|
|
2052
|
+
}
|
|
2053
|
+
default:
|
|
2054
|
+
throw makeHttpError(400, `/${parsed.name} is a native Pi TUI command, but this Web UI cannot run that interactive command yet.`);
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
async function closeTab(id) {
|
|
880
2059
|
const tab = tabs.get(id);
|
|
881
2060
|
if (!tab) throw makeHttpError(404, `Unknown Pi tab: ${id}`);
|
|
882
2061
|
if (tabs.size <= 1) throw makeHttpError(400, "Cannot close the last Pi tab");
|
|
883
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
|
+
|
|
2070
|
+
const closingEvent = { type: "webui_tab_closing", tabId: tab.id, tabTitle: tab.title };
|
|
2071
|
+
recordEvent(closingEvent);
|
|
884
2072
|
for (const client of tab.sseClients) {
|
|
885
|
-
sendSse(client,
|
|
2073
|
+
sendSse(client, closingEvent);
|
|
886
2074
|
client.end();
|
|
887
2075
|
}
|
|
888
2076
|
tab.sseClients.clear();
|
|
@@ -910,9 +2098,27 @@ function getRequestedTab(req, url, body = {}) {
|
|
|
910
2098
|
return tab;
|
|
911
2099
|
}
|
|
912
2100
|
|
|
913
|
-
|
|
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
|
+
|
|
2116
|
+
const serverStartedAt = new Date().toISOString();
|
|
2117
|
+
const initialTabs = await createInitialTabs();
|
|
2118
|
+
const initialTab = initialTabs[0];
|
|
914
2119
|
let currentHost = options.host;
|
|
915
2120
|
let networkRebindInProgress = false;
|
|
2121
|
+
let networkRebindTargetHost = null;
|
|
916
2122
|
|
|
917
2123
|
function localNetworkAddresses() {
|
|
918
2124
|
const addresses = [];
|
|
@@ -927,10 +2133,14 @@ function localNetworkAddresses() {
|
|
|
927
2133
|
|
|
928
2134
|
function networkStatus() {
|
|
929
2135
|
const open = !isLocalHost(currentHost);
|
|
2136
|
+
const targetHost = networkRebindTargetHost || currentHost;
|
|
2137
|
+
const opening = networkRebindInProgress && !isLocalHost(targetHost);
|
|
2138
|
+
const closing = networkRebindInProgress && isLocalHost(targetHost);
|
|
930
2139
|
const networkUrls = open ? localNetworkAddresses().map((address) => `http://${address}:${options.port}/`) : [];
|
|
931
2140
|
return {
|
|
932
2141
|
open,
|
|
933
|
-
opening
|
|
2142
|
+
opening,
|
|
2143
|
+
closing,
|
|
934
2144
|
host: currentHost,
|
|
935
2145
|
port: options.port,
|
|
936
2146
|
localUrl: `http://127.0.0.1:${options.port}/`,
|
|
@@ -940,8 +2150,18 @@ function networkStatus() {
|
|
|
940
2150
|
|
|
941
2151
|
function closeSseClientsForRebind(nextHost) {
|
|
942
2152
|
for (const tab of tabs.values()) {
|
|
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
|
+
};
|
|
2162
|
+
recordEvent(rebindEvent);
|
|
943
2163
|
for (const client of tab.sseClients) {
|
|
944
|
-
sendSse(client,
|
|
2164
|
+
sendSse(client, rebindEvent);
|
|
945
2165
|
client.end();
|
|
946
2166
|
}
|
|
947
2167
|
tab.sseClients.clear();
|
|
@@ -954,10 +2174,18 @@ function closeServerListener() {
|
|
|
954
2174
|
resolve();
|
|
955
2175
|
return;
|
|
956
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?.();
|
|
957
2183
|
server.close((error) => {
|
|
2184
|
+
clearTimeout(forceCloseTimer);
|
|
958
2185
|
if (error) reject(error);
|
|
959
2186
|
else resolve();
|
|
960
2187
|
});
|
|
2188
|
+
server.closeIdleConnections?.();
|
|
961
2189
|
});
|
|
962
2190
|
}
|
|
963
2191
|
|
|
@@ -986,6 +2214,7 @@ async function openToLocalNetwork() {
|
|
|
986
2214
|
if (!isLocalHost(currentHost) || networkRebindInProgress) return networkStatus();
|
|
987
2215
|
|
|
988
2216
|
networkRebindInProgress = true;
|
|
2217
|
+
networkRebindTargetHost = nextHost;
|
|
989
2218
|
closeSseClientsForRebind(nextHost);
|
|
990
2219
|
const previousHost = currentHost;
|
|
991
2220
|
try {
|
|
@@ -1006,7 +2235,115 @@ async function openToLocalNetwork() {
|
|
|
1006
2235
|
throw error;
|
|
1007
2236
|
} finally {
|
|
1008
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;
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
async function safeRpcData(tab, command, timeoutMs = STATUS_RPC_TIMEOUT_MS) {
|
|
2273
|
+
try {
|
|
2274
|
+
const response = await tab.rpc.send(command, timeoutMs);
|
|
2275
|
+
if (response?.success === false) return { ok: false, error: response.error || `${command.type} failed` };
|
|
2276
|
+
if (command?.type === "get_state") rememberTabState(tab, response?.data);
|
|
2277
|
+
return { ok: true, data: response?.data ?? null };
|
|
2278
|
+
} catch (error) {
|
|
2279
|
+
return { ok: false, error: sanitizeError(error) };
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
function providerList(models) {
|
|
2284
|
+
const providers = new Set();
|
|
2285
|
+
for (const model of Array.isArray(models) ? models : []) {
|
|
2286
|
+
if (model?.provider) providers.add(String(model.provider));
|
|
2287
|
+
}
|
|
2288
|
+
return [...providers].sort();
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
async function tabStatusDetails(tab) {
|
|
2292
|
+
const [stateResult, modelsResult, statsResult, workspaceResult] = await Promise.all([
|
|
2293
|
+
safeRpcData(tab, { type: "get_state" }),
|
|
2294
|
+
safeRpcData(tab, { type: "get_available_models" }),
|
|
2295
|
+
safeRpcData(tab, { type: "get_session_stats" }),
|
|
2296
|
+
getWorkspaceInfo(tab.cwd, tab.rpc.startedAt).then((data) => ({ ok: true, data })).catch((error) => ({ ok: false, error: sanitizeError(error) })),
|
|
2297
|
+
]);
|
|
2298
|
+
const models = modelsResult.ok ? modelsResult.data?.models || [] : [];
|
|
2299
|
+
const stateData = stateResult.ok ? stateResult.data : tab.lastState || null;
|
|
2300
|
+
return {
|
|
2301
|
+
...tabMeta(tab),
|
|
2302
|
+
state: stateData,
|
|
2303
|
+
stateError: stateResult.ok ? undefined : stateResult.error,
|
|
2304
|
+
stats: statsResult.ok ? statsResult.data : null,
|
|
2305
|
+
statsError: statsResult.ok ? undefined : statsResult.error,
|
|
2306
|
+
workspace: workspaceResult.ok ? workspaceResult.data : null,
|
|
2307
|
+
workspaceError: workspaceResult.ok ? undefined : workspaceResult.error,
|
|
2308
|
+
pendingExtensionUiRequests: pendingExtensionUiRequestSummaries(tab),
|
|
2309
|
+
models: {
|
|
2310
|
+
count: models.length,
|
|
2311
|
+
providers: providerList(models),
|
|
2312
|
+
error: modelsResult.ok ? undefined : modelsResult.error,
|
|
2313
|
+
},
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
async function webuiStatus({ detailed = false, eventLimit = 40 } = {}) {
|
|
2318
|
+
const tab = firstTab();
|
|
2319
|
+
const network = networkStatus();
|
|
2320
|
+
const statusTabs = listTabs();
|
|
2321
|
+
const data = {
|
|
2322
|
+
online: true,
|
|
2323
|
+
webuiVersion: packageJson.version,
|
|
2324
|
+
webuiPid: process.pid,
|
|
2325
|
+
startedAt: serverStartedAt,
|
|
2326
|
+
cwd: options.cwd,
|
|
2327
|
+
boundHost: currentHost,
|
|
2328
|
+
port: options.port,
|
|
2329
|
+
pageUrl: network.localUrl,
|
|
2330
|
+
boundUrl: `http://${formatUrlHost(currentHost)}:${options.port}/`,
|
|
2331
|
+
network,
|
|
2332
|
+
piPid: tab?.rpc.child?.pid,
|
|
2333
|
+
piRunning: !!tab?.rpc.child && tab.rpc.child.exitCode === null,
|
|
2334
|
+
tabs: statusTabs,
|
|
2335
|
+
restorableTabs: mergeRestorableTabDescriptors(statusTabs, closedRestorableTabs),
|
|
2336
|
+
};
|
|
2337
|
+
|
|
2338
|
+
if (detailed) {
|
|
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();
|
|
2343
|
+
data.events = latestEvents(eventLimit);
|
|
1009
2344
|
}
|
|
2345
|
+
|
|
2346
|
+
return data;
|
|
1010
2347
|
}
|
|
1011
2348
|
|
|
1012
2349
|
const server = createServer(async (req, res) => {
|
|
@@ -1014,7 +2351,7 @@ const server = createServer(async (req, res) => {
|
|
|
1014
2351
|
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
1015
2352
|
|
|
1016
2353
|
if (url.pathname === "/api/tabs" && req.method === "GET") {
|
|
1017
|
-
sendJson(res, 200, { ok: true, data: { tabs:
|
|
2354
|
+
sendJson(res, 200, { ok: true, data: { tabs: await listTabsWithReconciledActivity() } });
|
|
1018
2355
|
return;
|
|
1019
2356
|
}
|
|
1020
2357
|
|
|
@@ -1035,7 +2372,7 @@ const server = createServer(async (req, res) => {
|
|
|
1035
2372
|
|
|
1036
2373
|
if (url.pathname.startsWith("/api/tabs/") && req.method === "DELETE") {
|
|
1037
2374
|
const id = decodeURIComponent(url.pathname.slice("/api/tabs/".length));
|
|
1038
|
-
closeTab(id);
|
|
2375
|
+
await closeTab(id);
|
|
1039
2376
|
sendJson(res, 200, { ok: true, data: { tabs: listTabs(), activeTabId: firstTab()?.id || null } });
|
|
1040
2377
|
return;
|
|
1041
2378
|
}
|
|
@@ -1058,7 +2395,10 @@ const server = createServer(async (req, res) => {
|
|
|
1058
2395
|
pid: tab.rpc.child?.pid,
|
|
1059
2396
|
cwd: tab.cwd,
|
|
1060
2397
|
startedAt: tab.rpc.startedAt,
|
|
2398
|
+
tabActivity: tabActivitySnapshot(tab),
|
|
2399
|
+
pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
|
|
1061
2400
|
});
|
|
2401
|
+
replayPendingExtensionUiRequests(tab, res);
|
|
1062
2402
|
const keepAlive = setInterval(() => res.write(": keepalive\n\n"), 15000);
|
|
1063
2403
|
req.on("close", () => {
|
|
1064
2404
|
clearInterval(keepAlive);
|
|
@@ -1068,20 +2408,34 @@ const server = createServer(async (req, res) => {
|
|
|
1068
2408
|
}
|
|
1069
2409
|
|
|
1070
2410
|
if (url.pathname === "/api/health" && req.method === "GET") {
|
|
1071
|
-
const
|
|
2411
|
+
const status = await webuiStatus();
|
|
1072
2412
|
sendJson(res, 200, {
|
|
1073
2413
|
ok: true,
|
|
1074
|
-
webuiVersion:
|
|
1075
|
-
webuiPid:
|
|
1076
|
-
piPid:
|
|
1077
|
-
piRunning:
|
|
1078
|
-
cwd:
|
|
1079
|
-
network:
|
|
1080
|
-
tabs:
|
|
2414
|
+
webuiVersion: status.webuiVersion,
|
|
2415
|
+
webuiPid: status.webuiPid,
|
|
2416
|
+
piPid: status.piPid,
|
|
2417
|
+
piRunning: status.piRunning,
|
|
2418
|
+
cwd: status.cwd,
|
|
2419
|
+
network: status.network,
|
|
2420
|
+
tabs: status.tabs,
|
|
2421
|
+
restorableTabs: status.restorableTabs,
|
|
1081
2422
|
});
|
|
1082
2423
|
return;
|
|
1083
2424
|
}
|
|
1084
2425
|
|
|
2426
|
+
if (url.pathname === "/api/webui-status" && req.method === "GET") {
|
|
2427
|
+
const detailed = ["1", "true", "yes", "detailed"].includes(String(url.searchParams.get("detailed") || "").toLowerCase());
|
|
2428
|
+
const parsedEventLimit = Number.parseInt(url.searchParams.get("events") || "40", 10);
|
|
2429
|
+
const eventLimit = Number.isFinite(parsedEventLimit) ? parsedEventLimit : 40;
|
|
2430
|
+
sendJson(res, 200, { ok: true, data: await webuiStatus({ detailed, eventLimit }) });
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
if (url.pathname === "/api/themes" && req.method === "GET") {
|
|
2435
|
+
sendJson(res, 200, { ok: true, data: await readBundledThemes() });
|
|
2436
|
+
return;
|
|
2437
|
+
}
|
|
2438
|
+
|
|
1085
2439
|
if (url.pathname === "/api/network" && req.method === "GET") {
|
|
1086
2440
|
sendJson(res, 200, { ok: true, data: networkStatus() });
|
|
1087
2441
|
return;
|
|
@@ -1090,9 +2444,20 @@ const server = createServer(async (req, res) => {
|
|
|
1090
2444
|
if (url.pathname === "/api/network/open" && req.method === "POST") {
|
|
1091
2445
|
if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Opening to the network is only allowed from localhost");
|
|
1092
2446
|
const before = networkStatus();
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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();
|
|
1096
2461
|
}
|
|
1097
2462
|
return;
|
|
1098
2463
|
}
|
|
@@ -1122,6 +2487,64 @@ const server = createServer(async (req, res) => {
|
|
|
1122
2487
|
return;
|
|
1123
2488
|
}
|
|
1124
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
|
+
|
|
2496
|
+
if (url.pathname === "/api/path-fast-picks" && req.method === "GET") {
|
|
2497
|
+
sendJson(res, 200, { ok: true, data: { picks: await readPathFastPicks() } });
|
|
2498
|
+
return;
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
if (url.pathname === "/api/path-fast-picks" && req.method === "POST") {
|
|
2502
|
+
const body = await readJsonBody(req);
|
|
2503
|
+
const picks = await writePathFastPicks(body.picks ?? body);
|
|
2504
|
+
sendJson(res, 200, { ok: true, data: { picks } });
|
|
2505
|
+
return;
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
if (url.pathname === "/api/scoped-models" && req.method === "GET") {
|
|
2509
|
+
const tab = getRequestedTab(req, url);
|
|
2510
|
+
sendJson(res, 200, { ok: true, data: await getScopedModelData(tab) });
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
if (url.pathname === "/api/commands" && req.method === "GET") {
|
|
2515
|
+
const tab = getRequestedTab(req, url);
|
|
2516
|
+
sendJson(res, 200, { type: "response", command: "get_commands", success: true, data: await getCommandData(tab) });
|
|
2517
|
+
return;
|
|
2518
|
+
}
|
|
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
|
+
|
|
2528
|
+
if (url.pathname === "/api/prompt" && req.method === "POST") {
|
|
2529
|
+
const body = await readJsonBody(req);
|
|
2530
|
+
const tab = getRequestedTab(req, url, body);
|
|
2531
|
+
const nativeResponse = await handleNativeSlashCommand(tab, body);
|
|
2532
|
+
if (nativeResponse) {
|
|
2533
|
+
sendJson(res, nativeResponse.success === false ? 400 : 200, responseWithTab(nativeResponse, tab));
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2536
|
+
const command = commandFromPost(url.pathname, body);
|
|
2537
|
+
const startsVisibleWork = commandStartsVisibleWork(command);
|
|
2538
|
+
if (startsVisibleWork) {
|
|
2539
|
+
maybeNameTabForConversation(tab, command);
|
|
2540
|
+
markTabWorking(tab);
|
|
2541
|
+
}
|
|
2542
|
+
const response = await tab.rpc.send(command);
|
|
2543
|
+
if (response.success === false && startsVisibleWork) markTabIdle(tab);
|
|
2544
|
+
sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
2547
|
+
|
|
1125
2548
|
if (url.pathname.startsWith("/api/git-workflow/")) {
|
|
1126
2549
|
const body = req.method === "POST" ? await readJsonBody(req) : {};
|
|
1127
2550
|
const tab = getRequestedTab(req, url, body);
|
|
@@ -1147,7 +2570,18 @@ const server = createServer(async (req, res) => {
|
|
|
1147
2570
|
if (payload.type !== "extension_ui_response") payload.type = "extension_ui_response";
|
|
1148
2571
|
if (!payload.id) throw new Error("id is required");
|
|
1149
2572
|
await tab.rpc.writeRaw(payload);
|
|
1150
|
-
|
|
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) });
|
|
1151
2585
|
return;
|
|
1152
2586
|
}
|
|
1153
2587
|
|
|
@@ -1156,8 +2590,21 @@ const server = createServer(async (req, res) => {
|
|
|
1156
2590
|
const command = commandFromPost(url.pathname, body);
|
|
1157
2591
|
if (command) {
|
|
1158
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
|
+
}
|
|
1159
2599
|
const response = await tab.rpc.send(command);
|
|
1160
|
-
|
|
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));
|
|
1161
2608
|
return;
|
|
1162
2609
|
}
|
|
1163
2610
|
}
|
|
@@ -1185,6 +2632,7 @@ server.listen(options.port, currentHost, () => {
|
|
|
1185
2632
|
console.log(`Pi Web UI: http://${urlHost}:${options.port}/`);
|
|
1186
2633
|
console.log(`Working directory: ${options.cwd}`);
|
|
1187
2634
|
console.log(`Pi RPC: ${initialTab.rpc.displayCommand}`);
|
|
2635
|
+
if (restoreTabs.length) console.log(`Restored Web UI tabs: ${initialTabs.length}`);
|
|
1188
2636
|
if (!isLocalHost(currentHost)) {
|
|
1189
2637
|
console.warn("WARNING: Web UI has no authentication. Only expose it on trusted networks.");
|
|
1190
2638
|
}
|