@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 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` (alias: `/start-webui`): a Pi slash command that launches `pi-webui` for the current Pi working directory and opens the browser.
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` (also available as `/start-webui`) and `/webui-status` are loaded:
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`/`/start-webui` captures its open terminal tabs plus any terminal tabs closed during the current server run, stops that instance, then starts a fresh server and reopens those tabs from their session files when available.
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`/`/start-webui`; restart captures detailed tab status first so open and recently closed tabs can be restored with their session files
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
- async function readJsonBody(req) {
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 > BODY_LIMIT_BYTES) throw new Error("Request body too large");
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.rpc.send({ type: "clone" });
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, closedRestorableTabs),
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, closedRestorableTabs);
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);