@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/README.md +77 -118
- package/WEBUI_TUI_NATIVE_PARITY.json +666 -0
- package/bin/pi-webui.mjs +375 -28
- package/package.json +6 -3
- package/public/app.js +802 -94
- package/public/index.html +25 -21
- package/public/styles.css +209 -82
- package/start-webui.ps1 +368 -0
- package/start-webui.sh +510 -0
- package/tests/mobile-static.test.mjs +118 -12
- package/tests/native-parity.test.mjs +148 -0
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
],
|