@firstpick/pi-package-webui 0.2.1 → 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.
@@ -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
+ }