@firstpick/pi-package-webui 0.4.0 → 0.4.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 +8 -5
- package/bin/pi-webui.mjs +161 -14
- package/package.json +1 -1
- package/public/app.js +715 -45
- package/public/index.html +37 -1
- package/public/styles.css +516 -4
- package/tests/http-endpoints-harness.test.mjs +42 -1
- package/tests/mobile-static.test.mjs +19 -10
package/README.md
CHANGED
|
@@ -120,12 +120,14 @@ Environment variables:
|
|
|
120
120
|
## Main features
|
|
121
121
|
|
|
122
122
|
- Pathless `pi-webui` startup: the server opens first, then the browser prompts for the first terminal CWD.
|
|
123
|
-
- Multi-tab Pi sessions with isolated processes, working directories, prompt drafts, and
|
|
123
|
+
- Multi-tab Pi sessions with isolated processes, working directories, prompt drafts, activity state, and a workspace dashboard for common actions.
|
|
124
|
+
- Unified command palette (`Ctrl/Cmd+K`) for commands, tabs, models, sessions, settings, and frequent Web UI actions.
|
|
124
125
|
- Automatic tab naming from the first prompt, with `--name <name>` still available for an explicit initial tab name.
|
|
125
|
-
- Streaming chat transcript with Markdown, thinking output, tool/bash cards, queue and compaction events, and abort controls.
|
|
126
|
+
- Streaming chat transcript with Markdown, thinking output, tool/bash cards, queue and compaction events, edit-and-retry from user prompts, and abort controls.
|
|
126
127
|
- Prompt composer with uploads, drag/drop/paste, inline image support, slash-command autocomplete, and `@` file/path references with live suggestions.
|
|
127
128
|
- Browser dialogs for common Pi selectors such as `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, `/tree`, `/scoped-models`, `/tools`, and `/skills`.
|
|
128
129
|
- Model, thinking, session, workspace, theme, optional-feature, Codex usage, network, update/restart, event, and notification controls in the side panel.
|
|
130
|
+
- Persistent context-window meter with manual compact and auto-compaction controls near the composer.
|
|
129
131
|
- Side-panel theme picker backed by optional `@firstpick/pi-themes-bundle` themes when loaded.
|
|
130
132
|
- Per-tab cwd changes, a clickable footer cwd picker, saved path fast picks, server-persisted fast picks, and restart-safe restoration of open tabs.
|
|
131
133
|
- Detected app runner dropdown for the active tab cwd, including Cargo, Bun, npm/npx/pnpm, Python/uv, Go/Golang, Zig, C/C++, Docker Compose, root/dev/scripts shell scripts, and other common project runners with live output pinned at the top of the terminal. Projects can add browseable custom runners in `.pi-webui-runners.json` with a command (default `./`) plus a relative path to the file to run.
|
|
@@ -138,7 +140,8 @@ Useful browser endpoints exposed by the local server include:
|
|
|
138
140
|
|
|
139
141
|
- `GET /api/path-suggestions?tab=<tabId>&query=<path>` for `@` file/path references with live suggestions.
|
|
140
142
|
- `POST /api/action-feedback?tab=<tabId>` for feedback on final assistant output and action cards.
|
|
141
|
-
- `
|
|
143
|
+
- `GET /api/optional-features` for optional companion package install/update status.
|
|
144
|
+
- `POST /api/optional-feature-install` for installing or updating known optional companion packages from the side panel.
|
|
142
145
|
- `GET /api/update-status` and localhost-only `POST /api/update` for checking Pi/Web UI updates and running `pi update` plus all detected local/global Web UI and Pi package-manager updates followed by a Web UI server restart.
|
|
143
146
|
|
|
144
147
|
For local development, run the checkout helper directly, for example:
|
|
@@ -151,9 +154,9 @@ Run `../dev/scripts/sync-pi-package-symlinks.sh` first when developing companion
|
|
|
151
154
|
|
|
152
155
|
## Optional companion packages
|
|
153
156
|
|
|
154
|
-
A normal Pi/npm install includes the optional companion packages unless optional dependencies are disabled. Each Web UI tab curates Pi resources from the Web UI package that started the server, while preserving unrelated user/project resources
|
|
157
|
+
A normal Pi/npm install includes the optional companion packages unless optional dependencies are disabled. Each Web UI tab curates Pi resources from the Web UI package that started the server, while preserving unrelated user/project resources. Companion packages installed as global/npm-prefix siblings of the started Web UI package are reused when the Web UI package does not have its own nested optional dependency copy, avoiding duplicate loads while keeping global `pi-webui` launches working. Startup checks loaded Pi capabilities directly through RPC-visible commands and live widget events, then the side panel shows each optional feature as enabled, disabled, installed-but-not-loaded, update-available, or install-needed. Installing or updating a feature is an explicit, warned action with running/failure feedback in the row and activity log; it is localhost-only, limited to known packages, and requires reloading the active Pi tab after installation.
|
|
155
158
|
|
|
156
|
-
When the standalone global `pi-webui` launcher is used, optional companion installs
|
|
159
|
+
When the standalone global `pi-webui` launcher is used, optional companion installs target the npm prefix containing the Web UI package when that prefix is safe, otherwise the Pi agent npm root if it contains Web UI. Override the target explicitly with `PI_WEBUI_OPTIONAL_FEATURE_INSTALL_ROOT=/path/to/package-root` when needed.
|
|
157
160
|
|
|
158
161
|
Optional companions:
|
|
159
162
|
|
package/bin/pi-webui.mjs
CHANGED
|
@@ -933,6 +933,10 @@ async function installRootDeclaresPackage(root, packageName) {
|
|
|
933
933
|
return declaredDependencySpec(pkg, packageName) !== undefined;
|
|
934
934
|
}
|
|
935
935
|
|
|
936
|
+
async function installRootContainsPackage(root, packageName) {
|
|
937
|
+
return directoryExists(packageNodeModulesPath(path.join(root, "node_modules"), packageName));
|
|
938
|
+
}
|
|
939
|
+
|
|
936
940
|
function configuredAgentNpmRoot() {
|
|
937
941
|
const root = process.env.PI_CODING_AGENT_DIR ? path.resolve(expandUserPath(process.env.PI_CODING_AGENT_DIR)) : agentDir;
|
|
938
942
|
return path.join(root, "npm");
|
|
@@ -943,10 +947,10 @@ async function optionalDependencyInstallRoot() {
|
|
|
943
947
|
if (configuredRoot) return path.resolve(expandUserPath(configuredRoot));
|
|
944
948
|
|
|
945
949
|
const installRoot = nodeModulesParentForPackageRoot(packageRoot);
|
|
946
|
-
if (await installRootDeclaresPackage(installRoot, "@firstpick/pi-package-webui")) return installRoot;
|
|
950
|
+
if (await installRootDeclaresPackage(installRoot, "@firstpick/pi-package-webui") || await installRootContainsPackage(installRoot, "@firstpick/pi-package-webui")) return installRoot;
|
|
947
951
|
|
|
948
952
|
const agentNpmRoot = configuredAgentNpmRoot();
|
|
949
|
-
if (installRoot !== agentNpmRoot && await installRootDeclaresPackage(agentNpmRoot, "@firstpick/pi-package-webui")) return agentNpmRoot;
|
|
953
|
+
if (installRoot !== agentNpmRoot && (await installRootDeclaresPackage(agentNpmRoot, "@firstpick/pi-package-webui") || await installRootContainsPackage(agentNpmRoot, "@firstpick/pi-package-webui"))) return agentNpmRoot;
|
|
950
954
|
|
|
951
955
|
if (webuiDevServer) return installRoot;
|
|
952
956
|
|
|
@@ -956,13 +960,102 @@ async function optionalDependencyInstallRoot() {
|
|
|
956
960
|
);
|
|
957
961
|
}
|
|
958
962
|
|
|
963
|
+
function minimumPackageVersionFromSpec(spec) {
|
|
964
|
+
const match = String(spec || "").match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/);
|
|
965
|
+
return match?.[0] || "";
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function packageVersionBelowSpec(currentVersion, spec) {
|
|
969
|
+
const minimum = minimumPackageVersionFromSpec(spec);
|
|
970
|
+
return !!(currentVersion && minimum && isNewerPackageVersion(minimum, currentVersion));
|
|
971
|
+
}
|
|
972
|
+
|
|
959
973
|
function formatCommandForDisplay(command, args) {
|
|
960
974
|
return [command, ...args].map((part) => (/\s/.test(part) ? JSON.stringify(part) : part)).join(" ");
|
|
961
975
|
}
|
|
962
976
|
|
|
963
|
-
|
|
977
|
+
let optionalPackageNodeModulesRootsCache = null;
|
|
978
|
+
async function optionalPackageNodeModulesRoots() {
|
|
979
|
+
if (optionalPackageNodeModulesRootsCache) return optionalPackageNodeModulesRootsCache;
|
|
980
|
+
const roots = [];
|
|
981
|
+
const seen = new Set();
|
|
982
|
+
const add = (root) => {
|
|
983
|
+
if (!root) return;
|
|
984
|
+
const normalized = path.resolve(root);
|
|
985
|
+
if (seen.has(normalized)) return;
|
|
986
|
+
seen.add(normalized);
|
|
987
|
+
roots.push(normalized);
|
|
988
|
+
};
|
|
989
|
+
const configuredRoot = process.env[OPTIONAL_FEATURE_INSTALL_ROOT_ENV];
|
|
990
|
+
if (configuredRoot) add(path.join(path.resolve(expandUserPath(configuredRoot)), "node_modules"));
|
|
991
|
+
add(path.join(packageRoot, "node_modules"));
|
|
992
|
+
add(path.join(nodeModulesParentForPackageRoot(packageRoot), "node_modules"));
|
|
993
|
+
add(path.join(configuredAgentNpmRoot(), "node_modules"));
|
|
994
|
+
const npmGlobalRoot = await npmGlobalNodeModulesRoot();
|
|
995
|
+
if (npmGlobalRoot) add(npmGlobalRoot);
|
|
996
|
+
for (const bunRoot of await bunGlobalNodeModulesRoots()) add(bunRoot);
|
|
997
|
+
optionalPackageNodeModulesRootsCache = roots;
|
|
998
|
+
return roots;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
async function optionalPackageCandidateRoots(packageName) {
|
|
1002
|
+
return (await optionalPackageNodeModulesRoots()).map((root) => packageNodeModulesPath(root, packageName));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async function resolveInstalledPackageRoot(packageName) {
|
|
1006
|
+
const workspaceRoot = await workspacePackageRootForName(packageName);
|
|
1007
|
+
if (workspaceRoot) return workspaceRoot;
|
|
1008
|
+
for (const candidate of await optionalPackageCandidateRoots(packageName)) {
|
|
1009
|
+
if (await directoryExists(candidate)) return candidate;
|
|
1010
|
+
}
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
async function resolveInstalledPackageSubpath(packageName, subpath = "") {
|
|
1015
|
+
const root = await resolveInstalledPackageRoot(packageName);
|
|
1016
|
+
if (!root) return null;
|
|
1017
|
+
const candidate = path.join(root, subpath || "");
|
|
1018
|
+
try {
|
|
1019
|
+
await access(candidate);
|
|
1020
|
+
return candidate;
|
|
1021
|
+
} catch {
|
|
1022
|
+
return null;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function optionalFeatureDeclaredSpec(packageName) {
|
|
1027
|
+
return declaredDependencySpec(packageJson, packageName) || "";
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
async function optionalFeaturePackageStatus(featureId) {
|
|
964
1031
|
const packageName = OPTIONAL_FEATURE_PACKAGES.get(featureId);
|
|
965
1032
|
if (!packageName) throw makeHttpError(400, `Unknown optional feature: ${featureId}`);
|
|
1033
|
+
const declaredSpec = optionalFeatureDeclaredSpec(packageName);
|
|
1034
|
+
const installedRoot = await resolveInstalledPackageRoot(packageName);
|
|
1035
|
+
const manifest = installedRoot ? await readJsonFileIfExists(path.join(installedRoot, "package.json")) : null;
|
|
1036
|
+
const installedVersion = typeof manifest?.version === "string" ? manifest.version : "";
|
|
1037
|
+
const updateAvailable = !!(installedVersion && packageVersionBelowSpec(installedVersion, declaredSpec));
|
|
1038
|
+
return {
|
|
1039
|
+
featureId,
|
|
1040
|
+
packageName,
|
|
1041
|
+
declaredSpec,
|
|
1042
|
+
installed: !!installedRoot,
|
|
1043
|
+
installedVersion,
|
|
1044
|
+
installedRoot,
|
|
1045
|
+
updateAvailable,
|
|
1046
|
+
updateReason: updateAvailable ? `installed ${installedVersion} is older than Web UI expects (${declaredSpec})` : "",
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
async function optionalFeaturePackageStatuses() {
|
|
1051
|
+
const features = [];
|
|
1052
|
+
for (const featureId of OPTIONAL_FEATURE_PACKAGES.keys()) features.push(await optionalFeaturePackageStatus(featureId));
|
|
1053
|
+
return { features };
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
async function installOptionalFeaturePackage(featureId) {
|
|
1057
|
+
const beforeStatus = await optionalFeaturePackageStatus(featureId);
|
|
1058
|
+
const packageName = beforeStatus.packageName;
|
|
966
1059
|
|
|
967
1060
|
const installRoot = await optionalDependencyInstallRoot();
|
|
968
1061
|
const npmCommand = process.env.PI_WEBUI_NPM_BIN || "npm";
|
|
@@ -978,6 +1071,8 @@ async function installOptionalFeaturePackage(featureId) {
|
|
|
978
1071
|
const details = [result.error, result.timedOut ? "timed out" : undefined, result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join("\n");
|
|
979
1072
|
throw makeHttpError(500, `Optional feature install failed: ${command}${details ? `\n${details}` : ""}`);
|
|
980
1073
|
}
|
|
1074
|
+
const afterStatus = await optionalFeaturePackageStatus(featureId);
|
|
1075
|
+
const operation = beforeStatus.installed ? "Updated" : "Installed";
|
|
981
1076
|
return {
|
|
982
1077
|
featureId,
|
|
983
1078
|
packageName,
|
|
@@ -985,7 +1080,8 @@ async function installOptionalFeaturePackage(featureId) {
|
|
|
985
1080
|
command,
|
|
986
1081
|
stdout: result.stdout,
|
|
987
1082
|
stderr: result.stderr,
|
|
988
|
-
|
|
1083
|
+
status: afterStatus,
|
|
1084
|
+
message: `${operation} optional feature package ${packageName}${afterStatus.installedVersion ? ` to ${afterStatus.installedVersion}` : ""}. Reload the active Pi tab to load new resources.`,
|
|
989
1085
|
};
|
|
990
1086
|
}
|
|
991
1087
|
|
|
@@ -2955,6 +3051,58 @@ function cleanGitCommitMessageInput(value) {
|
|
|
2955
3051
|
return message;
|
|
2956
3052
|
}
|
|
2957
3053
|
|
|
3054
|
+
function parseGitPorcelainZEntries(text) {
|
|
3055
|
+
const fields = String(text || "").split("\0").filter(Boolean);
|
|
3056
|
+
const entries = [];
|
|
3057
|
+
for (let index = 0; index < fields.length; index++) {
|
|
3058
|
+
const field = fields[index];
|
|
3059
|
+
if (field.length < 4) {
|
|
3060
|
+
entries.push({ x: "", y: "", path: field, unsupported: true });
|
|
3061
|
+
continue;
|
|
3062
|
+
}
|
|
3063
|
+
const x = field[0] || " ";
|
|
3064
|
+
const y = field[1] || " ";
|
|
3065
|
+
const filePath = field.slice(3);
|
|
3066
|
+
const entry = { x, y, path: filePath };
|
|
3067
|
+
if ((x === "R" || x === "C") && index + 1 < fields.length) entry.oldPath = fields[++index];
|
|
3068
|
+
entries.push(entry);
|
|
3069
|
+
}
|
|
3070
|
+
return entries;
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
function gitWorkflowDefaultCommitAction(entry) {
|
|
3074
|
+
if (!entry || entry.y !== " ") return "";
|
|
3075
|
+
if (entry.x === "A") return "created";
|
|
3076
|
+
if (entry.x === "M" || entry.x === "T") return "updated";
|
|
3077
|
+
if (entry.x === "D") return "deleted";
|
|
3078
|
+
return "";
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
function formatGitWorkflowDefaultCommitPath(filePath) {
|
|
3082
|
+
return String(filePath || "").replace(/[\0\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 4000);
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
async function readGitWorkflowDefaultCommitMessage(cwd) {
|
|
3086
|
+
const root = await getGitRoot(cwd);
|
|
3087
|
+
const statusText = await runGitReadCommand(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { maxOutputLength: 120_000 });
|
|
3088
|
+
const entries = parseGitPorcelainZEntries(statusText);
|
|
3089
|
+
const empty = (reason, extra = {}) => ({ root, message: "", reason, ...extra });
|
|
3090
|
+
if (entries.length === 0) return empty("No changed files are ready for a default commit message.");
|
|
3091
|
+
if (entries.length !== 1) return empty(`Expected exactly one changed file for a default commit message; found ${entries.length}.`);
|
|
3092
|
+
const [entry] = entries;
|
|
3093
|
+
const action = gitWorkflowDefaultCommitAction(entry);
|
|
3094
|
+
const displayPath = formatGitWorkflowDefaultCommitPath(entry.path);
|
|
3095
|
+
if (!action || !displayPath) {
|
|
3096
|
+
return empty("The only changed file is not a staged created, updated, or deleted file.", { path: entry.path || "" });
|
|
3097
|
+
}
|
|
3098
|
+
return {
|
|
3099
|
+
root,
|
|
3100
|
+
message: `${action} ${displayPath}`,
|
|
3101
|
+
action,
|
|
3102
|
+
path: entry.path,
|
|
3103
|
+
};
|
|
3104
|
+
}
|
|
3105
|
+
|
|
2958
3106
|
function cleanGitHubUsername(value) {
|
|
2959
3107
|
const username = String(value || "").trim().replace(/^@+/, "");
|
|
2960
3108
|
if (!username) throw new Error("GitHub username is required");
|
|
@@ -3300,6 +3448,8 @@ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd)
|
|
|
3300
3448
|
switch (pathname) {
|
|
3301
3449
|
case "/api/git-workflow/message":
|
|
3302
3450
|
return { ok: true, data: await readGitWorkflowMessages(cwd) };
|
|
3451
|
+
case "/api/git-workflow/default-commit-message":
|
|
3452
|
+
return { ok: true, data: await readGitWorkflowDefaultCommitMessage(cwd) };
|
|
3303
3453
|
case "/api/git-workflow/branch-name":
|
|
3304
3454
|
return { ok: true, data: await readGitWorkflowBranchName(cwd) };
|
|
3305
3455
|
case "/api/git-workflow/pr-description":
|
|
@@ -3917,16 +4067,8 @@ function parseNodeModulesPackageRef(manifestEntry) {
|
|
|
3917
4067
|
async function resolveStartedWebuiManifestResource(manifestEntry) {
|
|
3918
4068
|
const nodeModulesRef = parseNodeModulesPackageRef(manifestEntry);
|
|
3919
4069
|
if (nodeModulesRef && WEBUI_CONTROLLED_PACKAGES.has(nodeModulesRef.packageName)) {
|
|
3920
|
-
const
|
|
3921
|
-
if (
|
|
3922
|
-
const devCandidate = path.join(workspaceRoot, nodeModulesRef.subpath);
|
|
3923
|
-
try {
|
|
3924
|
-
await access(devCandidate);
|
|
3925
|
-
return devCandidate;
|
|
3926
|
-
} catch {
|
|
3927
|
-
// Fall back to the started package's node_modules copy below.
|
|
3928
|
-
}
|
|
3929
|
-
}
|
|
4070
|
+
const installedCandidate = await resolveInstalledPackageSubpath(nodeModulesRef.packageName, nodeModulesRef.subpath);
|
|
4071
|
+
if (installedCandidate) return installedCandidate;
|
|
3930
4072
|
}
|
|
3931
4073
|
|
|
3932
4074
|
const candidate = path.resolve(packageRoot, manifestEntry);
|
|
@@ -6964,6 +7106,11 @@ const server = createServer(async (req, res) => {
|
|
|
6964
7106
|
return;
|
|
6965
7107
|
}
|
|
6966
7108
|
|
|
7109
|
+
if (url.pathname === "/api/optional-features" && req.method === "GET") {
|
|
7110
|
+
sendJson(res, 200, { ok: true, data: await optionalFeaturePackageStatuses() });
|
|
7111
|
+
return;
|
|
7112
|
+
}
|
|
7113
|
+
|
|
6967
7114
|
if (url.pathname === "/api/optional-feature-install" && req.method === "POST") {
|
|
6968
7115
|
requireLocalhostRoute(req, url.pathname);
|
|
6969
7116
|
const body = await readJsonBody(req);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firstpick/pi-package-webui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.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
|
"homepage": "https://github.com/Firstp1ck/npm-packages/tree/main/pi-package-webui#readme",
|