@firstpick/pi-package-webui 0.2.3 → 0.2.5
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 +23 -7
- package/bin/pi-webui.mjs +98 -11
- package/package.json +1 -1
- package/public/app.js +132 -21
- package/public/index.html +23 -2
- package/public/service-worker.js +1 -1
- package/public/styles.css +51 -0
- package/tests/mobile-static.test.mjs +29 -4
- package/tests/native-parity.test.mjs +5 -1
package/README.md
CHANGED
|
@@ -117,18 +117,34 @@ 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
|
+
- Prompt composer with uploads, drag/drop/paste, inline image support, slash-command autocomplete, and `@` file/path references with live suggestions.
|
|
122
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
|
|
|
@@ -157,7 +173,7 @@ This requires `/git-staged-msg` from `@firstpick/pi-prompts-git-pr`. Review the
|
|
|
157
173
|
## Mobile and PWA notes
|
|
158
174
|
|
|
159
175
|
- The mobile composer starts as a compact `Ask Pi…` input and grows as you type.
|
|
160
|
-
- 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.
|
|
161
177
|
- Plain `http://<LAN-IP>` can show the app, but some browsers disable PWA install and notifications there.
|
|
162
178
|
|
|
163
179
|
## Network safety
|
package/bin/pi-webui.mjs
CHANGED
|
@@ -17,6 +17,7 @@ const packageRoot = path.resolve(__dirname, "..");
|
|
|
17
17
|
const publicDir = path.join(packageRoot, "public");
|
|
18
18
|
const webuiHelperExtensionPath = path.join(packageRoot, "webui-rpc-helper.mjs");
|
|
19
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";
|
|
20
21
|
const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
|
|
21
22
|
const nativeParityMatrix = JSON.parse(await readFile(path.join(packageRoot, "WEBUI_TUI_NATIVE_PARITY.json"), "utf8"));
|
|
22
23
|
const webuiDevServer = isTruthyEnv(process.env.PI_WEBUI_DEV) || isSourceCheckout(packageRoot);
|
|
@@ -40,6 +41,7 @@ const ATTACHMENT_UPLOAD_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
|
40
41
|
const INLINE_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
|
|
41
42
|
const INLINE_IMAGE_TOTAL_MAX_BYTES = 16 * 1024 * 1024;
|
|
42
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"];
|
|
43
45
|
const EVENT_HISTORY_LIMIT = 200;
|
|
44
46
|
const EXTENSION_UI_BLOCKING_METHODS = new Set(["select", "confirm", "input", "editor"]);
|
|
45
47
|
const STATUS_RPC_TIMEOUT_MS = 1_800;
|
|
@@ -768,14 +770,51 @@ function runCommand(command, args, { cwd, timeoutMs = 2000, maxOutputLength = 20
|
|
|
768
770
|
});
|
|
769
771
|
}
|
|
770
772
|
|
|
771
|
-
function
|
|
772
|
-
const parts =
|
|
773
|
+
function nodeModulesParentForPackageRoot(root = packageRoot) {
|
|
774
|
+
const parts = root.split(path.sep);
|
|
773
775
|
const nodeModulesIndex = parts.lastIndexOf("node_modules");
|
|
774
776
|
if (nodeModulesIndex >= 0) {
|
|
775
|
-
const
|
|
776
|
-
return
|
|
777
|
+
const parent = parts.slice(0, nodeModulesIndex).join(path.sep);
|
|
778
|
+
return parent || path.parse(root).root;
|
|
777
779
|
}
|
|
778
|
-
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
|
+
);
|
|
779
818
|
}
|
|
780
819
|
|
|
781
820
|
function formatCommandForDisplay(command, args) {
|
|
@@ -786,7 +825,7 @@ async function installOptionalFeaturePackage(featureId) {
|
|
|
786
825
|
const packageName = OPTIONAL_FEATURE_PACKAGES.get(featureId);
|
|
787
826
|
if (!packageName) throw makeHttpError(400, `Unknown optional feature: ${featureId}`);
|
|
788
827
|
|
|
789
|
-
const installRoot = optionalDependencyInstallRoot();
|
|
828
|
+
const installRoot = await optionalDependencyInstallRoot();
|
|
790
829
|
const npmCommand = process.env.PI_WEBUI_NPM_BIN || "npm";
|
|
791
830
|
const args = ["install", "--prefix", installRoot, packageName];
|
|
792
831
|
const result = await runCommand(npmCommand, args, {
|
|
@@ -1958,7 +1997,7 @@ function commandFromPost(pathname, body) {
|
|
|
1958
1997
|
}
|
|
1959
1998
|
case "/api/thinking": {
|
|
1960
1999
|
const level = String(body.level || "").trim();
|
|
1961
|
-
if (!
|
|
2000
|
+
if (!THINKING_LEVELS.includes(level)) {
|
|
1962
2001
|
throw new Error("Invalid thinking level");
|
|
1963
2002
|
}
|
|
1964
2003
|
return { type: "set_thinking_level", level };
|
|
@@ -2121,10 +2160,21 @@ function rememberTabState(tab, state) {
|
|
|
2121
2160
|
if (!options.noSession && Object.prototype.hasOwnProperty.call(state, "sessionFile")) tab.sessionFile = sessionFileFromState(state);
|
|
2122
2161
|
}
|
|
2123
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
|
+
|
|
2124
2173
|
function forgetTabState(tab) {
|
|
2125
2174
|
if (!tab) return;
|
|
2126
2175
|
tab.lastState = null;
|
|
2127
2176
|
tab.sessionFile = undefined;
|
|
2177
|
+
tab.pendingThinkingLevel = undefined;
|
|
2128
2178
|
}
|
|
2129
2179
|
|
|
2130
2180
|
function tabRestorableSessionFile(tab) {
|
|
@@ -2481,6 +2531,7 @@ async function createTab({ id: requestedId, index, title, titleSource, conversat
|
|
|
2481
2531
|
createdAt,
|
|
2482
2532
|
sessionFile: options.noSession ? undefined : normalizedRestoreString(sessionFile, 4096),
|
|
2483
2533
|
lastState: null,
|
|
2534
|
+
pendingThinkingLevel: undefined,
|
|
2484
2535
|
activity: createTabActivity(createdAt),
|
|
2485
2536
|
pendingExtensionUiRequests: new Map(),
|
|
2486
2537
|
webuiHelperRequests: new Map(),
|
|
@@ -2523,6 +2574,7 @@ function tabMeta(tab) {
|
|
|
2523
2574
|
conversationStarted: !!tab.conversationStarted,
|
|
2524
2575
|
cwd: tab.cwd,
|
|
2525
2576
|
sessionFile: tabRestorableSessionFile(tab),
|
|
2577
|
+
pendingThinkingLevel: tab.pendingThinkingLevel || null,
|
|
2526
2578
|
createdAt: tab.createdAt,
|
|
2527
2579
|
startedAt: tab.rpc.startedAt,
|
|
2528
2580
|
pid: tab.rpc.child?.pid,
|
|
@@ -2767,10 +2819,10 @@ function fallbackRpcResponse(tab, command, error) {
|
|
|
2767
2819
|
|
|
2768
2820
|
async function safeRpcResponse(tab, command, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
2769
2821
|
try {
|
|
2770
|
-
return await tab.rpc.send(command, timeoutMs);
|
|
2822
|
+
return responseWithPendingThinking(tab, await tab.rpc.send(command, timeoutMs));
|
|
2771
2823
|
} catch (error) {
|
|
2772
2824
|
const message = sanitizeError(error);
|
|
2773
|
-
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));
|
|
2774
2826
|
throw error;
|
|
2775
2827
|
}
|
|
2776
2828
|
}
|
|
@@ -3732,12 +3784,38 @@ async function safeRpcData(tab, command, timeoutMs = STATUS_RPC_TIMEOUT_MS) {
|
|
|
3732
3784
|
const response = await tab.rpc.send(command, timeoutMs);
|
|
3733
3785
|
if (response?.success === false) return { ok: false, error: response.error || `${command.type} failed` };
|
|
3734
3786
|
if (command?.type === "get_state") rememberTabState(tab, response?.data);
|
|
3735
|
-
return { ok: true, data: response?.data ?? null };
|
|
3787
|
+
return { ok: true, data: command?.type === "get_state" ? stateWithPendingThinking(tab, response?.data) : response?.data ?? null };
|
|
3736
3788
|
} catch (error) {
|
|
3737
3789
|
return { ok: false, error: sanitizeError(error) };
|
|
3738
3790
|
}
|
|
3739
3791
|
}
|
|
3740
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
|
+
|
|
3741
3819
|
function providerList(models) {
|
|
3742
3820
|
const providers = new Set();
|
|
3743
3821
|
for (const model of Array.isArray(models) ? models : []) {
|
|
@@ -4132,6 +4210,11 @@ const server = createServer(async (req, res) => {
|
|
|
4132
4210
|
return;
|
|
4133
4211
|
}
|
|
4134
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
|
+
}
|
|
4135
4218
|
const startsVisibleWork = commandStartsVisibleWork(command);
|
|
4136
4219
|
if (startsVisibleWork) {
|
|
4137
4220
|
maybeNameTabForConversation(tab, command);
|
|
@@ -4194,7 +4277,11 @@ const server = createServer(async (req, res) => {
|
|
|
4194
4277
|
maybeNameTabForConversation(tab, command);
|
|
4195
4278
|
markTabWorking(tab);
|
|
4196
4279
|
}
|
|
4197
|
-
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);
|
|
4198
4285
|
if (response.success === false && startsVisibleWork) markTabIdle(tab);
|
|
4199
4286
|
if (response.success !== false && command.type === "new_session") {
|
|
4200
4287
|
tab.conversationStarted = false;
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -43,6 +43,10 @@ const elements = {
|
|
|
43
43
|
publishMenu: $("#publishMenu"),
|
|
44
44
|
releaseNpmButton: $("#releaseNpmButton"),
|
|
45
45
|
releaseAurButton: $("#releaseAurButton"),
|
|
46
|
+
nativeCommandMenuButton: $("#nativeCommandMenuButton"),
|
|
47
|
+
nativeCommandMenu: $("#nativeCommandMenu"),
|
|
48
|
+
nativeSkillsButton: $("#nativeSkillsButton"),
|
|
49
|
+
nativeToolsButton: $("#nativeToolsButton"),
|
|
46
50
|
gitWorkflowPanel: $("#gitWorkflowPanel"),
|
|
47
51
|
gitWorkflowTitle: $("#gitWorkflowTitle"),
|
|
48
52
|
gitWorkflowHint: $("#gitWorkflowHint"),
|
|
@@ -143,6 +147,7 @@ let pathFastPicksReady = false;
|
|
|
143
147
|
let pathFastPicksLoadPromise = null;
|
|
144
148
|
let mobileTabsExpanded = false;
|
|
145
149
|
let openTerminalTabGroupKey = null;
|
|
150
|
+
let nativeCommandMenuOpen = false;
|
|
146
151
|
let availableCommands = [];
|
|
147
152
|
let rawAvailableCommands = [];
|
|
148
153
|
let commandSuggestions = [];
|
|
@@ -361,6 +366,8 @@ const OPTIONAL_COMMAND_FEATURES = new Map([
|
|
|
361
366
|
["git-staged-msg", "gitWorkflow"],
|
|
362
367
|
["release-npm", "releaseNpm"],
|
|
363
368
|
["release-aur", "releaseAur"],
|
|
369
|
+
["skills", "tuiSkillsCommand"],
|
|
370
|
+
["tools", "tuiToolsCommand"],
|
|
364
371
|
["stats", "statsCommand"],
|
|
365
372
|
["git-footer-refresh", "gitFooterStatus"],
|
|
366
373
|
["todo-progress-status", "todoProgressWidget"],
|
|
@@ -3982,10 +3989,15 @@ function renderStatus() {
|
|
|
3982
3989
|
const compacting = state?.isCompacting ? " · compacting" : "";
|
|
3983
3990
|
|
|
3984
3991
|
elements.stateDetails.replaceChildren();
|
|
3992
|
+
const pendingThinkingLevel = state?.pendingThinkingLevel || null;
|
|
3993
|
+
const shownThinkingLevel = pendingThinkingLevel || state?.thinkingLevel;
|
|
3994
|
+
const thinkingDetail = pendingThinkingLevel && pendingThinkingLevel !== state?.thinkingLevel
|
|
3995
|
+
? `${state?.thinkingLevel || "unknown"} → ${pendingThinkingLevel} next prompt`
|
|
3996
|
+
: state?.thinkingLevel || "unknown";
|
|
3985
3997
|
const details = {
|
|
3986
3998
|
Status: `${running}${compacting}`,
|
|
3987
3999
|
Model: modelLabel(state?.model),
|
|
3988
|
-
Thinking:
|
|
4000
|
+
Thinking: thinkingDetail,
|
|
3989
4001
|
Session: state?.sessionName || state?.sessionId || "unknown",
|
|
3990
4002
|
File: state?.sessionFile || "in-memory",
|
|
3991
4003
|
Messages: String(state?.messageCount ?? "?"),
|
|
@@ -3996,7 +4008,7 @@ function renderStatus() {
|
|
|
3996
4008
|
elements.stateDetails.append(make("dt", undefined, key), make("dd", undefined, value));
|
|
3997
4009
|
}
|
|
3998
4010
|
|
|
3999
|
-
if (
|
|
4011
|
+
if (shownThinkingLevel) elements.thinkingSelect.value = shownThinkingLevel;
|
|
4000
4012
|
elements.compactButton.disabled = !!state?.isCompacting;
|
|
4001
4013
|
elements.compactButton.textContent = state?.isCompacting ? "Compacting…" : "Compact";
|
|
4002
4014
|
syncModelSelectToState();
|
|
@@ -6938,6 +6950,13 @@ function setPublishMenuOpen(open) {
|
|
|
6938
6950
|
elements.publishButton.parentElement?.classList.toggle("open", publishMenuOpen);
|
|
6939
6951
|
}
|
|
6940
6952
|
|
|
6953
|
+
function setNativeCommandMenuOpen(open) {
|
|
6954
|
+
nativeCommandMenuOpen = !!open;
|
|
6955
|
+
elements.nativeCommandMenuButton.setAttribute("aria-expanded", nativeCommandMenuOpen ? "true" : "false");
|
|
6956
|
+
elements.nativeCommandMenuButton.classList.toggle("menu-open", nativeCommandMenuOpen);
|
|
6957
|
+
elements.nativeCommandMenuButton.parentElement?.classList.toggle("open", nativeCommandMenuOpen);
|
|
6958
|
+
}
|
|
6959
|
+
|
|
6941
6960
|
function optionalFeatureIdForCommand(name) {
|
|
6942
6961
|
if (OPTIONAL_COMMAND_FEATURES.has(name)) return OPTIONAL_COMMAND_FEATURES.get(name);
|
|
6943
6962
|
if (name === "release-toggle" || name === "release-abort" || name === "release-npm-logs") return "releaseNpm";
|
|
@@ -7091,6 +7110,18 @@ function renderOptionalFeatureControls() {
|
|
|
7091
7110
|
);
|
|
7092
7111
|
if (!hasPublishWorkflow && publishMenuOpen) setPublishMenuOpen(false);
|
|
7093
7112
|
|
|
7113
|
+
const hasNativeCommandMenu = isOptionalFeatureEnabled("tuiSkillsCommand") || isOptionalFeatureEnabled("tuiToolsCommand");
|
|
7114
|
+
elements.nativeSkillsButton.hidden = !isOptionalFeatureEnabled("tuiSkillsCommand");
|
|
7115
|
+
elements.nativeToolsButton.hidden = !isOptionalFeatureEnabled("tuiToolsCommand");
|
|
7116
|
+
const nativeCommandMenuContainer = elements.nativeCommandMenuButton.parentElement;
|
|
7117
|
+
if (nativeCommandMenuContainer) nativeCommandMenuContainer.hidden = !hasNativeCommandMenu;
|
|
7118
|
+
setOptionalControlState(
|
|
7119
|
+
elements.nativeCommandMenuButton,
|
|
7120
|
+
hasNativeCommandMenu,
|
|
7121
|
+
"Slash command menu unavailable: enable/install TUI Skills command and/or TUI Tools command in Optional features.",
|
|
7122
|
+
);
|
|
7123
|
+
if (!hasNativeCommandMenu && nativeCommandMenuOpen) setNativeCommandMenuOpen(false);
|
|
7124
|
+
|
|
7094
7125
|
renderOptionalFeaturePanel();
|
|
7095
7126
|
}
|
|
7096
7127
|
|
|
@@ -7154,6 +7185,23 @@ function runPublishWorkflow(command) {
|
|
|
7154
7185
|
sendPrompt("prompt", command);
|
|
7155
7186
|
}
|
|
7156
7187
|
|
|
7188
|
+
async function runNativeCommandMenu(command) {
|
|
7189
|
+
setComposerActionsOpen(false);
|
|
7190
|
+
setPublishMenuOpen(false);
|
|
7191
|
+
setNativeCommandMenuOpen(false);
|
|
7192
|
+
const commandName = String(command || "").replace(/^\//, "").split(/\s+/)[0].toLowerCase();
|
|
7193
|
+
const featureId = optionalFeatureIdForCommand(commandName);
|
|
7194
|
+
if ((featureId && !isOptionalFeatureEnabled(featureId)) || !hasAvailableCommand(commandName)) {
|
|
7195
|
+
const tabContext = activeTabContext();
|
|
7196
|
+
addEvent(commandUnavailableMessage(commandName), "warn");
|
|
7197
|
+
refreshCommands(tabContext).catch((error) => {
|
|
7198
|
+
if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
|
|
7199
|
+
});
|
|
7200
|
+
return;
|
|
7201
|
+
}
|
|
7202
|
+
await handleNativeSlashSelectorCommand(command);
|
|
7203
|
+
}
|
|
7204
|
+
|
|
7157
7205
|
function slashCommandName(message) {
|
|
7158
7206
|
const match = String(message || "").trim().match(/^\/([^\s]+)$/);
|
|
7159
7207
|
return match ? match[1].toLowerCase() : "";
|
|
@@ -7207,7 +7255,8 @@ function renderNativeLoading(label = "Loading…") {
|
|
|
7207
7255
|
function nativeSelectorMatches(item, query) {
|
|
7208
7256
|
if (!query) return true;
|
|
7209
7257
|
const needle = query.toLowerCase();
|
|
7210
|
-
|
|
7258
|
+
const tags = Array.isArray(item.tags) ? item.tags.map((tag) => tag?.label) : [];
|
|
7259
|
+
return [item.label, item.description, item.meta, item.badge, ...tags]
|
|
7211
7260
|
.filter(Boolean)
|
|
7212
7261
|
.some((value) => String(value).toLowerCase().includes(needle));
|
|
7213
7262
|
}
|
|
@@ -7240,6 +7289,10 @@ function renderNativeSelectorItems(items, { emptyText = "No choices.", onSelect,
|
|
|
7240
7289
|
}
|
|
7241
7290
|
title.append(badge);
|
|
7242
7291
|
}
|
|
7292
|
+
for (const tag of Array.isArray(item.tags) ? item.tags : []) {
|
|
7293
|
+
if (!tag?.label) continue;
|
|
7294
|
+
title.append(make("span", `native-selector-badge${tag.className ? ` ${tag.className}` : ""}`, tag.label));
|
|
7295
|
+
}
|
|
7243
7296
|
const detail = make("span", "native-selector-detail", item.description || "");
|
|
7244
7297
|
const meta = make("span", "native-selector-meta", item.meta || "");
|
|
7245
7298
|
button.append(title);
|
|
@@ -7578,6 +7631,12 @@ function nativeResourceSourceLabel(resource) {
|
|
|
7578
7631
|
return [info.source, info.scope, info.origin].filter(Boolean).join(" · ") || resource?.location || "loaded resource";
|
|
7579
7632
|
}
|
|
7580
7633
|
|
|
7634
|
+
function nativeToolOriginTag(resource) {
|
|
7635
|
+
return resource?.sourceInfo?.source === "builtin"
|
|
7636
|
+
? { label: "Pi Native", className: "native-selector-badge-pi-native" }
|
|
7637
|
+
: { label: "External", className: "native-selector-badge-external" };
|
|
7638
|
+
}
|
|
7639
|
+
|
|
7581
7640
|
function nativeResourceCounts(resources) {
|
|
7582
7641
|
const disabled = resources.filter((resource) => resource.enabled === false).length;
|
|
7583
7642
|
return { total: resources.length, disabled, enabled: resources.length - disabled };
|
|
@@ -7589,19 +7648,23 @@ function nativeResourceFilterMatches(resource, filter) {
|
|
|
7589
7648
|
return true;
|
|
7590
7649
|
}
|
|
7591
7650
|
|
|
7592
|
-
function renderNativeResourceToggles(resources, { savingName, filter = "all", onToggle } = {}) {
|
|
7651
|
+
function renderNativeResourceToggles(resources, { savingName, filter = "all", onToggle, getResourceTag } = {}) {
|
|
7593
7652
|
const filteredResources = resources.filter((resource) => nativeResourceFilterMatches(resource, filter));
|
|
7594
7653
|
const counts = nativeResourceCounts(resources);
|
|
7595
|
-
const items = filteredResources.map((resource) =>
|
|
7596
|
-
|
|
7597
|
-
|
|
7598
|
-
|
|
7599
|
-
|
|
7600
|
-
|
|
7601
|
-
|
|
7602
|
-
|
|
7603
|
-
|
|
7604
|
-
|
|
7654
|
+
const items = filteredResources.map((resource) => {
|
|
7655
|
+
const resourceTag = getResourceTag?.(resource);
|
|
7656
|
+
return {
|
|
7657
|
+
id: resource.name,
|
|
7658
|
+
label: resource.name,
|
|
7659
|
+
description: resource.description || "No description provided.",
|
|
7660
|
+
meta: nativeResourceSourceLabel(resource),
|
|
7661
|
+
badge: resource.enabled === false ? "disabled" : "enabled",
|
|
7662
|
+
badgeClass: resource.enabled === false ? "disabled native-selector-badge-disabled" : "enabled native-selector-badge-enabled",
|
|
7663
|
+
tags: resourceTag ? [resourceTag] : [],
|
|
7664
|
+
disabled: Boolean(savingName),
|
|
7665
|
+
resource,
|
|
7666
|
+
};
|
|
7667
|
+
});
|
|
7605
7668
|
const filterLabel = filter === "enabled" ? "enabled" : filter === "disabled" ? "disabled" : "all";
|
|
7606
7669
|
renderNativeSelectorItems(items, {
|
|
7607
7670
|
emptyText: `No ${filterLabel} entries match this filter.`,
|
|
@@ -7626,7 +7689,7 @@ function renderNativeResourceFilterActions(filter, setFilter, render) {
|
|
|
7626
7689
|
}
|
|
7627
7690
|
|
|
7628
7691
|
async function openNativeToolsSelector() {
|
|
7629
|
-
openNativeCommandDialog({ title: "
|
|
7692
|
+
openNativeCommandDialog({ title: "Tools Setup", 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
7693
|
renderNativeLoading("Loading tools…");
|
|
7631
7694
|
let tools = [];
|
|
7632
7695
|
let savingName = "";
|
|
@@ -7635,6 +7698,7 @@ async function openNativeToolsSelector() {
|
|
|
7635
7698
|
renderNativeResourceToggles(tools, {
|
|
7636
7699
|
savingName,
|
|
7637
7700
|
filter,
|
|
7701
|
+
getResourceTag: nativeToolOriginTag,
|
|
7638
7702
|
onToggle: async (tool) => {
|
|
7639
7703
|
if (!tool || savingName) return;
|
|
7640
7704
|
const enabledTools = new Set(tools.filter((item) => item.enabled !== false).map((item) => item.name));
|
|
@@ -7669,7 +7733,7 @@ async function openNativeToolsSelector() {
|
|
|
7669
7733
|
}
|
|
7670
7734
|
|
|
7671
7735
|
async function openNativeSkillsSelector() {
|
|
7672
|
-
openNativeCommandDialog({ title: "
|
|
7736
|
+
openNativeCommandDialog({ title: "Skills Setup", 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
7737
|
renderNativeLoading("Loading skills…");
|
|
7674
7738
|
let skills = [];
|
|
7675
7739
|
let savingName = "";
|
|
@@ -7725,6 +7789,15 @@ function openNativeAuthInfo(mode) {
|
|
|
7725
7789
|
async function handleNativeSlashSelectorCommand(message, { usesPromptInput = false } = {}) {
|
|
7726
7790
|
const name = slashCommandName(message);
|
|
7727
7791
|
if (!NATIVE_SELECTOR_COMMANDS.has(name)) return false;
|
|
7792
|
+
const featureId = optionalFeatureIdForCommand(name);
|
|
7793
|
+
if (featureId && !isOptionalFeatureEnabled(featureId)) {
|
|
7794
|
+
const tabContext = activeTabContext();
|
|
7795
|
+
addEvent(commandUnavailableMessage(name), "warn");
|
|
7796
|
+
refreshCommands(tabContext).catch((error) => {
|
|
7797
|
+
if (isCurrentTabContext(tabContext)) addEvent(error.message || String(error), "error");
|
|
7798
|
+
});
|
|
7799
|
+
return true;
|
|
7800
|
+
}
|
|
7728
7801
|
setComposerActionsOpen(false);
|
|
7729
7802
|
hideCommandSuggestions();
|
|
7730
7803
|
if (usesPromptInput) {
|
|
@@ -9208,7 +9281,7 @@ function showNextDialog() {
|
|
|
9208
9281
|
if (isGuardrailDialog && /^Block$/i.test(optionLabel)) button.classList.add("guardrail-safe-action");
|
|
9209
9282
|
if (isGuardrailDialog && /^Allow/i.test(optionLabel)) button.classList.add("guardrail-allow-action");
|
|
9210
9283
|
if (isReleaseDialog && /^(?:Yes|All eligible packages\b|Publish selected packages \([1-9]\d*\))/.test(optionLabel)) button.classList.add("primary", "release-publish-action");
|
|
9211
|
-
if (isReleaseDialog && /^Publish selected packages
|
|
9284
|
+
if (isReleaseDialog && /^Publish selected packages$/i.test(optionLabel)) button.classList.add("release-publish-disabled-action");
|
|
9212
9285
|
if (isReleaseDialog && /^\[x\]/.test(optionLabel)) button.classList.add("release-target-option", "release-target-selected");
|
|
9213
9286
|
if (isReleaseDialog && /^\[ \]/.test(optionLabel)) button.classList.add("release-target-option");
|
|
9214
9287
|
if (isReleaseDialog && /^(?:No|Cancel)$/i.test(optionLabel)) button.classList.add("release-cancel-action");
|
|
@@ -9510,18 +9583,46 @@ elements.gitWorkflowButton.addEventListener("click", () => {
|
|
|
9510
9583
|
});
|
|
9511
9584
|
const publishMenuContainer = elements.publishButton.parentElement;
|
|
9512
9585
|
elements.publishButton.addEventListener("click", () => {
|
|
9586
|
+
setNativeCommandMenuOpen(false);
|
|
9587
|
+
setPublishMenuOpen(true);
|
|
9588
|
+
});
|
|
9589
|
+
publishMenuContainer?.addEventListener("pointerenter", () => {
|
|
9590
|
+
setNativeCommandMenuOpen(false);
|
|
9513
9591
|
setPublishMenuOpen(true);
|
|
9514
9592
|
});
|
|
9515
|
-
publishMenuContainer?.addEventListener("pointerenter", () => setPublishMenuOpen(true));
|
|
9516
9593
|
publishMenuContainer?.addEventListener("pointerleave", () => setPublishMenuOpen(false));
|
|
9517
|
-
publishMenuContainer?.addEventListener("focusin", () =>
|
|
9594
|
+
publishMenuContainer?.addEventListener("focusin", () => {
|
|
9595
|
+
setNativeCommandMenuOpen(false);
|
|
9596
|
+
setPublishMenuOpen(true);
|
|
9597
|
+
});
|
|
9518
9598
|
publishMenuContainer?.addEventListener("focusout", () => {
|
|
9519
9599
|
setTimeout(() => {
|
|
9520
9600
|
if (!publishMenuContainer?.contains(document.activeElement)) setPublishMenuOpen(false);
|
|
9521
9601
|
}, 0);
|
|
9522
9602
|
});
|
|
9603
|
+
const nativeCommandMenuContainer = elements.nativeCommandMenuButton.parentElement;
|
|
9604
|
+
elements.nativeCommandMenuButton.addEventListener("click", () => {
|
|
9605
|
+
setPublishMenuOpen(false);
|
|
9606
|
+
setNativeCommandMenuOpen(true);
|
|
9607
|
+
});
|
|
9608
|
+
nativeCommandMenuContainer?.addEventListener("pointerenter", () => {
|
|
9609
|
+
setPublishMenuOpen(false);
|
|
9610
|
+
setNativeCommandMenuOpen(true);
|
|
9611
|
+
});
|
|
9612
|
+
nativeCommandMenuContainer?.addEventListener("pointerleave", () => setNativeCommandMenuOpen(false));
|
|
9613
|
+
nativeCommandMenuContainer?.addEventListener("focusin", () => {
|
|
9614
|
+
setPublishMenuOpen(false);
|
|
9615
|
+
setNativeCommandMenuOpen(true);
|
|
9616
|
+
});
|
|
9617
|
+
nativeCommandMenuContainer?.addEventListener("focusout", () => {
|
|
9618
|
+
setTimeout(() => {
|
|
9619
|
+
if (!nativeCommandMenuContainer?.contains(document.activeElement)) setNativeCommandMenuOpen(false);
|
|
9620
|
+
}, 0);
|
|
9621
|
+
});
|
|
9523
9622
|
elements.releaseNpmButton.addEventListener("click", () => runPublishWorkflow("/release-npm"));
|
|
9524
9623
|
elements.releaseAurButton.addEventListener("click", () => runPublishWorkflow("/release-aur"));
|
|
9624
|
+
elements.nativeSkillsButton.addEventListener("click", () => runNativeCommandMenu("/skills"));
|
|
9625
|
+
elements.nativeToolsButton.addEventListener("click", () => runNativeCommandMenu("/tools"));
|
|
9525
9626
|
elements.gitWorkflowCancelButton.addEventListener("click", () => cancelGitWorkflow());
|
|
9526
9627
|
elements.nativeCommandDialog.addEventListener("close", () => {
|
|
9527
9628
|
elements.nativeCommandSearch.oninput = null;
|
|
@@ -9650,8 +9751,11 @@ elements.setModelButton.addEventListener("click", async () => {
|
|
|
9650
9751
|
elements.setThinkingButton.addEventListener("click", async () => {
|
|
9651
9752
|
const tabContext = activeTabContext();
|
|
9652
9753
|
try {
|
|
9653
|
-
await api("/api/thinking", { method: "POST", body: { level: elements.thinkingSelect.value }, tabId: tabContext.tabId });
|
|
9654
|
-
if (isCurrentTabContext(tabContext))
|
|
9754
|
+
const response = await api("/api/thinking", { method: "POST", body: { level: elements.thinkingSelect.value }, tabId: tabContext.tabId });
|
|
9755
|
+
if (isCurrentTabContext(tabContext)) {
|
|
9756
|
+
if (response.data?.pending) addEvent(response.data.message || `Thinking level ${response.data.level} will apply to the next prompt.`, "info");
|
|
9757
|
+
await refreshState(tabContext);
|
|
9758
|
+
}
|
|
9655
9759
|
} catch (error) {
|
|
9656
9760
|
if (isCurrentTabContext(tabContext)) addEvent(error.message, "error");
|
|
9657
9761
|
}
|
|
@@ -9719,6 +9823,9 @@ document.addEventListener("pointerdown", (event) => {
|
|
|
9719
9823
|
if (publishMenuOpen && !event.target?.closest?.(".composer-publish-menu")) {
|
|
9720
9824
|
setPublishMenuOpen(false);
|
|
9721
9825
|
}
|
|
9826
|
+
if (nativeCommandMenuOpen && !event.target?.closest?.(".composer-native-command-menu")) {
|
|
9827
|
+
setNativeCommandMenuOpen(false);
|
|
9828
|
+
}
|
|
9722
9829
|
if (document.body.classList.contains("mobile-tabs-expanded") && !elements.tabBar.contains(event.target) && !elements.terminalTabsToggleButton.contains(event.target)) {
|
|
9723
9830
|
setMobileTabsExpanded(false);
|
|
9724
9831
|
}
|
|
@@ -9805,6 +9912,10 @@ window.addEventListener("keydown", (event) => {
|
|
|
9805
9912
|
setPublishMenuOpen(false);
|
|
9806
9913
|
return;
|
|
9807
9914
|
}
|
|
9915
|
+
if (nativeCommandMenuOpen) {
|
|
9916
|
+
setNativeCommandMenuOpen(false);
|
|
9917
|
+
return;
|
|
9918
|
+
}
|
|
9808
9919
|
if (document.body.classList.contains("composer-actions-open")) {
|
|
9809
9920
|
setComposerActionsOpen(false);
|
|
9810
9921
|
return;
|
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?v=
|
|
15
|
+
<link rel="stylesheet" href="/styles.css?v=23" />
|
|
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">
|
|
@@ -133,6 +133,27 @@
|
|
|
133
133
|
</button>
|
|
134
134
|
</div>
|
|
135
135
|
</div>
|
|
136
|
+
<div class="composer-publish-menu composer-native-command-menu">
|
|
137
|
+
<button
|
|
138
|
+
id="nativeCommandMenuButton"
|
|
139
|
+
class="composer-icon-button composer-publish-button composer-native-command-button"
|
|
140
|
+
type="button"
|
|
141
|
+
title="Open skills and tools commands"
|
|
142
|
+
aria-label="Open /skills and /tools commands"
|
|
143
|
+
aria-haspopup="menu"
|
|
144
|
+
aria-expanded="false"
|
|
145
|
+
aria-controls="nativeCommandMenu"
|
|
146
|
+
data-tooltip="Skills/tools setup: open skill or tool setup."
|
|
147
|
+
><svg class="composer-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 5h16v14H4z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="m7 10 2.5 2L7 14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 15h5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>
|
|
148
|
+
<div id="nativeCommandMenu" class="composer-publish-menu-panel composer-native-command-menu-panel" role="menu" aria-label="Skills and tools setup">
|
|
149
|
+
<button id="nativeSkillsButton" class="composer-publish-menu-item composer-native-command-menu-item" type="button" role="menuitem" data-command="/skills">
|
|
150
|
+
<span>Skills Setup</span>
|
|
151
|
+
</button>
|
|
152
|
+
<button id="nativeToolsButton" class="composer-publish-menu-item composer-native-command-menu-item" type="button" role="menuitem" data-command="/tools">
|
|
153
|
+
<span>Tools Setup</span>
|
|
154
|
+
</button>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
136
157
|
</div>
|
|
137
158
|
<div class="spacer"></div>
|
|
138
159
|
<button
|
|
@@ -364,6 +385,6 @@
|
|
|
364
385
|
</form>
|
|
365
386
|
</dialog>
|
|
366
387
|
|
|
367
|
-
<script type="module" src="/app.js?v=
|
|
388
|
+
<script type="module" src="/app.js?v=23"></script>
|
|
368
389
|
</body>
|
|
369
390
|
</html>
|
package/public/service-worker.js
CHANGED
package/public/styles.css
CHANGED
|
@@ -3092,6 +3092,22 @@ summary { cursor: pointer; color: var(--warning); }
|
|
|
3092
3092
|
.composer-publish-button.menu-open {
|
|
3093
3093
|
background: linear-gradient(120deg, var(--ctp-peach), var(--ctp-yellow), var(--ctp-mauve));
|
|
3094
3094
|
}
|
|
3095
|
+
.composer-native-command-button {
|
|
3096
|
+
color: var(--ctp-mauve);
|
|
3097
|
+
border-color: rgba(203, 166, 247, 0.40);
|
|
3098
|
+
background:
|
|
3099
|
+
linear-gradient(120deg, rgba(203, 166, 247, 0.15), rgba(137, 180, 250, 0.10)),
|
|
3100
|
+
linear-gradient(180deg, rgba(var(--ctp-surface-rgb), 0.88), rgba(var(--ctp-crust-rgb), 0.88));
|
|
3101
|
+
}
|
|
3102
|
+
.composer-native-command-button:hover,
|
|
3103
|
+
.composer-native-command-button.menu-open {
|
|
3104
|
+
color: #11111b;
|
|
3105
|
+
background: linear-gradient(120deg, var(--ctp-mauve), var(--ctp-blue), var(--ctp-teal));
|
|
3106
|
+
border-color: transparent;
|
|
3107
|
+
}
|
|
3108
|
+
.composer-native-command-menu.open .composer-native-command-button {
|
|
3109
|
+
border-color: rgba(203, 166, 247, 0.62);
|
|
3110
|
+
}
|
|
3095
3111
|
.composer-publish-menu-panel {
|
|
3096
3112
|
position: absolute;
|
|
3097
3113
|
z-index: 100;
|
|
@@ -3141,6 +3157,20 @@ summary { cursor: pointer; color: var(--warning); }
|
|
|
3141
3157
|
background: linear-gradient(120deg, var(--ctp-peach), var(--ctp-yellow));
|
|
3142
3158
|
box-shadow: 0 0 1rem rgba(250, 179, 135, 0.20);
|
|
3143
3159
|
}
|
|
3160
|
+
.composer-native-command-menu-item {
|
|
3161
|
+
color: var(--ctp-mauve);
|
|
3162
|
+
border-color: rgba(203, 166, 247, 0.32);
|
|
3163
|
+
background:
|
|
3164
|
+
linear-gradient(120deg, rgba(203, 166, 247, 0.12), rgba(137, 180, 250, 0.08)),
|
|
3165
|
+
var(--ctp-crust);
|
|
3166
|
+
}
|
|
3167
|
+
.composer-native-command-menu-item:hover,
|
|
3168
|
+
.composer-native-command-menu-item:focus-visible {
|
|
3169
|
+
color: #11111b;
|
|
3170
|
+
border-color: transparent;
|
|
3171
|
+
background: linear-gradient(120deg, var(--ctp-mauve), var(--ctp-blue));
|
|
3172
|
+
box-shadow: 0 0 1rem rgba(203, 166, 247, 0.20);
|
|
3173
|
+
}
|
|
3144
3174
|
.composer button[data-tooltip] {
|
|
3145
3175
|
position: relative;
|
|
3146
3176
|
}
|
|
@@ -3213,6 +3243,15 @@ summary { cursor: pointer; color: var(--warning); }
|
|
|
3213
3243
|
.composer-input-row button[data-tooltip].tooltip-open::before {
|
|
3214
3244
|
transform: translate(-1.2rem, 0) rotate(45deg);
|
|
3215
3245
|
}
|
|
3246
|
+
.composer-publish-menu:hover > .composer-publish-button[data-tooltip]::before,
|
|
3247
|
+
.composer-publish-menu:hover > .composer-publish-button[data-tooltip]::after,
|
|
3248
|
+
.composer-publish-menu:focus-within > .composer-publish-button[data-tooltip]::before,
|
|
3249
|
+
.composer-publish-menu:focus-within > .composer-publish-button[data-tooltip]::after,
|
|
3250
|
+
.composer-publish-menu.open > .composer-publish-button[data-tooltip]::before,
|
|
3251
|
+
.composer-publish-menu.open > .composer-publish-button[data-tooltip]::after {
|
|
3252
|
+
display: none !important;
|
|
3253
|
+
opacity: 0 !important;
|
|
3254
|
+
}
|
|
3216
3255
|
|
|
3217
3256
|
.details {
|
|
3218
3257
|
display: grid;
|
|
@@ -3489,6 +3528,18 @@ summary { cursor: pointer; color: var(--warning); }
|
|
|
3489
3528
|
color: #ff9f43 !important;
|
|
3490
3529
|
background: rgba(255, 159, 67, 0.10);
|
|
3491
3530
|
}
|
|
3531
|
+
.native-selector-badge.native-selector-badge-pi-native,
|
|
3532
|
+
.native-command-body:has(.native-resource-summary) .native-selector-badge.native-selector-badge-pi-native {
|
|
3533
|
+
border-color: rgba(137, 180, 250, 0.48);
|
|
3534
|
+
color: var(--ctp-blue);
|
|
3535
|
+
background: rgba(137, 180, 250, 0.10);
|
|
3536
|
+
}
|
|
3537
|
+
.native-selector-badge.native-selector-badge-external,
|
|
3538
|
+
.native-command-body:has(.native-resource-summary) .native-selector-badge.native-selector-badge-external {
|
|
3539
|
+
border-color: rgba(203, 166, 247, 0.46);
|
|
3540
|
+
color: var(--ctp-mauve);
|
|
3541
|
+
background: rgba(203, 166, 247, 0.10);
|
|
3542
|
+
}
|
|
3492
3543
|
.native-selector-detail,
|
|
3493
3544
|
.native-selector-meta,
|
|
3494
3545
|
.native-settings-hint {
|
|
@@ -98,6 +98,10 @@ assert.doesNotMatch(html, /class="side-panel-controls"[\s\S]*id="abortButton"/,
|
|
|
98
98
|
assert.match(html, /id="publishButton"[\s\S]*?aria-controls="publishMenu"/, "composer should expose a Publish workflow menu button");
|
|
99
99
|
assert.match(html, /id="releaseNpmButton"[^>]*data-command="\/release-npm"[\s\S]*?<span>NPM Release<\/span>/, "Publish menu should include the npm release workflow by label");
|
|
100
100
|
assert.match(html, /id="releaseAurButton"[^>]*data-command="\/release-aur"[\s\S]*?<span>AUR Release<\/span>/, "Publish menu should include the AUR release workflow by label");
|
|
101
|
+
assert.match(html, /id="nativeCommandMenuButton"[\s\S]*?aria-controls="nativeCommandMenu"/, "composer should expose a /skills and /tools command menu button");
|
|
102
|
+
assert.ok(html.indexOf('id="publishButton"') < html.indexOf('id="nativeCommandMenuButton"'), "skills/tools command menu should render immediately after the Publish workflow button");
|
|
103
|
+
assert.match(html, /id="nativeSkillsButton"[^>]*data-command="\/skills"[\s\S]*?<span>Skills Setup<\/span>/, "skills/tools command menu should include Skills Setup");
|
|
104
|
+
assert.match(html, /id="nativeToolsButton"[^>]*data-command="\/tools"[\s\S]*?<span>Tools Setup<\/span>/, "skills/tools command menu should include Tools Setup");
|
|
101
105
|
assert.doesNotMatch(html, /<code>\/release-(?:npm|aur)<\/code>/, "Publish menu should not show slash command names as option labels");
|
|
102
106
|
assert.doesNotMatch(html, /data-tooltip="[^"]*\/release-(?:npm|aur)/, "Publish tooltip should not show slash command names");
|
|
103
107
|
assert.match(html, /id="steerButton"[\s\S]*?data-tooltip="Steer usage:/, "Steer should explain type-first usage in a tooltip");
|
|
@@ -180,9 +184,12 @@ assert.match(css, /\.action-feedback-controls:not\(:hover\):not\(:focus-within\)
|
|
|
180
184
|
assert.match(css, /\.action-feedback-button\.feedback-question\.active/, "question-mark reaction should have selected styling");
|
|
181
185
|
assert.match(css, /\.composer-row button\[data-tooltip\]::after/, "composer button tooltips should be shared across Git, Steer, and Follow-up buttons");
|
|
182
186
|
assert.match(css, /\.composer-row button\[data-tooltip\]\.tooltip-open::after/, "composer button tooltips should be triggerable from JS for empty mobile taps");
|
|
187
|
+
assert.match(css, /\.composer-publish-menu:hover > \.composer-publish-button\[data-tooltip\]::before,[\s\S]*?\.composer-publish-menu\.open > \.composer-publish-button\[data-tooltip\]::after \{[\s\S]*?display:\s*none !important;[\s\S]*?opacity:\s*0 !important;/, "dropdown button tooltips should hide while publish or setup menus are open");
|
|
183
188
|
assert.match(css, /\.composer-publish-menu-panel \{[\s\S]*?display:\s*none;[\s\S]*?flex-direction:\s*column/, "Publish workflow menu should hide when closed and expand like grouped tabs");
|
|
184
189
|
assert.match(css, /\.composer-publish-menu:hover \.composer-publish-menu-panel,[\s\S]*?\.composer-publish-menu:focus-within \.composer-publish-menu-panel,[\s\S]*?\.composer-publish-menu\.open \.composer-publish-menu-panel \{\n\s+display:\s*flex;/, "Publish workflow menu should open on hover, focus, or explicit open state");
|
|
185
|
-
assert.match(css, /\.composer-
|
|
190
|
+
assert.match(css, /\.composer-native-command-button \{[\s\S]*?color:\s*var\(--ctp-mauve\)/, "skills/tools command menu should have a distinct slash-command button style");
|
|
191
|
+
assert.match(css, /\.composer-native-command-menu-item \{[\s\S]*?color:\s*var\(--ctp-mauve\)/, "skills/tools command menu items should be styled separately from publish actions");
|
|
192
|
+
assert.match(css, /\.composer-actions-panel > \.composer-publish-menu[\s\S]*?grid-column: span 1/, "Publish and command menu buttons should fit beside Git workflow in mobile actions");
|
|
186
193
|
assert.match(css, /\.composer-actions-panel[\s\S]*?bottom:\s*calc\(100% \+ 0\.42rem\)/, "mobile composer actions should open as an above-composer sheet");
|
|
187
194
|
assert.match(css, /body\.composer-actions-open \.composer-actions-panel \{ display: grid; \}/, "composer actions panel should only open when toggled");
|
|
188
195
|
assert.match(css, /\.terminal-tabs-toggle-button \{ display: none; \}/, "terminal tab toggle should be hidden outside mobile CSS");
|
|
@@ -220,6 +227,8 @@ assert.match(css, /\.extension-dialog[\s\S]*?inset:\s*auto 0 0 0/, "mobile dialo
|
|
|
220
227
|
assert.match(css, /#dialogMessage \{[\s\S]*?white-space:\s*pre-wrap/, "extension dialog messages should preserve multiline prompts");
|
|
221
228
|
assert.match(css, /\.native-command-dialog \{[\s\S]*?width:\s*min\(56rem/, "native slash selector dialog should have roomy desktop layout");
|
|
222
229
|
assert.match(css, /\.native-selector-item \{[\s\S]*?--tree-depth/, "native slash selector choices should support tree indentation");
|
|
230
|
+
assert.match(css, /\.native-selector-badge\.native-selector-badge-pi-native[\s\S]*?color:\s*var\(--ctp-blue\)/, "Tools Setup should distinguish Pi native tools with a Pi Native tag");
|
|
231
|
+
assert.match(css, /\.native-selector-badge\.native-selector-badge-external[\s\S]*?color:\s*var\(--ctp-mauve\)/, "Tools Setup should distinguish external tools with an External tag");
|
|
223
232
|
assert.match(css, /\.native-settings-grid,[\s\S]*?\.native-tree-options \{[\s\S]*?grid-template-columns:/, "native settings and tree selector options should use responsive grids");
|
|
224
233
|
assert.match(css, /\.extension-dialog\.guardrail-dialog[\s\S]*?border-color:\s*rgba\(249, 226, 175/, "guardrail dialogs should have warning-specific styling");
|
|
225
234
|
assert.match(css, /\.extension-dialog\.release-dialog[\s\S]*?width:\s*min\(64rem/, "release confirmation dialogs should have more horizontal room");
|
|
@@ -339,6 +348,9 @@ assert.match(app, /OPTIONAL_FEATURES_STORAGE_KEY/, "optional feature disable tog
|
|
|
339
348
|
assert.match(app, /function renderOptionalFeatureDependentDisplays\(\)[\s\S]*renderOptionalFeatureControls\(\);[\s\S]*renderThemeSelect\(\);[\s\S]*renderWidgets\(\);[\s\S]*renderStatus\(\);[\s\S]*renderCommands\(\);[\s\S]*renderAllMessages\(\{ preserveScroll: true \}\);[\s\S]*if \(streamRawText\) renderStreamingAssistantText\(\);/, "optional feature toggles should immediately refresh visible controls, commands, transcript, and live stream displays");
|
|
340
349
|
assert.match(app, /function setOptionalFeatureDisabled\(featureId, disabled\)[\s\S]*renderOptionalFeatureDependentDisplays\(\);[\s\S]*const tabContext = activeTabContext\(\);[\s\S]*refreshCommands\(tabContext\)/, "optional feature enable/disable should re-render the GUI and then refresh command capabilities");
|
|
341
350
|
assert.match(app, /function setOptionalControlState\(button, available, unavailableTitle\)[\s\S]*setAttribute\("aria-label", nextAriaLabel\)[\s\S]*setAttribute\("data-tooltip", nextTooltip\)/, "optional feature button disabled state should update accessible labels and visible tooltips");
|
|
351
|
+
assert.match(app, /\["skills", "tuiSkillsCommand"\][\s\S]*\["tools", "tuiToolsCommand"\]/, "optional feature toggles should gate /skills and /tools command surfaces");
|
|
352
|
+
assert.match(app, /function setNativeCommandMenuOpen\(open\)/, "frontend should track the skills/tools command menu open state separately from Publish");
|
|
353
|
+
assert.match(app, /nativeSkillsButton\.hidden = !isOptionalFeatureEnabled\("tuiSkillsCommand"\)[\s\S]*nativeToolsButton\.hidden = !isOptionalFeatureEnabled\("tuiToolsCommand"\)/, "skills/tools menu items should be hidden by their optional feature toggles");
|
|
342
354
|
assert.match(app, /function renderCommands\(\)/, "side-panel commands should be re-renderable from current optional feature state");
|
|
343
355
|
assert.match(app, /function installOptionalFeature\(featureId\)/, "optional features should expose an install action");
|
|
344
356
|
assert.match(app, /api\("\/api\/optional-feature-install"/, "optional feature install action should call the backend installer endpoint");
|
|
@@ -486,10 +498,18 @@ assert.match(app, /function showComposerButtonTooltip\(button\)/, "empty mode-bu
|
|
|
486
498
|
assert.match(app, /sendPromptFromModeButton\("steer", elements\.steerButton\)/, "Steer should show tooltip instead of silently doing nothing when input is empty");
|
|
487
499
|
assert.match(app, /sendPromptFromModeButton\("follow-up", elements\.followUpButton\)/, "Follow-up should show tooltip instead of silently doing nothing when input is empty");
|
|
488
500
|
assert.match(app, /function runPublishWorkflow\(command\)[\s\S]*?sendPrompt\("prompt", command\)/, "Publish workflows should send slash commands directly without replacing the draft");
|
|
489
|
-
assert.match(app, /
|
|
501
|
+
assert.match(app, /async function runNativeCommandMenu\(command\)[\s\S]*?await handleNativeSlashSelectorCommand\(command\)/, "skills/tools command menu should open native selector dialogs directly");
|
|
502
|
+
assert.match(app, /function nativeToolOriginTag\(resource\)[\s\S]*?sourceInfo\?\.source === "builtin"[\s\S]*?label: "Pi Native"[\s\S]*?label: "External"/, "Tools Setup should classify built-in Pi tools separately from external tools");
|
|
503
|
+
assert.match(app, /renderNativeResourceToggles\(tools, \{[\s\S]*?getResourceTag: nativeToolOriginTag/, "Tools Setup should render Pi Native\/External tags");
|
|
504
|
+
assert.match(app, /const tags = Array\.isArray\(item\.tags\)[\s\S]*?item\.badge, \.\.\.tags/, "native selector filtering should include extra resource tags");
|
|
505
|
+
assert.match(app, /publishMenuContainer\?\.addEventListener\("pointerenter", \(\) => \{[\s\S]*?setPublishMenuOpen\(true\);[\s\S]*?\}\)/, "Publish menu should expand on hover");
|
|
490
506
|
assert.match(app, /publishMenuContainer\?\.addEventListener\("pointerleave", \(\) => setPublishMenuOpen\(false\)\)/, "Publish menu should collapse after hover leaves");
|
|
507
|
+
assert.match(app, /nativeCommandMenuContainer\?\.addEventListener\("pointerenter", \(\) => \{[\s\S]*?setNativeCommandMenuOpen\(true\);[\s\S]*?\}\)/, "skills/tools command menu should expand on hover");
|
|
508
|
+
assert.match(app, /nativeCommandMenuContainer\?\.addEventListener\("pointerleave", \(\) => setNativeCommandMenuOpen\(false\)\)/, "skills/tools command menu should collapse after hover leaves");
|
|
491
509
|
assert.match(app, /releaseNpmButton\.addEventListener\("click", \(\) => runPublishWorkflow\("\/release-npm"\)\)/, "Publish menu should launch /release-npm");
|
|
492
510
|
assert.match(app, /releaseAurButton\.addEventListener\("click", \(\) => runPublishWorkflow\("\/release-aur"\)\)/, "Publish menu should launch /release-aur");
|
|
511
|
+
assert.match(app, /nativeSkillsButton\.addEventListener\("click", \(\) => runNativeCommandMenu\("\/skills"\)\)/, "skills/tools command menu should launch /skills");
|
|
512
|
+
assert.match(app, /nativeToolsButton\.addEventListener\("click", \(\) => runNativeCommandMenu\("\/tools"\)\)/, "skills/tools command menu should launch /tools");
|
|
493
513
|
assert.match(app, /async function sendPrompt\(kind = "prompt", explicitMessage\)/, "prompt sending should accept direct messages that bypass the input field");
|
|
494
514
|
assert.match(app, /const rawMessage = usesPromptInput \? elements\.promptInput\.value : explicitMessage/, "direct prompt sends should not read the input textarea");
|
|
495
515
|
assert.match(app, /if \(usesPromptInput\) \{[\s\S]*?if \(targetStillActive\) \{[\s\S]*?elements\.promptInput\.value = "";/, "direct prompt sends should preserve the input textarea draft");
|
|
@@ -600,7 +620,7 @@ assert.equal(manifest.start_url, "/", "PWA manifest should start at the web UI r
|
|
|
600
620
|
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");
|
|
601
621
|
assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-192.png" && icon.sizes === "192x192"), "PWA manifest should include a 192px icon");
|
|
602
622
|
assert.ok(manifest.icons?.some((icon) => icon.src === "/icon-512.png" && icon.sizes === "512x512"), "PWA manifest should include a 512px icon");
|
|
603
|
-
assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-
|
|
623
|
+
assert.match(serviceWorker, /const CACHE_NAME = "pi-webui-pwa-v23"/, "PWA service worker should define an app-shell cache");
|
|
604
624
|
assert.match(serviceWorker, /self\.addEventListener\("notificationclick"/, "PWA service worker should focus Web UI when blocked-tab notifications are clicked");
|
|
605
625
|
assert.match(serviceWorker, /event\.notification\.data\?\.url/, "blocked-tab notifications should carry a URL for service-worker click handling");
|
|
606
626
|
assert.match(serviceWorker, /"\/apple-touch-icon\.png"/, "PWA service worker should cache the apple touch icon");
|
|
@@ -682,7 +702,7 @@ assert.match(app, /function triggerNativeDownload\(download\)/, "frontend should
|
|
|
682
702
|
assert.match(server, /case "\/api\/bash": \{[\s\S]*?type: "bash", command, excludeFromContext: body\.excludeFromContext === true/, "server should expose user bash execution with exclude-from-context support");
|
|
683
703
|
assert.match(server, /case "\/api\/abort-bash":[\s\S]*?type: "abort_bash"/, "server should expose user bash abort");
|
|
684
704
|
assert.match(server, /function sendQueuedBashCommand\(tab, command\)/, "server should serialize user bash through a per-tab FIFO queue");
|
|
685
|
-
assert.match(server, /command\.type === "bash"
|
|
705
|
+
assert.match(server, /command\.type === "bash"[\s\S]*?await sendQueuedBashCommand\(tab, command\)[\s\S]*?: await tab\.rpc\.send\(command\)/, "POST routing should use the bash FIFO queue before RPC send");
|
|
686
706
|
assert.match(app, /function parseUserBashInput\(message\)/, "frontend should parse leading ! and !! bash commands");
|
|
687
707
|
assert.match(app, /let userBashQueuesByTab = new Map\(\)/, "frontend should keep a per-tab user bash queue");
|
|
688
708
|
assert.match(app, /enqueueUserBashCommand\(parsed, \{ usesPromptInput, targetTabId \}\)/, "frontend should queue additional bash commands while one is active");
|
|
@@ -720,6 +740,11 @@ assert.match(server, /const OPTIONAL_FEATURE_PACKAGES = new Map/, "server should
|
|
|
720
740
|
assert.match(server, /\["tuiSkillsCommand", "@firstpick\/pi-extension-setup-skills"\]/, "server should allow installing the TUI skills optional feature");
|
|
721
741
|
assert.match(server, /\["tuiToolsCommand", "@firstpick\/pi-extension-tools"\]/, "server should allow installing the TUI tools optional feature");
|
|
722
742
|
assert.match(server, /function installOptionalFeaturePackage\(featureId\)/, "server should provide optional feature package installation helper");
|
|
743
|
+
assert.match(server, /PI_WEBUI_OPTIONAL_FEATURE_INSTALL_ROOT/, "optional feature installs should support an explicit package-manager root override");
|
|
744
|
+
assert.match(server, /function configuredAgentNpmRoot\(\)/, "global Web UI launches should install optional feature packages into Pi's agent npm root, not the npm global prefix");
|
|
745
|
+
assert.match(server, /installRootDeclaresPackage\(.*?@firstpick\/pi-package-webui/s, "optional feature installs should only reuse a node_modules parent that declares the Web UI package dependency");
|
|
746
|
+
assert.match(server, /if \(webuiDevServer\) return installRoot/, "source-checkout Web UI launches should still use the checkout root for optional feature installs");
|
|
747
|
+
assert.match(server, /Could not determine a safe optional feature install root/, "optional feature installs should fail closed when no declared package root can be found");
|
|
723
748
|
assert.match(server, /url\.pathname === "\/api\/optional-feature-install" && req\.method === "POST"/, "server should expose optional feature install endpoint");
|
|
724
749
|
assert.match(server, /Installing optional Web UI features is only allowed from localhost/, "optional feature install endpoint should be localhost-only");
|
|
725
750
|
assert.match(server, /url\.pathname === "\/api\/themes" && req\.method === "GET"/, "server should expose GET /api/themes");
|
|
@@ -146,6 +146,10 @@ assert.match(app, /api\("\/api\/abort-bash", \{ method: "POST", body: \{\}, tabI
|
|
|
146
146
|
assert.match(server, /async function cycleTabModel\(tab, direction = "forward"\)/, "server should provide scoped\/all model cycling helper");
|
|
147
147
|
assert.match(server, /url\.pathname === "\/api\/model-cycle" && req\.method === "POST"/, "server should expose model-cycle endpoint for shortcuts");
|
|
148
148
|
assert.match(server, /case "\/api\/thinking-cycle":[\s\S]*?type: "cycle_thinking_level"/, "server should expose thinking-cycle endpoint for shortcuts");
|
|
149
|
+
assert.match(server, /async function setThinkingLevelForTab\(tab, level, \{ allowPending = true \} = \{\}\)[\s\S]*?stateIsBusyForSettings\(stateResult\.data\)[\s\S]*?tab\.pendingThinkingLevel = level/, "server should queue side-panel thinking changes while a tab is running");
|
|
150
|
+
assert.match(server, /const pendingThinkingResponse = await applyPendingThinkingBeforePrompt\(tab\)/, "server should apply queued thinking level before the next prompt");
|
|
151
|
+
assert.match(app, /pendingThinkingLevel[\s\S]*?next prompt/, "frontend should show queued thinking changes as applying on the next prompt");
|
|
152
|
+
assert.match(app, /response\.data\?\.pending[\s\S]*?will apply to the next prompt/, "frontend should announce queued side-panel thinking changes");
|
|
149
153
|
assert.match(app, /function handleNativeAppShortcut\(event\)/, "frontend should centralize native app shortcut handling");
|
|
150
154
|
assert.match(app, /openNativeModelSelector\(\)/, "Ctrl+L shortcut should open the native model selector");
|
|
151
155
|
assert.match(app, /cycleModelFromShortcut\(event\.shiftKey \? "backward" : "forward"\)/, "Ctrl+P shortcuts should cycle models forward and backward");
|
|
@@ -157,6 +161,6 @@ assert.match(app, /event\.altKey && key === "ArrowUp"[\s\S]*?restoreQueuedMessag
|
|
|
157
161
|
assert.match(app, /let userBashQueuesByTab = new Map\(\)/, "frontend should track per-tab user bash FIFO queues");
|
|
158
162
|
assert.match(app, /enqueueUserBashCommand\(parsed, \{ usesPromptInput, targetTabId \}\)/, "user bash should enqueue while an active or queued bash command exists");
|
|
159
163
|
assert.match(server, /function sendQueuedBashCommand\(tab, command\)/, "server should serialize user bash commands per tab");
|
|
160
|
-
assert.match(server, /command\.type === "bash"
|
|
164
|
+
assert.match(server, /command\.type === "bash"[\s\S]*?await sendQueuedBashCommand\(tab, command\)[\s\S]*?: await tab\.rpc\.send\(command\)/, "generic POST handling should route bash through the FIFO queue");
|
|
161
165
|
assert.ok(pkg.files.includes("WEBUI_TUI_NATIVE_PARITY.json"), "published package should include the native parity matrix");
|
|
162
166
|
assert.ok(pkg.files.includes("webui-rpc-helper.mjs"), "published package should include the Web UI RPC helper extension");
|