@firstpick/pi-package-webui 0.2.2 → 0.2.4
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 +26 -8
- package/WEBUI_TUI_NATIVE_PARITY.json +26 -0
- package/bin/pi-webui.mjs +402 -14
- package/package.json +10 -3
- package/public/app.js +202 -10
- package/public/index.html +2 -2
- package/public/service-worker.js +1 -1
- package/public/styles.css +25 -0
- package/tests/mobile-static.test.mjs +20 -5
- package/tests/native-parity.test.mjs +19 -1
- package/webui-rpc-helper.mjs +231 -0
package/README.md
CHANGED
|
@@ -117,25 +117,43 @@ Environment variables:
|
|
|
117
117
|
## Main features
|
|
118
118
|
|
|
119
119
|
- Multi-tab Pi sessions with isolated processes, working directories, prompt drafts, and activity state.
|
|
120
|
+
- Automatic tab naming from the first prompt, with `--name <name>` still available for an explicit initial tab name.
|
|
120
121
|
- Streaming chat transcript with Markdown, thinking output, tool/bash cards, queue and compaction events, and abort controls.
|
|
121
|
-
- Prompt composer with uploads, drag/drop/paste, inline image support, slash-command autocomplete, and `@` file/path references.
|
|
122
|
-
- Browser dialogs for common Pi selectors such as `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, `/tree`,
|
|
122
|
+
- Prompt composer with uploads, drag/drop/paste, inline image support, slash-command autocomplete, and `@` file/path references with live suggestions.
|
|
123
|
+
- Browser dialogs for common Pi selectors such as `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, `/tree`, `/scoped-models`, `/tools`, and `/skills`.
|
|
123
124
|
- Model, thinking, session, workspace, theme, optional-feature, Codex usage, network, event, and notification controls in the side panel.
|
|
124
|
-
-
|
|
125
|
-
-
|
|
126
|
-
-
|
|
125
|
+
- Side-panel theme picker backed by optional `@firstpick/pi-themes-bundle` themes when loaded.
|
|
126
|
+
- Per-tab cwd changes, a clickable footer cwd picker, saved path fast picks, server-persisted fast picks, and restart-safe restoration of open tabs.
|
|
127
|
+
- Browser support for Pi extension UI prompts, widgets, status updates, browser notifications when a tab needs an extension UI response and an optional side-panel toggle for agent-done notifications.
|
|
128
|
+
- Feedback reactions (`👍`, `👎`, `?`) on final assistant output plus tool/bash action cards, which can ask Pi to create or update a LEARNING.
|
|
127
129
|
- Mobile-friendly layout and PWA install support where the browser allows it.
|
|
128
130
|
|
|
129
|
-
|
|
131
|
+
Useful browser endpoints exposed by the local server include:
|
|
130
132
|
|
|
131
|
-
|
|
133
|
+
- `GET /api/path-suggestions?tab=<tabId>&query=<path>` for `@` file/path references with live suggestions.
|
|
134
|
+
- `POST /api/action-feedback?tab=<tabId>` for feedback on final assistant output and action cards.
|
|
135
|
+
- `POST /api/optional-feature-install` for installing known optional companion packages from the side panel.
|
|
136
|
+
|
|
137
|
+
For local development, run the checkout helper directly, for example:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
./start-webui.sh --dev --cwd /path/to/project
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Optional companion packages
|
|
144
|
+
|
|
145
|
+
A normal Pi/npm install includes the optional companion packages unless optional dependencies are disabled. 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, or install-needed. Installing a missing feature is an explicit, warned action; it is localhost-only, limited to known packages, and requires reloading the active Pi tab after installation.
|
|
146
|
+
|
|
147
|
+
When the standalone global `pi-webui` launcher is used, optional companion installs should target the Pi agent npm root instead of the global npm prefix. Override the target explicitly with `PI_WEBUI_OPTIONAL_FEATURE_INSTALL_ROOT=/path/to/package-root` when needed.
|
|
132
148
|
|
|
133
149
|
Optional companions:
|
|
134
150
|
|
|
135
151
|
- `@firstpick/pi-prompts-git-pr` — guided Git commit/push workflow.
|
|
136
152
|
- `@firstpick/pi-extension-release-npm` — NPM publish menu and release widgets.
|
|
137
153
|
- `@firstpick/pi-extension-release-aur` — AUR publish menu and release widgets.
|
|
154
|
+
- `@firstpick/pi-extension-setup-skills` — TUI `/skills` setup command alongside WebUI-native skill toggles.
|
|
138
155
|
- `@firstpick/pi-extension-todo-progress` — todo-progress rendering.
|
|
156
|
+
- `@firstpick/pi-extension-tools` — TUI `/tools` active-tool manager alongside WebUI-native tool toggles.
|
|
139
157
|
- `@firstpick/pi-extension-git-footer-status` — richer git/footer status.
|
|
140
158
|
- `@firstpick/pi-extension-stats` — stats commands and status data.
|
|
141
159
|
- `@firstpick/pi-themes-bundle` — Web UI and Pi theme resources.
|
|
@@ -155,7 +173,7 @@ This requires `/git-staged-msg` from `@firstpick/pi-prompts-git-pr`. Review the
|
|
|
155
173
|
## Mobile and PWA notes
|
|
156
174
|
|
|
157
175
|
- The mobile composer starts as a compact `Ask Pi…` input and grows as you type.
|
|
158
|
-
- Installable PWA support and notifications
|
|
176
|
+
- Installable PWA support, blocked-tab browser notifications, and optional agent-done notifications require browser service-worker/notification support and usually require `localhost` or HTTPS.
|
|
159
177
|
- Plain `http://<LAN-IP>` can show the app, but some browsers disable PWA install and notifications there.
|
|
160
178
|
|
|
161
179
|
## Network safety
|
|
@@ -56,6 +56,32 @@
|
|
|
56
56
|
"currentBehavior": "Informational dialog plus footer scoped picker.",
|
|
57
57
|
"targetBehavior": "Searchable model table, provider toggles, ordered explicit provider/model IDs, advanced patterns, and safe global/project persistence."
|
|
58
58
|
},
|
|
59
|
+
{
|
|
60
|
+
"id": "/tools",
|
|
61
|
+
"kind": "slash-command",
|
|
62
|
+
"category": "native-command",
|
|
63
|
+
"title": "Tool enable/disable selector",
|
|
64
|
+
"command": { "name": "tools", "description": "Enable/disable tools for the active tab" },
|
|
65
|
+
"webStatus": "implemented",
|
|
66
|
+
"priority": "P1",
|
|
67
|
+
"sensitive": false,
|
|
68
|
+
"guards": ["confirmation"],
|
|
69
|
+
"currentBehavior": "Browser-native selector uses a hidden Web UI RPC helper extension to read all tools, set active tools, and persist selection on the session branch.",
|
|
70
|
+
"targetBehavior": "Keep browser-native runtime tool toggles in parity with Pi's TUI tools extension while exposing provenance and branch-persistent state."
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"id": "/skills",
|
|
74
|
+
"kind": "slash-command",
|
|
75
|
+
"category": "native-command",
|
|
76
|
+
"title": "Skill enable/disable selector",
|
|
77
|
+
"command": { "name": "skills", "description": "Enable/disable skills for automatic model invocation" },
|
|
78
|
+
"webStatus": "implemented",
|
|
79
|
+
"priority": "P1",
|
|
80
|
+
"sensitive": false,
|
|
81
|
+
"guards": ["confirmation"],
|
|
82
|
+
"currentBehavior": "Browser-native selector uses a hidden Web UI RPC helper extension to filter disabled skills out of the system prompt, block disabled /skill:name invocations, and persist selection on the session branch.",
|
|
83
|
+
"targetBehavior": "Keep browser-native skill toggles runtime-scoped with clear distinction from package-level pi config enable/disable."
|
|
84
|
+
},
|
|
59
85
|
{
|
|
60
86
|
"id": "/export",
|
|
61
87
|
"kind": "slash-command",
|
package/bin/pi-webui.mjs
CHANGED
|
@@ -8,13 +8,16 @@ import { access, copyFile, mkdir, readFile, readdir, rename, stat, writeFile } f
|
|
|
8
8
|
import { homedir, networkInterfaces, tmpdir } from "node:os";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { StringDecoder } from "node:string_decoder";
|
|
11
|
-
import { fileURLToPath } from "node:url";
|
|
12
|
-
import { AuthStorage, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
|
+
import { AuthStorage, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
|
|
14
14
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
15
|
const require = createRequire(import.meta.url);
|
|
16
16
|
const packageRoot = path.resolve(__dirname, "..");
|
|
17
17
|
const publicDir = path.join(packageRoot, "public");
|
|
18
|
+
const webuiHelperExtensionPath = path.join(packageRoot, "webui-rpc-helper.mjs");
|
|
19
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR || path.join(homedir(), ".pi", "agent");
|
|
20
|
+
const OPTIONAL_FEATURE_INSTALL_ROOT_ENV = "PI_WEBUI_OPTIONAL_FEATURE_INSTALL_ROOT";
|
|
18
21
|
const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
|
|
19
22
|
const nativeParityMatrix = JSON.parse(await readFile(path.join(packageRoot, "WEBUI_TUI_NATIVE_PARITY.json"), "utf8"));
|
|
20
23
|
const webuiDevServer = isTruthyEnv(process.env.PI_WEBUI_DEV) || isSourceCheckout(packageRoot);
|
|
@@ -22,6 +25,9 @@ const webuiDevServer = isTruthyEnv(process.env.PI_WEBUI_DEV) || isSourceCheckout
|
|
|
22
25
|
const DEFAULT_HOST = "127.0.0.1";
|
|
23
26
|
const DEFAULT_PORT = 31415;
|
|
24
27
|
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
28
|
+
const WEBUI_HELPER_TIMEOUT_MS = 8 * 1000;
|
|
29
|
+
const WEBUI_HELPER_COMMAND = "webui-helper";
|
|
30
|
+
const WEBUI_HELPER_RESPONSE_PREFIX = "__PI_WEBUI_HELPER_RESPONSE__:";
|
|
25
31
|
const CODEX_USAGE_TIMEOUT_MS = 15 * 1000;
|
|
26
32
|
const CODEX_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
|
|
27
33
|
const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
|
|
@@ -35,6 +41,7 @@ const ATTACHMENT_UPLOAD_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
|
35
41
|
const INLINE_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
|
|
36
42
|
const INLINE_IMAGE_TOTAL_MAX_BYTES = 16 * 1024 * 1024;
|
|
37
43
|
const RPC_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
|
|
44
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
38
45
|
const EVENT_HISTORY_LIMIT = 200;
|
|
39
46
|
const EXTENSION_UI_BLOCKING_METHODS = new Set(["select", "confirm", "input", "editor"]);
|
|
40
47
|
const STATUS_RPC_TIMEOUT_MS = 1_800;
|
|
@@ -141,7 +148,9 @@ const OPTIONAL_FEATURE_PACKAGES = new Map([
|
|
|
141
148
|
["gitWorkflow", "@firstpick/pi-prompts-git-pr"],
|
|
142
149
|
["releaseNpm", "@firstpick/pi-extension-release-npm"],
|
|
143
150
|
["releaseAur", "@firstpick/pi-extension-release-aur"],
|
|
151
|
+
["tuiSkillsCommand", "@firstpick/pi-extension-setup-skills"],
|
|
144
152
|
["todoProgressWidget", "@firstpick/pi-extension-todo-progress"],
|
|
153
|
+
["tuiToolsCommand", "@firstpick/pi-extension-tools"],
|
|
145
154
|
["gitFooterStatus", "@firstpick/pi-extension-git-footer-status"],
|
|
146
155
|
["statsCommand", "@firstpick/pi-extension-stats"],
|
|
147
156
|
["themeBundle", "@firstpick/pi-themes-bundle"],
|
|
@@ -761,14 +770,51 @@ function runCommand(command, args, { cwd, timeoutMs = 2000, maxOutputLength = 20
|
|
|
761
770
|
});
|
|
762
771
|
}
|
|
763
772
|
|
|
764
|
-
function
|
|
765
|
-
const parts =
|
|
773
|
+
function nodeModulesParentForPackageRoot(root = packageRoot) {
|
|
774
|
+
const parts = root.split(path.sep);
|
|
766
775
|
const nodeModulesIndex = parts.lastIndexOf("node_modules");
|
|
767
776
|
if (nodeModulesIndex >= 0) {
|
|
768
|
-
const
|
|
769
|
-
return
|
|
777
|
+
const parent = parts.slice(0, nodeModulesIndex).join(path.sep);
|
|
778
|
+
return parent || path.parse(root).root;
|
|
770
779
|
}
|
|
771
|
-
return
|
|
780
|
+
return root;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function declaredDependencySpec(pkg, packageName) {
|
|
784
|
+
return firstDefined(
|
|
785
|
+
pkg?.dependencies?.[packageName],
|
|
786
|
+
pkg?.optionalDependencies?.[packageName],
|
|
787
|
+
pkg?.devDependencies?.[packageName],
|
|
788
|
+
pkg?.peerDependencies?.[packageName],
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function installRootDeclaresPackage(root, packageName) {
|
|
793
|
+
const pkg = await readJsonFileIfExists(path.join(root, "package.json"));
|
|
794
|
+
return declaredDependencySpec(pkg, packageName) !== undefined;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function configuredAgentNpmRoot() {
|
|
798
|
+
const root = process.env.PI_CODING_AGENT_DIR ? path.resolve(expandUserPath(process.env.PI_CODING_AGENT_DIR)) : agentDir;
|
|
799
|
+
return path.join(root, "npm");
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function optionalDependencyInstallRoot() {
|
|
803
|
+
const configuredRoot = process.env[OPTIONAL_FEATURE_INSTALL_ROOT_ENV];
|
|
804
|
+
if (configuredRoot) return path.resolve(expandUserPath(configuredRoot));
|
|
805
|
+
|
|
806
|
+
const installRoot = nodeModulesParentForPackageRoot(packageRoot);
|
|
807
|
+
if (await installRootDeclaresPackage(installRoot, "@firstpick/pi-package-webui")) return installRoot;
|
|
808
|
+
|
|
809
|
+
const agentNpmRoot = configuredAgentNpmRoot();
|
|
810
|
+
if (installRoot !== agentNpmRoot && await installRootDeclaresPackage(agentNpmRoot, "@firstpick/pi-package-webui")) return agentNpmRoot;
|
|
811
|
+
|
|
812
|
+
if (webuiDevServer) return installRoot;
|
|
813
|
+
|
|
814
|
+
throw makeHttpError(
|
|
815
|
+
500,
|
|
816
|
+
`Could not determine a safe optional feature install root. Set ${OPTIONAL_FEATURE_INSTALL_ROOT_ENV} to the Pi package root.`,
|
|
817
|
+
);
|
|
772
818
|
}
|
|
773
819
|
|
|
774
820
|
function formatCommandForDisplay(command, args) {
|
|
@@ -779,7 +825,7 @@ async function installOptionalFeaturePackage(featureId) {
|
|
|
779
825
|
const packageName = OPTIONAL_FEATURE_PACKAGES.get(featureId);
|
|
780
826
|
if (!packageName) throw makeHttpError(400, `Unknown optional feature: ${featureId}`);
|
|
781
827
|
|
|
782
|
-
const installRoot = optionalDependencyInstallRoot();
|
|
828
|
+
const installRoot = await optionalDependencyInstallRoot();
|
|
783
829
|
const npmCommand = process.env.PI_WEBUI_NPM_BIN || "npm";
|
|
784
830
|
const args = ["install", "--prefix", installRoot, packageName];
|
|
785
831
|
const result = await runCommand(npmCommand, args, {
|
|
@@ -1951,7 +1997,7 @@ function commandFromPost(pathname, body) {
|
|
|
1951
1997
|
}
|
|
1952
1998
|
case "/api/thinking": {
|
|
1953
1999
|
const level = String(body.level || "").trim();
|
|
1954
|
-
if (!
|
|
2000
|
+
if (!THINKING_LEVELS.includes(level)) {
|
|
1955
2001
|
throw new Error("Invalid thinking level");
|
|
1956
2002
|
}
|
|
1957
2003
|
return { type: "set_thinking_level", level };
|
|
@@ -2067,6 +2113,11 @@ function buildPiArgsForTab(tabIndex, title) {
|
|
|
2067
2113
|
const args = ["--mode", "rpc"];
|
|
2068
2114
|
if (options.noSession) args.push("--no-session");
|
|
2069
2115
|
|
|
2116
|
+
// Load a browser-safe RPC helper into every Web UI tab. It exposes hidden
|
|
2117
|
+
// extension commands for Web UI-native /tools and /skills selectors without
|
|
2118
|
+
// depending on TUI-only extension UIs.
|
|
2119
|
+
args.push("--extension", webuiHelperExtensionPath);
|
|
2120
|
+
|
|
2070
2121
|
// Keep tab naming inside Web UI metadata. Some bundled Pi CLI versions do not
|
|
2071
2122
|
// support --name, and passing Web UI-generated tab titles through to child
|
|
2072
2123
|
// RPC processes makes every tab after the first exit immediately.
|
|
@@ -2109,10 +2160,21 @@ function rememberTabState(tab, state) {
|
|
|
2109
2160
|
if (!options.noSession && Object.prototype.hasOwnProperty.call(state, "sessionFile")) tab.sessionFile = sessionFileFromState(state);
|
|
2110
2161
|
}
|
|
2111
2162
|
|
|
2163
|
+
function stateWithPendingThinking(tab, state) {
|
|
2164
|
+
if (!state || typeof state !== "object" || !tab?.pendingThinkingLevel) return state;
|
|
2165
|
+
return { ...state, pendingThinkingLevel: tab.pendingThinkingLevel };
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
function responseWithPendingThinking(tab, response) {
|
|
2169
|
+
if (!response || typeof response !== "object" || response.success === false || response.command !== "get_state") return response;
|
|
2170
|
+
return { ...response, data: stateWithPendingThinking(tab, response.data) };
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2112
2173
|
function forgetTabState(tab) {
|
|
2113
2174
|
if (!tab) return;
|
|
2114
2175
|
tab.lastState = null;
|
|
2115
2176
|
tab.sessionFile = undefined;
|
|
2177
|
+
tab.pendingThinkingLevel = undefined;
|
|
2116
2178
|
}
|
|
2117
2179
|
|
|
2118
2180
|
function tabRestorableSessionFile(tab) {
|
|
@@ -2434,6 +2496,7 @@ function attachRpcToTab(tab, rpc) {
|
|
|
2434
2496
|
tab.rpcUnsubscribe?.();
|
|
2435
2497
|
tab.rpc = rpc;
|
|
2436
2498
|
tab.rpcUnsubscribe = rpc.onEvent((event) => {
|
|
2499
|
+
if (resolveWebuiHelperResponse(tab, event) || resolveWebuiHelperRpcResponse(tab, event)) return;
|
|
2437
2500
|
updateTabActivityFromEvent(tab, event);
|
|
2438
2501
|
let scopedEvent = { ...event, tabId: tab.id, tabTitle: tab.title, tabActivity: tabActivitySnapshot(tab) };
|
|
2439
2502
|
if (event?.type === "pi_process_exit" || event?.type === "pi_process_error") clearPendingExtensionUiRequests(tab);
|
|
@@ -2468,8 +2531,11 @@ async function createTab({ id: requestedId, index, title, titleSource, conversat
|
|
|
2468
2531
|
createdAt,
|
|
2469
2532
|
sessionFile: options.noSession ? undefined : normalizedRestoreString(sessionFile, 4096),
|
|
2470
2533
|
lastState: null,
|
|
2534
|
+
pendingThinkingLevel: undefined,
|
|
2471
2535
|
activity: createTabActivity(createdAt),
|
|
2472
2536
|
pendingExtensionUiRequests: new Map(),
|
|
2537
|
+
webuiHelperRequests: new Map(),
|
|
2538
|
+
webuiHelperResponseIds: new Set(),
|
|
2473
2539
|
bashQueue: [],
|
|
2474
2540
|
bashQueueDraining: false,
|
|
2475
2541
|
rpc: undefined,
|
|
@@ -2508,6 +2574,7 @@ function tabMeta(tab) {
|
|
|
2508
2574
|
conversationStarted: !!tab.conversationStarted,
|
|
2509
2575
|
cwd: tab.cwd,
|
|
2510
2576
|
sessionFile: tabRestorableSessionFile(tab),
|
|
2577
|
+
pendingThinkingLevel: tab.pendingThinkingLevel || null,
|
|
2511
2578
|
createdAt: tab.createdAt,
|
|
2512
2579
|
startedAt: tab.rpc.startedAt,
|
|
2513
2580
|
pid: tab.rpc.child?.pid,
|
|
@@ -2752,19 +2819,279 @@ function fallbackRpcResponse(tab, command, error) {
|
|
|
2752
2819
|
|
|
2753
2820
|
async function safeRpcResponse(tab, command, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
2754
2821
|
try {
|
|
2755
|
-
return await tab.rpc.send(command, timeoutMs);
|
|
2822
|
+
return responseWithPendingThinking(tab, await tab.rpc.send(command, timeoutMs));
|
|
2756
2823
|
} catch (error) {
|
|
2757
2824
|
const message = sanitizeError(error);
|
|
2758
|
-
if (/Pi RPC process is not running/i.test(message)) return fallbackRpcResponse(tab, command, error);
|
|
2825
|
+
if (/Pi RPC process is not running/i.test(message)) return responseWithPendingThinking(tab, fallbackRpcResponse(tab, command, error));
|
|
2759
2826
|
throw error;
|
|
2760
2827
|
}
|
|
2761
2828
|
}
|
|
2762
2829
|
|
|
2830
|
+
function parseWebuiHelperResponseEvent(event) {
|
|
2831
|
+
if (event?.type !== "extension_ui_request" || event.method !== "notify") return undefined;
|
|
2832
|
+
const message = String(event.message || "");
|
|
2833
|
+
if (!message.startsWith(WEBUI_HELPER_RESPONSE_PREFIX)) return undefined;
|
|
2834
|
+
try {
|
|
2835
|
+
return JSON.parse(message.slice(WEBUI_HELPER_RESPONSE_PREFIX.length));
|
|
2836
|
+
} catch (error) {
|
|
2837
|
+
return { ok: false, error: `Invalid Web UI helper response: ${sanitizeError(error)}` };
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
function resolveWebuiHelperResponse(tab, event) {
|
|
2842
|
+
const payload = parseWebuiHelperResponseEvent(event);
|
|
2843
|
+
if (!payload) return false;
|
|
2844
|
+
const requestId = String(payload.requestId || "");
|
|
2845
|
+
const pending = tab?.webuiHelperRequests?.get(requestId);
|
|
2846
|
+
if (pending) {
|
|
2847
|
+
tab.webuiHelperRequests.delete(requestId);
|
|
2848
|
+
clearTimeout(pending.timeout);
|
|
2849
|
+
if (payload.ok === false) pending.reject(makeHttpError(400, payload.error || "Web UI helper command failed"));
|
|
2850
|
+
else pending.resolve(payload.data || {});
|
|
2851
|
+
}
|
|
2852
|
+
return true;
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
function resolveWebuiHelperRpcResponse(tab, event) {
|
|
2856
|
+
if (event?.type !== "response" || event.command !== "prompt" || !event.id) return false;
|
|
2857
|
+
return tab?.webuiHelperResponseIds?.delete(String(event.id)) === true;
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
function webuiHelperRequestMap(tab) {
|
|
2861
|
+
if (!tab.webuiHelperRequests) tab.webuiHelperRequests = new Map();
|
|
2862
|
+
return tab.webuiHelperRequests;
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
async function sendWebuiHelperCommand(tab, action, payload = {}, timeoutMs = WEBUI_HELPER_TIMEOUT_MS) {
|
|
2866
|
+
const requestId = randomUUID();
|
|
2867
|
+
const pending = new Promise((resolve, reject) => {
|
|
2868
|
+
const timeout = setTimeout(() => {
|
|
2869
|
+
webuiHelperRequestMap(tab).delete(requestId);
|
|
2870
|
+
tab.webuiHelperResponseIds?.delete(requestId);
|
|
2871
|
+
reject(makeHttpError(504, `Timed out waiting for Web UI helper action: ${action}. Try /reload in this tab, then retry.`));
|
|
2872
|
+
}, timeoutMs);
|
|
2873
|
+
webuiHelperRequestMap(tab).set(requestId, { resolve, reject, timeout });
|
|
2874
|
+
});
|
|
2875
|
+
pending.catch(() => {});
|
|
2876
|
+
|
|
2877
|
+
try {
|
|
2878
|
+
tab.webuiHelperResponseIds?.add(requestId);
|
|
2879
|
+
const response = await tab.rpc.send({
|
|
2880
|
+
id: requestId,
|
|
2881
|
+
type: "prompt",
|
|
2882
|
+
message: `/${WEBUI_HELPER_COMMAND} ${JSON.stringify({ requestId, action, payload })}`,
|
|
2883
|
+
}, timeoutMs);
|
|
2884
|
+
if (response.success === false) throw makeHttpError(400, response.error || `Web UI helper action failed: ${action}`);
|
|
2885
|
+
return await pending;
|
|
2886
|
+
} catch (error) {
|
|
2887
|
+
tab.webuiHelperResponseIds?.delete(requestId);
|
|
2888
|
+
const request = webuiHelperRequestMap(tab).get(requestId);
|
|
2889
|
+
if (request) {
|
|
2890
|
+
clearTimeout(request.timeout);
|
|
2891
|
+
webuiHelperRequestMap(tab).delete(requestId);
|
|
2892
|
+
}
|
|
2893
|
+
throw error;
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
async function getToolConfigData(tab) {
|
|
2898
|
+
return sendWebuiHelperCommand(tab, "tools-state");
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
let packageManagerModulePromise;
|
|
2902
|
+
async function loadPackageManagerModule() {
|
|
2903
|
+
if (!packageManagerModulePromise) {
|
|
2904
|
+
const packageMain = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
|
|
2905
|
+
const codingAgentRoot = path.dirname(path.dirname(packageMain));
|
|
2906
|
+
packageManagerModulePromise = import(pathToFileURL(path.join(codingAgentRoot, "dist", "core", "package-manager.js")).href);
|
|
2907
|
+
}
|
|
2908
|
+
return packageManagerModulePromise;
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
function parseSkillFrontmatter(text, filePath) {
|
|
2912
|
+
const frontmatter = String(text || "").match(/^---\s*\n([\s\S]*?)\n---/);
|
|
2913
|
+
const fields = {};
|
|
2914
|
+
if (frontmatter) {
|
|
2915
|
+
for (const line of frontmatter[1].split(/\r?\n/)) {
|
|
2916
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
2917
|
+
if (match) fields[match[1]] = match[2].replace(/^['"]|['"]$/g, "").trim();
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
const parent = path.basename(path.dirname(filePath));
|
|
2921
|
+
const base = path.basename(filePath, path.extname(filePath));
|
|
2922
|
+
return {
|
|
2923
|
+
name: fields.name || (path.basename(filePath) === "SKILL.md" ? parent : base),
|
|
2924
|
+
description: fields.description || "",
|
|
2925
|
+
};
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
function sourceInfoFromResolvedResource(resource) {
|
|
2929
|
+
const metadata = resource?.metadata || {};
|
|
2930
|
+
return {
|
|
2931
|
+
path: resource?.path,
|
|
2932
|
+
source: metadata.source,
|
|
2933
|
+
scope: metadata.scope,
|
|
2934
|
+
origin: metadata.origin,
|
|
2935
|
+
baseDir: metadata.baseDir,
|
|
2936
|
+
};
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
async function resolveSkillResources(tab) {
|
|
2940
|
+
const { DefaultPackageManager } = await loadPackageManagerModule();
|
|
2941
|
+
const settingsManager = SettingsManager.create(tab?.cwd || options.cwd, agentDir);
|
|
2942
|
+
const packageManager = new DefaultPackageManager({ cwd: tab?.cwd || options.cwd, agentDir, settingsManager });
|
|
2943
|
+
const resolved = await packageManager.resolve();
|
|
2944
|
+
const skills = [];
|
|
2945
|
+
for (const resource of resolved.skills || []) {
|
|
2946
|
+
try {
|
|
2947
|
+
const metadata = parseSkillFrontmatter(await readFile(resource.path, "utf8"), resource.path);
|
|
2948
|
+
skills.push({
|
|
2949
|
+
...metadata,
|
|
2950
|
+
filePath: resource.path,
|
|
2951
|
+
enabled: resource.enabled === true,
|
|
2952
|
+
configEnabled: resource.enabled === true,
|
|
2953
|
+
configManaged: true,
|
|
2954
|
+
sourceInfo: sourceInfoFromResolvedResource(resource),
|
|
2955
|
+
});
|
|
2956
|
+
} catch {
|
|
2957
|
+
// Ignore unreadable skill candidates; Pi will also skip invalid resources.
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
return { skills, settingsManager };
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
function skillResourceKey(skill) {
|
|
2964
|
+
return skill.filePath || skill.name;
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
function mergeRuntimeAndResolvedSkills(runtimeSkills, resolvedSkills) {
|
|
2968
|
+
const byName = new Map();
|
|
2969
|
+
for (const skill of resolvedSkills) byName.set(skill.name, { ...skill });
|
|
2970
|
+
for (const skill of runtimeSkills || []) {
|
|
2971
|
+
const existing = byName.get(skill.name);
|
|
2972
|
+
byName.set(skill.name, existing ? { ...existing, ...skill, configManaged: existing.configManaged, configEnabled: existing.configEnabled, filePath: existing.filePath || skill.filePath, sourceInfo: existing.sourceInfo || skill.sourceInfo } : { ...skill, configManaged: false, configEnabled: true });
|
|
2973
|
+
}
|
|
2974
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
async function getMergedSkillConfigData(tab) {
|
|
2978
|
+
const [runtime, resolved] = await Promise.all([
|
|
2979
|
+
getSkillConfigDataFromRuntime(tab).catch(() => ({ skills: [] })),
|
|
2980
|
+
resolveSkillResources(tab).catch((error) => {
|
|
2981
|
+
console.warn(`failed to resolve configured skills: ${sanitizeError(error)}`);
|
|
2982
|
+
return { skills: [] };
|
|
2983
|
+
}),
|
|
2984
|
+
]);
|
|
2985
|
+
return { skills: mergeRuntimeAndResolvedSkills(runtime.skills || [], resolved.skills || []) };
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
function getResourcePatternForSkill(tab, skill) {
|
|
2989
|
+
const info = skill.sourceInfo || {};
|
|
2990
|
+
const baseDir = info.baseDir || (info.scope === "project" ? path.join(tab?.cwd || options.cwd, ".pi") : agentDir);
|
|
2991
|
+
return path.relative(baseDir, skill.filePath);
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
async function setToolConfigData(tab, body) {
|
|
2995
|
+
return sendWebuiHelperCommand(tab, "tools-set", {
|
|
2996
|
+
enabledTools: Array.isArray(body.enabledTools) ? body.enabledTools : undefined,
|
|
2997
|
+
disabledTools: Array.isArray(body.disabledTools) ? body.disabledTools : undefined,
|
|
2998
|
+
});
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
async function getSkillConfigDataFromRuntime(tab) {
|
|
3002
|
+
return sendWebuiHelperCommand(tab, "skills-state");
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
function desiredSkillEnabledFromBody(skillName, body) {
|
|
3006
|
+
if (Array.isArray(body.enabledSkills)) return body.enabledSkills.map(String).includes(skillName);
|
|
3007
|
+
if (Array.isArray(body.disabledSkills)) return !body.disabledSkills.map(String).includes(skillName);
|
|
3008
|
+
throw makeHttpError(400, "Skill update requires enabledSkills or disabledSkills");
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
function updatePatternListForResource(current, pattern, enabled) {
|
|
3012
|
+
const updated = (current || []).filter((item) => {
|
|
3013
|
+
const text = String(item || "");
|
|
3014
|
+
const stripped = text.startsWith("!") || text.startsWith("+") || text.startsWith("-") ? text.slice(1) : text;
|
|
3015
|
+
return stripped !== pattern;
|
|
3016
|
+
});
|
|
3017
|
+
updated.push(`${enabled ? "+" : "-"}${pattern}`);
|
|
3018
|
+
return updated;
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
function setSkillPathsForScope(settingsManager, scope, updated) {
|
|
3022
|
+
if (scope === "project") settingsManager.setProjectSkillPaths(updated);
|
|
3023
|
+
else settingsManager.setSkillPaths(updated);
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
function toggleConfiguredSkill(tab, settingsManager, skill, enabled) {
|
|
3027
|
+
const info = skill.sourceInfo || {};
|
|
3028
|
+
const scope = info.scope === "project" ? "project" : "user";
|
|
3029
|
+
if (info.origin === "package") {
|
|
3030
|
+
const settings = scope === "project" ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
|
|
3031
|
+
const packages = [...(settings.packages || [])];
|
|
3032
|
+
const packageIndex = packages.findIndex((item) => (typeof item === "string" ? item : item?.source) === info.source);
|
|
3033
|
+
if (packageIndex < 0) return false;
|
|
3034
|
+
let packageEntry = packages[packageIndex];
|
|
3035
|
+
if (typeof packageEntry === "string") {
|
|
3036
|
+
packageEntry = { source: packageEntry };
|
|
3037
|
+
packages[packageIndex] = packageEntry;
|
|
3038
|
+
}
|
|
3039
|
+
const pattern = path.relative(info.baseDir || path.dirname(skill.filePath), skill.filePath);
|
|
3040
|
+
packageEntry.skills = updatePatternListForResource(packageEntry.skills || [], pattern, enabled);
|
|
3041
|
+
if (scope === "project") settingsManager.setProjectPackages(packages);
|
|
3042
|
+
else settingsManager.setPackages(packages);
|
|
3043
|
+
return true;
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
const settings = scope === "project" ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
|
|
3047
|
+
const pattern = getResourcePatternForSkill(tab, skill);
|
|
3048
|
+
setSkillPathsForScope(settingsManager, scope, updatePatternListForResource(settings.skills || [], pattern, enabled));
|
|
3049
|
+
return true;
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
async function setSkillConfigData(tab, body) {
|
|
3053
|
+
const { skills, settingsManager } = await resolveSkillResources(tab);
|
|
3054
|
+
let configChanged = false;
|
|
3055
|
+
for (const skill of skills) {
|
|
3056
|
+
const desiredEnabled = desiredSkillEnabledFromBody(skill.name, body);
|
|
3057
|
+
if (skill.configEnabled !== desiredEnabled && toggleConfiguredSkill(tab, settingsManager, skill, desiredEnabled)) configChanged = true;
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
const runtimeOnly = skills.length === 0;
|
|
3061
|
+
if (runtimeOnly) {
|
|
3062
|
+
await sendWebuiHelperCommand(tab, "skills-set", {
|
|
3063
|
+
enabledSkills: Array.isArray(body.enabledSkills) ? body.enabledSkills : undefined,
|
|
3064
|
+
disabledSkills: Array.isArray(body.disabledSkills) ? body.disabledSkills : undefined,
|
|
3065
|
+
});
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
const activeTab = configChanged ? await restartTabRpc(tab, "skills-config") : tab;
|
|
3069
|
+
return getMergedSkillConfigData(activeTab);
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
async function annotateSkillCommandState(tab, commands) {
|
|
3073
|
+
let disabledSkills = new Set();
|
|
3074
|
+
try {
|
|
3075
|
+
const state = await getMergedSkillConfigData(tab);
|
|
3076
|
+
disabledSkills = new Set((state.skills || []).filter((skill) => skill.enabled === false).map((skill) => skill.name));
|
|
3077
|
+
} catch {
|
|
3078
|
+
// Commands should remain available even if an older tab has not loaded the helper yet.
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
return commands
|
|
3082
|
+
.filter((command) => command?.name !== WEBUI_HELPER_COMMAND)
|
|
3083
|
+
.map((command) => {
|
|
3084
|
+
const skillName = command?.source === "skill" && String(command.name || "").startsWith("skill:") ? String(command.name).slice("skill:".length) : "";
|
|
3085
|
+
return skillName ? { ...command, enabled: !disabledSkills.has(skillName) } : command;
|
|
3086
|
+
});
|
|
3087
|
+
}
|
|
3088
|
+
|
|
2763
3089
|
async function getCommandData(tab) {
|
|
2764
3090
|
try {
|
|
2765
3091
|
const response = await tab.rpc.send({ type: "get_commands" });
|
|
2766
3092
|
if (response.success === false) throw makeHttpError(400, response.error || "failed to load commands");
|
|
2767
|
-
|
|
3093
|
+
const rpcCommands = await annotateSkillCommandState(tab, response.data?.commands || []);
|
|
3094
|
+
return { commands: [...NATIVE_SLASH_COMMANDS, ...rpcCommands], rpcRunning: true };
|
|
2768
3095
|
} catch (error) {
|
|
2769
3096
|
const message = sanitizeError(error);
|
|
2770
3097
|
if (!/Pi RPC process is not running/i.test(message)) throw error;
|
|
@@ -3457,12 +3784,38 @@ async function safeRpcData(tab, command, timeoutMs = STATUS_RPC_TIMEOUT_MS) {
|
|
|
3457
3784
|
const response = await tab.rpc.send(command, timeoutMs);
|
|
3458
3785
|
if (response?.success === false) return { ok: false, error: response.error || `${command.type} failed` };
|
|
3459
3786
|
if (command?.type === "get_state") rememberTabState(tab, response?.data);
|
|
3460
|
-
return { ok: true, data: response?.data ?? null };
|
|
3787
|
+
return { ok: true, data: command?.type === "get_state" ? stateWithPendingThinking(tab, response?.data) : response?.data ?? null };
|
|
3461
3788
|
} catch (error) {
|
|
3462
3789
|
return { ok: false, error: sanitizeError(error) };
|
|
3463
3790
|
}
|
|
3464
3791
|
}
|
|
3465
3792
|
|
|
3793
|
+
function stateIsBusyForSettings(state) {
|
|
3794
|
+
return !!(state?.isStreaming || state?.isCompacting);
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
async function setThinkingLevelForTab(tab, level, { allowPending = true } = {}) {
|
|
3798
|
+
if (!THINKING_LEVELS.includes(level)) throw makeHttpError(400, "Invalid thinking level");
|
|
3799
|
+
const stateResult = allowPending ? await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS) : { ok: false };
|
|
3800
|
+
if (allowPending && stateResult.ok && stateIsBusyForSettings(stateResult.data)) {
|
|
3801
|
+
tab.pendingThinkingLevel = level;
|
|
3802
|
+
return rpcSuccess("set_thinking_level", { level, pending: true, message: `Thinking level ${level} will apply to the next prompt.` });
|
|
3803
|
+
}
|
|
3804
|
+
const response = await tab.rpc.send({ type: "set_thinking_level", level });
|
|
3805
|
+
if (response.success !== false) tab.pendingThinkingLevel = undefined;
|
|
3806
|
+
return response;
|
|
3807
|
+
}
|
|
3808
|
+
|
|
3809
|
+
async function applyPendingThinkingBeforePrompt(tab) {
|
|
3810
|
+
const level = tab?.pendingThinkingLevel;
|
|
3811
|
+
if (!level) return null;
|
|
3812
|
+
const stateResult = await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
|
|
3813
|
+
if (stateResult.ok && stateIsBusyForSettings(stateResult.data)) return null;
|
|
3814
|
+
const response = await setThinkingLevelForTab(tab, level, { allowPending: false });
|
|
3815
|
+
if (response.success === false) return response;
|
|
3816
|
+
return { ...response, pendingApplied: true };
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3466
3819
|
function providerList(models) {
|
|
3467
3820
|
const providers = new Set();
|
|
3468
3821
|
for (const model of Array.isArray(models) ? models : []) {
|
|
@@ -3808,6 +4161,32 @@ const server = createServer(async (req, res) => {
|
|
|
3808
4161
|
return;
|
|
3809
4162
|
}
|
|
3810
4163
|
|
|
4164
|
+
if (url.pathname === "/api/tools" && req.method === "GET") {
|
|
4165
|
+
const tab = getRequestedTab(req, url);
|
|
4166
|
+
sendJson(res, 200, { ok: true, data: await getToolConfigData(tab) });
|
|
4167
|
+
return;
|
|
4168
|
+
}
|
|
4169
|
+
|
|
4170
|
+
if (url.pathname === "/api/tools" && req.method === "POST") {
|
|
4171
|
+
const body = await readJsonBody(req);
|
|
4172
|
+
const tab = getRequestedTab(req, url, body);
|
|
4173
|
+
sendJson(res, 200, { ok: true, data: await setToolConfigData(tab, body) });
|
|
4174
|
+
return;
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4177
|
+
if (url.pathname === "/api/skills" && req.method === "GET") {
|
|
4178
|
+
const tab = getRequestedTab(req, url);
|
|
4179
|
+
sendJson(res, 200, { ok: true, data: await getMergedSkillConfigData(tab) });
|
|
4180
|
+
return;
|
|
4181
|
+
}
|
|
4182
|
+
|
|
4183
|
+
if (url.pathname === "/api/skills" && req.method === "POST") {
|
|
4184
|
+
const body = await readJsonBody(req);
|
|
4185
|
+
const tab = getRequestedTab(req, url, body);
|
|
4186
|
+
sendJson(res, 200, { ok: true, data: await setSkillConfigData(tab, body) });
|
|
4187
|
+
return;
|
|
4188
|
+
}
|
|
4189
|
+
|
|
3811
4190
|
if (url.pathname === "/api/commands" && req.method === "GET") {
|
|
3812
4191
|
const tab = getRequestedTab(req, url);
|
|
3813
4192
|
sendJson(res, 200, { type: "response", command: "get_commands", success: true, data: await getCommandData(tab) });
|
|
@@ -3831,6 +4210,11 @@ const server = createServer(async (req, res) => {
|
|
|
3831
4210
|
return;
|
|
3832
4211
|
}
|
|
3833
4212
|
const command = commandFromPost(url.pathname, body);
|
|
4213
|
+
const pendingThinkingResponse = await applyPendingThinkingBeforePrompt(tab);
|
|
4214
|
+
if (pendingThinkingResponse?.success === false) {
|
|
4215
|
+
sendJson(res, 400, responseWithTab(pendingThinkingResponse, tab));
|
|
4216
|
+
return;
|
|
4217
|
+
}
|
|
3834
4218
|
const startsVisibleWork = commandStartsVisibleWork(command);
|
|
3835
4219
|
if (startsVisibleWork) {
|
|
3836
4220
|
maybeNameTabForConversation(tab, command);
|
|
@@ -3893,7 +4277,11 @@ const server = createServer(async (req, res) => {
|
|
|
3893
4277
|
maybeNameTabForConversation(tab, command);
|
|
3894
4278
|
markTabWorking(tab);
|
|
3895
4279
|
}
|
|
3896
|
-
const response = command.type === "
|
|
4280
|
+
const response = command.type === "set_thinking_level"
|
|
4281
|
+
? await setThinkingLevelForTab(tab, command.level)
|
|
4282
|
+
: command.type === "bash"
|
|
4283
|
+
? await sendQueuedBashCommand(tab, command)
|
|
4284
|
+
: await tab.rpc.send(command);
|
|
3897
4285
|
if (response.success === false && startsVisibleWork) markTabIdle(tab);
|
|
3898
4286
|
if (response.success !== false && command.type === "new_session") {
|
|
3899
4287
|
tab.conversationStarted = false;
|