@firstpick/pi-package-webui 0.1.5 → 0.1.7
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 +9 -5
- package/bin/pi-webui.mjs +425 -13
- package/index.ts +82 -10
- package/package.json +1 -1
- package/public/app.js +2853 -234
- package/public/catppuccin-mocha-background.png +0 -0
- package/public/index.html +165 -54
- package/public/matrix-background.webp +0 -0
- package/public/service-worker.js +3 -1
- package/public/styles.css +695 -16
- package/tests/mobile-static.test.mjs +155 -30
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Local browser companion for [Pi coding agent](https://www.npmjs.com/package/@ear
|
|
|
5
5
|
This package provides:
|
|
6
6
|
|
|
7
7
|
- `pi-webui`: a local HTTP/SSE server that starts `pi --mode rpc`, serves the static browser UI, and proxies browser actions to Pi RPC commands.
|
|
8
|
-
- `/webui-start
|
|
8
|
+
- `/webui-start`: a Pi slash command that launches `pi-webui` for the current Pi working directory and opens the browser.
|
|
9
9
|
- `/webui-status`: a Pi slash command that reports the Web UI URL, online state, network exposure, and optional detailed runtime info.
|
|
10
10
|
- A no-build web app in `public/` with no runtime frontend dependencies.
|
|
11
11
|
|
|
@@ -35,7 +35,7 @@ Optional companions:
|
|
|
35
35
|
|
|
36
36
|
## Quick start
|
|
37
37
|
|
|
38
|
-
Install the package from npm into Pi, then restart Pi so `/webui-start`
|
|
38
|
+
Install the package from npm into Pi, then restart Pi so `/webui-start` and `/webui-status` are loaded:
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
41
|
pi install npm:@firstpick/pi-package-webui
|
|
@@ -70,12 +70,14 @@ pi-webui --cwd /path/to/project
|
|
|
70
70
|
- Per-tab activity indicators for idle, working, blocked, and completed unseen work, with browser notifications when a tab needs an extension UI response and an optional side-panel toggle for agent-done notifications
|
|
71
71
|
- Live assistant text streaming, including streamed thinking blocks when exposed by the provider
|
|
72
72
|
- Prompt, steer, follow-up, abort, new session, and manual compact controls
|
|
73
|
+
- Attachment button plus drag/drop and clipboard paste for images, documents, video, audio, and other files; uploaded files are saved to a server temp path and supported images are also sent through Pi RPC image attachments
|
|
73
74
|
- Busy-session behavior selector for follow-up vs steer
|
|
74
75
|
- Model and thinking-level controls
|
|
76
|
+
- Browser-native selector dialogs for native slash commands such as `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, and `/tree`; `/login`/`/logout` currently show non-secret guidance rather than accepting credentials in the browser
|
|
75
77
|
- Slash-command autocomplete while typing `/...`
|
|
76
78
|
- `@` file/path references with live suggestions from the active tab cwd
|
|
77
79
|
- Tool, process, compaction, queue, and extension event log
|
|
78
|
-
- Collapsible side panel with session state, queue, available commands, events, local-network exposure status/control, and a theme picker
|
|
80
|
+
- Collapsible side panel with independently collapsible sections for controls, optional features, session state, queue, available commands, events, local-network exposure status/control, and a theme picker
|
|
79
81
|
- Pi-style footer with token, cache, estimated Pi-context tokens, speed, cost, context usage, clickable per-tab cwd picker with server-persisted fast picks, git branch, changes, runtime, model, and thinking level
|
|
80
82
|
- Guided Git workflow: `git add .`, ask Pi to run `/git-staged-msg`, preview short/long messages, commit with the selected message, and `git push`
|
|
81
83
|
- Hover-expand Publish workflow menu beside Git workflow, currently offering NPM Release and AUR Release
|
|
@@ -120,7 +122,7 @@ Examples:
|
|
|
120
122
|
/webui-start --name browser -- --model anthropic/claude-sonnet-4-5:high
|
|
121
123
|
```
|
|
122
124
|
|
|
123
|
-
If a compatible Web UI is already running on the target URL, `/webui-start
|
|
125
|
+
If a compatible Web UI is already running on the target URL, `/webui-start` captures its currently open terminal tabs, stops that instance, then starts a fresh server and reopens only those open tabs from their session files when available. Tabs you closed in the Web UI stay closed; use `/resume` if you want to reopen an older Pi session manually.
|
|
124
126
|
|
|
125
127
|
Status commands:
|
|
126
128
|
|
|
@@ -200,11 +202,13 @@ The local server exposes:
|
|
|
200
202
|
- `GET /api/directories?tab=<tabId>&path=<path>` for the browser cwd picker
|
|
201
203
|
- `GET /api/path-suggestions?tab=<tabId>&query=<path>` for `@` file/path reference autocomplete in the prompt composer
|
|
202
204
|
- `GET /api/path-fast-picks` and `POST /api/path-fast-picks` for cwd picker fast picks persisted across browser tabs, Pi terminal tabs, and Web UI server restarts
|
|
205
|
+
- `POST /api/attachments` for browser-selected, pasted, or dropped files; files are stored under the OS temp directory and referenced in the prompt, while supported images can also be sent inline via RPC `images`
|
|
203
206
|
- `GET /api/themes` for optional theme data from `@firstpick/pi-themes-bundle` when available
|
|
207
|
+
- `GET /api/fork-messages`, `POST /api/fork`, `POST /api/clone`, `GET /api/sessions`, `POST /api/switch-session`, `GET /api/session-tree`, and `POST /api/tree-navigate` for browser-native native slash selectors
|
|
204
208
|
- localhost-only `POST /api/optional-feature-install` for explicit, warned installation of whitelisted optional feature packages
|
|
205
209
|
- `GET /api/network` and localhost-only `POST /api/network/open` for local-network exposure status/control
|
|
206
210
|
- `GET /api/webui-status?detailed=1` for slash-command status reporting
|
|
207
|
-
- `POST /api/shutdown` for localhost-only graceful restarts from `/webui-start
|
|
211
|
+
- `POST /api/shutdown` for localhost-only graceful restarts from `/webui-start`; restart captures detailed open-tab status first so currently open tabs can be restored with their session files
|
|
208
212
|
- HTTP endpoints for prompt/session/model/thinking/compact/git actions; tab-scoped calls use `?tab=<tabId>`
|
|
209
213
|
- `POST /api/action-feedback?tab=<tabId>` to turn queued action/final-output reactions into a Pi prompt that creates/updates a LEARNING after the run is idle
|
|
210
214
|
- `/api/events?tab=<tabId>` as a per-tab Server-Sent Events stream for Pi RPC events
|
package/bin/pi-webui.mjs
CHANGED
|
@@ -4,10 +4,11 @@ import { randomUUID } from "node:crypto";
|
|
|
4
4
|
import { createServer } from "node:http";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import { access, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
|
7
|
-
import { homedir, networkInterfaces } from "node:os";
|
|
7
|
+
import { homedir, networkInterfaces, tmpdir } from "node:os";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { StringDecoder } from "node:string_decoder";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
11
12
|
|
|
12
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
14
|
const require = createRequire(import.meta.url);
|
|
@@ -19,6 +20,14 @@ const DEFAULT_HOST = "127.0.0.1";
|
|
|
19
20
|
const DEFAULT_PORT = 31415;
|
|
20
21
|
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
21
22
|
const BODY_LIMIT_BYTES = 1024 * 1024;
|
|
23
|
+
const PROMPT_BODY_LIMIT_BYTES = 24 * 1024 * 1024;
|
|
24
|
+
const UPLOAD_BODY_LIMIT_BYTES = 96 * 1024 * 1024;
|
|
25
|
+
const ATTACHMENT_UPLOAD_MAX_FILES = 12;
|
|
26
|
+
const ATTACHMENT_UPLOAD_MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
27
|
+
const ATTACHMENT_UPLOAD_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
28
|
+
const INLINE_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
|
|
29
|
+
const INLINE_IMAGE_TOTAL_MAX_BYTES = 16 * 1024 * 1024;
|
|
30
|
+
const RPC_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
|
|
22
31
|
const EVENT_HISTORY_LIMIT = 200;
|
|
23
32
|
const EXTENSION_UI_BLOCKING_METHODS = new Set(["select", "confirm", "input", "editor"]);
|
|
24
33
|
const STATUS_RPC_TIMEOUT_MS = 1_800;
|
|
@@ -29,6 +38,8 @@ const PATH_SUGGESTION_SCAN_LIMIT = 5000;
|
|
|
29
38
|
const PATH_SUGGESTION_MAX_OUTPUT_LENGTH = 300000;
|
|
30
39
|
const PATH_SUGGESTION_EXCLUDED_DIRS = new Set([".git", "node_modules"]);
|
|
31
40
|
const RESTORE_TAB_LIMIT = 30;
|
|
41
|
+
const SESSION_SELECTOR_LIMIT = 200;
|
|
42
|
+
const TREE_SELECTOR_TEXT_LIMIT = 260;
|
|
32
43
|
const NETWORK_REBIND_DELAY_MS = 100;
|
|
33
44
|
const NETWORK_REBIND_FORCE_CLOSE_MS = 750;
|
|
34
45
|
const AUTO_TAB_TITLE_MAX_LENGTH = 44;
|
|
@@ -77,6 +88,7 @@ const MIME_TYPES = new Map([
|
|
|
77
88
|
[".css", "text/css; charset=utf-8"],
|
|
78
89
|
[".svg", "image/svg+xml"],
|
|
79
90
|
[".png", "image/png"],
|
|
91
|
+
[".webp", "image/webp"],
|
|
80
92
|
[".webmanifest", "application/manifest+json; charset=utf-8"],
|
|
81
93
|
]);
|
|
82
94
|
|
|
@@ -432,12 +444,24 @@ function sendError(res, statusCode, error) {
|
|
|
432
444
|
sendJson(res, statusCode, { ok: false, error: sanitizeError(error) });
|
|
433
445
|
}
|
|
434
446
|
|
|
435
|
-
|
|
447
|
+
function formatBytes(bytes) {
|
|
448
|
+
const value = Number(bytes) || 0;
|
|
449
|
+
if (value < 1024) return `${value} B`;
|
|
450
|
+
const units = ["KB", "MB", "GB"];
|
|
451
|
+
let scaled = value / 1024;
|
|
452
|
+
for (const unit of units) {
|
|
453
|
+
if (scaled < 1024 || unit === units[units.length - 1]) return `${scaled.toFixed(scaled >= 10 ? 1 : 2)} ${unit}`;
|
|
454
|
+
scaled /= 1024;
|
|
455
|
+
}
|
|
456
|
+
return `${value} B`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function readJsonBody(req, { limitBytes = BODY_LIMIT_BYTES } = {}) {
|
|
436
460
|
const chunks = [];
|
|
437
461
|
let size = 0;
|
|
438
462
|
for await (const chunk of req) {
|
|
439
463
|
size += chunk.length;
|
|
440
|
-
if (size >
|
|
464
|
+
if (size > limitBytes) throw makeHttpError(413, `Request body too large (limit ${formatBytes(limitBytes)})`);
|
|
441
465
|
chunks.push(chunk);
|
|
442
466
|
}
|
|
443
467
|
if (chunks.length === 0) return {};
|
|
@@ -1359,7 +1383,7 @@ async function readBundledThemes() {
|
|
|
1359
1383
|
function normalizeStaticPath(urlPath) {
|
|
1360
1384
|
if (urlPath === "/") return "index.html";
|
|
1361
1385
|
const name = urlPath.startsWith("/") ? urlPath.slice(1) : urlPath;
|
|
1362
|
-
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;
|
|
1386
|
+
if (!["index.html", "app.js", "styles.css", "favicon.svg", "apple-touch-icon.png", "icon-192.png", "icon-512.png", "catppuccin-mocha-background.png", "matrix-background.webp", "manifest.webmanifest", "service-worker.js"].includes(name)) return undefined;
|
|
1363
1387
|
return name;
|
|
1364
1388
|
}
|
|
1365
1389
|
|
|
@@ -1380,6 +1404,97 @@ async function serveStatic(req, res, url) {
|
|
|
1380
1404
|
return true;
|
|
1381
1405
|
}
|
|
1382
1406
|
|
|
1407
|
+
function requestBodyLimitForPath(pathname) {
|
|
1408
|
+
if (pathname === "/api/attachments") return UPLOAD_BODY_LIMIT_BYTES;
|
|
1409
|
+
if (["/api/prompt", "/api/steer", "/api/follow-up"].includes(pathname)) return PROMPT_BODY_LIMIT_BYTES;
|
|
1410
|
+
return BODY_LIMIT_BYTES;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function sanitizeUploadFileName(name) {
|
|
1414
|
+
const base = path.basename(String(name || "attachment").replace(/\0/g, ""));
|
|
1415
|
+
const safe = base.replace(/[^A-Za-z0-9._ -]+/g, "_").replace(/\s+/g, " ").trim().slice(0, 180);
|
|
1416
|
+
return safe && safe !== "." && safe !== ".." ? safe : "attachment";
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function normalizeMimeType(value) {
|
|
1420
|
+
const mimeType = String(value || "application/octet-stream").split(";", 1)[0].trim().toLowerCase();
|
|
1421
|
+
return mimeType || "application/octet-stream";
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function stripDataUrlPrefix(data) {
|
|
1425
|
+
const text = String(data || "").trim();
|
|
1426
|
+
if (!text.toLowerCase().startsWith("data:")) return text;
|
|
1427
|
+
const comma = text.indexOf(",");
|
|
1428
|
+
return comma === -1 ? text : text.slice(comma + 1);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function decodeAttachmentData(data) {
|
|
1432
|
+
const base64 = stripDataUrlPrefix(data).replace(/\s+/g, "");
|
|
1433
|
+
if (!base64) throw new Error("attachment data is required");
|
|
1434
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(base64)) throw new Error("attachment data must be base64 encoded");
|
|
1435
|
+
return Buffer.from(base64, "base64");
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
async function saveUploadedAttachments(body) {
|
|
1439
|
+
const rawFiles = Array.isArray(body?.files) ? body.files : [];
|
|
1440
|
+
if (rawFiles.length === 0) throw new Error("files are required");
|
|
1441
|
+
if (rawFiles.length > ATTACHMENT_UPLOAD_MAX_FILES) throw new Error(`attachments are limited to ${ATTACHMENT_UPLOAD_MAX_FILES} files`);
|
|
1442
|
+
|
|
1443
|
+
const decoded = [];
|
|
1444
|
+
let totalBytes = 0;
|
|
1445
|
+
for (const [index, file] of rawFiles.entries()) {
|
|
1446
|
+
const buffer = decodeAttachmentData(file?.data);
|
|
1447
|
+
if (buffer.length === 0) throw new Error(`attachment ${index + 1} is empty`);
|
|
1448
|
+
if (buffer.length > ATTACHMENT_UPLOAD_MAX_FILE_BYTES) throw new Error(`attachment ${index + 1} exceeds ${formatBytes(ATTACHMENT_UPLOAD_MAX_FILE_BYTES)}`);
|
|
1449
|
+
totalBytes += buffer.length;
|
|
1450
|
+
if (totalBytes > ATTACHMENT_UPLOAD_MAX_TOTAL_BYTES) throw new Error(`attachments exceed ${formatBytes(ATTACHMENT_UPLOAD_MAX_TOTAL_BYTES)} total`);
|
|
1451
|
+
decoded.push({
|
|
1452
|
+
id: String(file?.id || `attachment-${index + 1}`).slice(0, 120),
|
|
1453
|
+
name: sanitizeUploadFileName(file?.name),
|
|
1454
|
+
mimeType: normalizeMimeType(file?.mimeType || file?.type),
|
|
1455
|
+
size: buffer.length,
|
|
1456
|
+
buffer,
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const uploadDir = path.join(tmpdir(), "pi-webui-uploads", randomUUID());
|
|
1461
|
+
await mkdir(uploadDir, { recursive: true });
|
|
1462
|
+
const saved = [];
|
|
1463
|
+
for (const [index, file] of decoded.entries()) {
|
|
1464
|
+
const fileName = `${String(index + 1).padStart(2, "0")}-${file.name}`;
|
|
1465
|
+
const filePath = path.join(uploadDir, fileName);
|
|
1466
|
+
await writeFile(filePath, file.buffer);
|
|
1467
|
+
saved.push({ id: file.id, name: file.name, mimeType: file.mimeType, size: file.size, path: filePath });
|
|
1468
|
+
}
|
|
1469
|
+
return { files: saved, uploadDir };
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function normalizeRpcImages(value) {
|
|
1473
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
1474
|
+
if (value.length > ATTACHMENT_UPLOAD_MAX_FILES) throw new Error(`images are limited to ${ATTACHMENT_UPLOAD_MAX_FILES} files`);
|
|
1475
|
+
const images = [];
|
|
1476
|
+
let totalBytes = 0;
|
|
1477
|
+
for (const [index, image] of value.entries()) {
|
|
1478
|
+
const mimeType = normalizeMimeType(image?.mimeType);
|
|
1479
|
+
if (!RPC_IMAGE_MIME_TYPES.has(mimeType)) throw new Error(`image ${index + 1} has unsupported MIME type ${mimeType}`);
|
|
1480
|
+
const data = stripDataUrlPrefix(image?.data).replace(/\s+/g, "");
|
|
1481
|
+
if (!data) throw new Error(`image ${index + 1} data is required`);
|
|
1482
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(data)) throw new Error(`image ${index + 1} data must be base64 encoded`);
|
|
1483
|
+
const approxBytes = Math.floor((data.length * 3) / 4);
|
|
1484
|
+
if (approxBytes > INLINE_IMAGE_MAX_BYTES) throw new Error(`image ${index + 1} exceeds ${formatBytes(INLINE_IMAGE_MAX_BYTES)} inline limit`);
|
|
1485
|
+
totalBytes += approxBytes;
|
|
1486
|
+
if (totalBytes > INLINE_IMAGE_TOTAL_MAX_BYTES) throw new Error(`inline images exceed ${formatBytes(INLINE_IMAGE_TOTAL_MAX_BYTES)} total`);
|
|
1487
|
+
images.push({ type: "image", data, mimeType });
|
|
1488
|
+
}
|
|
1489
|
+
return images.length ? images : undefined;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
function attachImages(command, body) {
|
|
1493
|
+
const images = normalizeRpcImages(body?.images);
|
|
1494
|
+
if (images) command.images = images;
|
|
1495
|
+
return command;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1383
1498
|
function commandFromPost(pathname, body) {
|
|
1384
1499
|
switch (pathname) {
|
|
1385
1500
|
case "/api/prompt": {
|
|
@@ -1389,17 +1504,17 @@ function commandFromPost(pathname, body) {
|
|
|
1389
1504
|
if (body.streamingBehavior === "steer" || body.streamingBehavior === "followUp") {
|
|
1390
1505
|
command.streamingBehavior = body.streamingBehavior;
|
|
1391
1506
|
}
|
|
1392
|
-
return command;
|
|
1507
|
+
return attachImages(command, body);
|
|
1393
1508
|
}
|
|
1394
1509
|
case "/api/steer": {
|
|
1395
1510
|
const message = String(body.message || "").trim();
|
|
1396
1511
|
if (!message) throw new Error("message is required");
|
|
1397
|
-
return { type: "steer", message };
|
|
1512
|
+
return attachImages({ type: "steer", message }, body);
|
|
1398
1513
|
}
|
|
1399
1514
|
case "/api/follow-up": {
|
|
1400
1515
|
const message = String(body.message || "").trim();
|
|
1401
1516
|
if (!message) throw new Error("message is required");
|
|
1402
|
-
return { type: "follow_up", message };
|
|
1517
|
+
return attachImages({ type: "follow_up", message }, body);
|
|
1403
1518
|
}
|
|
1404
1519
|
case "/api/abort":
|
|
1405
1520
|
return { type: "abort" };
|
|
@@ -1418,6 +1533,18 @@ function commandFromPost(pathname, body) {
|
|
|
1418
1533
|
}
|
|
1419
1534
|
return { type: "set_thinking_level", level };
|
|
1420
1535
|
}
|
|
1536
|
+
case "/api/steering-mode": {
|
|
1537
|
+
const mode = String(body.mode || "").trim();
|
|
1538
|
+
if (!["all", "one-at-a-time"].includes(mode)) throw new Error("Invalid steering mode");
|
|
1539
|
+
return { type: "set_steering_mode", mode };
|
|
1540
|
+
}
|
|
1541
|
+
case "/api/follow-up-mode": {
|
|
1542
|
+
const mode = String(body.mode || "").trim();
|
|
1543
|
+
if (!["all", "one-at-a-time"].includes(mode)) throw new Error("Invalid follow-up mode");
|
|
1544
|
+
return { type: "set_follow_up_mode", mode };
|
|
1545
|
+
}
|
|
1546
|
+
case "/api/auto-compaction":
|
|
1547
|
+
return { type: "set_auto_compaction", enabled: body.enabled === true };
|
|
1421
1548
|
case "/api/compact":
|
|
1422
1549
|
return body.customInstructions ? { type: "compact", customInstructions: String(body.customInstructions) } : { type: "compact" };
|
|
1423
1550
|
default:
|
|
@@ -2112,6 +2239,235 @@ async function getCommandData(tab) {
|
|
|
2112
2239
|
}
|
|
2113
2240
|
}
|
|
2114
2241
|
|
|
2242
|
+
function resolveCliPath(value) {
|
|
2243
|
+
const text = String(value || "").trim();
|
|
2244
|
+
if (!text) return "";
|
|
2245
|
+
return path.isAbsolute(text) ? text : path.resolve(options.cwd, text);
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
function resolveTabPath(tab, value) {
|
|
2249
|
+
const text = String(value || "").trim();
|
|
2250
|
+
if (!text) return "";
|
|
2251
|
+
return path.isAbsolute(text) ? text : path.resolve(tab?.cwd || options.cwd, text);
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
function configuredSessionDir() {
|
|
2255
|
+
for (let index = 0; index < options.piArgs.length; index++) {
|
|
2256
|
+
const arg = options.piArgs[index];
|
|
2257
|
+
if (arg === "--session-dir" && options.piArgs[index + 1]) return resolveCliPath(options.piArgs[index + 1]);
|
|
2258
|
+
if (arg.startsWith("--session-dir=")) return resolveCliPath(arg.slice("--session-dir=".length));
|
|
2259
|
+
}
|
|
2260
|
+
return undefined;
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
function requirePersistentSessions() {
|
|
2264
|
+
if (options.noSession) throw makeHttpError(400, "Session selectors are unavailable when Web UI was started with --no-session.");
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
function isoDate(value) {
|
|
2268
|
+
const date = value instanceof Date ? value : new Date(value || 0);
|
|
2269
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
function normalizeSessionInfo(info, currentSessionFile) {
|
|
2273
|
+
const sessionPath = String(info.path || "");
|
|
2274
|
+
return {
|
|
2275
|
+
path: sessionPath,
|
|
2276
|
+
id: String(info.id || ""),
|
|
2277
|
+
name: info.name || undefined,
|
|
2278
|
+
cwd: String(info.cwd || ""),
|
|
2279
|
+
created: isoDate(info.created),
|
|
2280
|
+
modified: isoDate(info.modified),
|
|
2281
|
+
messageCount: Number.isFinite(info.messageCount) ? info.messageCount : 0,
|
|
2282
|
+
firstMessage: truncateStatusText(info.firstMessage || "(no messages)", 220),
|
|
2283
|
+
parentSessionPath: info.parentSessionPath || undefined,
|
|
2284
|
+
current: !!currentSessionFile && path.resolve(sessionPath) === path.resolve(currentSessionFile),
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
async function currentSessionState(tab) {
|
|
2289
|
+
const response = await safeRpcResponse(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
|
|
2290
|
+
if (response.success === false) throw makeHttpError(400, response.error || "failed to load current session state");
|
|
2291
|
+
rememberTabState(tab, response.data);
|
|
2292
|
+
return response.data || {};
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
async function getSessionSelectorData(tab, scope = "current") {
|
|
2296
|
+
requirePersistentSessions();
|
|
2297
|
+
const state = await currentSessionState(tab).catch(() => tab.lastState || {});
|
|
2298
|
+
const sessionDir = configuredSessionDir();
|
|
2299
|
+
const listAll = String(scope || "current").toLowerCase() === "all";
|
|
2300
|
+
const sessions = listAll ? await SessionManager.listAll(sessionDir) : await SessionManager.list(tab.cwd, sessionDir);
|
|
2301
|
+
return {
|
|
2302
|
+
scope: listAll ? "all" : "current",
|
|
2303
|
+
sessionDir: sessionDir || undefined,
|
|
2304
|
+
currentSessionFile: state.sessionFile || tabRestorableSessionFile(tab),
|
|
2305
|
+
sessions: sessions.slice(0, SESSION_SELECTOR_LIMIT).map((info) => normalizeSessionInfo(info, state.sessionFile || tabRestorableSessionFile(tab))),
|
|
2306
|
+
limited: sessions.length > SESSION_SELECTOR_LIMIT,
|
|
2307
|
+
};
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
function extractSessionTextContent(content) {
|
|
2311
|
+
if (typeof content === "string") return content;
|
|
2312
|
+
if (!Array.isArray(content)) return "";
|
|
2313
|
+
return content
|
|
2314
|
+
.map((part) => {
|
|
2315
|
+
if (typeof part === "string") return part;
|
|
2316
|
+
if (part?.type === "text" && typeof part.text === "string") return part.text;
|
|
2317
|
+
if (part?.type === "toolCall") return `[tool call: ${part.toolName || "tool"}]`;
|
|
2318
|
+
if (part?.type === "thinking") return "[thinking]";
|
|
2319
|
+
if (part?.type === "image") return "[image]";
|
|
2320
|
+
return "";
|
|
2321
|
+
})
|
|
2322
|
+
.filter(Boolean)
|
|
2323
|
+
.join(" ");
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
function sessionTreeEntryLabel(entry) {
|
|
2327
|
+
if (!entry || typeof entry !== "object") return "entry";
|
|
2328
|
+
if (entry.type === "message") return entry.message?.role || "message";
|
|
2329
|
+
if (entry.type === "branch_summary") return "branch summary";
|
|
2330
|
+
if (entry.type === "compaction") return "compaction";
|
|
2331
|
+
if (entry.type === "model_change") return "model";
|
|
2332
|
+
if (entry.type === "thinking_level_change") return "thinking";
|
|
2333
|
+
if (entry.type === "custom_message") return entry.customType || "custom";
|
|
2334
|
+
return entry.type || "entry";
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
function sessionTreeEntryText(entry) {
|
|
2338
|
+
if (!entry || typeof entry !== "object") return "";
|
|
2339
|
+
if (entry.type === "message") return extractSessionTextContent(entry.message?.content);
|
|
2340
|
+
if (entry.type === "custom_message") return extractSessionTextContent(entry.content);
|
|
2341
|
+
if (entry.type === "branch_summary") return entry.summary || "branch summary";
|
|
2342
|
+
if (entry.type === "compaction") return entry.summary || "compaction summary";
|
|
2343
|
+
if (entry.type === "model_change") return [entry.provider, entry.modelId].filter(Boolean).join("/");
|
|
2344
|
+
if (entry.type === "thinking_level_change") return entry.thinkingLevel || "";
|
|
2345
|
+
return "";
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
function flattenSessionTree(nodes, { depth = 0, leafId, result = [] } = {}) {
|
|
2349
|
+
for (const node of nodes || []) {
|
|
2350
|
+
const entry = node.entry || {};
|
|
2351
|
+
result.push({
|
|
2352
|
+
id: entry.id,
|
|
2353
|
+
parentId: entry.parentId ?? null,
|
|
2354
|
+
depth,
|
|
2355
|
+
type: entry.type || "entry",
|
|
2356
|
+
role: entry.message?.role || undefined,
|
|
2357
|
+
label: node.label || undefined,
|
|
2358
|
+
timestamp: entry.timestamp || undefined,
|
|
2359
|
+
title: sessionTreeEntryLabel(entry),
|
|
2360
|
+
text: truncateStatusText(sessionTreeEntryText(entry), TREE_SELECTOR_TEXT_LIMIT),
|
|
2361
|
+
childCount: Array.isArray(node.children) ? node.children.length : 0,
|
|
2362
|
+
currentLeaf: !!leafId && entry.id === leafId,
|
|
2363
|
+
});
|
|
2364
|
+
flattenSessionTree(node.children || [], { depth: depth + 1, leafId, result });
|
|
2365
|
+
}
|
|
2366
|
+
return result;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
async function getSessionTreeData(tab) {
|
|
2370
|
+
requirePersistentSessions();
|
|
2371
|
+
const state = await currentSessionState(tab).catch(() => tab.lastState || {});
|
|
2372
|
+
const sessionFile = state.sessionFile || tabRestorableSessionFile(tab);
|
|
2373
|
+
if (!sessionFile) throw makeHttpError(400, "No persisted session file is available for /tree.");
|
|
2374
|
+
const manager = SessionManager.open(sessionFile, configuredSessionDir(), tab.cwd);
|
|
2375
|
+
const leafId = manager.getLeafId();
|
|
2376
|
+
return {
|
|
2377
|
+
sessionFile: manager.getSessionFile(),
|
|
2378
|
+
sessionId: manager.getSessionId(),
|
|
2379
|
+
cwd: manager.getCwd(),
|
|
2380
|
+
leafId,
|
|
2381
|
+
nodes: flattenSessionTree(manager.getTree(), { leafId }),
|
|
2382
|
+
};
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
async function getForkMessagesData(tab) {
|
|
2386
|
+
const response = await safeRpcResponse(tab, { type: "get_fork_messages" });
|
|
2387
|
+
if (response.success === false) throw makeHttpError(400, response.error || "failed to load fork points");
|
|
2388
|
+
return { messages: Array.isArray(response.data?.messages) ? response.data.messages : [] };
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
async function requireIdleForSessionAction(tab, actionLabel) {
|
|
2392
|
+
const state = await currentSessionState(tab);
|
|
2393
|
+
if (state.isStreaming || state.isCompacting) throw makeHttpError(409, `Wait for the current agent run or compaction to finish before ${actionLabel}.`);
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
async function runForkCommand(tab, entryId) {
|
|
2397
|
+
await requireIdleForSessionAction(tab, "forking the session");
|
|
2398
|
+
const targetEntryId = String(entryId || "").trim();
|
|
2399
|
+
if (!targetEntryId) throw makeHttpError(400, "entryId is required");
|
|
2400
|
+
const response = await tab.rpc.send({ type: "fork", entryId: targetEntryId });
|
|
2401
|
+
if (response.success === false) return response;
|
|
2402
|
+
const state = await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
|
|
2403
|
+
if (state.ok) rememberTabState(tab, state.data);
|
|
2404
|
+
return rpcSuccess("fork", {
|
|
2405
|
+
message: response.data?.cancelled ? "Fork cancelled." : "Forked the current session.",
|
|
2406
|
+
text: response.data?.text || "",
|
|
2407
|
+
result: response.data,
|
|
2408
|
+
tab: tabMeta(tab),
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
async function runCloneCommand(tab) {
|
|
2413
|
+
await requireIdleForSessionAction(tab, "cloning the session");
|
|
2414
|
+
const response = await tab.rpc.send({ type: "clone" });
|
|
2415
|
+
if (response.success === false) return response;
|
|
2416
|
+
const state = await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
|
|
2417
|
+
if (state.ok) rememberTabState(tab, state.data);
|
|
2418
|
+
return rpcSuccess("clone", {
|
|
2419
|
+
message: response.data?.cancelled ? "Clone cancelled." : "Cloned the current session.",
|
|
2420
|
+
result: response.data,
|
|
2421
|
+
tab: tabMeta(tab),
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
async function switchTabSession(tab, sessionPath) {
|
|
2426
|
+
requirePersistentSessions();
|
|
2427
|
+
await requireIdleForSessionAction(tab, "switching sessions");
|
|
2428
|
+
const targetPath = resolveTabPath(tab, sessionPath);
|
|
2429
|
+
if (!targetPath) throw makeHttpError(400, "sessionPath is required");
|
|
2430
|
+
if (!targetPath.endsWith(".jsonl")) throw makeHttpError(400, "sessionPath must point to a .jsonl session file");
|
|
2431
|
+
const targetStats = await stat(targetPath).catch(() => null);
|
|
2432
|
+
if (!targetStats?.isFile()) throw makeHttpError(404, `Session file not found: ${targetPath}`);
|
|
2433
|
+
const manager = SessionManager.open(targetPath, configuredSessionDir());
|
|
2434
|
+
const response = await tab.rpc.send({ type: "switch_session", sessionPath: manager.getSessionFile() });
|
|
2435
|
+
if (response.success === false) return response;
|
|
2436
|
+
if (!response.data?.cancelled) {
|
|
2437
|
+
tab.cwd = manager.getCwd();
|
|
2438
|
+
const state = await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
|
|
2439
|
+
if (state.ok) rememberTabState(tab, state.data);
|
|
2440
|
+
}
|
|
2441
|
+
return rpcSuccess("switch_session", {
|
|
2442
|
+
message: response.data?.cancelled ? "Resume cancelled." : "Resumed selected session.",
|
|
2443
|
+
result: response.data,
|
|
2444
|
+
tab: tabMeta(tab),
|
|
2445
|
+
});
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
async function navigateSessionTree(tab, body) {
|
|
2449
|
+
requirePersistentSessions();
|
|
2450
|
+
await requireIdleForSessionAction(tab, "navigating the session tree");
|
|
2451
|
+
const entryId = String(body.entryId || body.targetId || "").trim();
|
|
2452
|
+
if (!entryId) throw makeHttpError(400, "entryId is required");
|
|
2453
|
+
const payload = {
|
|
2454
|
+
entryId,
|
|
2455
|
+
summarize: body.summarize === true,
|
|
2456
|
+
customInstructions: typeof body.customInstructions === "string" ? body.customInstructions : undefined,
|
|
2457
|
+
replaceInstructions: body.replaceInstructions === true,
|
|
2458
|
+
label: typeof body.label === "string" ? body.label : undefined,
|
|
2459
|
+
};
|
|
2460
|
+
const response = await tab.rpc.send({ type: "prompt", message: `/webui-tree-navigate ${JSON.stringify(payload)}` });
|
|
2461
|
+
if (response.success === false) return response;
|
|
2462
|
+
const state = await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
|
|
2463
|
+
if (state.ok) rememberTabState(tab, state.data);
|
|
2464
|
+
return rpcSuccess("tree", {
|
|
2465
|
+
message: "Navigated the session tree.",
|
|
2466
|
+
result: response.data,
|
|
2467
|
+
tab: tabMeta(tab),
|
|
2468
|
+
});
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2115
2471
|
function formatSessionOutput(tab, state, stats) {
|
|
2116
2472
|
return [
|
|
2117
2473
|
`Session: ${state.sessionName || state.sessionId || "unknown"}`,
|
|
@@ -2185,8 +2541,8 @@ async function handleNativeSlashCommand(tab, body) {
|
|
|
2185
2541
|
return rpcSuccess("native_slash_command", { command: "hotkeys", message: webuiHotkeysOutput() });
|
|
2186
2542
|
}
|
|
2187
2543
|
case "clone": {
|
|
2188
|
-
const response = await tab
|
|
2189
|
-
return response.success === false ? response : rpcSuccess("native_slash_command", { command: "clone", message: "Cloned the current session.", result: response.data });
|
|
2544
|
+
const response = await runCloneCommand(tab);
|
|
2545
|
+
return response.success === false ? response : rpcSuccess("native_slash_command", { command: "clone", message: response.data?.message || "Cloned the current session.", result: response.data?.result });
|
|
2190
2546
|
}
|
|
2191
2547
|
default:
|
|
2192
2548
|
throw makeHttpError(400, `/${parsed.name} is a native Pi TUI command, but this Web UI cannot run that interactive command yet.`);
|
|
@@ -2487,13 +2843,13 @@ async function webuiStatus({ detailed = false, eventLimit = 40 } = {}) {
|
|
|
2487
2843
|
piPid: tab?.rpc.child?.pid,
|
|
2488
2844
|
piRunning: !!tab?.rpc.child && tab.rpc.child.exitCode === null,
|
|
2489
2845
|
tabs: statusTabs,
|
|
2490
|
-
restorableTabs: mergeRestorableTabDescriptors(statusTabs
|
|
2846
|
+
restorableTabs: mergeRestorableTabDescriptors(statusTabs),
|
|
2491
2847
|
};
|
|
2492
2848
|
|
|
2493
2849
|
if (detailed) {
|
|
2494
2850
|
const detailedTabs = await Promise.all([...tabs.values()].map((item) => tabStatusDetails(item)));
|
|
2495
2851
|
data.tabs = detailedTabs;
|
|
2496
|
-
data.restorableTabs = mergeRestorableTabDescriptors(detailedTabs
|
|
2852
|
+
data.restorableTabs = mergeRestorableTabDescriptors(detailedTabs);
|
|
2497
2853
|
data.closedTabs = closedRestorableTabs.slice();
|
|
2498
2854
|
data.events = latestEvents(eventLimit);
|
|
2499
2855
|
}
|
|
@@ -2667,12 +3023,68 @@ const server = createServer(async (req, res) => {
|
|
|
2667
3023
|
return;
|
|
2668
3024
|
}
|
|
2669
3025
|
|
|
3026
|
+
if (url.pathname === "/api/attachments" && req.method === "POST") {
|
|
3027
|
+
const body = await readJsonBody(req, { limitBytes: requestBodyLimitForPath(url.pathname) });
|
|
3028
|
+
sendJson(res, 201, { ok: true, data: await saveUploadedAttachments(body) });
|
|
3029
|
+
return;
|
|
3030
|
+
}
|
|
3031
|
+
|
|
2670
3032
|
if (url.pathname === "/api/scoped-models" && req.method === "GET") {
|
|
2671
3033
|
const tab = getRequestedTab(req, url);
|
|
2672
3034
|
sendJson(res, 200, { ok: true, data: await getScopedModelData(tab) });
|
|
2673
3035
|
return;
|
|
2674
3036
|
}
|
|
2675
3037
|
|
|
3038
|
+
if (url.pathname === "/api/fork-messages" && req.method === "GET") {
|
|
3039
|
+
const tab = getRequestedTab(req, url);
|
|
3040
|
+
sendJson(res, 200, { ok: true, data: await getForkMessagesData(tab) });
|
|
3041
|
+
return;
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
if (url.pathname === "/api/sessions" && req.method === "GET") {
|
|
3045
|
+
const tab = getRequestedTab(req, url);
|
|
3046
|
+
sendJson(res, 200, { ok: true, data: await getSessionSelectorData(tab, url.searchParams.get("scope") || "current") });
|
|
3047
|
+
return;
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
if (url.pathname === "/api/session-tree" && req.method === "GET") {
|
|
3051
|
+
const tab = getRequestedTab(req, url);
|
|
3052
|
+
sendJson(res, 200, { ok: true, data: await getSessionTreeData(tab) });
|
|
3053
|
+
return;
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
if (url.pathname === "/api/fork" && req.method === "POST") {
|
|
3057
|
+
const body = await readJsonBody(req);
|
|
3058
|
+
const tab = getRequestedTab(req, url, body);
|
|
3059
|
+
const response = await runForkCommand(tab, body.entryId);
|
|
3060
|
+
sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
|
|
3061
|
+
return;
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
if (url.pathname === "/api/clone" && req.method === "POST") {
|
|
3065
|
+
const body = await readJsonBody(req);
|
|
3066
|
+
const tab = getRequestedTab(req, url, body);
|
|
3067
|
+
const response = await runCloneCommand(tab);
|
|
3068
|
+
sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
|
|
3069
|
+
return;
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
if (url.pathname === "/api/switch-session" && req.method === "POST") {
|
|
3073
|
+
const body = await readJsonBody(req);
|
|
3074
|
+
const tab = getRequestedTab(req, url, body);
|
|
3075
|
+
const response = await switchTabSession(tab, body.sessionPath || body.path);
|
|
3076
|
+
sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
|
|
3077
|
+
return;
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
if (url.pathname === "/api/tree-navigate" && req.method === "POST") {
|
|
3081
|
+
const body = await readJsonBody(req);
|
|
3082
|
+
const tab = getRequestedTab(req, url, body);
|
|
3083
|
+
const response = await navigateSessionTree(tab, body);
|
|
3084
|
+
sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
|
|
3085
|
+
return;
|
|
3086
|
+
}
|
|
3087
|
+
|
|
2676
3088
|
if (url.pathname === "/api/optional-feature-install" && req.method === "POST") {
|
|
2677
3089
|
if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Installing optional Web UI features is only allowed from localhost");
|
|
2678
3090
|
const body = await readJsonBody(req);
|
|
@@ -2696,7 +3108,7 @@ const server = createServer(async (req, res) => {
|
|
|
2696
3108
|
}
|
|
2697
3109
|
|
|
2698
3110
|
if (url.pathname === "/api/prompt" && req.method === "POST") {
|
|
2699
|
-
const body = await readJsonBody(req);
|
|
3111
|
+
const body = await readJsonBody(req, { limitBytes: requestBodyLimitForPath(url.pathname) });
|
|
2700
3112
|
const tab = getRequestedTab(req, url, body);
|
|
2701
3113
|
const nativeResponse = await handleNativeSlashCommand(tab, body);
|
|
2702
3114
|
if (nativeResponse) {
|
|
@@ -2756,7 +3168,7 @@ const server = createServer(async (req, res) => {
|
|
|
2756
3168
|
}
|
|
2757
3169
|
|
|
2758
3170
|
if (req.method === "POST") {
|
|
2759
|
-
const body = await readJsonBody(req);
|
|
3171
|
+
const body = await readJsonBody(req, { limitBytes: requestBodyLimitForPath(url.pathname) });
|
|
2760
3172
|
const command = commandFromPost(url.pathname, body);
|
|
2761
3173
|
if (command) {
|
|
2762
3174
|
const tab = getRequestedTab(req, url, body);
|