@firstpick/pi-package-webui 0.1.9 → 0.2.1

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
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { createReadStream } from "node:fs";
4
5
  import { createServer } from "node:http";
5
6
  import { createRequire } from "node:module";
6
- import { access, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
7
+ import { access, copyFile, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
7
8
  import { homedir, networkInterfaces, tmpdir } from "node:os";
8
9
  import path from "node:path";
9
10
  import { StringDecoder } from "node:string_decoder";
@@ -15,6 +16,7 @@ const require = createRequire(import.meta.url);
15
16
  const packageRoot = path.resolve(__dirname, "..");
16
17
  const publicDir = path.join(packageRoot, "public");
17
18
  const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
19
+ const nativeParityMatrix = JSON.parse(await readFile(path.join(packageRoot, "WEBUI_TUI_NATIVE_PARITY.json"), "utf8"));
18
20
 
19
21
  const DEFAULT_HOST = "127.0.0.1";
20
22
  const DEFAULT_PORT = 31415;
@@ -46,6 +48,7 @@ const SESSION_SELECTOR_LIMIT = 200;
46
48
  const TREE_SELECTOR_TEXT_LIMIT = 260;
47
49
  const NETWORK_REBIND_DELAY_MS = 100;
48
50
  const NETWORK_REBIND_FORCE_CLOSE_MS = 750;
51
+ const NATIVE_DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000;
49
52
  const AUTO_TAB_TITLE_MAX_LENGTH = 44;
50
53
  const AUTO_TAB_TITLE_WORD_LIMIT = 8;
51
54
  const AUTO_TAB_TITLE_STOP_WORDS = new Set([
@@ -88,6 +91,7 @@ const AUTO_TAB_TITLE_STOP_WORDS = new Set([
88
91
 
89
92
  const MIME_TYPES = new Map([
90
93
  [".html", "text/html; charset=utf-8"],
94
+ [".jsonl", "application/x-ndjson; charset=utf-8"],
91
95
  [".js", "text/javascript; charset=utf-8"],
92
96
  [".css", "text/css; charset=utf-8"],
93
97
  [".svg", "image/svg+xml"],
@@ -96,29 +100,32 @@ const MIME_TYPES = new Map([
96
100
  [".webmanifest", "application/manifest+json; charset=utf-8"],
97
101
  ]);
98
102
 
99
- const NATIVE_SLASH_COMMANDS = [
100
- { name: "settings", description: "Open settings menu" },
101
- { name: "model", description: "Select model (opens selector UI)" },
102
- { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
103
- { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" },
104
- { name: "import", description: "Import and resume a session from a JSONL file" },
105
- { name: "share", description: "Share session as a secret GitHub gist" },
106
- { name: "copy", description: "Copy last agent message to clipboard" },
107
- { name: "name", description: "Set session display name" },
108
- { name: "session", description: "Show session info and stats" },
109
- { name: "changelog", description: "Show changelog entries" },
110
- { name: "hotkeys", description: "Show all keyboard shortcuts" },
111
- { name: "fork", description: "Create a new fork from a previous user message" },
112
- { name: "clone", description: "Duplicate the current session at the current position" },
113
- { name: "tree", description: "Navigate session tree (switch branches)" },
114
- { name: "login", description: "Configure provider authentication" },
115
- { name: "logout", description: "Remove provider authentication" },
116
- { name: "new", description: "Start a new session" },
117
- { name: "compact", description: "Manually compact the session context" },
118
- { name: "resume", description: "Resume a different session" },
119
- { name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes" },
120
- { name: "quit", description: "Quit Pi" },
121
- ].map((command) => ({ ...command, source: "native", location: "Pi" }));
103
+ function nativeParitySurfaces(matrix = nativeParityMatrix) {
104
+ return Array.isArray(matrix?.surfaces) ? matrix.surfaces : [];
105
+ }
106
+
107
+ function nativeSlashCommandEntries(matrix = nativeParityMatrix) {
108
+ return nativeParitySurfaces(matrix)
109
+ .filter((surface) => surface?.kind === "slash-command")
110
+ .map((surface) => {
111
+ const name = String(surface.command?.name || surface.id || "").replace(/^\//, "").trim();
112
+ return {
113
+ name,
114
+ description: String(surface.command?.description || surface.title || `/${name}`),
115
+ source: "native",
116
+ location: "Pi",
117
+ nativeParity: {
118
+ status: surface.webStatus || "unsupported",
119
+ priority: surface.priority || "P2",
120
+ guards: Array.isArray(surface.guards) ? surface.guards : [],
121
+ sensitive: surface.sensitive === true,
122
+ },
123
+ };
124
+ })
125
+ .filter((command) => command.name);
126
+ }
127
+
128
+ const NATIVE_SLASH_COMMANDS = nativeSlashCommandEntries();
122
129
  const NATIVE_SLASH_COMMAND_NAMES = new Set(NATIVE_SLASH_COMMANDS.map((command) => command.name));
123
130
  const OPTIONAL_FEATURE_PACKAGES = new Map([
124
131
  ["gitWorkflow", "@firstpick/pi-prompts-git-pr"],
@@ -482,6 +489,71 @@ function rpcSuccess(command, data = {}) {
482
489
  return { type: "response", command, success: true, data };
483
490
  }
484
491
 
492
+ const nativeDownloadTokens = new Map();
493
+
494
+ function pruneNativeDownloadTokens(now = Date.now()) {
495
+ for (const [token, item] of nativeDownloadTokens) {
496
+ if (!item || item.expiresAt <= now) nativeDownloadTokens.delete(token);
497
+ }
498
+ }
499
+
500
+ function safeDownloadFileName(name, fallback = "pi-export") {
501
+ const text = String(name || fallback).replace(/[\r\n\\/]+/g, " ").replace(/\s+/g, " ").trim();
502
+ return (text || fallback).slice(0, 180);
503
+ }
504
+
505
+ function contentDispositionAttachment(fileName) {
506
+ const safeName = safeDownloadFileName(fileName);
507
+ const asciiName = safeName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
508
+ return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(safeName)}`;
509
+ }
510
+
511
+ function registerNativeDownload(filePath, { fileName, contentType, command = "native" } = {}) {
512
+ pruneNativeDownloadTokens();
513
+ const token = randomUUID();
514
+ const expiresAt = Date.now() + NATIVE_DOWNLOAD_TOKEN_TTL_MS;
515
+ const record = {
516
+ path: filePath,
517
+ fileName: safeDownloadFileName(fileName || path.basename(filePath)),
518
+ contentType: contentType || MIME_TYPES.get(path.extname(filePath).toLowerCase()) || "application/octet-stream",
519
+ command,
520
+ expiresAt,
521
+ };
522
+ nativeDownloadTokens.set(token, record);
523
+ return {
524
+ url: `/api/native-download/${encodeURIComponent(token)}`,
525
+ fileName: record.fileName,
526
+ contentType: record.contentType,
527
+ expiresAt: new Date(expiresAt).toISOString(),
528
+ };
529
+ }
530
+
531
+ async function sendNativeDownload(res, token) {
532
+ pruneNativeDownloadTokens();
533
+ const item = nativeDownloadTokens.get(token);
534
+ if (!item) throw makeHttpError(404, "Download token expired or not found");
535
+ const fileStats = await stat(item.path).catch(() => null);
536
+ if (!fileStats?.isFile()) {
537
+ nativeDownloadTokens.delete(token);
538
+ throw makeHttpError(404, "Download file expired or not found");
539
+ }
540
+ res.writeHead(200, {
541
+ "content-type": item.contentType,
542
+ "content-length": String(fileStats.size),
543
+ "content-disposition": contentDispositionAttachment(item.fileName),
544
+ "cache-control": "no-store",
545
+ "x-content-type-options": "nosniff",
546
+ });
547
+ await new Promise((resolve, reject) => {
548
+ const stream = createReadStream(item.path);
549
+ stream.on("error", reject);
550
+ res.on("error", reject);
551
+ res.on("close", resolve);
552
+ stream.on("end", resolve);
553
+ stream.pipe(res);
554
+ });
555
+ }
556
+
485
557
  const ACTION_FEEDBACK_REACTIONS = new Set(["up", "down", "question"]);
486
558
 
487
559
  function trimFeedbackField(value, maxLength) {
@@ -1198,6 +1270,40 @@ async function getScopedModelData(tab) {
1198
1270
  return { models: resolveScopedModelsFromPatterns(patterns, response.data?.models || []), patterns, source, rpcRunning: response.rpcRunning !== false };
1199
1271
  }
1200
1272
 
1273
+ function modelKey(model) {
1274
+ return model?.provider && model?.id ? `${model.provider}/${model.id}` : "";
1275
+ }
1276
+
1277
+ async function cycleTabModel(tab, direction = "forward") {
1278
+ const availableResponse = await tab.rpc.send({ type: "get_available_models" });
1279
+ if (availableResponse.success === false) return availableResponse;
1280
+ const allModels = Array.isArray(availableResponse.data?.models) ? availableResponse.data.models : [];
1281
+ const { patterns, source } = await configuredScopedModelPatterns(tab.cwd);
1282
+ const scopedModels = patterns.length ? resolveScopedModelsFromPatterns(patterns, allModels) : [];
1283
+ const candidates = scopedModels.length ? scopedModels : allModels;
1284
+ if (!candidates.length) throw makeHttpError(400, "No models are available to cycle.");
1285
+
1286
+ const state = await currentSessionState(tab).catch(() => tab.lastState || {});
1287
+ const currentKey = modelKey(state.model);
1288
+ const currentIndex = candidates.findIndex((model) => modelKey(model) === currentKey);
1289
+ const backwards = direction === "backward" || direction === "previous" || direction === "prev";
1290
+ let nextIndex;
1291
+ if (backwards) nextIndex = currentIndex > 0 ? currentIndex - 1 : candidates.length - 1;
1292
+ else nextIndex = currentIndex >= 0 && currentIndex < candidates.length - 1 ? currentIndex + 1 : 0;
1293
+ const nextModel = candidates[nextIndex];
1294
+ const response = await tab.rpc.send({ type: "set_model", provider: nextModel.provider, modelId: nextModel.id });
1295
+ if (response.success === false) return response;
1296
+ return rpcSuccess("cycle_model", {
1297
+ model: response.data || nextModel,
1298
+ direction: backwards ? "backward" : "forward",
1299
+ scoped: scopedModels.length > 0,
1300
+ scopeSource: scopedModels.length > 0 ? source : "all",
1301
+ index: nextIndex,
1302
+ count: candidates.length,
1303
+ tab: tabMeta(tab),
1304
+ });
1305
+ }
1306
+
1201
1307
  function pathPickerRoots(activeCwd, viewedCwd) {
1202
1308
  const home = process.env.HOME || process.env.USERPROFILE;
1203
1309
  return uniquePathItems([
@@ -1818,6 +1924,13 @@ function commandFromPost(pathname, body) {
1818
1924
  }
1819
1925
  case "/api/abort":
1820
1926
  return { type: "abort" };
1927
+ case "/api/bash": {
1928
+ const command = String(body.command || "").trim();
1929
+ if (!command) throw new Error("command is required");
1930
+ return { type: "bash", command, excludeFromContext: body.excludeFromContext === true };
1931
+ }
1932
+ case "/api/abort-bash":
1933
+ return { type: "abort_bash" };
1821
1934
  case "/api/new-session":
1822
1935
  return body.parentSession ? { type: "new_session", parentSession: String(body.parentSession) } : { type: "new_session" };
1823
1936
  case "/api/model": {
@@ -1833,6 +1946,8 @@ function commandFromPost(pathname, body) {
1833
1946
  }
1834
1947
  return { type: "set_thinking_level", level };
1835
1948
  }
1949
+ case "/api/thinking-cycle":
1950
+ return { type: "cycle_thinking_level" };
1836
1951
  case "/api/steering-mode": {
1837
1952
  const mode = String(body.mode || "").trim();
1838
1953
  if (!["all", "one-at-a-time"].includes(mode)) throw new Error("Invalid steering mode");
@@ -2013,6 +2128,77 @@ function pendingExtensionUiMap(tab) {
2013
2128
  return tab.pendingExtensionUiRequests;
2014
2129
  }
2015
2130
 
2131
+ function bashQueueForTab(tab) {
2132
+ if (!tab.bashQueue) tab.bashQueue = [];
2133
+ return tab.bashQueue;
2134
+ }
2135
+
2136
+ function settleBashQueueItem(item, kind, value) {
2137
+ if (!item || item.settled) return;
2138
+ item.settled = true;
2139
+ if (kind === "resolve") item.resolve(value);
2140
+ else item.reject(value);
2141
+ }
2142
+
2143
+ function bashQueueEvent(tab) {
2144
+ const queue = bashQueueForTab(tab);
2145
+ const activeItem = tab.bashQueueDraining ? queue[0] : null;
2146
+ return {
2147
+ type: "webui_bash_queue_update",
2148
+ tabId: tab.id,
2149
+ tabTitle: tab.title,
2150
+ activeCommand: activeItem?.command?.command,
2151
+ queueLength: Math.max(0, queue.length - (activeItem ? 1 : 0)),
2152
+ tabActivity: tabActivitySnapshot(tab),
2153
+ };
2154
+ }
2155
+
2156
+ function broadcastBashQueueUpdate(tab) {
2157
+ if (tab?.sseClients) broadcastTabEvent(tab, bashQueueEvent(tab));
2158
+ }
2159
+
2160
+ function rejectTabBashQueue(tab, error) {
2161
+ const queue = tab?.bashQueue;
2162
+ if (!queue?.length) return;
2163
+ for (const item of queue.splice(0)) settleBashQueueItem(item, "reject", error);
2164
+ tab.bashQueueDraining = false;
2165
+ broadcastBashQueueUpdate(tab);
2166
+ }
2167
+
2168
+ async function drainTabBashQueue(tab) {
2169
+ if (tab.bashQueueDraining) return;
2170
+ const queue = bashQueueForTab(tab);
2171
+ tab.bashQueueDraining = true;
2172
+ try {
2173
+ while (queue.length > 0) {
2174
+ const item = queue[0];
2175
+ broadcastBashQueueUpdate(tab);
2176
+ try {
2177
+ const response = await tab.rpc.send(item.command);
2178
+ settleBashQueueItem(item, "resolve", response);
2179
+ } catch (error) {
2180
+ settleBashQueueItem(item, "reject", error);
2181
+ } finally {
2182
+ const index = queue.indexOf(item);
2183
+ if (index >= 0) queue.splice(index, 1);
2184
+ broadcastBashQueueUpdate(tab);
2185
+ }
2186
+ }
2187
+ } finally {
2188
+ tab.bashQueueDraining = false;
2189
+ broadcastBashQueueUpdate(tab);
2190
+ }
2191
+ }
2192
+
2193
+ function sendQueuedBashCommand(tab, command) {
2194
+ return new Promise((resolve, reject) => {
2195
+ const queue = bashQueueForTab(tab);
2196
+ queue.push({ id: randomUUID(), command, resolve, reject, settled: false, queuedAt: new Date().toISOString() });
2197
+ broadcastBashQueueUpdate(tab);
2198
+ void drainTabBashQueue(tab);
2199
+ });
2200
+ }
2201
+
2016
2202
  function isPendingExtensionUiRequest(event) {
2017
2203
  return event?.type === "extension_ui_request" && EXTENSION_UI_BLOCKING_METHODS.has(event.method) && event.id;
2018
2204
  }
@@ -2268,6 +2454,8 @@ async function createTab({ id: requestedId, index, title, titleSource, conversat
2268
2454
  lastState: null,
2269
2455
  activity: createTabActivity(createdAt),
2270
2456
  pendingExtensionUiRequests: new Map(),
2457
+ bashQueue: [],
2458
+ bashQueueDraining: false,
2271
2459
  rpc: undefined,
2272
2460
  rpcUnsubscribe: undefined,
2273
2461
  sseClients: new Set(),
@@ -2427,6 +2615,7 @@ async function updateTabCwd(id, cwd) {
2427
2615
  const oldRpc = tab.rpc;
2428
2616
  tab.rpcUnsubscribe?.();
2429
2617
  tab.rpcUnsubscribe = undefined;
2618
+ rejectTabBashQueue(tab, new Error("Pi tab is restarting; queued bash commands were cancelled"));
2430
2619
  oldRpc.stop();
2431
2620
 
2432
2621
  tab.cwd = nextCwd;
@@ -2462,6 +2651,7 @@ async function restartTabRpc(tab, reason = "reload") {
2462
2651
  const oldRpc = tab.rpc;
2463
2652
  tab.rpcUnsubscribe?.();
2464
2653
  tab.rpcUnsubscribe = undefined;
2654
+ rejectTabBashQueue(tab, new Error("Pi tab is reloading; queued bash commands were cancelled"));
2465
2655
  oldRpc.stop();
2466
2656
 
2467
2657
  resetTabActivity(tab);
@@ -2784,6 +2974,102 @@ function formatSessionOutput(tab, state, stats) {
2784
2974
  ].filter(Boolean).join("\n");
2785
2975
  }
2786
2976
 
2977
+ function nativeExportBaseName(tab, state = {}) {
2978
+ const source = state.sessionName || tab?.title || state.sessionId || "pi-session";
2979
+ const date = new Date().toISOString().replace(/[:.]/g, "-");
2980
+ return safeDownloadFileName(`${source}-${date}`, "pi-session").replace(/\s+/g, "-");
2981
+ }
2982
+
2983
+ async function nativeExportTempPath(tab, state = {}, ext = ".html") {
2984
+ const dir = path.join(tmpdir(), "pi-webui-native-exports");
2985
+ await mkdir(dir, { recursive: true });
2986
+ return path.join(dir, `${nativeExportBaseName(tab, state)}-${randomUUID()}${ext}`);
2987
+ }
2988
+
2989
+ function exportTargetExtension(targetPath) {
2990
+ return path.extname(targetPath).toLowerCase();
2991
+ }
2992
+
2993
+ async function exportTargetExists(targetPath) {
2994
+ const targetStats = await stat(targetPath).catch(() => null);
2995
+ return !!targetStats;
2996
+ }
2997
+
2998
+ async function handleNativeExportCommand(tab, args, req) {
2999
+ const explicitTarget = String(args || "").trim();
3000
+ const state = await currentSessionState(tab).catch(() => tab.lastState || {});
3001
+
3002
+ if (!explicitTarget) {
3003
+ const outputPath = await nativeExportTempPath(tab, state, ".html");
3004
+ const response = await tab.rpc.send({ type: "export_html", outputPath });
3005
+ if (response.success === false) return response;
3006
+ const exportedPath = response.data?.path || outputPath;
3007
+ const download = registerNativeDownload(exportedPath, {
3008
+ command: "export",
3009
+ fileName: `${nativeExportBaseName(tab, state)}.html`,
3010
+ contentType: MIME_TYPES.get(".html"),
3011
+ });
3012
+ return nativeCommandResponse("export", {
3013
+ status: "succeeded",
3014
+ level: "info",
3015
+ message: `Exported current session to HTML.\nDownload: ${download.fileName}\nLink expires: ${download.expiresAt}`,
3016
+ download,
3017
+ result: response.data,
3018
+ });
3019
+ }
3020
+
3021
+ if (!isLocalAddress(req?.socket?.remoteAddress)) {
3022
+ return nativeCommandResponse("export", {
3023
+ status: "unavailable",
3024
+ level: "warn",
3025
+ reason: "Server-side export paths are only allowed from localhost.",
3026
+ safetyRestriction: "Explicit /export paths write files on the server and are blocked for non-local browser clients.",
3027
+ message: "Explicit /export paths are only allowed from localhost. Run /export without a path for a browser download, or retry from the local machine.",
3028
+ });
3029
+ }
3030
+
3031
+ const targetPath = resolveTabPath(tab, explicitTarget);
3032
+ const ext = exportTargetExtension(targetPath);
3033
+ if (![".html", ".jsonl"].includes(ext)) throw makeHttpError(400, "Usage: /export [path.html|path.jsonl]");
3034
+ if (await exportTargetExists(targetPath)) {
3035
+ return nativeCommandResponse("export", {
3036
+ status: "confirmation_required",
3037
+ level: "warn",
3038
+ reason: `Export target already exists: ${targetPath}`,
3039
+ safetyRestriction: "Overwrites require an explicit confirmation flow, which is not available from plain slash-command text yet.",
3040
+ message: `Export target already exists and was not overwritten:\n${targetPath}\n\nUse /export without a path for a browser download, or delete/rename the existing file first.`,
3041
+ });
3042
+ }
3043
+
3044
+ await mkdir(path.dirname(targetPath), { recursive: true });
3045
+
3046
+ if (ext === ".html") {
3047
+ const response = await tab.rpc.send({ type: "export_html", outputPath: targetPath });
3048
+ if (response.success === false) return response;
3049
+ return nativeCommandResponse("export", {
3050
+ status: "succeeded",
3051
+ level: "info",
3052
+ message: `Exported current session HTML to server path:\n${response.data?.path || targetPath}`,
3053
+ serverPath: response.data?.path || targetPath,
3054
+ result: response.data,
3055
+ });
3056
+ }
3057
+
3058
+ requirePersistentSessions();
3059
+ const sessionFile = state.sessionFile || tabRestorableSessionFile(tab);
3060
+ if (!sessionFile) throw makeHttpError(400, "No persisted session file is available for JSONL export.");
3061
+ const sourceStats = await stat(sessionFile).catch(() => null);
3062
+ if (!sourceStats?.isFile()) throw makeHttpError(404, `Current session file not found: ${sessionFile}`);
3063
+ await copyFile(sessionFile, targetPath);
3064
+ return nativeCommandResponse("export", {
3065
+ status: "succeeded",
3066
+ level: "info",
3067
+ message: `Copied current session JSONL to server path:\n${targetPath}`,
3068
+ serverPath: targetPath,
3069
+ result: { path: targetPath, sourcePath: sessionFile },
3070
+ });
3071
+ }
3072
+
2787
3073
  function webuiHotkeysOutput() {
2788
3074
  return [
2789
3075
  "Web UI hotkeys:",
@@ -2796,7 +3082,46 @@ function webuiHotkeysOutput() {
2796
3082
  ].join("\n");
2797
3083
  }
2798
3084
 
2799
- async function handleNativeSlashCommand(tab, body) {
3085
+ function nativeParitySurfaceForCommand(name) {
3086
+ return nativeParitySurfaces().find((surface) => surface.kind === "slash-command" && surface.command?.name === name) || null;
3087
+ }
3088
+
3089
+ function nativeCommandResponse(command, data = {}) {
3090
+ const surface = nativeParitySurfaceForCommand(command);
3091
+ const status = data.status || (surface?.webStatus === "implemented" ? "succeeded" : surface?.webStatus === "degraded" ? "degraded" : "unavailable");
3092
+ const level = data.level || (status === "succeeded" ? "info" : "warn");
3093
+ return rpcSuccess("native_slash_command", {
3094
+ command,
3095
+ status,
3096
+ level,
3097
+ nativeParity: surface ? {
3098
+ webStatus: surface.webStatus,
3099
+ priority: surface.priority,
3100
+ sensitive: surface.sensitive === true,
3101
+ guards: Array.isArray(surface.guards) ? surface.guards : [],
3102
+ } : undefined,
3103
+ ...data,
3104
+ });
3105
+ }
3106
+
3107
+ function nativeCommandUnavailable(command, details = {}) {
3108
+ const surface = nativeParitySurfaceForCommand(command);
3109
+ const guards = Array.isArray(surface?.guards) ? surface.guards.filter((guard) => guard !== "none") : [];
3110
+ const reason = details.reason || surface?.currentBehavior || "This native Pi TUI command is not implemented in the Web UI yet.";
3111
+ const nextActions = details.nextActions || [
3112
+ surface?.targetBehavior ? `Planned Web UI behavior: ${surface.targetBehavior}` : "Use the Pi TUI for this command until Web UI parity is implemented.",
3113
+ ];
3114
+ return nativeCommandResponse(command, {
3115
+ status: "unavailable",
3116
+ level: "warn",
3117
+ reason,
3118
+ safetyRestriction: details.safetyRestriction || (guards.length ? `Guarded by: ${guards.join(", ")}.` : undefined),
3119
+ nextActions,
3120
+ message: details.message || [`/${command} is not available in the Web UI yet.`, reason, ...nextActions].filter(Boolean).join("\n"),
3121
+ });
3122
+ }
3123
+
3124
+ async function handleNativeSlashCommand(tab, body, req) {
2800
3125
  const parsed = parseSlashCommand(body.message);
2801
3126
  if (!parsed) return undefined;
2802
3127
 
@@ -2830,6 +3155,9 @@ async function handleNativeSlashCommand(tab, body) {
2830
3155
  if (state.success === false) return state;
2831
3156
  return rpcSuccess("native_slash_command", { command: "session", message: formatSessionOutput(tab, state.data || {}, stats.success === false ? null : stats.data) });
2832
3157
  }
3158
+ case "export": {
3159
+ return handleNativeExportCommand(tab, parsed.args, req);
3160
+ }
2833
3161
  case "copy": {
2834
3162
  const response = await tab.rpc.send({ type: "get_last_assistant_text" });
2835
3163
  if (response.success === false) return response;
@@ -2845,7 +3173,7 @@ async function handleNativeSlashCommand(tab, body) {
2845
3173
  return response.success === false ? response : rpcSuccess("native_slash_command", { command: "clone", message: response.data?.message || "Cloned the current session.", result: response.data?.result });
2846
3174
  }
2847
3175
  default:
2848
- throw makeHttpError(400, `/${parsed.name} is a native Pi TUI command, but this Web UI cannot run that interactive command yet.`);
3176
+ return nativeCommandUnavailable(parsed.name);
2849
3177
  }
2850
3178
  }
2851
3179
 
@@ -2869,6 +3197,7 @@ async function closeTab(id) {
2869
3197
  }
2870
3198
  tab.sseClients.clear();
2871
3199
  tab.rpcUnsubscribe?.();
3200
+ rejectTabBashQueue(tab, new Error("Pi tab closed; queued bash commands were cancelled"));
2872
3201
  tab.rpc.stop();
2873
3202
  tabs.delete(id);
2874
3203
  return tab;
@@ -3249,6 +3578,16 @@ const server = createServer(async (req, res) => {
3249
3578
  return;
3250
3579
  }
3251
3580
 
3581
+ if (url.pathname === "/api/native-parity" && req.method === "GET") {
3582
+ sendJson(res, 200, { ok: true, data: nativeParityMatrix });
3583
+ return;
3584
+ }
3585
+
3586
+ if (url.pathname.startsWith("/api/native-download/") && req.method === "GET") {
3587
+ await sendNativeDownload(res, decodeURIComponent(url.pathname.slice("/api/native-download/".length)));
3588
+ return;
3589
+ }
3590
+
3252
3591
  if (url.pathname === "/api/themes" && req.method === "GET") {
3253
3592
  sendJson(res, 200, { ok: true, data: await readBundledThemes() });
3254
3593
  return;
@@ -3345,6 +3684,14 @@ const server = createServer(async (req, res) => {
3345
3684
  return;
3346
3685
  }
3347
3686
 
3687
+ if (url.pathname === "/api/model-cycle" && req.method === "POST") {
3688
+ const body = await readJsonBody(req);
3689
+ const tab = getRequestedTab(req, url, body);
3690
+ const response = await cycleTabModel(tab, body.direction || body.mode);
3691
+ sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
3692
+ return;
3693
+ }
3694
+
3348
3695
  if (url.pathname === "/api/fork-messages" && req.method === "GET") {
3349
3696
  const tab = getRequestedTab(req, url);
3350
3697
  sendJson(res, 200, { ok: true, data: await getForkMessagesData(tab) });
@@ -3420,7 +3767,7 @@ const server = createServer(async (req, res) => {
3420
3767
  if (url.pathname === "/api/prompt" && req.method === "POST") {
3421
3768
  const body = await readJsonBody(req, { limitBytes: requestBodyLimitForPath(url.pathname) });
3422
3769
  const tab = getRequestedTab(req, url, body);
3423
- const nativeResponse = await handleNativeSlashCommand(tab, body);
3770
+ const nativeResponse = await handleNativeSlashCommand(tab, body, req);
3424
3771
  if (nativeResponse) {
3425
3772
  sendJson(res, nativeResponse.success === false ? 400 : 200, responseWithTab(nativeResponse, tab));
3426
3773
  return;
@@ -3488,7 +3835,7 @@ const server = createServer(async (req, res) => {
3488
3835
  maybeNameTabForConversation(tab, command);
3489
3836
  markTabWorking(tab);
3490
3837
  }
3491
- const response = await tab.rpc.send(command);
3838
+ const response = command.type === "bash" ? await sendQueuedBashCommand(tab, command) : await tab.rpc.send(command);
3492
3839
  if (response.success === false && startsVisibleWork) markTabIdle(tab);
3493
3840
  if (response.success !== false && command.type === "new_session") {
3494
3841
  tab.conversationStarted = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.1.9",
3
+ "version": "0.2.1",
4
4
  "description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,8 +45,8 @@
45
45
  "pi-webui": "./bin/pi-webui.mjs"
46
46
  },
47
47
  "scripts": {
48
- "check": "node --check public/app.js && node --check bin/pi-webui.mjs && node tests/mobile-static.test.mjs",
49
- "test": "node tests/mobile-static.test.mjs"
48
+ "check": "node --check public/app.js && node --check bin/pi-webui.mjs && node tests/mobile-static.test.mjs && node tests/native-parity.test.mjs",
49
+ "test": "node tests/mobile-static.test.mjs && node tests/native-parity.test.mjs"
50
50
  },
51
51
  "dependencies": {
52
52
  "@earendil-works/pi-coding-agent": "^0.78.0"
@@ -66,6 +66,9 @@
66
66
  "public",
67
67
  "images",
68
68
  "tests",
69
+ "start-webui.sh",
70
+ "start-webui.ps1",
71
+ "WEBUI_TUI_NATIVE_PARITY.json",
69
72
  "README.md",
70
73
  "LICENSE"
71
74
  ],