@firstpick/pi-package-webui 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/pi-webui.mjs CHANGED
@@ -2,8 +2,8 @@
2
2
  import { spawn } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { createServer } from "node:http";
5
- import { access, readFile, readdir, stat } from "node:fs/promises";
6
- import { networkInterfaces } from "node:os";
5
+ import { access, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
6
+ import { homedir, networkInterfaces } from "node:os";
7
7
  import path from "node:path";
8
8
  import { StringDecoder } from "node:string_decoder";
9
9
  import { fileURLToPath } from "node:url";
@@ -17,14 +17,44 @@ const DEFAULT_HOST = "127.0.0.1";
17
17
  const DEFAULT_PORT = 31415;
18
18
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
19
19
  const BODY_LIMIT_BYTES = 1024 * 1024;
20
+ const EVENT_HISTORY_LIMIT = 200;
21
+ const STATUS_RPC_TIMEOUT_MS = 1_800;
22
+ const FAST_PICK_LIMIT = 30;
20
23
 
21
24
  const MIME_TYPES = new Map([
22
25
  [".html", "text/html; charset=utf-8"],
23
26
  [".js", "text/javascript; charset=utf-8"],
24
27
  [".css", "text/css; charset=utf-8"],
25
28
  [".svg", "image/svg+xml"],
29
+ [".png", "image/png"],
30
+ [".webmanifest", "application/manifest+json; charset=utf-8"],
26
31
  ]);
27
32
 
33
+ const NATIVE_SLASH_COMMANDS = [
34
+ { name: "settings", description: "Open settings menu" },
35
+ { name: "model", description: "Select model (opens selector UI)" },
36
+ { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
37
+ { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" },
38
+ { name: "import", description: "Import and resume a session from a JSONL file" },
39
+ { name: "share", description: "Share session as a secret GitHub gist" },
40
+ { name: "copy", description: "Copy last agent message to clipboard" },
41
+ { name: "name", description: "Set session display name" },
42
+ { name: "session", description: "Show session info and stats" },
43
+ { name: "changelog", description: "Show changelog entries" },
44
+ { name: "hotkeys", description: "Show all keyboard shortcuts" },
45
+ { name: "fork", description: "Create a new fork from a previous user message" },
46
+ { name: "clone", description: "Duplicate the current session at the current position" },
47
+ { name: "tree", description: "Navigate session tree (switch branches)" },
48
+ { name: "login", description: "Configure provider authentication" },
49
+ { name: "logout", description: "Remove provider authentication" },
50
+ { name: "new", description: "Start a new session" },
51
+ { name: "compact", description: "Manually compact the session context" },
52
+ { name: "resume", description: "Resume a different session" },
53
+ { name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes" },
54
+ { name: "quit", description: "Quit Pi" },
55
+ ].map((command) => ({ ...command, source: "native", location: "Pi" }));
56
+ const NATIVE_SLASH_COMMAND_NAMES = new Set(NATIVE_SLASH_COMMANDS.map((command) => command.name));
57
+
28
58
  function usage() {
29
59
  console.log(`pi-webui ${packageJson.version}
30
60
 
@@ -352,6 +382,51 @@ function sendSse(res, event) {
352
382
  res.write(`data: ${JSON.stringify(event)}\n\n`);
353
383
  }
354
384
 
385
+ function rpcSuccess(command, data = {}) {
386
+ return { type: "response", command, success: true, data };
387
+ }
388
+
389
+ function parseSlashCommand(message) {
390
+ const text = String(message || "").trim();
391
+ if (!text.startsWith("/") || text.includes("\n")) return undefined;
392
+ const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
393
+ if (!match) return undefined;
394
+ const name = match[1].toLowerCase();
395
+ if (!NATIVE_SLASH_COMMAND_NAMES.has(name)) return undefined;
396
+ return { name, args: (match[2] || "").trim(), text };
397
+ }
398
+
399
+ const eventHistory = [];
400
+
401
+ function truncateStatusText(value, maxLength = 240) {
402
+ const text = String(value || "").replace(/\s+/g, " ").trim();
403
+ return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
404
+ }
405
+
406
+ function statusEventSummary(event) {
407
+ const summary = {
408
+ timestamp: new Date().toISOString(),
409
+ type: String(event?.type || "event"),
410
+ };
411
+ for (const key of ["tabId", "tabTitle", "pid", "cwd", "code", "signal", "command", "queueLength", "pendingMessageCount"]) {
412
+ if (event?.[key] !== undefined) summary[key] = event[key];
413
+ }
414
+ if (event?.assistantMessageEvent?.type) summary.updateType = event.assistantMessageEvent.type;
415
+ if (event?.message?.role) summary.messageRole = event.message.role;
416
+ if (event?.error) summary.error = truncateStatusText(event.error);
417
+ if (event?.text && summary.type === "pi_stderr") summary.text = truncateStatusText(event.text);
418
+ return summary;
419
+ }
420
+
421
+ function recordEvent(event) {
422
+ eventHistory.push(statusEventSummary(event));
423
+ if (eventHistory.length > EVENT_HISTORY_LIMIT) eventHistory.splice(0, eventHistory.length - EVENT_HISTORY_LIMIT);
424
+ }
425
+
426
+ function latestEvents(limit = 40) {
427
+ return eventHistory.slice(-Math.max(0, Math.min(EVENT_HISTORY_LIMIT, limit)));
428
+ }
429
+
355
430
  function runCommand(command, args, { cwd, timeoutMs = 2000 } = {}) {
356
431
  return new Promise((resolve) => {
357
432
  const child = spawn(command, args, {
@@ -434,6 +509,137 @@ function uniquePathItems(items) {
434
509
  return result;
435
510
  }
436
511
 
512
+ function normalizePathFastPicks(value) {
513
+ const items = Array.isArray(value) ? value : Array.isArray(value?.picks) ? value.picks : [];
514
+ const seen = new Set();
515
+ const picks = [];
516
+ for (const item of items) {
517
+ const rawCwd = typeof item === "string" ? item : item?.cwd;
518
+ if (!rawCwd) continue;
519
+ let cwd;
520
+ try {
521
+ cwd = path.resolve(options.cwd, expandUserPath(rawCwd));
522
+ } catch {
523
+ continue;
524
+ }
525
+ if (!cwd || seen.has(cwd)) continue;
526
+ seen.add(cwd);
527
+ const displayCwd = String(typeof item === "object" && item?.displayCwd ? item.displayCwd : displayPath(cwd)).slice(0, 4096);
528
+ picks.push({ cwd, displayCwd });
529
+ if (picks.length >= FAST_PICK_LIMIT) break;
530
+ }
531
+ return picks;
532
+ }
533
+
534
+ function fastPicksStorageFile() {
535
+ if (process.env.PI_WEBUI_FAST_PICKS_FILE) return path.resolve(expandUserPath(process.env.PI_WEBUI_FAST_PICKS_FILE));
536
+ const stateRoot = process.env.XDG_STATE_HOME || path.join(homedir(), ".local", "state");
537
+ return path.join(stateRoot, "pi-webui", "fast-picks.json");
538
+ }
539
+
540
+ let pathFastPicksCache = null;
541
+
542
+ async function readPathFastPicks() {
543
+ if (pathFastPicksCache) return pathFastPicksCache;
544
+ try {
545
+ const parsed = JSON.parse(await readFile(fastPicksStorageFile(), "utf8"));
546
+ pathFastPicksCache = normalizePathFastPicks(parsed);
547
+ } catch (error) {
548
+ if (error?.code !== "ENOENT") console.warn(`failed to read path fast picks: ${sanitizeError(error)}`);
549
+ pathFastPicksCache = [];
550
+ }
551
+ return pathFastPicksCache;
552
+ }
553
+
554
+ async function writePathFastPicks(picks) {
555
+ const normalized = normalizePathFastPicks(picks);
556
+ const storageFile = fastPicksStorageFile();
557
+ await mkdir(path.dirname(storageFile), { recursive: true });
558
+ const tmpFile = `${storageFile}.${process.pid}.${Date.now()}.tmp`;
559
+ await writeFile(tmpFile, `${JSON.stringify({ version: 1, picks: normalized }, null, 2)}\n`, { mode: 0o600 });
560
+ await rename(tmpFile, storageFile);
561
+ pathFastPicksCache = normalized;
562
+ return normalized;
563
+ }
564
+
565
+ function parseCliScopedModelPatterns() {
566
+ for (let index = 0; index < options.piArgs.length; index++) {
567
+ const arg = options.piArgs[index];
568
+ if (arg === "--models" && options.piArgs[index + 1]) return options.piArgs[index + 1].split(",").map((item) => item.trim()).filter(Boolean);
569
+ if (arg.startsWith("--models=")) return arg.slice("--models=".length).split(",").map((item) => item.trim()).filter(Boolean);
570
+ }
571
+ return undefined;
572
+ }
573
+
574
+ async function readJsonFileIfExists(filePath) {
575
+ try {
576
+ return JSON.parse(await readFile(filePath, "utf8"));
577
+ } catch (error) {
578
+ if (error?.code === "ENOENT") return undefined;
579
+ console.warn(`failed to read ${filePath}: ${sanitizeError(error)}`);
580
+ return undefined;
581
+ }
582
+ }
583
+
584
+ async function configuredScopedModelPatterns(cwd = options.cwd) {
585
+ const cliPatterns = parseCliScopedModelPatterns();
586
+ if (cliPatterns !== undefined) return { patterns: cliPatterns, source: "cli" };
587
+
588
+ const agentDir = process.env.PI_CODING_AGENT_DIR ? path.resolve(expandUserPath(process.env.PI_CODING_AGENT_DIR)) : path.join(homedir(), ".pi", "agent");
589
+ const [globalSettings, projectSettings] = await Promise.all([
590
+ readJsonFileIfExists(path.join(agentDir, "settings.json")),
591
+ readJsonFileIfExists(path.join(cwd, ".pi", "settings.json")),
592
+ ]);
593
+
594
+ if (Array.isArray(projectSettings?.enabledModels)) return { patterns: projectSettings.enabledModels, source: "project" };
595
+ if (Array.isArray(globalSettings?.enabledModels)) return { patterns: globalSettings.enabledModels, source: "global" };
596
+ return { patterns: [], source: "none" };
597
+ }
598
+
599
+ function stripThinkingSuffix(pattern) {
600
+ const text = String(pattern || "").trim();
601
+ const slashIndex = text.indexOf("/");
602
+ const colonIndex = text.lastIndexOf(":");
603
+ if (colonIndex > (slashIndex === -1 ? -1 : slashIndex)) return text.slice(0, colonIndex);
604
+ return text;
605
+ }
606
+
607
+ function globToRegExp(pattern) {
608
+ const escaped = pattern.replace(/[.+^${}()|\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
609
+ return new RegExp(`^${escaped}$`, "i");
610
+ }
611
+
612
+ function modelMatchesPattern(model, pattern) {
613
+ const clean = stripThinkingSuffix(pattern).toLowerCase();
614
+ if (!clean) return false;
615
+ const full = `${model.provider}/${model.id}`.toLowerCase();
616
+ const id = String(model.id || "").toLowerCase();
617
+ if (/[?*\[]/.test(clean)) return globToRegExp(clean).test(full) || globToRegExp(clean).test(id);
618
+ return full === clean || id === clean || full.includes(clean) || id.includes(clean);
619
+ }
620
+
621
+ function resolveScopedModelsFromPatterns(patterns, models) {
622
+ const scoped = [];
623
+ const seen = new Set();
624
+ for (const pattern of patterns || []) {
625
+ for (const model of models || []) {
626
+ const key = `${model.provider}/${model.id}`;
627
+ if (seen.has(key) || !modelMatchesPattern(model, pattern)) continue;
628
+ seen.add(key);
629
+ scoped.push(model);
630
+ }
631
+ }
632
+ return scoped;
633
+ }
634
+
635
+ async function getScopedModelData(tab) {
636
+ const { patterns, source } = await configuredScopedModelPatterns(tab.cwd);
637
+ if (!patterns.length) return { models: [], patterns, source };
638
+ const response = await tab.rpc.send({ type: "get_available_models" });
639
+ if (response.success === false) throw makeHttpError(400, response.error || "failed to load available models");
640
+ return { models: resolveScopedModelsFromPatterns(patterns, response.data?.models || []), patterns, source };
641
+ }
642
+
437
643
  function pathPickerRoots(activeCwd, viewedCwd) {
438
644
  const home = process.env.HOME || process.env.USERPROFILE;
439
645
  return uniquePathItems([
@@ -650,7 +856,7 @@ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd)
650
856
  function normalizeStaticPath(urlPath) {
651
857
  if (urlPath === "/") return "index.html";
652
858
  const name = urlPath.startsWith("/") ? urlPath.slice(1) : urlPath;
653
- if (!["index.html", "app.js", "styles.css"].includes(name)) return undefined;
859
+ 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
860
  return name;
655
861
  }
656
862
 
@@ -795,6 +1001,7 @@ function attachRpcToTab(tab, rpc) {
795
1001
  tab.rpc = rpc;
796
1002
  tab.rpcUnsubscribe = rpc.onEvent((event) => {
797
1003
  const scopedEvent = { ...event, tabId: tab.id, tabTitle: tab.title };
1004
+ recordEvent(scopedEvent);
798
1005
  for (const client of tab.sseClients) sendSse(client, scopedEvent);
799
1006
  });
800
1007
  }
@@ -856,8 +1063,10 @@ async function updateTabCwd(id, cwd) {
856
1063
 
857
1064
  const piArgs = buildPiArgsForTab(tab.index, tab.title);
858
1065
  const piCommand = await resolvePiCommand(piArgs);
1066
+ const restartingEvent = { type: "webui_tab_restarting", tabId: tab.id, tabTitle: tab.title, cwd: nextCwd };
1067
+ recordEvent(restartingEvent);
859
1068
  for (const client of tab.sseClients) {
860
- sendSse(client, { type: "webui_tab_restarting", tabId: tab.id, tabTitle: tab.title, cwd: nextCwd });
1069
+ sendSse(client, restartingEvent);
861
1070
  }
862
1071
 
863
1072
  const oldRpc = tab.rpc;
@@ -870,19 +1079,134 @@ async function updateTabCwd(id, cwd) {
870
1079
  attachRpcToTab(tab, rpc);
871
1080
  rpc.start();
872
1081
 
1082
+ const changedEvent = { type: "webui_cwd_changed", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid };
1083
+ recordEvent(changedEvent);
873
1084
  for (const client of tab.sseClients) {
874
- sendSse(client, { type: "webui_cwd_changed", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid });
1085
+ sendSse(client, changedEvent);
875
1086
  }
876
1087
  return { tab, changed: true };
877
1088
  }
878
1089
 
1090
+ async function restartTabRpc(tab, reason = "reload") {
1091
+ const state = await tab.rpc.send({ type: "get_state" });
1092
+ if (state.success === false) throw makeHttpError(400, state.error || "Unable to read Pi state before reload");
1093
+ if (state.data?.isStreaming) throw makeHttpError(409, "Wait for the current response to finish before reloading.");
1094
+ if (state.data?.isCompacting) throw makeHttpError(409, "Wait for compaction to finish before reloading.");
1095
+
1096
+ const piArgs = buildPiArgsForTab(tab.index, tab.title);
1097
+ if (state.data?.sessionFile && !options.noSession) piArgs.push("--session", state.data.sessionFile);
1098
+ const piCommand = await resolvePiCommand(piArgs);
1099
+ const reloadingEvent = { type: "webui_tab_reloading", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, reason, sessionFile: state.data?.sessionFile };
1100
+ recordEvent(reloadingEvent);
1101
+ for (const client of tab.sseClients) sendSse(client, reloadingEvent);
1102
+
1103
+ const oldRpc = tab.rpc;
1104
+ tab.rpcUnsubscribe?.();
1105
+ tab.rpcUnsubscribe = undefined;
1106
+ oldRpc.stop();
1107
+
1108
+ const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
1109
+ attachRpcToTab(tab, rpc);
1110
+ rpc.start();
1111
+
1112
+ const reloadedEvent = { type: "webui_tab_reloaded", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid, reason, sessionFile: state.data?.sessionFile };
1113
+ recordEvent(reloadedEvent);
1114
+ for (const client of tab.sseClients) sendSse(client, reloadedEvent);
1115
+ return tab;
1116
+ }
1117
+
1118
+ async function getCommandData(tab) {
1119
+ const response = await tab.rpc.send({ type: "get_commands" });
1120
+ if (response.success === false) throw makeHttpError(400, response.error || "failed to load commands");
1121
+ return { commands: [...NATIVE_SLASH_COMMANDS, ...(response.data?.commands || [])] };
1122
+ }
1123
+
1124
+ function formatSessionOutput(tab, state, stats) {
1125
+ return [
1126
+ `Session: ${state.sessionName || state.sessionId || "unknown"}`,
1127
+ `Tab: ${tab.title}`,
1128
+ `CWD: ${tab.cwd}`,
1129
+ `Model: ${state.model ? `${state.model.provider}/${state.model.id}` : "none"}`,
1130
+ `Thinking: ${state.thinkingLevel || "unknown"}`,
1131
+ `Status: ${state.isStreaming ? "running" : state.isCompacting ? "compacting" : "idle"}`,
1132
+ `Messages: ${state.messageCount ?? "?"}`,
1133
+ `Queue: ${state.pendingMessageCount ?? 0}`,
1134
+ `Session file: ${state.sessionFile || "none"}`,
1135
+ stats ? `Tokens: input ${stats.tokens?.input ?? 0}, output ${stats.tokens?.output ?? 0}, cache read ${stats.tokens?.cacheRead ?? 0}` : undefined,
1136
+ stats?.cost !== undefined ? `Cost: ${stats.cost}` : undefined,
1137
+ ].filter(Boolean).join("\n");
1138
+ }
1139
+
1140
+ function webuiHotkeysOutput() {
1141
+ return [
1142
+ "Web UI hotkeys:",
1143
+ "Enter: send on desktop; newline on mobile",
1144
+ "Ctrl/Cmd+Enter: send from textarea",
1145
+ "Tab: accept slash-command suggestion",
1146
+ "Arrow up/down: move through slash-command suggestions",
1147
+ "Escape: close actions, tabs, model picker, or mobile drawer",
1148
+ "Mobile: Send button submits; Return inserts a newline",
1149
+ ].join("\n");
1150
+ }
1151
+
1152
+ async function handleNativeSlashCommand(tab, body) {
1153
+ const parsed = parseSlashCommand(body.message);
1154
+ if (!parsed) return undefined;
1155
+
1156
+ switch (parsed.name) {
1157
+ case "reload": {
1158
+ const reloaded = await restartTabRpc(tab, "slash-command");
1159
+ return rpcSuccess("native_slash_command", { command: "reload", tab: tabMeta(reloaded), message: "Reloaded keybindings, extensions, skills, prompts, and themes." });
1160
+ }
1161
+ case "new": {
1162
+ const response = await tab.rpc.send({ type: "new_session" });
1163
+ return response.success === false ? response : rpcSuccess("native_slash_command", { command: "new", message: "Started a new session.", result: response.data });
1164
+ }
1165
+ case "compact": {
1166
+ const response = await tab.rpc.send(parsed.args ? { type: "compact", customInstructions: parsed.args } : { type: "compact" });
1167
+ return response.success === false ? response : rpcSuccess("native_slash_command", { command: "compact", message: "Compaction finished.", result: response.data });
1168
+ }
1169
+ case "name": {
1170
+ if (!parsed.args) throw makeHttpError(400, "Usage: /name <session name>");
1171
+ const response = await tab.rpc.send({ type: "set_session_name", name: parsed.args });
1172
+ return response.success === false ? response : rpcSuccess("native_slash_command", { command: "name", message: `Session name set to: ${parsed.args}` });
1173
+ }
1174
+ case "session": {
1175
+ const [state, stats] = await Promise.all([
1176
+ tab.rpc.send({ type: "get_state" }),
1177
+ tab.rpc.send({ type: "get_session_stats" }).catch((error) => ({ success: false, error: sanitizeError(error) })),
1178
+ ]);
1179
+ if (state.success === false) return state;
1180
+ return rpcSuccess("native_slash_command", { command: "session", message: formatSessionOutput(tab, state.data || {}, stats.success === false ? null : stats.data) });
1181
+ }
1182
+ case "copy": {
1183
+ const response = await tab.rpc.send({ type: "get_last_assistant_text" });
1184
+ if (response.success === false) return response;
1185
+ const text = String(response.data?.text || "");
1186
+ if (!text.trim()) throw makeHttpError(400, "No assistant message to copy.");
1187
+ return rpcSuccess("native_slash_command", { command: "copy", message: "Copied the last assistant message.", copyText: text });
1188
+ }
1189
+ case "hotkeys": {
1190
+ return rpcSuccess("native_slash_command", { command: "hotkeys", message: webuiHotkeysOutput() });
1191
+ }
1192
+ case "clone": {
1193
+ const response = await tab.rpc.send({ type: "clone" });
1194
+ return response.success === false ? response : rpcSuccess("native_slash_command", { command: "clone", message: "Cloned the current session.", result: response.data });
1195
+ }
1196
+ default:
1197
+ throw makeHttpError(400, `/${parsed.name} is a native Pi TUI command, but this Web UI cannot run that interactive command yet.`);
1198
+ }
1199
+ }
1200
+
879
1201
  function closeTab(id) {
880
1202
  const tab = tabs.get(id);
881
1203
  if (!tab) throw makeHttpError(404, `Unknown Pi tab: ${id}`);
882
1204
  if (tabs.size <= 1) throw makeHttpError(400, "Cannot close the last Pi tab");
883
1205
 
1206
+ const closingEvent = { type: "webui_tab_closing", tabId: tab.id, tabTitle: tab.title };
1207
+ recordEvent(closingEvent);
884
1208
  for (const client of tab.sseClients) {
885
- sendSse(client, { type: "webui_tab_closing", tabId: tab.id, tabTitle: tab.title });
1209
+ sendSse(client, closingEvent);
886
1210
  client.end();
887
1211
  }
888
1212
  tab.sseClients.clear();
@@ -910,6 +1234,7 @@ function getRequestedTab(req, url, body = {}) {
910
1234
  return tab;
911
1235
  }
912
1236
 
1237
+ const serverStartedAt = new Date().toISOString();
913
1238
  const initialTab = await createTab();
914
1239
  let currentHost = options.host;
915
1240
  let networkRebindInProgress = false;
@@ -940,8 +1265,10 @@ function networkStatus() {
940
1265
 
941
1266
  function closeSseClientsForRebind(nextHost) {
942
1267
  for (const tab of tabs.values()) {
1268
+ const rebindEvent = { type: "webui_network_rebinding", tabId: tab.id, tabTitle: tab.title, host: nextHost, port: options.port };
1269
+ recordEvent(rebindEvent);
943
1270
  for (const client of tab.sseClients) {
944
- sendSse(client, { type: "webui_network_rebinding", tabId: tab.id, tabTitle: tab.title, host: nextHost, port: options.port });
1271
+ sendSse(client, rebindEvent);
945
1272
  client.end();
946
1273
  }
947
1274
  tab.sseClients.clear();
@@ -1009,6 +1336,75 @@ async function openToLocalNetwork() {
1009
1336
  }
1010
1337
  }
1011
1338
 
1339
+ async function safeRpcData(tab, command, timeoutMs = STATUS_RPC_TIMEOUT_MS) {
1340
+ try {
1341
+ const response = await tab.rpc.send(command, timeoutMs);
1342
+ if (response?.success === false) return { ok: false, error: response.error || `${command.type} failed` };
1343
+ return { ok: true, data: response?.data ?? null };
1344
+ } catch (error) {
1345
+ return { ok: false, error: sanitizeError(error) };
1346
+ }
1347
+ }
1348
+
1349
+ function providerList(models) {
1350
+ const providers = new Set();
1351
+ for (const model of Array.isArray(models) ? models : []) {
1352
+ if (model?.provider) providers.add(String(model.provider));
1353
+ }
1354
+ return [...providers].sort();
1355
+ }
1356
+
1357
+ async function tabStatusDetails(tab) {
1358
+ const [stateResult, modelsResult, statsResult, workspaceResult] = await Promise.all([
1359
+ safeRpcData(tab, { type: "get_state" }),
1360
+ safeRpcData(tab, { type: "get_available_models" }),
1361
+ safeRpcData(tab, { type: "get_session_stats" }),
1362
+ getWorkspaceInfo(tab.cwd, tab.rpc.startedAt).then((data) => ({ ok: true, data })).catch((error) => ({ ok: false, error: sanitizeError(error) })),
1363
+ ]);
1364
+ const models = modelsResult.ok ? modelsResult.data?.models || [] : [];
1365
+ return {
1366
+ ...tabMeta(tab),
1367
+ state: stateResult.ok ? stateResult.data : null,
1368
+ stateError: stateResult.ok ? undefined : stateResult.error,
1369
+ stats: statsResult.ok ? statsResult.data : null,
1370
+ statsError: statsResult.ok ? undefined : statsResult.error,
1371
+ workspace: workspaceResult.ok ? workspaceResult.data : null,
1372
+ workspaceError: workspaceResult.ok ? undefined : workspaceResult.error,
1373
+ models: {
1374
+ count: models.length,
1375
+ providers: providerList(models),
1376
+ error: modelsResult.ok ? undefined : modelsResult.error,
1377
+ },
1378
+ };
1379
+ }
1380
+
1381
+ async function webuiStatus({ detailed = false, eventLimit = 40 } = {}) {
1382
+ const tab = firstTab();
1383
+ const network = networkStatus();
1384
+ const data = {
1385
+ online: true,
1386
+ webuiVersion: packageJson.version,
1387
+ webuiPid: process.pid,
1388
+ startedAt: serverStartedAt,
1389
+ cwd: options.cwd,
1390
+ boundHost: currentHost,
1391
+ port: options.port,
1392
+ pageUrl: network.localUrl,
1393
+ boundUrl: `http://${formatUrlHost(currentHost)}:${options.port}/`,
1394
+ network,
1395
+ piPid: tab?.rpc.child?.pid,
1396
+ piRunning: !!tab?.rpc.child && tab.rpc.child.exitCode === null,
1397
+ tabs: listTabs(),
1398
+ };
1399
+
1400
+ if (detailed) {
1401
+ data.tabs = await Promise.all([...tabs.values()].map((item) => tabStatusDetails(item)));
1402
+ data.events = latestEvents(eventLimit);
1403
+ }
1404
+
1405
+ return data;
1406
+ }
1407
+
1012
1408
  const server = createServer(async (req, res) => {
1013
1409
  try {
1014
1410
  const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
@@ -1068,20 +1464,28 @@ const server = createServer(async (req, res) => {
1068
1464
  }
1069
1465
 
1070
1466
  if (url.pathname === "/api/health" && req.method === "GET") {
1071
- const tab = firstTab();
1467
+ const status = await webuiStatus();
1072
1468
  sendJson(res, 200, {
1073
1469
  ok: true,
1074
- webuiVersion: packageJson.version,
1075
- webuiPid: process.pid,
1076
- piPid: tab?.rpc.child?.pid,
1077
- piRunning: !!tab?.rpc.child && tab.rpc.child.exitCode === null,
1078
- cwd: options.cwd,
1079
- network: networkStatus(),
1080
- tabs: listTabs(),
1470
+ webuiVersion: status.webuiVersion,
1471
+ webuiPid: status.webuiPid,
1472
+ piPid: status.piPid,
1473
+ piRunning: status.piRunning,
1474
+ cwd: status.cwd,
1475
+ network: status.network,
1476
+ tabs: status.tabs,
1081
1477
  });
1082
1478
  return;
1083
1479
  }
1084
1480
 
1481
+ if (url.pathname === "/api/webui-status" && req.method === "GET") {
1482
+ const detailed = ["1", "true", "yes", "detailed"].includes(String(url.searchParams.get("detailed") || "").toLowerCase());
1483
+ const parsedEventLimit = Number.parseInt(url.searchParams.get("events") || "40", 10);
1484
+ const eventLimit = Number.isFinite(parsedEventLimit) ? parsedEventLimit : 40;
1485
+ sendJson(res, 200, { ok: true, data: await webuiStatus({ detailed, eventLimit }) });
1486
+ return;
1487
+ }
1488
+
1085
1489
  if (url.pathname === "/api/network" && req.method === "GET") {
1086
1490
  sendJson(res, 200, { ok: true, data: networkStatus() });
1087
1491
  return;
@@ -1122,6 +1526,44 @@ const server = createServer(async (req, res) => {
1122
1526
  return;
1123
1527
  }
1124
1528
 
1529
+ if (url.pathname === "/api/path-fast-picks" && req.method === "GET") {
1530
+ sendJson(res, 200, { ok: true, data: { picks: await readPathFastPicks() } });
1531
+ return;
1532
+ }
1533
+
1534
+ if (url.pathname === "/api/path-fast-picks" && req.method === "POST") {
1535
+ const body = await readJsonBody(req);
1536
+ const picks = await writePathFastPicks(body.picks ?? body);
1537
+ sendJson(res, 200, { ok: true, data: { picks } });
1538
+ return;
1539
+ }
1540
+
1541
+ if (url.pathname === "/api/scoped-models" && req.method === "GET") {
1542
+ const tab = getRequestedTab(req, url);
1543
+ sendJson(res, 200, { ok: true, data: await getScopedModelData(tab) });
1544
+ return;
1545
+ }
1546
+
1547
+ if (url.pathname === "/api/commands" && req.method === "GET") {
1548
+ const tab = getRequestedTab(req, url);
1549
+ sendJson(res, 200, { type: "response", command: "get_commands", success: true, data: await getCommandData(tab) });
1550
+ return;
1551
+ }
1552
+
1553
+ if (url.pathname === "/api/prompt" && req.method === "POST") {
1554
+ const body = await readJsonBody(req);
1555
+ const tab = getRequestedTab(req, url, body);
1556
+ const nativeResponse = await handleNativeSlashCommand(tab, body);
1557
+ if (nativeResponse) {
1558
+ sendJson(res, nativeResponse.success === false ? 400 : 200, nativeResponse);
1559
+ return;
1560
+ }
1561
+ const command = commandFromPost(url.pathname, body);
1562
+ const response = await tab.rpc.send(command);
1563
+ sendJson(res, response.success === false ? 400 : 200, response);
1564
+ return;
1565
+ }
1566
+
1125
1567
  if (url.pathname.startsWith("/api/git-workflow/")) {
1126
1568
  const body = req.method === "POST" ? await readJsonBody(req) : {};
1127
1569
  const tab = getRequestedTab(req, url, body);