@firstpick/pi-package-webui 0.2.2 → 0.2.4

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