@firstpick/pi-package-webui 0.2.2 → 0.2.3
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 +3 -1
- package/WEBUI_TUI_NATIVE_PARITY.json +26 -0
- package/bin/pi-webui.mjs +304 -3
- package/package.json +10 -3
- package/public/app.js +190 -6
- 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 +14 -4
- package/tests/native-parity.test.mjs +14 -0
- package/webui-rpc-helper.mjs +231 -0
package/README.md
CHANGED
|
@@ -119,7 +119,7 @@ Environment variables:
|
|
|
119
119
|
- Multi-tab Pi sessions with isolated processes, working directories, prompt drafts, and activity state.
|
|
120
120
|
- Streaming chat transcript with Markdown, thinking output, tool/bash cards, queue and compaction events, and abort controls.
|
|
121
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
|
+
- Browser dialogs for common Pi selectors such as `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, `/tree`, `/scoped-models`, `/tools`, and `/skills`.
|
|
123
123
|
- Model, thinking, session, workspace, theme, optional-feature, Codex usage, network, event, and notification controls in the side panel.
|
|
124
124
|
- Per-tab cwd changes, a clickable footer cwd picker, saved path fast picks, and restart-safe restoration of open tabs.
|
|
125
125
|
- Browser support for Pi extension UI prompts, widgets, status updates, and notifications.
|
|
@@ -135,7 +135,9 @@ Optional companions:
|
|
|
135
135
|
- `@firstpick/pi-prompts-git-pr` — guided Git commit/push workflow.
|
|
136
136
|
- `@firstpick/pi-extension-release-npm` — NPM publish menu and release widgets.
|
|
137
137
|
- `@firstpick/pi-extension-release-aur` — AUR publish menu and release widgets.
|
|
138
|
+
- `@firstpick/pi-extension-setup-skills` — TUI `/skills` setup command alongside WebUI-native skill toggles.
|
|
138
139
|
- `@firstpick/pi-extension-todo-progress` — todo-progress rendering.
|
|
140
|
+
- `@firstpick/pi-extension-tools` — TUI `/tools` active-tool manager alongside WebUI-native tool toggles.
|
|
139
141
|
- `@firstpick/pi-extension-git-footer-status` — richer git/footer status.
|
|
140
142
|
- `@firstpick/pi-extension-stats` — stats commands and status data.
|
|
141
143
|
- `@firstpick/pi-themes-bundle` — Web UI and Pi theme resources.
|
|
@@ -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,15 @@ 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");
|
|
18
20
|
const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
|
|
19
21
|
const nativeParityMatrix = JSON.parse(await readFile(path.join(packageRoot, "WEBUI_TUI_NATIVE_PARITY.json"), "utf8"));
|
|
20
22
|
const webuiDevServer = isTruthyEnv(process.env.PI_WEBUI_DEV) || isSourceCheckout(packageRoot);
|
|
@@ -22,6 +24,9 @@ const webuiDevServer = isTruthyEnv(process.env.PI_WEBUI_DEV) || isSourceCheckout
|
|
|
22
24
|
const DEFAULT_HOST = "127.0.0.1";
|
|
23
25
|
const DEFAULT_PORT = 31415;
|
|
24
26
|
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
27
|
+
const WEBUI_HELPER_TIMEOUT_MS = 8 * 1000;
|
|
28
|
+
const WEBUI_HELPER_COMMAND = "webui-helper";
|
|
29
|
+
const WEBUI_HELPER_RESPONSE_PREFIX = "__PI_WEBUI_HELPER_RESPONSE__:";
|
|
25
30
|
const CODEX_USAGE_TIMEOUT_MS = 15 * 1000;
|
|
26
31
|
const CODEX_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
|
|
27
32
|
const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
|
|
@@ -141,7 +146,9 @@ const OPTIONAL_FEATURE_PACKAGES = new Map([
|
|
|
141
146
|
["gitWorkflow", "@firstpick/pi-prompts-git-pr"],
|
|
142
147
|
["releaseNpm", "@firstpick/pi-extension-release-npm"],
|
|
143
148
|
["releaseAur", "@firstpick/pi-extension-release-aur"],
|
|
149
|
+
["tuiSkillsCommand", "@firstpick/pi-extension-setup-skills"],
|
|
144
150
|
["todoProgressWidget", "@firstpick/pi-extension-todo-progress"],
|
|
151
|
+
["tuiToolsCommand", "@firstpick/pi-extension-tools"],
|
|
145
152
|
["gitFooterStatus", "@firstpick/pi-extension-git-footer-status"],
|
|
146
153
|
["statsCommand", "@firstpick/pi-extension-stats"],
|
|
147
154
|
["themeBundle", "@firstpick/pi-themes-bundle"],
|
|
@@ -2067,6 +2074,11 @@ function buildPiArgsForTab(tabIndex, title) {
|
|
|
2067
2074
|
const args = ["--mode", "rpc"];
|
|
2068
2075
|
if (options.noSession) args.push("--no-session");
|
|
2069
2076
|
|
|
2077
|
+
// Load a browser-safe RPC helper into every Web UI tab. It exposes hidden
|
|
2078
|
+
// extension commands for Web UI-native /tools and /skills selectors without
|
|
2079
|
+
// depending on TUI-only extension UIs.
|
|
2080
|
+
args.push("--extension", webuiHelperExtensionPath);
|
|
2081
|
+
|
|
2070
2082
|
// Keep tab naming inside Web UI metadata. Some bundled Pi CLI versions do not
|
|
2071
2083
|
// support --name, and passing Web UI-generated tab titles through to child
|
|
2072
2084
|
// RPC processes makes every tab after the first exit immediately.
|
|
@@ -2434,6 +2446,7 @@ function attachRpcToTab(tab, rpc) {
|
|
|
2434
2446
|
tab.rpcUnsubscribe?.();
|
|
2435
2447
|
tab.rpc = rpc;
|
|
2436
2448
|
tab.rpcUnsubscribe = rpc.onEvent((event) => {
|
|
2449
|
+
if (resolveWebuiHelperResponse(tab, event) || resolveWebuiHelperRpcResponse(tab, event)) return;
|
|
2437
2450
|
updateTabActivityFromEvent(tab, event);
|
|
2438
2451
|
let scopedEvent = { ...event, tabId: tab.id, tabTitle: tab.title, tabActivity: tabActivitySnapshot(tab) };
|
|
2439
2452
|
if (event?.type === "pi_process_exit" || event?.type === "pi_process_error") clearPendingExtensionUiRequests(tab);
|
|
@@ -2470,6 +2483,8 @@ async function createTab({ id: requestedId, index, title, titleSource, conversat
|
|
|
2470
2483
|
lastState: null,
|
|
2471
2484
|
activity: createTabActivity(createdAt),
|
|
2472
2485
|
pendingExtensionUiRequests: new Map(),
|
|
2486
|
+
webuiHelperRequests: new Map(),
|
|
2487
|
+
webuiHelperResponseIds: new Set(),
|
|
2473
2488
|
bashQueue: [],
|
|
2474
2489
|
bashQueueDraining: false,
|
|
2475
2490
|
rpc: undefined,
|
|
@@ -2760,11 +2775,271 @@ async function safeRpcResponse(tab, command, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
|
2760
2775
|
}
|
|
2761
2776
|
}
|
|
2762
2777
|
|
|
2778
|
+
function parseWebuiHelperResponseEvent(event) {
|
|
2779
|
+
if (event?.type !== "extension_ui_request" || event.method !== "notify") return undefined;
|
|
2780
|
+
const message = String(event.message || "");
|
|
2781
|
+
if (!message.startsWith(WEBUI_HELPER_RESPONSE_PREFIX)) return undefined;
|
|
2782
|
+
try {
|
|
2783
|
+
return JSON.parse(message.slice(WEBUI_HELPER_RESPONSE_PREFIX.length));
|
|
2784
|
+
} catch (error) {
|
|
2785
|
+
return { ok: false, error: `Invalid Web UI helper response: ${sanitizeError(error)}` };
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
function resolveWebuiHelperResponse(tab, event) {
|
|
2790
|
+
const payload = parseWebuiHelperResponseEvent(event);
|
|
2791
|
+
if (!payload) return false;
|
|
2792
|
+
const requestId = String(payload.requestId || "");
|
|
2793
|
+
const pending = tab?.webuiHelperRequests?.get(requestId);
|
|
2794
|
+
if (pending) {
|
|
2795
|
+
tab.webuiHelperRequests.delete(requestId);
|
|
2796
|
+
clearTimeout(pending.timeout);
|
|
2797
|
+
if (payload.ok === false) pending.reject(makeHttpError(400, payload.error || "Web UI helper command failed"));
|
|
2798
|
+
else pending.resolve(payload.data || {});
|
|
2799
|
+
}
|
|
2800
|
+
return true;
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
function resolveWebuiHelperRpcResponse(tab, event) {
|
|
2804
|
+
if (event?.type !== "response" || event.command !== "prompt" || !event.id) return false;
|
|
2805
|
+
return tab?.webuiHelperResponseIds?.delete(String(event.id)) === true;
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
function webuiHelperRequestMap(tab) {
|
|
2809
|
+
if (!tab.webuiHelperRequests) tab.webuiHelperRequests = new Map();
|
|
2810
|
+
return tab.webuiHelperRequests;
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
async function sendWebuiHelperCommand(tab, action, payload = {}, timeoutMs = WEBUI_HELPER_TIMEOUT_MS) {
|
|
2814
|
+
const requestId = randomUUID();
|
|
2815
|
+
const pending = new Promise((resolve, reject) => {
|
|
2816
|
+
const timeout = setTimeout(() => {
|
|
2817
|
+
webuiHelperRequestMap(tab).delete(requestId);
|
|
2818
|
+
tab.webuiHelperResponseIds?.delete(requestId);
|
|
2819
|
+
reject(makeHttpError(504, `Timed out waiting for Web UI helper action: ${action}. Try /reload in this tab, then retry.`));
|
|
2820
|
+
}, timeoutMs);
|
|
2821
|
+
webuiHelperRequestMap(tab).set(requestId, { resolve, reject, timeout });
|
|
2822
|
+
});
|
|
2823
|
+
pending.catch(() => {});
|
|
2824
|
+
|
|
2825
|
+
try {
|
|
2826
|
+
tab.webuiHelperResponseIds?.add(requestId);
|
|
2827
|
+
const response = await tab.rpc.send({
|
|
2828
|
+
id: requestId,
|
|
2829
|
+
type: "prompt",
|
|
2830
|
+
message: `/${WEBUI_HELPER_COMMAND} ${JSON.stringify({ requestId, action, payload })}`,
|
|
2831
|
+
}, timeoutMs);
|
|
2832
|
+
if (response.success === false) throw makeHttpError(400, response.error || `Web UI helper action failed: ${action}`);
|
|
2833
|
+
return await pending;
|
|
2834
|
+
} catch (error) {
|
|
2835
|
+
tab.webuiHelperResponseIds?.delete(requestId);
|
|
2836
|
+
const request = webuiHelperRequestMap(tab).get(requestId);
|
|
2837
|
+
if (request) {
|
|
2838
|
+
clearTimeout(request.timeout);
|
|
2839
|
+
webuiHelperRequestMap(tab).delete(requestId);
|
|
2840
|
+
}
|
|
2841
|
+
throw error;
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
async function getToolConfigData(tab) {
|
|
2846
|
+
return sendWebuiHelperCommand(tab, "tools-state");
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
let packageManagerModulePromise;
|
|
2850
|
+
async function loadPackageManagerModule() {
|
|
2851
|
+
if (!packageManagerModulePromise) {
|
|
2852
|
+
const packageMain = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
|
|
2853
|
+
const codingAgentRoot = path.dirname(path.dirname(packageMain));
|
|
2854
|
+
packageManagerModulePromise = import(pathToFileURL(path.join(codingAgentRoot, "dist", "core", "package-manager.js")).href);
|
|
2855
|
+
}
|
|
2856
|
+
return packageManagerModulePromise;
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
function parseSkillFrontmatter(text, filePath) {
|
|
2860
|
+
const frontmatter = String(text || "").match(/^---\s*\n([\s\S]*?)\n---/);
|
|
2861
|
+
const fields = {};
|
|
2862
|
+
if (frontmatter) {
|
|
2863
|
+
for (const line of frontmatter[1].split(/\r?\n/)) {
|
|
2864
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
2865
|
+
if (match) fields[match[1]] = match[2].replace(/^['"]|['"]$/g, "").trim();
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
const parent = path.basename(path.dirname(filePath));
|
|
2869
|
+
const base = path.basename(filePath, path.extname(filePath));
|
|
2870
|
+
return {
|
|
2871
|
+
name: fields.name || (path.basename(filePath) === "SKILL.md" ? parent : base),
|
|
2872
|
+
description: fields.description || "",
|
|
2873
|
+
};
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
function sourceInfoFromResolvedResource(resource) {
|
|
2877
|
+
const metadata = resource?.metadata || {};
|
|
2878
|
+
return {
|
|
2879
|
+
path: resource?.path,
|
|
2880
|
+
source: metadata.source,
|
|
2881
|
+
scope: metadata.scope,
|
|
2882
|
+
origin: metadata.origin,
|
|
2883
|
+
baseDir: metadata.baseDir,
|
|
2884
|
+
};
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
async function resolveSkillResources(tab) {
|
|
2888
|
+
const { DefaultPackageManager } = await loadPackageManagerModule();
|
|
2889
|
+
const settingsManager = SettingsManager.create(tab?.cwd || options.cwd, agentDir);
|
|
2890
|
+
const packageManager = new DefaultPackageManager({ cwd: tab?.cwd || options.cwd, agentDir, settingsManager });
|
|
2891
|
+
const resolved = await packageManager.resolve();
|
|
2892
|
+
const skills = [];
|
|
2893
|
+
for (const resource of resolved.skills || []) {
|
|
2894
|
+
try {
|
|
2895
|
+
const metadata = parseSkillFrontmatter(await readFile(resource.path, "utf8"), resource.path);
|
|
2896
|
+
skills.push({
|
|
2897
|
+
...metadata,
|
|
2898
|
+
filePath: resource.path,
|
|
2899
|
+
enabled: resource.enabled === true,
|
|
2900
|
+
configEnabled: resource.enabled === true,
|
|
2901
|
+
configManaged: true,
|
|
2902
|
+
sourceInfo: sourceInfoFromResolvedResource(resource),
|
|
2903
|
+
});
|
|
2904
|
+
} catch {
|
|
2905
|
+
// Ignore unreadable skill candidates; Pi will also skip invalid resources.
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
return { skills, settingsManager };
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
function skillResourceKey(skill) {
|
|
2912
|
+
return skill.filePath || skill.name;
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
function mergeRuntimeAndResolvedSkills(runtimeSkills, resolvedSkills) {
|
|
2916
|
+
const byName = new Map();
|
|
2917
|
+
for (const skill of resolvedSkills) byName.set(skill.name, { ...skill });
|
|
2918
|
+
for (const skill of runtimeSkills || []) {
|
|
2919
|
+
const existing = byName.get(skill.name);
|
|
2920
|
+
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 });
|
|
2921
|
+
}
|
|
2922
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
2923
|
+
}
|
|
2924
|
+
|
|
2925
|
+
async function getMergedSkillConfigData(tab) {
|
|
2926
|
+
const [runtime, resolved] = await Promise.all([
|
|
2927
|
+
getSkillConfigDataFromRuntime(tab).catch(() => ({ skills: [] })),
|
|
2928
|
+
resolveSkillResources(tab).catch((error) => {
|
|
2929
|
+
console.warn(`failed to resolve configured skills: ${sanitizeError(error)}`);
|
|
2930
|
+
return { skills: [] };
|
|
2931
|
+
}),
|
|
2932
|
+
]);
|
|
2933
|
+
return { skills: mergeRuntimeAndResolvedSkills(runtime.skills || [], resolved.skills || []) };
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
function getResourcePatternForSkill(tab, skill) {
|
|
2937
|
+
const info = skill.sourceInfo || {};
|
|
2938
|
+
const baseDir = info.baseDir || (info.scope === "project" ? path.join(tab?.cwd || options.cwd, ".pi") : agentDir);
|
|
2939
|
+
return path.relative(baseDir, skill.filePath);
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
async function setToolConfigData(tab, body) {
|
|
2943
|
+
return sendWebuiHelperCommand(tab, "tools-set", {
|
|
2944
|
+
enabledTools: Array.isArray(body.enabledTools) ? body.enabledTools : undefined,
|
|
2945
|
+
disabledTools: Array.isArray(body.disabledTools) ? body.disabledTools : undefined,
|
|
2946
|
+
});
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
async function getSkillConfigDataFromRuntime(tab) {
|
|
2950
|
+
return sendWebuiHelperCommand(tab, "skills-state");
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
function desiredSkillEnabledFromBody(skillName, body) {
|
|
2954
|
+
if (Array.isArray(body.enabledSkills)) return body.enabledSkills.map(String).includes(skillName);
|
|
2955
|
+
if (Array.isArray(body.disabledSkills)) return !body.disabledSkills.map(String).includes(skillName);
|
|
2956
|
+
throw makeHttpError(400, "Skill update requires enabledSkills or disabledSkills");
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
function updatePatternListForResource(current, pattern, enabled) {
|
|
2960
|
+
const updated = (current || []).filter((item) => {
|
|
2961
|
+
const text = String(item || "");
|
|
2962
|
+
const stripped = text.startsWith("!") || text.startsWith("+") || text.startsWith("-") ? text.slice(1) : text;
|
|
2963
|
+
return stripped !== pattern;
|
|
2964
|
+
});
|
|
2965
|
+
updated.push(`${enabled ? "+" : "-"}${pattern}`);
|
|
2966
|
+
return updated;
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
function setSkillPathsForScope(settingsManager, scope, updated) {
|
|
2970
|
+
if (scope === "project") settingsManager.setProjectSkillPaths(updated);
|
|
2971
|
+
else settingsManager.setSkillPaths(updated);
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
function toggleConfiguredSkill(tab, settingsManager, skill, enabled) {
|
|
2975
|
+
const info = skill.sourceInfo || {};
|
|
2976
|
+
const scope = info.scope === "project" ? "project" : "user";
|
|
2977
|
+
if (info.origin === "package") {
|
|
2978
|
+
const settings = scope === "project" ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
|
|
2979
|
+
const packages = [...(settings.packages || [])];
|
|
2980
|
+
const packageIndex = packages.findIndex((item) => (typeof item === "string" ? item : item?.source) === info.source);
|
|
2981
|
+
if (packageIndex < 0) return false;
|
|
2982
|
+
let packageEntry = packages[packageIndex];
|
|
2983
|
+
if (typeof packageEntry === "string") {
|
|
2984
|
+
packageEntry = { source: packageEntry };
|
|
2985
|
+
packages[packageIndex] = packageEntry;
|
|
2986
|
+
}
|
|
2987
|
+
const pattern = path.relative(info.baseDir || path.dirname(skill.filePath), skill.filePath);
|
|
2988
|
+
packageEntry.skills = updatePatternListForResource(packageEntry.skills || [], pattern, enabled);
|
|
2989
|
+
if (scope === "project") settingsManager.setProjectPackages(packages);
|
|
2990
|
+
else settingsManager.setPackages(packages);
|
|
2991
|
+
return true;
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
const settings = scope === "project" ? settingsManager.getProjectSettings() : settingsManager.getGlobalSettings();
|
|
2995
|
+
const pattern = getResourcePatternForSkill(tab, skill);
|
|
2996
|
+
setSkillPathsForScope(settingsManager, scope, updatePatternListForResource(settings.skills || [], pattern, enabled));
|
|
2997
|
+
return true;
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
async function setSkillConfigData(tab, body) {
|
|
3001
|
+
const { skills, settingsManager } = await resolveSkillResources(tab);
|
|
3002
|
+
let configChanged = false;
|
|
3003
|
+
for (const skill of skills) {
|
|
3004
|
+
const desiredEnabled = desiredSkillEnabledFromBody(skill.name, body);
|
|
3005
|
+
if (skill.configEnabled !== desiredEnabled && toggleConfiguredSkill(tab, settingsManager, skill, desiredEnabled)) configChanged = true;
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
const runtimeOnly = skills.length === 0;
|
|
3009
|
+
if (runtimeOnly) {
|
|
3010
|
+
await sendWebuiHelperCommand(tab, "skills-set", {
|
|
3011
|
+
enabledSkills: Array.isArray(body.enabledSkills) ? body.enabledSkills : undefined,
|
|
3012
|
+
disabledSkills: Array.isArray(body.disabledSkills) ? body.disabledSkills : undefined,
|
|
3013
|
+
});
|
|
3014
|
+
}
|
|
3015
|
+
|
|
3016
|
+
const activeTab = configChanged ? await restartTabRpc(tab, "skills-config") : tab;
|
|
3017
|
+
return getMergedSkillConfigData(activeTab);
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
async function annotateSkillCommandState(tab, commands) {
|
|
3021
|
+
let disabledSkills = new Set();
|
|
3022
|
+
try {
|
|
3023
|
+
const state = await getMergedSkillConfigData(tab);
|
|
3024
|
+
disabledSkills = new Set((state.skills || []).filter((skill) => skill.enabled === false).map((skill) => skill.name));
|
|
3025
|
+
} catch {
|
|
3026
|
+
// Commands should remain available even if an older tab has not loaded the helper yet.
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
return commands
|
|
3030
|
+
.filter((command) => command?.name !== WEBUI_HELPER_COMMAND)
|
|
3031
|
+
.map((command) => {
|
|
3032
|
+
const skillName = command?.source === "skill" && String(command.name || "").startsWith("skill:") ? String(command.name).slice("skill:".length) : "";
|
|
3033
|
+
return skillName ? { ...command, enabled: !disabledSkills.has(skillName) } : command;
|
|
3034
|
+
});
|
|
3035
|
+
}
|
|
3036
|
+
|
|
2763
3037
|
async function getCommandData(tab) {
|
|
2764
3038
|
try {
|
|
2765
3039
|
const response = await tab.rpc.send({ type: "get_commands" });
|
|
2766
3040
|
if (response.success === false) throw makeHttpError(400, response.error || "failed to load commands");
|
|
2767
|
-
|
|
3041
|
+
const rpcCommands = await annotateSkillCommandState(tab, response.data?.commands || []);
|
|
3042
|
+
return { commands: [...NATIVE_SLASH_COMMANDS, ...rpcCommands], rpcRunning: true };
|
|
2768
3043
|
} catch (error) {
|
|
2769
3044
|
const message = sanitizeError(error);
|
|
2770
3045
|
if (!/Pi RPC process is not running/i.test(message)) throw error;
|
|
@@ -3808,6 +4083,32 @@ const server = createServer(async (req, res) => {
|
|
|
3808
4083
|
return;
|
|
3809
4084
|
}
|
|
3810
4085
|
|
|
4086
|
+
if (url.pathname === "/api/tools" && req.method === "GET") {
|
|
4087
|
+
const tab = getRequestedTab(req, url);
|
|
4088
|
+
sendJson(res, 200, { ok: true, data: await getToolConfigData(tab) });
|
|
4089
|
+
return;
|
|
4090
|
+
}
|
|
4091
|
+
|
|
4092
|
+
if (url.pathname === "/api/tools" && req.method === "POST") {
|
|
4093
|
+
const body = await readJsonBody(req);
|
|
4094
|
+
const tab = getRequestedTab(req, url, body);
|
|
4095
|
+
sendJson(res, 200, { ok: true, data: await setToolConfigData(tab, body) });
|
|
4096
|
+
return;
|
|
4097
|
+
}
|
|
4098
|
+
|
|
4099
|
+
if (url.pathname === "/api/skills" && req.method === "GET") {
|
|
4100
|
+
const tab = getRequestedTab(req, url);
|
|
4101
|
+
sendJson(res, 200, { ok: true, data: await getMergedSkillConfigData(tab) });
|
|
4102
|
+
return;
|
|
4103
|
+
}
|
|
4104
|
+
|
|
4105
|
+
if (url.pathname === "/api/skills" && req.method === "POST") {
|
|
4106
|
+
const body = await readJsonBody(req);
|
|
4107
|
+
const tab = getRequestedTab(req, url, body);
|
|
4108
|
+
sendJson(res, 200, { ok: true, data: await setSkillConfigData(tab, body) });
|
|
4109
|
+
return;
|
|
4110
|
+
}
|
|
4111
|
+
|
|
3811
4112
|
if (url.pathname === "/api/commands" && req.method === "GET") {
|
|
3812
4113
|
const tab = getRequestedTab(req, url);
|
|
3813
4114
|
sendJson(res, 200, { type: "response", command: "get_commands", success: true, data: await getCommandData(tab) });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firstpick/pi-package-webui",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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",
|
|
@@ -20,13 +20,17 @@
|
|
|
20
20
|
"../pi-extension-git-footer-status/index.ts",
|
|
21
21
|
"../pi-extension-release-aur/index.ts",
|
|
22
22
|
"../pi-extension-release-npm/index.ts",
|
|
23
|
+
"../pi-extension-setup-skills/index.ts",
|
|
23
24
|
"../pi-extension-stats/index.ts",
|
|
24
25
|
"../pi-extension-todo-progress/index.ts",
|
|
26
|
+
"../pi-extension-tools/index.ts",
|
|
25
27
|
"node_modules/@firstpick/pi-extension-git-footer-status/index.ts",
|
|
26
28
|
"node_modules/@firstpick/pi-extension-release-aur/index.ts",
|
|
27
29
|
"node_modules/@firstpick/pi-extension-release-npm/index.ts",
|
|
30
|
+
"node_modules/@firstpick/pi-extension-setup-skills/index.ts",
|
|
28
31
|
"node_modules/@firstpick/pi-extension-stats/index.ts",
|
|
29
|
-
"node_modules/@firstpick/pi-extension-todo-progress/index.ts"
|
|
32
|
+
"node_modules/@firstpick/pi-extension-todo-progress/index.ts",
|
|
33
|
+
"node_modules/@firstpick/pi-extension-tools/index.ts"
|
|
30
34
|
],
|
|
31
35
|
"skills": [
|
|
32
36
|
"../pi-extension-release-aur/skills",
|
|
@@ -45,7 +49,7 @@
|
|
|
45
49
|
"pi-webui": "./bin/pi-webui.mjs"
|
|
46
50
|
},
|
|
47
51
|
"scripts": {
|
|
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",
|
|
52
|
+
"check": "node --check public/app.js && node --check bin/pi-webui.mjs && node --check webui-rpc-helper.mjs && node tests/mobile-static.test.mjs && node tests/native-parity.test.mjs",
|
|
49
53
|
"test": "node tests/mobile-static.test.mjs && node tests/native-parity.test.mjs"
|
|
50
54
|
},
|
|
51
55
|
"dependencies": {
|
|
@@ -55,13 +59,16 @@
|
|
|
55
59
|
"@firstpick/pi-extension-git-footer-status": "^0.2.1",
|
|
56
60
|
"@firstpick/pi-extension-release-aur": "^0.1.3",
|
|
57
61
|
"@firstpick/pi-extension-release-npm": "^0.3.3",
|
|
62
|
+
"@firstpick/pi-extension-setup-skills": "^0.1.5",
|
|
58
63
|
"@firstpick/pi-extension-stats": "^0.2.0",
|
|
59
64
|
"@firstpick/pi-extension-todo-progress": "^0.1.7",
|
|
65
|
+
"@firstpick/pi-extension-tools": "^0.1.4",
|
|
60
66
|
"@firstpick/pi-prompts-git-pr": "^0.1.0",
|
|
61
67
|
"@firstpick/pi-themes-bundle": "^0.1.1"
|
|
62
68
|
},
|
|
63
69
|
"files": [
|
|
64
70
|
"index.ts",
|
|
71
|
+
"webui-rpc-helper.mjs",
|
|
65
72
|
"bin",
|
|
66
73
|
"public",
|
|
67
74
|
"images",
|
package/public/app.js
CHANGED
|
@@ -144,6 +144,7 @@ let pathFastPicksLoadPromise = null;
|
|
|
144
144
|
let mobileTabsExpanded = false;
|
|
145
145
|
let openTerminalTabGroupKey = null;
|
|
146
146
|
let availableCommands = [];
|
|
147
|
+
let rawAvailableCommands = [];
|
|
147
148
|
let commandSuggestions = [];
|
|
148
149
|
let pathSuggestions = [];
|
|
149
150
|
let suggestionMode = "none";
|
|
@@ -285,7 +286,9 @@ const optionalFeatureAvailability = {
|
|
|
285
286
|
releaseAur: false,
|
|
286
287
|
statsCommand: false,
|
|
287
288
|
gitFooterStatus: false,
|
|
289
|
+
tuiSkillsCommand: false,
|
|
288
290
|
todoProgressWidget: false,
|
|
291
|
+
tuiToolsCommand: false,
|
|
289
292
|
themeBundle: false,
|
|
290
293
|
};
|
|
291
294
|
const OPTIONAL_FEATURES = [
|
|
@@ -310,6 +313,13 @@ const OPTIONAL_FEATURES = [
|
|
|
310
313
|
capabilityLabel: "/release-aur",
|
|
311
314
|
description: "Publish menu action, setup helpers, skills, and AUR release widgets.",
|
|
312
315
|
},
|
|
316
|
+
{
|
|
317
|
+
id: "tuiSkillsCommand",
|
|
318
|
+
label: "TUI Skills command",
|
|
319
|
+
packageName: "@firstpick/pi-extension-setup-skills",
|
|
320
|
+
capabilityLabel: "RPC /skills from setup-skills extension",
|
|
321
|
+
description: "Terminal-native skill setup command alongside WebUI-native /skills toggles.",
|
|
322
|
+
},
|
|
313
323
|
{
|
|
314
324
|
id: "todoProgressWidget",
|
|
315
325
|
label: "Todo progress widget",
|
|
@@ -317,6 +327,13 @@ const OPTIONAL_FEATURES = [
|
|
|
317
327
|
capabilityLabel: "/todo-progress-status or todo-progress widget event",
|
|
318
328
|
description: "Styled live checklist rendering for assistant todo updates.",
|
|
319
329
|
},
|
|
330
|
+
{
|
|
331
|
+
id: "tuiToolsCommand",
|
|
332
|
+
label: "TUI Tools command",
|
|
333
|
+
packageName: "@firstpick/pi-extension-tools",
|
|
334
|
+
capabilityLabel: "RPC /tools from tools extension",
|
|
335
|
+
description: "Terminal-native active-tool manager alongside WebUI-native /tools toggles.",
|
|
336
|
+
},
|
|
320
337
|
{
|
|
321
338
|
id: "gitFooterStatus",
|
|
322
339
|
label: "Git footer status",
|
|
@@ -348,8 +365,8 @@ const OPTIONAL_COMMAND_FEATURES = new Map([
|
|
|
348
365
|
["git-footer-refresh", "gitFooterStatus"],
|
|
349
366
|
["todo-progress-status", "todoProgressWidget"],
|
|
350
367
|
]);
|
|
351
|
-
const HIDDEN_COMMAND_NAMES = new Set(["webui-tree-navigate"]);
|
|
352
|
-
const NATIVE_SELECTOR_COMMANDS = new Set(["model", "settings", "theme", "fork", "clone", "resume", "tree", "login", "logout", "scoped-models"]);
|
|
368
|
+
const HIDDEN_COMMAND_NAMES = new Set(["webui-tree-navigate", "webui-helper"]);
|
|
369
|
+
const NATIVE_SELECTOR_COMMANDS = new Set(["model", "settings", "theme", "fork", "clone", "resume", "tree", "login", "logout", "scoped-models", "tools", "skills"]);
|
|
353
370
|
const optionalFeatureInstallInProgress = new Set();
|
|
354
371
|
|
|
355
372
|
function createGitWorkflowState() {
|
|
@@ -2398,6 +2415,7 @@ function resetActiveTabUi() {
|
|
|
2398
2415
|
liveToolRuns.clear();
|
|
2399
2416
|
liveToolCards.clear();
|
|
2400
2417
|
availableCommands = [];
|
|
2418
|
+
rawAvailableCommands = [];
|
|
2401
2419
|
resetOptionalFeatureAvailability();
|
|
2402
2420
|
commandSuggestions = [];
|
|
2403
2421
|
pathSuggestions = [];
|
|
@@ -6930,6 +6948,7 @@ function optionalFeatureIdForCommand(name) {
|
|
|
6930
6948
|
|
|
6931
6949
|
function isCommandVisible(command) {
|
|
6932
6950
|
if (HIDDEN_COMMAND_NAMES.has(command.name)) return false;
|
|
6951
|
+
if (command.enabled === false) return false;
|
|
6933
6952
|
const featureId = optionalFeatureIdForCommand(command.name);
|
|
6934
6953
|
return !featureId || isOptionalFeatureEnabled(featureId);
|
|
6935
6954
|
}
|
|
@@ -6942,6 +6961,10 @@ function hasAvailableCommand(name) {
|
|
|
6942
6961
|
return availableCommands.some((command) => command.name === name);
|
|
6943
6962
|
}
|
|
6944
6963
|
|
|
6964
|
+
function hasLoadedRpcCommand(name) {
|
|
6965
|
+
return rawAvailableCommands.some((command) => command.name === name && command.source !== "native");
|
|
6966
|
+
}
|
|
6967
|
+
|
|
6945
6968
|
function optionalFeatureUnavailableMessage(featureId) {
|
|
6946
6969
|
const feature = OPTIONAL_FEATURE_BY_ID.get(featureId);
|
|
6947
6970
|
if (!feature) return "Optional feature unavailable.";
|
|
@@ -6988,7 +7011,9 @@ function updateOptionalFeatureAvailability() {
|
|
|
6988
7011
|
optionalFeatureAvailability.releaseAur = hasAvailableCommand("release-aur");
|
|
6989
7012
|
optionalFeatureAvailability.statsCommand = hasAvailableCommand("stats");
|
|
6990
7013
|
optionalFeatureAvailability.gitFooterStatus = hasAvailableCommand("git-footer-refresh") || optionalFeatureAvailability.gitFooterStatus || statusEntries.has("git-footer");
|
|
7014
|
+
optionalFeatureAvailability.tuiSkillsCommand = hasLoadedRpcCommand("skills");
|
|
6991
7015
|
optionalFeatureAvailability.todoProgressWidget = hasAvailableCommand("todo-progress-status") || optionalFeatureAvailability.todoProgressWidget || widgets.has("todo-progress");
|
|
7016
|
+
optionalFeatureAvailability.tuiToolsCommand = hasLoadedRpcCommand("tools");
|
|
6992
7017
|
optionalFeatureAvailability.themeBundle = availableThemes.length > 0;
|
|
6993
7018
|
renderOptionalFeatureControls();
|
|
6994
7019
|
}
|
|
@@ -7131,7 +7156,7 @@ function runPublishWorkflow(command) {
|
|
|
7131
7156
|
|
|
7132
7157
|
function slashCommandName(message) {
|
|
7133
7158
|
const match = String(message || "").trim().match(/^\/([^\s]+)$/);
|
|
7134
|
-
return match ? match[1] : "";
|
|
7159
|
+
return match ? match[1].toLowerCase() : "";
|
|
7135
7160
|
}
|
|
7136
7161
|
|
|
7137
7162
|
function openNativeCommandDialog({ title, message = "", searchPlaceholder = "" } = {}) {
|
|
@@ -7204,7 +7229,17 @@ function renderNativeSelectorItems(items, { emptyText = "No choices.", onSelect,
|
|
|
7204
7229
|
button.addEventListener("click", () => onSelect?.(item));
|
|
7205
7230
|
const title = make("span", "native-selector-title");
|
|
7206
7231
|
title.append(make("strong", undefined, item.label || item.id || "choice"));
|
|
7207
|
-
if (item.badge)
|
|
7232
|
+
if (item.badge) {
|
|
7233
|
+
const badgeState = String(item.badge).toLowerCase();
|
|
7234
|
+
const badge = make("span", `native-selector-badge${item.badgeClass ? ` ${item.badgeClass}` : ""}`, item.badge);
|
|
7235
|
+
badge.dataset.badgeState = badgeState;
|
|
7236
|
+
if (badgeState === "disabled" || String(item.badgeClass || "").includes("disabled")) {
|
|
7237
|
+
badge.style.borderColor = "rgba(255, 159, 67, 0.62)";
|
|
7238
|
+
badge.style.color = "#ff9f43";
|
|
7239
|
+
badge.style.background = "rgba(255, 159, 67, 0.10)";
|
|
7240
|
+
}
|
|
7241
|
+
title.append(badge);
|
|
7242
|
+
}
|
|
7208
7243
|
const detail = make("span", "native-selector-detail", item.description || "");
|
|
7209
7244
|
const meta = make("span", "native-selector-meta", item.meta || "");
|
|
7210
7245
|
button.append(title);
|
|
@@ -7538,6 +7573,145 @@ function openNativeScopedModelsInfo() {
|
|
|
7538
7573
|
elements.nativeCommandBody.append(make("p", "native-command-note", "Use the footer model chip to choose among scoped models. The full native scoped-models editor is still TUI-only."));
|
|
7539
7574
|
}
|
|
7540
7575
|
|
|
7576
|
+
function nativeResourceSourceLabel(resource) {
|
|
7577
|
+
const info = resource?.sourceInfo || {};
|
|
7578
|
+
return [info.source, info.scope, info.origin].filter(Boolean).join(" · ") || resource?.location || "loaded resource";
|
|
7579
|
+
}
|
|
7580
|
+
|
|
7581
|
+
function nativeResourceCounts(resources) {
|
|
7582
|
+
const disabled = resources.filter((resource) => resource.enabled === false).length;
|
|
7583
|
+
return { total: resources.length, disabled, enabled: resources.length - disabled };
|
|
7584
|
+
}
|
|
7585
|
+
|
|
7586
|
+
function nativeResourceFilterMatches(resource, filter) {
|
|
7587
|
+
if (filter === "enabled") return resource.enabled !== false;
|
|
7588
|
+
if (filter === "disabled") return resource.enabled === false;
|
|
7589
|
+
return true;
|
|
7590
|
+
}
|
|
7591
|
+
|
|
7592
|
+
function renderNativeResourceToggles(resources, { savingName, filter = "all", onToggle } = {}) {
|
|
7593
|
+
const filteredResources = resources.filter((resource) => nativeResourceFilterMatches(resource, filter));
|
|
7594
|
+
const counts = nativeResourceCounts(resources);
|
|
7595
|
+
const items = filteredResources.map((resource) => ({
|
|
7596
|
+
id: resource.name,
|
|
7597
|
+
label: resource.name,
|
|
7598
|
+
description: resource.description || "No description provided.",
|
|
7599
|
+
meta: nativeResourceSourceLabel(resource),
|
|
7600
|
+
badge: resource.enabled === false ? "disabled" : "enabled",
|
|
7601
|
+
badgeClass: resource.enabled === false ? "disabled native-selector-badge-disabled" : "enabled native-selector-badge-enabled",
|
|
7602
|
+
disabled: Boolean(savingName),
|
|
7603
|
+
resource,
|
|
7604
|
+
}));
|
|
7605
|
+
const filterLabel = filter === "enabled" ? "enabled" : filter === "disabled" ? "disabled" : "all";
|
|
7606
|
+
renderNativeSelectorItems(items, {
|
|
7607
|
+
emptyText: `No ${filterLabel} entries match this filter.`,
|
|
7608
|
+
onSelect: (item) => onToggle?.(item.resource),
|
|
7609
|
+
});
|
|
7610
|
+
elements.nativeCommandBody.prepend(make("div", "native-resource-summary muted", `${counts.total} total · ${counts.enabled} enabled · ${counts.disabled} disabled · showing ${filterLabel}`));
|
|
7611
|
+
}
|
|
7612
|
+
|
|
7613
|
+
function renderNativeResourceFilterActions(filter, setFilter, render) {
|
|
7614
|
+
elements.nativeCommandActions.replaceChildren();
|
|
7615
|
+
for (const option of [
|
|
7616
|
+
{ value: "all", label: "All" },
|
|
7617
|
+
{ value: "enabled", label: "Enabled" },
|
|
7618
|
+
{ value: "disabled", label: "Disabled" },
|
|
7619
|
+
]) {
|
|
7620
|
+
addNativeCommandAction(option.label, () => {
|
|
7621
|
+
setFilter(option.value);
|
|
7622
|
+
render();
|
|
7623
|
+
}, filter === option.value ? "primary" : undefined);
|
|
7624
|
+
}
|
|
7625
|
+
addNativeCommandAction("Cancel", closeNativeCommandDialog);
|
|
7626
|
+
}
|
|
7627
|
+
|
|
7628
|
+
async function openNativeToolsSelector() {
|
|
7629
|
+
openNativeCommandDialog({ title: "/tools", message: "Enable or disable tools for the active Pi tab. Changes apply to the next model turn and persist on this session branch.", searchPlaceholder: "Filter tools…" });
|
|
7630
|
+
renderNativeLoading("Loading tools…");
|
|
7631
|
+
let tools = [];
|
|
7632
|
+
let savingName = "";
|
|
7633
|
+
let filter = "all";
|
|
7634
|
+
const render = () => {
|
|
7635
|
+
renderNativeResourceToggles(tools, {
|
|
7636
|
+
savingName,
|
|
7637
|
+
filter,
|
|
7638
|
+
onToggle: async (tool) => {
|
|
7639
|
+
if (!tool || savingName) return;
|
|
7640
|
+
const enabledTools = new Set(tools.filter((item) => item.enabled !== false).map((item) => item.name));
|
|
7641
|
+
if (tool.enabled === false) enabledTools.add(tool.name);
|
|
7642
|
+
else enabledTools.delete(tool.name);
|
|
7643
|
+
savingName = tool.name;
|
|
7644
|
+
setNativeCommandError("");
|
|
7645
|
+
render();
|
|
7646
|
+
try {
|
|
7647
|
+
const response = await nativeCommandApi("/api/tools", { method: "POST", body: { enabledTools: [...enabledTools] } });
|
|
7648
|
+
tools = Array.isArray(response.data?.tools) ? response.data.tools : [];
|
|
7649
|
+
addTransientMessage({ role: "native", title: "/tools", content: `Tool ${tool.name} ${enabledTools.has(tool.name) ? "enabled" : "disabled"}.`, level: "info" });
|
|
7650
|
+
} catch (error) {
|
|
7651
|
+
setNativeCommandError(error.message || String(error));
|
|
7652
|
+
} finally {
|
|
7653
|
+
savingName = "";
|
|
7654
|
+
render();
|
|
7655
|
+
}
|
|
7656
|
+
},
|
|
7657
|
+
});
|
|
7658
|
+
renderNativeResourceFilterActions(filter, (value) => { filter = value; }, render);
|
|
7659
|
+
};
|
|
7660
|
+
try {
|
|
7661
|
+
const response = await nativeCommandApi("/api/tools");
|
|
7662
|
+
tools = Array.isArray(response.data?.tools) ? response.data.tools : [];
|
|
7663
|
+
elements.nativeCommandSearch.oninput = render;
|
|
7664
|
+
render();
|
|
7665
|
+
} catch (error) {
|
|
7666
|
+
setNativeCommandError(error.message || String(error));
|
|
7667
|
+
elements.nativeCommandBody.replaceChildren();
|
|
7668
|
+
}
|
|
7669
|
+
}
|
|
7670
|
+
|
|
7671
|
+
async function openNativeSkillsSelector() {
|
|
7672
|
+
openNativeCommandDialog({ title: "/skills", message: "Enable or disable skills for automatic model invocation in the active Pi tab. Disabled skills are removed from the system prompt and their /skill:name commands are blocked by Web UI.", searchPlaceholder: "Filter skills…" });
|
|
7673
|
+
renderNativeLoading("Loading skills…");
|
|
7674
|
+
let skills = [];
|
|
7675
|
+
let savingName = "";
|
|
7676
|
+
let filter = "all";
|
|
7677
|
+
const render = () => {
|
|
7678
|
+
renderNativeResourceToggles(skills, {
|
|
7679
|
+
savingName,
|
|
7680
|
+
filter,
|
|
7681
|
+
onToggle: async (skill) => {
|
|
7682
|
+
if (!skill || savingName) return;
|
|
7683
|
+
const enabledSkills = new Set(skills.filter((item) => item.enabled !== false).map((item) => item.name));
|
|
7684
|
+
if (skill.enabled === false) enabledSkills.add(skill.name);
|
|
7685
|
+
else enabledSkills.delete(skill.name);
|
|
7686
|
+
savingName = skill.name;
|
|
7687
|
+
setNativeCommandError("");
|
|
7688
|
+
render();
|
|
7689
|
+
try {
|
|
7690
|
+
const response = await nativeCommandApi("/api/skills", { method: "POST", body: { enabledSkills: [...enabledSkills] } });
|
|
7691
|
+
skills = Array.isArray(response.data?.skills) ? response.data.skills : [];
|
|
7692
|
+
addTransientMessage({ role: "native", title: "/skills", content: `Skill ${skill.name} ${enabledSkills.has(skill.name) ? "enabled" : "disabled"}.`, level: "info" });
|
|
7693
|
+
refreshCommands(activeTabContext()).catch((error) => addEvent(error.message || String(error), "error"));
|
|
7694
|
+
} catch (error) {
|
|
7695
|
+
setNativeCommandError(error.message || String(error));
|
|
7696
|
+
} finally {
|
|
7697
|
+
savingName = "";
|
|
7698
|
+
render();
|
|
7699
|
+
}
|
|
7700
|
+
},
|
|
7701
|
+
});
|
|
7702
|
+
renderNativeResourceFilterActions(filter, (value) => { filter = value; }, render);
|
|
7703
|
+
};
|
|
7704
|
+
try {
|
|
7705
|
+
const response = await nativeCommandApi("/api/skills");
|
|
7706
|
+
skills = Array.isArray(response.data?.skills) ? response.data.skills : [];
|
|
7707
|
+
elements.nativeCommandSearch.oninput = render;
|
|
7708
|
+
render();
|
|
7709
|
+
} catch (error) {
|
|
7710
|
+
setNativeCommandError(error.message || String(error));
|
|
7711
|
+
elements.nativeCommandBody.replaceChildren();
|
|
7712
|
+
}
|
|
7713
|
+
}
|
|
7714
|
+
|
|
7541
7715
|
function openNativeAuthInfo(mode) {
|
|
7542
7716
|
const command = mode === "logout" ? "/logout" : "/login";
|
|
7543
7717
|
openNativeCommandDialog({ title: command, message: "Provider credential entry is intentionally not implemented in the browser yet." });
|
|
@@ -7582,6 +7756,12 @@ async function handleNativeSlashSelectorCommand(message, { usesPromptInput = fal
|
|
|
7582
7756
|
case "scoped-models":
|
|
7583
7757
|
openNativeScopedModelsInfo();
|
|
7584
7758
|
return true;
|
|
7759
|
+
case "tools":
|
|
7760
|
+
await openNativeToolsSelector();
|
|
7761
|
+
return true;
|
|
7762
|
+
case "skills":
|
|
7763
|
+
await openNativeSkillsSelector();
|
|
7764
|
+
return true;
|
|
7585
7765
|
case "login":
|
|
7586
7766
|
case "logout":
|
|
7587
7767
|
openNativeAuthInfo(name);
|
|
@@ -7969,7 +8149,7 @@ function syncModelSelectToState() {
|
|
|
7969
8149
|
}
|
|
7970
8150
|
}
|
|
7971
8151
|
|
|
7972
|
-
function normalizeCommands(commands) {
|
|
8152
|
+
function normalizeCommands(commands, { dedupe = true } = {}) {
|
|
7973
8153
|
const seen = new Set();
|
|
7974
8154
|
return (commands || [])
|
|
7975
8155
|
.map((command) => ({
|
|
@@ -7977,9 +8157,12 @@ function normalizeCommands(commands) {
|
|
|
7977
8157
|
description: String(command.description || "").trim(),
|
|
7978
8158
|
source: String(command.source || "command").trim(),
|
|
7979
8159
|
location: String(command.location || "").trim(),
|
|
8160
|
+
enabled: command.enabled !== false,
|
|
7980
8161
|
}))
|
|
7981
8162
|
.filter((command) => {
|
|
7982
|
-
if (!command.name
|
|
8163
|
+
if (!command.name) return false;
|
|
8164
|
+
if (!dedupe) return true;
|
|
8165
|
+
if (seen.has(command.name)) return false;
|
|
7983
8166
|
seen.add(command.name);
|
|
7984
8167
|
return true;
|
|
7985
8168
|
})
|
|
@@ -8358,6 +8541,7 @@ async function refreshCommands(tabContext = activeTabContext()) {
|
|
|
8358
8541
|
if (!tabContext.tabId) return;
|
|
8359
8542
|
const response = await api("/api/commands", { tabId: tabContext.tabId });
|
|
8360
8543
|
if (!isCurrentTabContext(tabContext)) return;
|
|
8544
|
+
rawAvailableCommands = normalizeCommands(response.data?.commands || [], { dedupe: false });
|
|
8361
8545
|
availableCommands = normalizeCommands(response.data?.commands || []);
|
|
8362
8546
|
updateOptionalFeatureAvailability();
|
|
8363
8547
|
renderCommands();
|
package/public/index.html
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<link rel="manifest" href="/manifest.webmanifest" />
|
|
13
13
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
14
14
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
15
|
-
<link rel="stylesheet" href="/styles.css" />
|
|
15
|
+
<link rel="stylesheet" href="/styles.css?v=20" />
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<button id="sidePanelExpandButton" class="side-panel-expand-button" type="button" aria-controls="sidePanel" aria-expanded="false" aria-label="Expand side panel" title="Expand side panel">
|
|
@@ -364,6 +364,6 @@
|
|
|
364
364
|
</form>
|
|
365
365
|
</dialog>
|
|
366
366
|
|
|
367
|
-
<script type="module" src="/app.js"></script>
|
|
367
|
+
<script type="module" src="/app.js?v=20"></script>
|
|
368
368
|
</body>
|
|
369
369
|
</html>
|
package/public/service-worker.js
CHANGED
package/public/styles.css
CHANGED
|
@@ -3464,6 +3464,31 @@ summary { cursor: pointer; color: var(--warning); }
|
|
|
3464
3464
|
text-transform: uppercase;
|
|
3465
3465
|
letter-spacing: 0.07em;
|
|
3466
3466
|
}
|
|
3467
|
+
.native-command-body:has(.native-resource-summary) .native-selector-badge {
|
|
3468
|
+
border-color: rgba(255, 159, 67, 0.62);
|
|
3469
|
+
color: #ff9f43;
|
|
3470
|
+
background: rgba(255, 159, 67, 0.10);
|
|
3471
|
+
}
|
|
3472
|
+
.native-selector-badge.enabled,
|
|
3473
|
+
.native-selector-badge.native-selector-badge-enabled,
|
|
3474
|
+
.native-selector-badge[data-badge-state="enabled"],
|
|
3475
|
+
.native-command-body:has(.native-resource-summary) .native-selector-badge.enabled,
|
|
3476
|
+
.native-command-body:has(.native-resource-summary) .native-selector-badge.native-selector-badge-enabled,
|
|
3477
|
+
.native-command-body:has(.native-resource-summary) .native-selector-badge[data-badge-state="enabled"] {
|
|
3478
|
+
border-color: rgba(166, 227, 161, 0.32);
|
|
3479
|
+
color: var(--ctp-green);
|
|
3480
|
+
background: transparent;
|
|
3481
|
+
}
|
|
3482
|
+
.native-selector-badge.disabled,
|
|
3483
|
+
.native-selector-badge.native-selector-badge-disabled,
|
|
3484
|
+
.native-selector-badge[data-badge-state="disabled"],
|
|
3485
|
+
.native-command-body:has(.native-resource-summary) .native-selector-badge.disabled,
|
|
3486
|
+
.native-command-body:has(.native-resource-summary) .native-selector-badge.native-selector-badge-disabled,
|
|
3487
|
+
.native-command-body:has(.native-resource-summary) .native-selector-badge[data-badge-state="disabled"] {
|
|
3488
|
+
border-color: rgba(255, 159, 67, 0.62) !important;
|
|
3489
|
+
color: #ff9f43 !important;
|
|
3490
|
+
background: rgba(255, 159, 67, 0.10);
|
|
3491
|
+
}
|
|
3467
3492
|
.native-selector-detail,
|
|
3468
3493
|
.native-selector-meta,
|
|
3469
3494
|
.native-settings-hint {
|
|
@@ -27,8 +27,10 @@ const companionDependencies = {
|
|
|
27
27
|
"@firstpick/pi-extension-git-footer-status": "^0.2.1",
|
|
28
28
|
"@firstpick/pi-extension-release-aur": "^0.1.3",
|
|
29
29
|
"@firstpick/pi-extension-release-npm": "^0.3.3",
|
|
30
|
+
"@firstpick/pi-extension-setup-skills": "^0.1.5",
|
|
30
31
|
"@firstpick/pi-extension-stats": "^0.2.0",
|
|
31
32
|
"@firstpick/pi-extension-todo-progress": "^0.1.7",
|
|
33
|
+
"@firstpick/pi-extension-tools": "^0.1.4",
|
|
32
34
|
"@firstpick/pi-prompts-git-pr": "^0.1.0",
|
|
33
35
|
"@firstpick/pi-themes-bundle": "^0.1.1",
|
|
34
36
|
};
|
|
@@ -340,7 +342,9 @@ assert.match(app, /function setOptionalControlState\(button, available, unavaila
|
|
|
340
342
|
assert.match(app, /function renderCommands\(\)/, "side-panel commands should be re-renderable from current optional feature state");
|
|
341
343
|
assert.match(app, /function installOptionalFeature\(featureId\)/, "optional features should expose an install action");
|
|
342
344
|
assert.match(app, /api\("\/api\/optional-feature-install"/, "optional feature install action should call the backend installer endpoint");
|
|
343
|
-
assert.match(app, /
|
|
345
|
+
assert.match(app, /id: "tuiSkillsCommand"[\s\S]*?@firstpick\/pi-extension-setup-skills/, "optional features should include the TUI skills command companion");
|
|
346
|
+
assert.match(app, /id: "tuiToolsCommand"[\s\S]*?@firstpick\/pi-extension-tools/, "optional features should include the TUI tools command companion");
|
|
347
|
+
assert.match(app, /function updateOptionalFeatureAvailability\(\)[\s\S]*hasAvailableCommand\("git-staged-msg"\)[\s\S]*hasAvailableCommand\("release-npm"\)[\s\S]*hasAvailableCommand\("release-aur"\)[\s\S]*hasLoadedRpcCommand\("skills"\)[\s\S]*hasAvailableCommand\("todo-progress-status"\)[\s\S]*hasLoadedRpcCommand\("tools"\)/, "optional feature detection should call RPC-visible commands directly and distinguish native resource selectors from TUI companions");
|
|
344
348
|
assert.match(app, /if \(!isOptionalFeatureEnabled\("todoProgressWidget"\)\) return String\(text \|\| ""\)/, "todo progress line stripping should only run when the todo feature is detected and enabled");
|
|
345
349
|
assert.match(app, /const releasePrompt = detectedReleasePrompt && isOptionalFeatureEnabled\(detectedReleasePrompt\.featureId\) \? detectedReleasePrompt : null/, "release confirmation dialogs should use specialized rendering only when their release optional feature is enabled");
|
|
346
350
|
assert.match(app, /case "webui_tab_reloaded":[\s\S]*resetOptionalFeatureAvailability\(\)/, "optional feature state should reset when the RPC tab reloads resources");
|
|
@@ -490,7 +494,7 @@ assert.match(app, /async function sendPrompt\(kind = "prompt", explicitMessage\)
|
|
|
490
494
|
assert.match(app, /const rawMessage = usesPromptInput \? elements\.promptInput\.value : explicitMessage/, "direct prompt sends should not read the input textarea");
|
|
491
495
|
assert.match(app, /if \(usesPromptInput\) \{[\s\S]*?if \(targetStillActive\) \{[\s\S]*?elements\.promptInput\.value = "";/, "direct prompt sends should preserve the input textarea draft");
|
|
492
496
|
assert.match(app, /make\("button", "command-item"\)[\s\S]*?sendPrompt\("prompt", `\/\$\{command\.name\}`\)/, "side-panel command clicks should send the slash command directly");
|
|
493
|
-
assert.match(app, /const NATIVE_SELECTOR_COMMANDS = new Set\(\["model", "settings", "theme", "fork", "clone", "resume", "tree", "login", "logout", "scoped-models"\]\)/, "frontend should route native slash commands into selector UIs");
|
|
497
|
+
assert.match(app, /const NATIVE_SELECTOR_COMMANDS = new Set\(\["model", "settings", "theme", "fork", "clone", "resume", "tree", "login", "logout", "scoped-models", "tools", "skills"\]\)/, "frontend should route native slash commands into selector UIs");
|
|
494
498
|
assert.match(app, /async function handleNativeSlashSelectorCommand\(message/, "frontend should intercept exact native slash commands before prompt forwarding");
|
|
495
499
|
assert.match(app, /kind === "prompt" && attachments\.length === 0 && await handleNativeSlashSelectorCommand/, "prompt sending should open native selector dialogs before marking a run active");
|
|
496
500
|
assert.match(app, /function openNativeModelSelector\(\)[\s\S]*?nativeCommandApi\("\/api\/models"\)/, "native /model selector should load models through the active tab API");
|
|
@@ -499,7 +503,7 @@ assert.match(app, /function openNativeForkSelector\(\)[\s\S]*?\/api\/fork-messag
|
|
|
499
503
|
assert.match(app, /function openNativeResumeSelector\(scope = "current"\)[\s\S]*?\/api\/sessions\?scope=\$\{encodeURIComponent\(selectedScope\)\}/, "native /resume selector should list current-cwd or all sessions");
|
|
500
504
|
assert.match(app, /function openNativeTreeSelector\(\)[\s\S]*?\/api\/session-tree[\s\S]*?\/api\/tree-navigate/, "native /tree selector should list tree entries and navigate through the backend helper");
|
|
501
505
|
assert.match(app, /Provider credential entry is intentionally not implemented in the browser yet/, "native /login should remain a safe non-secret guidance dialog");
|
|
502
|
-
assert.match(app, /const HIDDEN_COMMAND_NAMES = new Set\(\["webui-tree-navigate"\]\)/, "internal Web UI helper commands should stay out of command pickers");
|
|
506
|
+
assert.match(app, /const HIDDEN_COMMAND_NAMES = new Set\(\["webui-tree-navigate", "webui-helper"\]\)/, "internal Web UI helper commands should stay out of command pickers");
|
|
503
507
|
assert.match(app, /function shouldSendPromptFromEnter\(event\)/, "prompt keyboard handling should be centralized");
|
|
504
508
|
assert.match(app, /const PROMPT_HISTORY_STORAGE_KEY = "pi-webui-prompt-history"/, "prompt history should be persisted per browser for keyboard recall");
|
|
505
509
|
assert.match(app, /function recallPreviousPromptFromHistory\(\)/, "prompt history should support recalling older prompts from the textarea");
|
|
@@ -596,7 +600,7 @@ assert.equal(manifest.start_url, "/", "PWA manifest should start at the web UI r
|
|
|
596
600
|
assert.ok(manifest.icons?.some((icon) => icon.src === "/apple-touch-icon.png" && icon.sizes === "180x180"), "PWA manifest should include a conventional 180px apple touch icon");
|
|
597
601
|
assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-192.png" && icon.sizes === "192x192"), "PWA manifest should include a 192px icon");
|
|
598
602
|
assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-512.png" && icon.sizes === "512x512"), "PWA manifest should include a 512px icon");
|
|
599
|
-
assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-
|
|
603
|
+
assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v20"/, "PWA service worker should define an app-shell cache");
|
|
600
604
|
assert.match(serviceWorker, /self\.addEventListener\("notificationclick"/, "PWA service worker should focus Web UI when blocked-tab notifications are clicked");
|
|
601
605
|
assert.match(serviceWorker, /event\.notification\.data\?\.url/, "blocked-tab notifications should carry a URL for service-worker click handling");
|
|
602
606
|
assert.match(serviceWorker, /"\/apple-touch-icon\.png"/, "PWA service worker should cache the apple touch icon");
|
|
@@ -713,6 +717,8 @@ assert.match(server, /type: "set_follow_up_mode"/, "server should expose follow-
|
|
|
713
717
|
assert.match(server, /type: "set_auto_compaction"/, "server should expose auto-compaction changes for native /settings");
|
|
714
718
|
assert.match(server, /@firstpick\/pi-themes-bundle/, "server should discover themes from the optional theme package");
|
|
715
719
|
assert.match(server, /const OPTIONAL_FEATURE_PACKAGES = new Map/, "server should whitelist optional feature packages for install actions");
|
|
720
|
+
assert.match(server, /\["tuiSkillsCommand", "@firstpick\/pi-extension-setup-skills"\]/, "server should allow installing the TUI skills optional feature");
|
|
721
|
+
assert.match(server, /\["tuiToolsCommand", "@firstpick\/pi-extension-tools"\]/, "server should allow installing the TUI tools optional feature");
|
|
716
722
|
assert.match(server, /function installOptionalFeaturePackage\(featureId\)/, "server should provide optional feature package installation helper");
|
|
717
723
|
assert.match(server, /url\.pathname === "\/api\/optional-feature-install" && req\.method === "POST"/, "server should expose optional feature install endpoint");
|
|
718
724
|
assert.match(server, /Installing optional Web UI features is only allowed from localhost/, "optional feature install endpoint should be localhost-only");
|
|
@@ -761,13 +767,17 @@ for (const extensionPath of [
|
|
|
761
767
|
"../pi-extension-git-footer-status/index.ts",
|
|
762
768
|
"../pi-extension-release-aur/index.ts",
|
|
763
769
|
"../pi-extension-release-npm/index.ts",
|
|
770
|
+
"../pi-extension-setup-skills/index.ts",
|
|
764
771
|
"../pi-extension-stats/index.ts",
|
|
765
772
|
"../pi-extension-todo-progress/index.ts",
|
|
773
|
+
"../pi-extension-tools/index.ts",
|
|
766
774
|
"node_modules/@firstpick/pi-extension-git-footer-status/index.ts",
|
|
767
775
|
"node_modules/@firstpick/pi-extension-release-aur/index.ts",
|
|
768
776
|
"node_modules/@firstpick/pi-extension-release-npm/index.ts",
|
|
777
|
+
"node_modules/@firstpick/pi-extension-setup-skills/index.ts",
|
|
769
778
|
"node_modules/@firstpick/pi-extension-stats/index.ts",
|
|
770
779
|
"node_modules/@firstpick/pi-extension-todo-progress/index.ts",
|
|
780
|
+
"node_modules/@firstpick/pi-extension-tools/index.ts",
|
|
771
781
|
]) {
|
|
772
782
|
assert.ok(pkg.pi?.extensions?.includes(extensionPath), `webui Pi manifest should load ${extensionPath} when present`);
|
|
773
783
|
}
|
|
@@ -56,6 +56,8 @@ const requiredNativeCommands = [
|
|
|
56
56
|
"model",
|
|
57
57
|
"theme",
|
|
58
58
|
"scoped-models",
|
|
59
|
+
"tools",
|
|
60
|
+
"skills",
|
|
59
61
|
"export",
|
|
60
62
|
"import",
|
|
61
63
|
"share",
|
|
@@ -116,6 +118,17 @@ assert.match(server, /function nativeCommandUnavailable\(command, details = \{\}
|
|
|
116
118
|
assert.match(server, /default:\n\s+return nativeCommandUnavailable\(parsed\.name\)/, "unsupported native commands should return structured unavailable cards instead of raw HTTP errors");
|
|
117
119
|
assert.match(server, /url\.pathname === "\/api\/native-parity" && req\.method === "GET"/, "server should expose the native parity matrix for clients/tests");
|
|
118
120
|
assert.match(server, /const NATIVE_DOWNLOAD_TOKEN_TTL_MS = 10 \* 60 \* 1000/, "native downloads should use short-lived tokens");
|
|
121
|
+
assert.match(server, /const WEBUI_HELPER_COMMAND = "webui-helper"/, "server should declare the hidden Web UI RPC helper command");
|
|
122
|
+
assert.match(server, /args\.push\("--extension", webuiHelperExtensionPath\)/, "Web UI tabs should force-load the browser-native RPC helper extension");
|
|
123
|
+
assert.match(server, /url\.pathname === "\/api\/tools" && req\.method === "GET"/, "server should expose GET /api/tools for native /tools");
|
|
124
|
+
assert.match(server, /url\.pathname === "\/api\/tools" && req\.method === "POST"/, "server should expose POST /api/tools for native /tools updates");
|
|
125
|
+
assert.match(server, /url\.pathname === "\/api\/skills" && req\.method === "GET"/, "server should expose GET /api/skills for native /skills");
|
|
126
|
+
assert.match(server, /url\.pathname === "\/api\/skills" && req\.method === "POST"/, "server should expose POST /api/skills for native /skills updates");
|
|
127
|
+
assert.match(app, /const HIDDEN_COMMAND_NAMES = new Set\(\["webui-tree-navigate", "webui-helper"\]\)/, "frontend should hide Web UI internal helper commands");
|
|
128
|
+
assert.match(app, /"scoped-models", "tools", "skills"/, "frontend native selector commands should include /tools and /skills");
|
|
129
|
+
assert.match(app, /return match \? match\[1\]\.toLowerCase\(\) : ""/, "frontend native slash command matching should be case-insensitive");
|
|
130
|
+
assert.match(app, /async function openNativeToolsSelector\(\)/, "frontend should implement a browser-native /tools selector");
|
|
131
|
+
assert.match(app, /async function openNativeSkillsSelector\(\)/, "frontend should implement a browser-native /skills selector");
|
|
119
132
|
assert.match(server, /function registerNativeDownload\(filePath, \{ fileName, contentType, command = "native" \} = \{\}\)/, "server should register opaque native download tokens");
|
|
120
133
|
assert.match(server, /url\.pathname\.startsWith\("\/api\/native-download\/"\) && req\.method === "GET"/, "server should expose opaque native download endpoint");
|
|
121
134
|
assert.match(server, /case "export": \{\n\s+return handleNativeExportCommand\(tab, parsed\.args, req\);\n\s+\}/, "native /export should route through the native command adapter");
|
|
@@ -146,3 +159,4 @@ assert.match(app, /enqueueUserBashCommand\(parsed, \{ usesPromptInput, targetTab
|
|
|
146
159
|
assert.match(server, /function sendQueuedBashCommand\(tab, command\)/, "server should serialize user bash commands per tab");
|
|
147
160
|
assert.match(server, /command\.type === "bash" \? await sendQueuedBashCommand\(tab, command\) : await tab\.rpc\.send\(command\)/, "generic POST handling should route bash through the FIFO queue");
|
|
148
161
|
assert.ok(pkg.files.includes("WEBUI_TUI_NATIVE_PARITY.json"), "published package should include the native parity matrix");
|
|
162
|
+
assert.ok(pkg.files.includes("webui-rpc-helper.mjs"), "published package should include the Web UI RPC helper extension");
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { formatSkillsForPrompt } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
const HELPER_COMMAND = "webui-helper";
|
|
4
|
+
const RESPONSE_PREFIX = "__PI_WEBUI_HELPER_RESPONSE__:";
|
|
5
|
+
const TOOLS_CONFIG_TYPE = "webui-tools-config";
|
|
6
|
+
const SKILLS_CONFIG_TYPE = "webui-skills-config";
|
|
7
|
+
|
|
8
|
+
function responseMessage(payload) {
|
|
9
|
+
return `${RESPONSE_PREFIX}${JSON.stringify(payload)}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function safeSourceInfo(sourceInfo) {
|
|
13
|
+
if (!sourceInfo || typeof sourceInfo !== "object") return undefined;
|
|
14
|
+
return {
|
|
15
|
+
path: typeof sourceInfo.path === "string" ? sourceInfo.path : undefined,
|
|
16
|
+
source: typeof sourceInfo.source === "string" ? sourceInfo.source : undefined,
|
|
17
|
+
scope: typeof sourceInfo.scope === "string" ? sourceInfo.scope : undefined,
|
|
18
|
+
origin: typeof sourceInfo.origin === "string" ? sourceInfo.origin : undefined,
|
|
19
|
+
baseDir: typeof sourceInfo.baseDir === "string" ? sourceInfo.baseDir : undefined,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function lastBranchConfig(ctx, customType) {
|
|
24
|
+
let found;
|
|
25
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
26
|
+
if (entry?.type === "custom" && entry.customType === customType && entry.data && typeof entry.data === "object") {
|
|
27
|
+
found = entry.data;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return found;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeNameList(value) {
|
|
34
|
+
if (!Array.isArray(value)) return [];
|
|
35
|
+
const names = [];
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
for (const item of value) {
|
|
38
|
+
const name = String(item || "").trim();
|
|
39
|
+
if (!name || seen.has(name)) continue;
|
|
40
|
+
seen.add(name);
|
|
41
|
+
names.push(name);
|
|
42
|
+
}
|
|
43
|
+
return names;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseHelperArgs(args) {
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(args || "{}");
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw new Error(`Invalid ${HELPER_COMMAND} payload: ${error instanceof Error ? error.message : String(error)}`);
|
|
52
|
+
}
|
|
53
|
+
if (!parsed || typeof parsed !== "object") throw new Error(`${HELPER_COMMAND} payload must be an object`);
|
|
54
|
+
const requestId = String(parsed.requestId || "").trim();
|
|
55
|
+
const action = String(parsed.action || "").trim();
|
|
56
|
+
if (!requestId) throw new Error(`${HELPER_COMMAND} payload requires requestId`);
|
|
57
|
+
if (!action) throw new Error(`${HELPER_COMMAND} payload requires action`);
|
|
58
|
+
return { requestId, action, payload: parsed.payload && typeof parsed.payload === "object" ? parsed.payload : {} };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function skillBlockPattern(name) {
|
|
62
|
+
const escaped = String(name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
63
|
+
return new RegExp(`\\n? <skill>\\n <name>${escaped}<\\/name>[\\s\\S]*? <\\/skill>`, "g");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function replaceAvailableSkillsSection(systemPrompt, skills) {
|
|
67
|
+
const nextSection = formatSkillsForPrompt(skills);
|
|
68
|
+
const replacement = nextSection ? `\n${nextSection}\n` : "\n";
|
|
69
|
+
if (systemPrompt.includes("<available_skills>")) {
|
|
70
|
+
return systemPrompt.replace(/\n?The following skills provide[\s\S]*?<\/available_skills>\n?/m, replacement);
|
|
71
|
+
}
|
|
72
|
+
return systemPrompt;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default function webuiRpcHelper(pi) {
|
|
76
|
+
let enabledTools = new Set();
|
|
77
|
+
let disabledSkills = new Set();
|
|
78
|
+
|
|
79
|
+
function allToolNames() {
|
|
80
|
+
return pi.getAllTools().map((tool) => tool.name);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function persistToolsState() {
|
|
84
|
+
pi.appendEntry(TOOLS_CONFIG_TYPE, { enabledTools: [...enabledTools] });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function applyTools() {
|
|
88
|
+
const existing = new Set(allToolNames());
|
|
89
|
+
pi.setActiveTools([...enabledTools].filter((name) => existing.has(name)));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function restoreToolsFromBranch(ctx) {
|
|
93
|
+
const saved = lastBranchConfig(ctx, TOOLS_CONFIG_TYPE)?.enabledTools;
|
|
94
|
+
if (Array.isArray(saved)) {
|
|
95
|
+
const existing = new Set(allToolNames());
|
|
96
|
+
enabledTools = new Set(normalizeNameList(saved).filter((name) => existing.has(name)));
|
|
97
|
+
applyTools();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
enabledTools = new Set(pi.getActiveTools());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function toolState() {
|
|
104
|
+
const active = new Set(pi.getActiveTools());
|
|
105
|
+
enabledTools = new Set([...active]);
|
|
106
|
+
return {
|
|
107
|
+
tools: pi.getAllTools().map((tool) => ({
|
|
108
|
+
name: tool.name,
|
|
109
|
+
description: tool.description || "",
|
|
110
|
+
enabled: active.has(tool.name),
|
|
111
|
+
sourceInfo: safeSourceInfo(tool.sourceInfo),
|
|
112
|
+
})),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function setToolState(payload) {
|
|
117
|
+
const existing = new Set(allToolNames());
|
|
118
|
+
if (Array.isArray(payload.enabledTools)) {
|
|
119
|
+
enabledTools = new Set(normalizeNameList(payload.enabledTools).filter((name) => existing.has(name)));
|
|
120
|
+
} else if (Array.isArray(payload.disabledTools)) {
|
|
121
|
+
const disabled = new Set(normalizeNameList(payload.disabledTools));
|
|
122
|
+
enabledTools = new Set([...existing].filter((name) => !disabled.has(name)));
|
|
123
|
+
} else {
|
|
124
|
+
throw new Error("Tool update requires enabledTools or disabledTools");
|
|
125
|
+
}
|
|
126
|
+
applyTools();
|
|
127
|
+
persistToolsState();
|
|
128
|
+
return toolState();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function persistSkillsState() {
|
|
132
|
+
pi.appendEntry(SKILLS_CONFIG_TYPE, { disabledSkills: [...disabledSkills] });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function restoreSkillsFromBranch(ctx) {
|
|
136
|
+
const saved = lastBranchConfig(ctx, SKILLS_CONFIG_TYPE)?.disabledSkills;
|
|
137
|
+
disabledSkills = new Set(normalizeNameList(saved));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function skillsFromContext(ctx) {
|
|
141
|
+
const options = ctx.getSystemPromptOptions?.();
|
|
142
|
+
const skills = Array.isArray(options?.skills) ? options.skills : [];
|
|
143
|
+
return skills.map((skill) => ({
|
|
144
|
+
name: skill.name,
|
|
145
|
+
description: skill.description || "",
|
|
146
|
+
enabled: !disabledSkills.has(skill.name),
|
|
147
|
+
disableModelInvocation: skill.disableModelInvocation === true,
|
|
148
|
+
filePath: skill.filePath,
|
|
149
|
+
sourceInfo: safeSourceInfo(skill.sourceInfo),
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function skillState(ctx) {
|
|
154
|
+
const known = new Set(skillsFromContext(ctx).map((skill) => skill.name));
|
|
155
|
+
disabledSkills = new Set([...disabledSkills].filter((name) => known.has(name)));
|
|
156
|
+
return { skills: skillsFromContext(ctx) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function setSkillState(ctx, payload) {
|
|
160
|
+
const allNames = new Set(skillsFromContext(ctx).map((skill) => skill.name));
|
|
161
|
+
if (Array.isArray(payload.enabledSkills)) {
|
|
162
|
+
const enabled = new Set(normalizeNameList(payload.enabledSkills));
|
|
163
|
+
disabledSkills = new Set([...allNames].filter((name) => !enabled.has(name)));
|
|
164
|
+
} else if (Array.isArray(payload.disabledSkills)) {
|
|
165
|
+
disabledSkills = new Set(normalizeNameList(payload.disabledSkills).filter((name) => allNames.has(name)));
|
|
166
|
+
} else {
|
|
167
|
+
throw new Error("Skill update requires enabledSkills or disabledSkills");
|
|
168
|
+
}
|
|
169
|
+
persistSkillsState();
|
|
170
|
+
return skillState(ctx);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function executeAction(action, payload, ctx) {
|
|
174
|
+
switch (action) {
|
|
175
|
+
case "tools-state":
|
|
176
|
+
return toolState();
|
|
177
|
+
case "tools-set":
|
|
178
|
+
return setToolState(payload);
|
|
179
|
+
case "skills-state":
|
|
180
|
+
return skillState(ctx);
|
|
181
|
+
case "skills-set":
|
|
182
|
+
return setSkillState(ctx, payload);
|
|
183
|
+
default:
|
|
184
|
+
throw new Error(`Unknown ${HELPER_COMMAND} action: ${action}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
pi.registerCommand(HELPER_COMMAND, {
|
|
189
|
+
description: "Internal Web UI helper for browser-native tools and skills configuration",
|
|
190
|
+
handler: async (args, ctx) => {
|
|
191
|
+
let requestId = "";
|
|
192
|
+
try {
|
|
193
|
+
const request = parseHelperArgs(args);
|
|
194
|
+
requestId = request.requestId;
|
|
195
|
+
const data = executeAction(request.action, request.payload, ctx);
|
|
196
|
+
ctx.ui.notify(responseMessage({ requestId, ok: true, data }), "info");
|
|
197
|
+
} catch (error) {
|
|
198
|
+
ctx.ui.notify(responseMessage({ requestId, ok: false, error: error instanceof Error ? error.message : String(error) }), "error");
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
204
|
+
restoreToolsFromBranch(ctx);
|
|
205
|
+
restoreSkillsFromBranch(ctx);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
209
|
+
restoreToolsFromBranch(ctx);
|
|
210
|
+
restoreSkillsFromBranch(ctx);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
pi.on("input", async (event, ctx) => {
|
|
214
|
+
const match = String(event.text || "").trim().match(/^\/skill:([^\s]+)/i);
|
|
215
|
+
if (!match) return { action: "continue" };
|
|
216
|
+
const skillName = match[1];
|
|
217
|
+
if (!disabledSkills.has(skillName)) return { action: "continue" };
|
|
218
|
+
ctx.ui.notify(`Skill /skill:${skillName} is disabled in the Web UI /skills selector.`, "warning");
|
|
219
|
+
return { action: "handled" };
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
pi.on("before_agent_start", async (event) => {
|
|
223
|
+
if (disabledSkills.size === 0) return;
|
|
224
|
+
const allSkills = Array.isArray(event.systemPromptOptions?.skills) ? event.systemPromptOptions.skills : [];
|
|
225
|
+
if (allSkills.length === 0) return;
|
|
226
|
+
const filteredSkills = allSkills.filter((skill) => !disabledSkills.has(skill.name));
|
|
227
|
+
let nextPrompt = replaceAvailableSkillsSection(event.systemPrompt, filteredSkills);
|
|
228
|
+
for (const name of disabledSkills) nextPrompt = nextPrompt.replace(skillBlockPattern(name), "");
|
|
229
|
+
return { systemPrompt: nextPrompt };
|
|
230
|
+
});
|
|
231
|
+
}
|