pi-skillful 0.3.12 → 0.3.14

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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.14 - 2026-07-17
4
+
5
+ ## 0.3.13 - 2026-07-17
6
+
3
7
  ## 0.3.12 - 2026-07-12
4
8
 
5
9
  ## 0.3.11 - 2026-07-01
@@ -14,6 +18,12 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
14
18
 
15
19
  ### Fixed
16
20
 
21
+ - Ignore project `skillful` settings and block project-scope writes when Pi does not trust the current project.
22
+ - Let an explicit project `toggleModifier: "alt"` override a non-default global modifier; unsupported explicit values now follow the documented `"alt"` fallback.
23
+ - Consume only configured modifier-slot combinations in the prompt editor instead of registering all 54 possible shortcuts.
24
+ - Propagate focus and custom-editor hooks through the session-toggle editor wrapper for IME and extension compatibility.
25
+ - Restore red/dim coloring for hidden and visible skills in Pi's startup `[Skills]` list while preserving project-trust-aware settings.
26
+ - Remove the stale package-local `package-lock.json` in favor of the monorepo root lockfile.
17
27
  - Preserve configured hidden skills at session start. Skillful no longer treats a temporarily incomplete loaded-skill list as authoritative and removes global or project visibility settings.
18
28
 
19
29
  ## [0.3.10] - 2026-07-01
package/CONTRIBUTING.md CHANGED
@@ -7,6 +7,7 @@ Thanks for your interest in contributing to `pi-skillful`.
7
7
  ```bash
8
8
  npm install
9
9
  npm run check
10
+ npm test
10
11
  ```
11
12
 
12
13
  The package is source-distributed: Pi loads the TypeScript extension files directly. There is no build step for runtime use.
@@ -35,6 +36,7 @@ pi -e /path/to/pi-mono/packages/pi-skillful
35
36
  Before opening a pull request:
36
37
 
37
38
  - Run `npm run check`.
39
+ - Run `npm test`.
38
40
  - Run `npm run pack:dry-run` and confirm the package contents are intentional.
39
41
  - Update `README.md` if user-facing behavior changes.
40
42
  - Update `CHANGELOG.md` for notable changes.
package/README.md CHANGED
@@ -4,12 +4,12 @@
4
4
 
5
5
  # pi-skillful
6
6
 
7
- `pi-skillful` is a [Pi](https://pi.dev) package that improves skill workflows.
7
+ Make [Pi](https://pi.dev) skills easier to invoke, curate, and control without editing their source files.
8
8
 
9
- It currently provides three extensions:
9
+ `pi-skillful` provides three focused upgrades:
10
10
 
11
11
  - **Inline skill invocation**: invoke one or more skills anywhere in a prompt with `/skill:name`.
12
- - **Skill prompt visibility**: choose which skills are hidden from the model's automatic skill-discovery prompt, while keeping them explicitly invokable and visibly marked in Pi's startup skill list.
12
+ - **Skill prompt visibility**: choose which skills are hidden from the model's automatic skill-discovery prompt while keeping them explicitly invokable.
13
13
  - **Session skill toggles**: assign up to nine skills to number slots and toggle model visibility while writing a prompt.
14
14
 
15
15
  > [!WARNING]
@@ -33,6 +33,7 @@ Hide skills from the `<available_skills>` section of the system prompt without e
33
33
 
34
34
  Hidden skills:
35
35
 
36
+ - appear in the startup `[Skills]` list in the theme's error color, while visible skills appear dimmed;
36
37
  - are not advertised to the model for automatic skill selection;
37
38
  - remain loaded by Pi;
38
39
  - remain available for explicit invocation with `/skill:name`, including inline invocation.
@@ -64,9 +65,9 @@ Open the menu with:
64
65
 
65
66
  The menu lists configurable skills alphabetically. Toggle a skill off or on in the active scope. Use the Global/Project tabs to choose which settings file to edit. In the Project tab, inherited on/off values are shown normally; project overrides are highlighted. Press `1` through `9` on a selected skill to assign or clear that scope's session toggle slot. Visibility and toggle slots are independent.
66
67
 
67
- Pi's startup `[Skills]` list also highlights hidden skills in the error color (red in the default dark theme).
68
+ Project settings are read and the Project tab is available only when Pi trusts the current project. In an untrusted project, `pi-skillful` ignores `.pi/settings.json` and exposes only global settings.
68
69
 
69
- When the project settings file contains only `skillful` settings and the project `hiddenSkills` list becomes empty, `.pi/settings.json` is deleted instead of leaving an empty settings file behind.
70
+ When the project settings file contains only `skillful` settings and the project override is removed, `.pi/settings.json` is deleted instead of leaving an empty settings file behind.
70
71
 
71
72
  ### Session skill toggles
72
73
 
@@ -86,9 +87,9 @@ Assign skills to up to nine prompt-editor slots with JSON settings:
86
87
  }
87
88
  ```
88
89
 
89
- Configured slots appear on the prompt editor's top border as `N skill-name`. Project `toggleSlots`, when defined as part of a project override, replace global `toggleSlots`; otherwise global slots are used and shown in the Project tab. Long names are truncated per slot when needed so all configured slot numbers remain visible. Active slots use the theme accent color; inactive slots use the muted color. Press `alt+1` through `alt+9` by default to toggle a slot for the current session only.
90
+ Configured slots appear on the prompt editor's top border as `N skill-name`. Project `toggleSlots`, when defined as part of a project override, replace global `toggleSlots`; otherwise global slots are used and shown in the Project tab. Long names are truncated per slot when needed so all configured slot numbers remain visible. Active slots use the theme accent color; inactive slots use the muted color. Press `alt+1` through `alt+9` by default to toggle a slot for the current session only. Only the configured modifier and assigned slot numbers are consumed while the prompt editor has focus; no modifier-number keys are reserved when no slots are configured.
90
91
 
91
- `toggleModifier` defaults to `"alt"`. Supported values are `"alt"`, `"ctrl"`, `"ctrl+shift"`, `"alt+shift"`, `"ctrl+alt"`, and `"ctrl+alt+shift"`. Change it if your terminal reserves `alt+number` shortcuts.
92
+ `toggleModifier` defaults to `"alt"`. Supported values are `"alt"`, `"ctrl"`, `"ctrl+shift"`, `"alt+shift"`, `"ctrl+alt"`, and `"ctrl+alt+shift"`. Change it if your terminal reserves `alt+number` shortcuts. An explicitly configured project value takes precedence over the global value, including explicit `"alt"`. Unsupported explicit values fall back to `"alt"` in that scope.
92
93
 
93
94
  On app startup, non-hidden skills are active and hidden skills are inactive. Within a running Pi process, `/new` preserves the current toggle state for the new session. Resuming, forking, cloning, reloading, or restarting Pi resets toggle state from settings. Inline `/skill:name` invocation remains explicit and works even when that skill is inactive. Skills bundled in Pi packages are never modified by these toggles.
94
95
 
@@ -146,6 +147,7 @@ Common commands:
146
147
  ```bash
147
148
  npm install
148
149
  npm run check
150
+ npm test
149
151
  npm run pack:dry-run
150
152
  ```
151
153
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-skillful",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "description": "Pi package with skill invocation and visibility improvements.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -47,7 +47,7 @@
47
47
  "devDependencies": {
48
48
  "@earendil-works/pi-coding-agent": "^0.80.0",
49
49
  "@earendil-works/pi-tui": "^0.80.0",
50
- "@types/node": "^26.0.1",
50
+ "@types/node": "^26.1.0",
51
51
  "typescript": "^6.0.3"
52
52
  },
53
53
  "scripts": {
package/src/config.ts CHANGED
@@ -24,6 +24,7 @@ export interface SkillfulSettings extends SkillToggleConfig {
24
24
  hiddenSkillsDefined: boolean;
25
25
  visibleSkills: string[];
26
26
  toggleSlotsDefined: boolean;
27
+ toggleModifierDefined: boolean;
27
28
  }
28
29
 
29
30
  export interface EffectiveSkillfulSettings extends SkillfulSettings {
@@ -113,21 +114,27 @@ export async function readSkillfulSettings(path: string): Promise<SkillfulSettin
113
114
  return settingsFromRecord(settings[SKILLFUL_SETTINGS_KEY]);
114
115
  }
115
116
 
116
- export async function readScopedSkillfulSettings(cwd: string): Promise<Record<SkillfulScope, SkillfulSettings>> {
117
+ export async function readScopedSkillfulSettings(
118
+ cwd: string,
119
+ projectTrusted: boolean,
120
+ ): Promise<Record<SkillfulScope, SkillfulSettings>> {
117
121
  const [global, project] = await Promise.all([
118
122
  readSkillfulSettings(globalSettingsPath()),
119
- readSkillfulSettings(projectSettingsPath(cwd)),
123
+ projectTrusted ? readSkillfulSettings(projectSettingsPath(cwd)) : Promise.resolve(settingsFromRecord(undefined)),
120
124
  ]);
121
125
  return { global, project };
122
126
  }
123
127
 
124
- export async function readEffectiveHiddenSkills(cwd: string): Promise<Set<string>> {
125
- const scoped = await readScopedSkillfulSettings(cwd);
128
+ export async function readEffectiveHiddenSkills(cwd: string, projectTrusted: boolean): Promise<Set<string>> {
129
+ const scoped = await readScopedSkillfulSettings(cwd, projectTrusted);
126
130
  return effectiveHiddenSkillSet(scoped);
127
131
  }
128
132
 
129
- export async function readEffectiveSkillfulSettings(cwd: string): Promise<EffectiveSkillfulSettings> {
130
- const scoped = await readScopedSkillfulSettings(cwd);
133
+ export async function readEffectiveSkillfulSettings(
134
+ cwd: string,
135
+ projectTrusted: boolean,
136
+ ): Promise<EffectiveSkillfulSettings> {
137
+ const scoped = await readScopedSkillfulSettings(cwd, projectTrusted);
131
138
  const hiddenSkills = normalizeSkillNames(effectiveHiddenSkillSet(scoped));
132
139
  const toggleSlots = scoped.project.toggleSlotsDefined ? scoped.project.toggleSlots : scoped.global.toggleSlots;
133
140
  return {
@@ -137,8 +144,8 @@ export async function readEffectiveSkillfulSettings(cwd: string): Promise<Effect
137
144
  hiddenSkillSet: new Set(hiddenSkills),
138
145
  toggleSlots,
139
146
  toggleSlotsDefined: scoped.global.toggleSlotsDefined || scoped.project.toggleSlotsDefined,
140
- toggleModifier:
141
- scoped.project.toggleModifier !== DEFAULT_TOGGLE_MODIFIER ? scoped.project.toggleModifier : scoped.global.toggleModifier,
147
+ toggleModifier: scoped.project.toggleModifierDefined ? scoped.project.toggleModifier : scoped.global.toggleModifier,
148
+ toggleModifierDefined: scoped.global.toggleModifierDefined || scoped.project.toggleModifierDefined,
142
149
  };
143
150
  }
144
151
 
@@ -146,8 +153,9 @@ export async function writeHiddenSkills(
146
153
  scope: SkillfulScope,
147
154
  cwd: string,
148
155
  hiddenSkills: Iterable<string>,
156
+ projectTrusted = false,
149
157
  ): Promise<SkillfulSettings> {
150
- return updateSkillfulSettings(scope, cwd, (current) => {
158
+ return updateSkillfulSettings(scope, cwd, projectTrusted, (current) => {
151
159
  current.hiddenSkills = normalizeSkillNames(hiddenSkills);
152
160
  });
153
161
  }
@@ -157,8 +165,9 @@ export async function writeSkillVisibility(
157
165
  cwd: string,
158
166
  hiddenSkills: Iterable<string>,
159
167
  visibleSkills: Iterable<string> = [],
168
+ projectTrusted = false,
160
169
  ): Promise<SkillfulSettings> {
161
- return updateSkillfulSettings(scope, cwd, (current) => {
170
+ return updateSkillfulSettings(scope, cwd, projectTrusted, (current) => {
162
171
  const visible = normalizeSkillNames(visibleSkills);
163
172
  current.hiddenSkills = normalizeSkillNames(hiddenSkills);
164
173
  if (visible.length === 0) delete current.visibleSkills;
@@ -170,8 +179,9 @@ export async function writeToggleSlots(
170
179
  scope: SkillfulScope,
171
180
  cwd: string,
172
181
  toggleSlots: Partial<Record<SkillToggleSlot, string>>,
182
+ projectTrusted = false,
173
183
  ): Promise<SkillfulSettings> {
174
- return updateSkillfulSettings(scope, cwd, (current) => {
184
+ return updateSkillfulSettings(scope, cwd, projectTrusted, (current) => {
175
185
  current.toggleSlots = normalizeToggleSlots(toggleSlots);
176
186
  });
177
187
  }
@@ -180,8 +190,9 @@ export async function writeProjectSkillfulOverride(
180
190
  cwd: string,
181
191
  hiddenSkills: Iterable<string> | undefined,
182
192
  toggleSlots: Partial<Record<SkillToggleSlot, string>> | undefined,
193
+ projectTrusted: boolean,
183
194
  ): Promise<SkillfulSettings> {
184
- return updateSkillfulSettings("project", cwd, (current) => {
195
+ return updateSkillfulSettings("project", cwd, projectTrusted, (current) => {
185
196
  delete current.visibleSkills;
186
197
  if (hiddenSkills === undefined) delete current.hiddenSkills;
187
198
  else current.hiddenSkills = normalizeSkillNames(hiddenSkills);
@@ -193,8 +204,13 @@ export async function writeProjectSkillfulOverride(
193
204
  async function updateSkillfulSettings(
194
205
  scope: SkillfulScope,
195
206
  cwd: string,
207
+ projectTrusted: boolean,
196
208
  updater: (current: Record<string, unknown>) => void,
197
209
  ): Promise<SkillfulSettings> {
210
+ if (scope === "project" && !projectTrusted) {
211
+ throw new Error("Project skillful settings require a trusted project.");
212
+ }
213
+
198
214
  const path = settingsPath(scope, cwd);
199
215
  const document = await readSettingsDocument(path);
200
216
 
@@ -224,10 +240,15 @@ export async function updateHiddenSkills(
224
240
  scope: SkillfulScope,
225
241
  cwd: string,
226
242
  updater: (current: string[]) => string[],
243
+ projectTrusted = false,
227
244
  ): Promise<SkillfulSettings> {
245
+ if (scope === "project" && !projectTrusted) {
246
+ throw new Error("Project skillful settings require a trusted project.");
247
+ }
248
+
228
249
  const path = settingsPath(scope, cwd);
229
250
  const current = await readSkillfulSettings(path);
230
- return writeHiddenSkills(scope, cwd, updater(current.hiddenSkills));
251
+ return writeHiddenSkills(scope, cwd, updater(current.hiddenSkills), projectTrusted);
231
252
  }
232
253
 
233
254
  function settingsFromRecord(value: unknown): SkillfulSettings {
@@ -240,6 +261,7 @@ function settingsFromRecord(value: unknown): SkillfulSettings {
240
261
  toggleSlots: normalizeToggleSlots(skillful.toggleSlots),
241
262
  toggleSlotsDefined: Object.hasOwn(skillful, "toggleSlots"),
242
263
  toggleModifier: normalizeToggleModifier(skillful.toggleModifier),
264
+ toggleModifierDefined: Object.hasOwn(skillful, "toggleModifier"),
243
265
  };
244
266
  }
245
267
 
@@ -1,19 +1,24 @@
1
- import { CustomEditor, type ExtensionAPI, type KeybindingsManager, type Skill, type Theme } from "@earendil-works/pi-coding-agent";
2
- import type { AutocompleteProvider, Component, EditorComponent, EditorTheme, TUI } from "@earendil-works/pi-tui";
3
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
1
+ import {
2
+ CustomEditor,
3
+ type AppKeybinding,
4
+ type ExtensionAPI,
5
+ type KeybindingsManager,
6
+ type Skill,
7
+ type Theme,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import type { AutocompleteProvider, EditorComponent, EditorTheme, Focusable, KeyId, TUI } from "@earendil-works/pi-tui";
10
+ import { isFocusable, isKeyRelease, isKeyRepeat, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
11
  import {
5
12
  DEFAULT_TOGGLE_MODIFIER,
6
13
  normalizeSkillName,
7
14
  readEffectiveSkillfulSettings,
8
15
  SKILL_TOGGLE_SLOTS,
9
- SUPPORTED_TOGGLE_MODIFIERS,
10
16
  type SkillToggleModifier,
11
17
  type SkillToggleSlot,
12
18
  } from "../config.js";
13
19
  import { replaceSkillsSection } from "../skill-prompt.js";
14
20
  import { isTopLevelSkill, listLoadedSkills } from "../skills.js";
15
21
 
16
- const WIDGET_KEY = "pi-skillful-session-toggles";
17
22
  const STORE_KEY = Symbol.for("pi-skillful.sessionSkillTogglesStore");
18
23
  const BORDER_PREFIX = "─── ";
19
24
  const BORDER_SUFFIX = " ";
@@ -33,10 +38,10 @@ interface SessionToggleState {
33
38
  slots: ToggleSlotState[];
34
39
  activeBySkill: Map<string, boolean>;
35
40
  installedEditor: boolean;
36
- installedWidget: boolean;
37
41
  previousEditorFactory: SkillfulEditorFactory | undefined;
38
42
  activeTui: TUI | undefined;
39
43
  theme: Theme | undefined;
44
+ notify: (message: string, type?: "info" | "warning" | "error") => void;
40
45
  }
41
46
 
42
47
  interface SessionToggleStore {
@@ -50,20 +55,8 @@ const store = (((globalThis as Record<PropertyKey, unknown>)[STORE_KEY] as Sessi
50
55
  let state: SessionToggleState = createEmptyState();
51
56
 
52
57
  export default function sessionSkillToggles(pi: ExtensionAPI) {
53
- for (const modifier of SUPPORTED_TOGGLE_MODIFIERS) {
54
- for (const slot of SKILL_TOGGLE_SLOTS) {
55
- pi.registerShortcut(`${modifier}+${slot}`, {
56
- description: `Toggle pi-skillful slot ${slot}`,
57
- handler: (ctx) => {
58
- if (state.modifier !== modifier) return;
59
- toggleSlot(slot, ctx.ui.notify.bind(ctx.ui));
60
- },
61
- });
62
- }
63
- }
64
-
65
58
  pi.on("session_start", async (event, ctx) => {
66
- const settings = await readEffectiveSkillfulSettings(ctx.cwd);
59
+ const settings = await readEffectiveSkillfulSettings(ctx.cwd, ctx.isProjectTrusted());
67
60
  const slots = configuredToggleSlots(pi, settings.toggleSlots);
68
61
 
69
62
  const preservedActiveBySkill =
@@ -84,13 +77,13 @@ export default function sessionSkillToggles(pi: ExtensionAPI) {
84
77
  ]),
85
78
  ),
86
79
  installedEditor: false,
87
- installedWidget: false,
88
80
  previousEditorFactory: undefined,
89
81
  activeTui: undefined,
90
82
  theme: ctx.ui.theme,
83
+ notify: ctx.ui.notify.bind(ctx.ui),
91
84
  };
92
85
 
93
- if (ctx.hasUI && slots.length > 0) installEditor(ctx.ui);
86
+ if (ctx.mode === "tui" && slots.length > 0) installEditor(ctx.ui);
94
87
  refreshUi();
95
88
  });
96
89
 
@@ -108,7 +101,6 @@ export default function sessionSkillToggles(pi: ExtensionAPI) {
108
101
 
109
102
  pi.on("session_shutdown", (event, ctx) => {
110
103
  if (state.installedEditor) ctx.ui.setEditorComponent(state.previousEditorFactory);
111
- if (state.installedWidget) ctx.ui.setWidget(WIDGET_KEY, undefined);
112
104
  store.preservedNewSessionActiveBySkill =
113
105
  event.reason === "new" ? { cwd: state.cwd, activeBySkill: new Map(state.activeBySkill) } : undefined;
114
106
  state = createEmptyState();
@@ -123,10 +115,10 @@ function createEmptyState(): SessionToggleState {
123
115
  slots: [],
124
116
  activeBySkill: new Map<string, boolean>(),
125
117
  installedEditor: false,
126
- installedWidget: false,
127
118
  previousEditorFactory: undefined,
128
119
  activeTui: undefined,
129
120
  theme: undefined,
121
+ notify: () => undefined,
130
122
  };
131
123
  }
132
124
 
@@ -134,8 +126,13 @@ export function hasActiveSessionSkillToggles(): boolean {
134
126
  return state.slots.length > 0;
135
127
  }
136
128
 
137
- export async function refreshSessionSkillToggles(pi: ExtensionAPI, cwd: string, ui: SkillfulUi): Promise<void> {
138
- const settings = await readEffectiveSkillfulSettings(cwd);
129
+ export async function refreshSessionSkillToggles(
130
+ pi: ExtensionAPI,
131
+ cwd: string,
132
+ projectTrusted: boolean,
133
+ ui: SkillfulUi,
134
+ ): Promise<void> {
135
+ const settings = await readEffectiveSkillfulSettings(cwd, projectTrusted);
139
136
  const slots = configuredToggleSlots(pi, settings.toggleSlots);
140
137
 
141
138
  const previousActiveBySkill = state.activeBySkill;
@@ -146,13 +143,11 @@ export async function refreshSessionSkillToggles(pi: ExtensionAPI, cwd: string,
146
143
  slots.map(({ skillName }) => [skillName, previousActiveBySkill.get(skillName) ?? !settings.hiddenSkillSet.has(skillName)]),
147
144
  );
148
145
 
149
- if (slots.length > 0 && !state.installedEditor && !state.installedWidget) {
146
+ if (slots.length > 0 && !state.installedEditor) {
150
147
  installEditor(ui);
151
- } else if (slots.length === 0) {
152
- if (state.installedEditor) ui.setEditorComponent(state.previousEditorFactory);
153
- if (state.installedWidget) ui.setWidget(WIDGET_KEY, undefined);
148
+ } else if (slots.length === 0 && state.installedEditor) {
149
+ ui.setEditorComponent(state.previousEditorFactory);
154
150
  state.installedEditor = false;
155
- state.installedWidget = false;
156
151
  state.previousEditorFactory = undefined;
157
152
  }
158
153
 
@@ -179,17 +174,16 @@ function isSkillActive(skillName: string): boolean {
179
174
  return state.activeBySkill.get(skillName) ?? !state.hiddenSkills.has(skillName);
180
175
  }
181
176
 
182
- function toggleSlot(slot: SkillToggleSlot, notify: (message: string, type?: "info" | "warning" | "error") => void): void {
183
- const entry = state.slots.find((candidate) => candidate.slot === slot);
184
- if (!entry) {
185
- notify(`No pi-skillful skill assigned to slot ${slot}.`, "info");
186
- return;
187
- }
177
+ function handleToggleShortcut(data: string): boolean {
178
+ const entry = state.slots.find(({ slot }) => matchesKey(data, `${state.modifier}+${slot}` as KeyId));
179
+ if (!entry) return false;
180
+ if (isKeyRelease(data) || isKeyRepeat(data)) return true;
188
181
 
189
182
  const next = !isSkillActive(entry.skillName);
190
183
  state.activeBySkill.set(entry.skillName, next);
191
- notify(`${entry.skillName} ${next ? "active" : "inactive"} for this session.`, "info");
184
+ state.notify(`${entry.skillName} ${next ? "active" : "inactive"} for this session.`, "info");
192
185
  refreshUi();
186
+ return true;
193
187
  }
194
188
 
195
189
  type SkillfulEditorFactory = (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => EditorComponent;
@@ -197,31 +191,17 @@ type SkillfulEditorFactory = (tui: TUI, theme: EditorTheme, keybindings: Keybind
197
191
  type SkillfulUi = {
198
192
  getEditorComponent: () => SkillfulEditorFactory | undefined;
199
193
  setEditorComponent: (factory: SkillfulEditorFactory | undefined) => void;
200
- setWidget: (
201
- key: string,
202
- content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined,
203
- options?: { placement?: "aboveEditor" | "belowEditor" },
204
- ) => void;
205
194
  };
206
195
 
207
196
  function installEditor(ui: SkillfulUi): void {
208
197
  state.previousEditorFactory = ui.getEditorComponent();
209
-
210
- try {
211
- const previous = state.previousEditorFactory;
212
- ui.setEditorComponent((tui, theme, keybindings) => {
213
- state.activeTui = tui;
214
- if (previous) return new SkillToggleEditorWrapper(previous(tui, theme, keybindings));
215
- return new SkillToggleEditor(tui, theme, keybindings);
216
- });
217
- state.installedEditor = true;
218
- } catch {
219
- ui.setWidget(WIDGET_KEY, (tui) => {
220
- state.activeTui = tui;
221
- return new SkillToggleWidget();
222
- });
223
- state.installedWidget = true;
224
- }
198
+ const previous = state.previousEditorFactory;
199
+ ui.setEditorComponent((tui, theme, keybindings) => {
200
+ state.activeTui = tui;
201
+ if (previous) return new SkillToggleEditorWrapper(previous(tui, theme, keybindings));
202
+ return new SkillToggleEditor(tui, theme, keybindings);
203
+ });
204
+ state.installedEditor = true;
225
205
  }
226
206
 
227
207
  function refreshUi(): void {
@@ -229,6 +209,11 @@ function refreshUi(): void {
229
209
  }
230
210
 
231
211
  class SkillToggleEditor extends CustomEditor {
212
+ handleInput(data: string): void {
213
+ if (handleToggleShortcut(data)) return;
214
+ super.handleInput(data);
215
+ }
216
+
232
217
  render(width: number): string[] {
233
218
  const lines = super.render(width);
234
219
  if (lines.length === 0) return lines;
@@ -237,11 +222,78 @@ class SkillToggleEditor extends CustomEditor {
237
222
  }
238
223
  }
239
224
 
240
- class SkillToggleEditorWrapper implements EditorComponent {
241
- borderColor?: (str: string) => string;
225
+ interface CustomEditorHooks {
226
+ actionHandlers: Map<AppKeybinding, () => void>;
227
+ onEscape?: () => void;
228
+ onCtrlD?: () => void;
229
+ onPasteImage?: () => void;
230
+ onExtensionShortcut?: (data: string) => boolean;
231
+ }
232
+
233
+ function hasCustomEditorHooks(editor: EditorComponent): editor is EditorComponent & CustomEditorHooks {
234
+ return "actionHandlers" in editor && editor.actionHandlers instanceof Map;
235
+ }
236
+
237
+ class SkillToggleEditorWrapper implements EditorComponent, Focusable {
238
+ private _focused = false;
239
+
240
+ constructor(private readonly inner: EditorComponent) {}
241
+
242
+ get borderColor(): ((str: string) => string) | undefined {
243
+ return this.inner.borderColor?.bind(this.inner);
244
+ }
245
+
246
+ set borderColor(color: ((str: string) => string) | undefined) {
247
+ this.inner.borderColor = color;
248
+ }
249
+
250
+ get focused(): boolean {
251
+ return this._focused;
252
+ }
253
+
254
+ set focused(value: boolean) {
255
+ this._focused = value;
256
+ if (isFocusable(this.inner)) this.inner.focused = value;
257
+ }
242
258
 
243
- constructor(private readonly inner: EditorComponent) {
244
- this.borderColor = inner.borderColor?.bind(inner);
259
+ get wantsKeyRelease(): boolean | undefined {
260
+ return this.inner.wantsKeyRelease;
261
+ }
262
+
263
+ get actionHandlers(): Map<AppKeybinding, () => void> | undefined {
264
+ return hasCustomEditorHooks(this.inner) ? this.inner.actionHandlers : undefined;
265
+ }
266
+
267
+ get onEscape(): (() => void) | undefined {
268
+ return hasCustomEditorHooks(this.inner) ? this.inner.onEscape : undefined;
269
+ }
270
+
271
+ set onEscape(handler: (() => void) | undefined) {
272
+ if (hasCustomEditorHooks(this.inner)) this.inner.onEscape = handler;
273
+ }
274
+
275
+ get onCtrlD(): (() => void) | undefined {
276
+ return hasCustomEditorHooks(this.inner) ? this.inner.onCtrlD : undefined;
277
+ }
278
+
279
+ set onCtrlD(handler: (() => void) | undefined) {
280
+ if (hasCustomEditorHooks(this.inner)) this.inner.onCtrlD = handler;
281
+ }
282
+
283
+ get onPasteImage(): (() => void) | undefined {
284
+ return hasCustomEditorHooks(this.inner) ? this.inner.onPasteImage : undefined;
285
+ }
286
+
287
+ set onPasteImage(handler: (() => void) | undefined) {
288
+ if (hasCustomEditorHooks(this.inner)) this.inner.onPasteImage = handler;
289
+ }
290
+
291
+ get onExtensionShortcut(): ((data: string) => boolean) | undefined {
292
+ return hasCustomEditorHooks(this.inner) ? this.inner.onExtensionShortcut : undefined;
293
+ }
294
+
295
+ set onExtensionShortcut(handler: ((data: string) => boolean) | undefined) {
296
+ if (hasCustomEditorHooks(this.inner)) this.inner.onExtensionShortcut = handler;
245
297
  }
246
298
 
247
299
  get onSubmit(): ((text: string) => void) | undefined {
@@ -280,6 +332,7 @@ class SkillToggleEditorWrapper implements EditorComponent {
280
332
  }
281
333
 
282
334
  handleInput(data: string): void {
335
+ if (handleToggleShortcut(data)) return;
283
336
  this.inner.handleInput(data);
284
337
  }
285
338
 
@@ -312,14 +365,6 @@ class SkillToggleEditorWrapper implements EditorComponent {
312
365
  }
313
366
  }
314
367
 
315
- class SkillToggleWidget implements Component {
316
- render(width: number): string[] {
317
- return [renderToggleBorder(width, (text) => text)];
318
- }
319
-
320
- invalidate(): void {}
321
- }
322
-
323
368
  function renderToggleBorder(width: number, borderColor: (text: string) => string): string {
324
369
  if (width <= 0) return "";
325
370
  if (state.slots.length === 0) return borderColor("─".repeat(width));
@@ -24,11 +24,10 @@ import { isTopLevelSkill, listLoadedSkills, type LoadedSkillInfo } from "../skil
24
24
  import { hasActiveSessionSkillToggles, refreshSessionSkillToggles } from "./session-skill-toggles.js";
25
25
  const SCOPES: SkillfulScope[] = ["global", "project"];
26
26
  const STORE_KEY = Symbol.for("pi-skillful.skillVisibilityStore");
27
- const STARTUP_PATCH_KEY = Symbol.for("pi-skillful.startupPatchV2");
27
+ const STARTUP_PATCH_KEY = Symbol.for("pi-skillful.startupPatchV3");
28
28
 
29
29
  interface SkillVisibilityStore {
30
30
  hiddenSkillsByCwd: Map<string, Set<string>>;
31
- lastHiddenSkills: Set<string>;
32
31
  theme: Theme | null;
33
32
  }
34
33
 
@@ -50,7 +49,6 @@ interface InteractiveModeLike {
50
49
 
51
50
  const store = (((globalThis as Record<PropertyKey, unknown>)[STORE_KEY] as SkillVisibilityStore | undefined) ??= {
52
51
  hiddenSkillsByCwd: new Map<string, Set<string>>(),
53
- lastHiddenSkills: new Set<string>(),
54
52
  theme: null,
55
53
  }) as SkillVisibilityStore;
56
54
 
@@ -61,6 +59,7 @@ type DefinedByScope = Record<SkillfulScope, boolean>;
61
59
 
62
60
  interface SkillfulVisibilityMenuOptions {
63
61
  cwd: string;
62
+ projectTrusted: boolean;
64
63
  skills: SkillListItem[];
65
64
  hiddenByScope: HiddenSkillsByScope;
66
65
  hiddenSkillsDefinedByScope: DefinedByScope;
@@ -85,13 +84,13 @@ export default function skillVisibility(pi: ExtensionAPI) {
85
84
 
86
85
  pi.on("session_start", async (_event, ctx) => {
87
86
  store.theme = ctx.ui.theme;
88
- await refreshHiddenSkillCache(ctx.cwd);
87
+ await refreshHiddenSkillCache(ctx.cwd, ctx.isProjectTrusted());
89
88
  });
90
89
 
91
90
  pi.on("before_agent_start", async (event, ctx) => {
92
91
  if (hasActiveSessionSkillToggles()) return;
93
92
 
94
- const hidden = await refreshHiddenSkillCache(ctx.cwd);
93
+ const hidden = await refreshHiddenSkillCache(ctx.cwd, ctx.isProjectTrusted());
95
94
  if (hidden.size === 0 || !event.systemPromptOptions.skills?.length) return;
96
95
 
97
96
  const filteredSkills: Skill[] = event.systemPromptOptions.skills.map((skill) =>
@@ -105,7 +104,7 @@ export default function skillVisibility(pi: ExtensionAPI) {
105
104
  pi.registerCommand("skillful", {
106
105
  description: "Open the pi-skillful skill visibility menu.",
107
106
  handler: async (_args, ctx) => {
108
- if (!ctx.hasUI) {
107
+ if (ctx.mode !== "tui") {
109
108
  ctx.ui.notify("/skillful requires interactive UI", "warning");
110
109
  return;
111
110
  }
@@ -116,10 +115,12 @@ export default function skillVisibility(pi: ExtensionAPI) {
116
115
  return;
117
116
  }
118
117
 
119
- const scoped = await readScopedSkillfulSettings(ctx.cwd);
118
+ const projectTrusted = ctx.isProjectTrusted();
119
+ const scoped = await readScopedSkillfulSettings(ctx.cwd, projectTrusted);
120
120
  await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
121
121
  new SkillfulVisibilityMenu({
122
122
  cwd: ctx.cwd,
123
+ projectTrusted,
123
124
  skills,
124
125
  hiddenByScope: {
125
126
  global: new Set(scoped.global.hiddenSkills),
@@ -140,7 +141,7 @@ export default function skillVisibility(pi: ExtensionAPI) {
140
141
  theme,
141
142
  tui,
142
143
  notify: (message, type) => ctx.ui.notify(message, type),
143
- onToggleSlotsChanged: () => refreshSessionSkillToggles(pi, ctx.cwd, ctx.ui),
144
+ onToggleSlotsChanged: () => refreshSessionSkillToggles(pi, ctx.cwd, projectTrusted, ctx.ui),
144
145
  done,
145
146
  }),
146
147
  );
@@ -148,16 +149,17 @@ export default function skillVisibility(pi: ExtensionAPI) {
148
149
  });
149
150
  }
150
151
 
151
- async function refreshHiddenSkillCache(cwd: string): Promise<Set<string>> {
152
- const hidden = await readEffectiveHiddenSkills(cwd);
152
+ async function refreshHiddenSkillCache(cwd: string, projectTrusted: boolean): Promise<Set<string>> {
153
+ const hidden = await readEffectiveHiddenSkills(cwd, projectTrusted);
153
154
  store.hiddenSkillsByCwd.set(cwd, hidden);
154
- store.lastHiddenSkills = hidden;
155
155
  return hidden;
156
156
  }
157
157
 
158
- // Must patch the real prototype from pi's module — separate module resolutions have distinct class identities.
159
- function installStartupSkillListPatch(): void {
160
- const realPrototype = (InteractiveMode as unknown as { prototype: InteractiveModeLike }).prototype;
158
+ // Pi has no public hook for styling the built-in startup resource list, so patch the
159
+ // prototype from Pi's own module and keep the workaround isolated here.
160
+ export function installStartupSkillListPatch(
161
+ realPrototype: InteractiveModeLike = (InteractiveMode as unknown as { prototype: InteractiveModeLike }).prototype,
162
+ ): void {
161
163
  if (!realPrototype) return;
162
164
 
163
165
  const patchState = realPrototype as Record<PropertyKey, unknown>;
@@ -175,16 +177,14 @@ function installStartupSkillListPatch(): void {
175
177
  }
176
178
 
177
179
  const cwd = this.sessionManager?.getCwd?.();
178
-
179
180
  let rawSkillNames: string[] = [];
180
181
  loader.getSkills = () => {
181
182
  const result = originalGetSkills.call(loader);
182
- rawSkillNames = result.skills.map((s) => normalizeSkillName(s.name));
183
+ rawSkillNames = result.skills.map((skill) => normalizeSkillName(skill.name));
183
184
  return result;
184
185
  };
185
186
 
186
187
  const childrenBefore = this.loadedResourcesContainer?.children.length ?? 0;
187
-
188
188
  try {
189
189
  original.call(this, options);
190
190
  } finally {
@@ -193,14 +193,14 @@ function installStartupSkillListPatch(): void {
193
193
 
194
194
  if (rawSkillNames.length === 0 || !cwd || !this.loadedResourcesContainer) return;
195
195
 
196
+ const hidden = store.hiddenSkillsByCwd.get(cwd) ?? new Set<string>();
196
197
  const children = this.loadedResourcesContainer.children;
197
- for (let i = childrenBefore; i < children.length; i++) {
198
- const child = children[i] as ExpandableTextLike | undefined;
198
+ for (let index = childrenBefore; index < children.length; index++) {
199
+ const child = children[index] as ExpandableTextLike | undefined;
199
200
  if (!child || typeof child.getCollapsedText !== "function") continue;
200
- const collapsed = child.getCollapsedText();
201
- if (!collapsed.includes("[Skills]")) continue;
201
+ if (!child.getCollapsedText().includes("[Skills]")) continue;
202
202
 
203
- const colorized = buildColorizedSkillList(rawSkillNames, store.lastHiddenSkills, store.theme);
203
+ const colorized = buildColorizedSkillList(rawSkillNames, hidden, store.theme);
204
204
  child.getCollapsedText = () => colorized;
205
205
  child.setText(colorized);
206
206
  break;
@@ -210,13 +210,12 @@ function installStartupSkillListPatch(): void {
210
210
  patchState[STARTUP_PATCH_KEY] = true;
211
211
  }
212
212
 
213
- function buildColorizedSkillList(names: string[], hidden: Set<string>, theme: Theme | null): string {
213
+ export function buildColorizedSkillList(names: string[], hidden: Set<string>, theme: Theme | null): string {
214
214
  const sorted = [...names].sort((a, b) => a.localeCompare(b));
215
- if (!theme) {
216
- return `[Skills]\n ${sorted.join(", ")}`;
217
- }
215
+ if (!theme) return `[Skills]\n ${sorted.join(", ")}`;
216
+
218
217
  const header = theme.fg("mdHeading", "[Skills]");
219
- const parts = sorted.map((n) => (hidden.has(n) ? theme.fg("error", n) : theme.fg("dim", n)));
218
+ const parts = sorted.map((name) => (hidden.has(name) ? theme.fg("error", name) : theme.fg("dim", name)));
220
219
  return `${header}\n ${parts.join(", ")}`;
221
220
  }
222
221
 
@@ -226,6 +225,8 @@ function getSkillItems(pi: ExtensionAPI): SkillListItem[] {
226
225
 
227
226
  class SkillfulVisibilityMenu implements Component {
228
227
  private readonly cwd: string;
228
+ private readonly projectTrusted: boolean;
229
+ private readonly scopes: SkillfulScope[];
229
230
  private readonly skills: SkillListItem[];
230
231
  private readonly hiddenByScope: HiddenSkillsByScope;
231
232
  private readonly hiddenSkillsDefinedByScope: DefinedByScope;
@@ -238,12 +239,15 @@ class SkillfulVisibilityMenu implements Component {
238
239
  private readonly done: () => void;
239
240
  private readonly topBorder: DynamicBorder;
240
241
  private readonly bottomBorder: DynamicBorder;
241
- private scope: SkillfulScope = "project";
242
+ private scope: SkillfulScope;
242
243
  private settingsList: SettingsList;
243
244
  private saveQueue: Promise<void> = Promise.resolve();
244
245
 
245
246
  constructor(options: SkillfulVisibilityMenuOptions) {
246
247
  this.cwd = options.cwd;
248
+ this.projectTrusted = options.projectTrusted;
249
+ this.scopes = options.projectTrusted ? SCOPES : ["global"];
250
+ this.scope = options.projectTrusted ? "project" : "global";
247
251
  this.skills = options.skills;
248
252
  this.hiddenByScope = options.hiddenByScope;
249
253
  this.hiddenSkillsDefinedByScope = options.hiddenSkillsDefinedByScope;
@@ -267,7 +271,7 @@ class SkillfulVisibilityMenu implements Component {
267
271
  "",
268
272
  ...this.settingsList.render(width),
269
273
  "",
270
- truncateToWidth(this.theme.fg("dim", " Tab/←/→ switch scope · 1-9 assign/clear toggle · Enter/Space on/off · Esc close"), width),
274
+ truncateToWidth(this.theme.fg("dim", this.renderHelp()), width),
271
275
  ...this.bottomBorder.render(width),
272
276
  ];
273
277
  }
@@ -297,7 +301,7 @@ class SkillfulVisibilityMenu implements Component {
297
301
  }
298
302
 
299
303
  private renderTabs(): string {
300
- return SCOPES
304
+ return this.scopes
301
305
  .map((scope) => {
302
306
  const label = scope === "global" ? "Global" : "Project";
303
307
  return scope === this.scope
@@ -307,9 +311,14 @@ class SkillfulVisibilityMenu implements Component {
307
311
  .join(" ");
308
312
  }
309
313
 
314
+ private renderHelp(): string {
315
+ const scopeHelp = this.projectTrusted ? "Tab/←/→ switch scope · " : "";
316
+ return ` ${scopeHelp}1-9 assign/clear toggle · Enter/Space on/off · Esc close`;
317
+ }
318
+
310
319
  private switchScope(direction: 1 | -1): void {
311
- const current = SCOPES.indexOf(this.scope);
312
- this.scope = SCOPES[(current + direction + SCOPES.length) % SCOPES.length];
320
+ const current = this.scopes.indexOf(this.scope);
321
+ this.scope = this.scopes[(current + direction + this.scopes.length) % this.scopes.length];
313
322
  this.settingsList = this.createSettingsList();
314
323
  this.tui.requestRender();
315
324
  }
@@ -431,7 +440,7 @@ class SkillfulVisibilityMenu implements Component {
431
440
  .then(async () => {
432
441
  if (scope === "project") await this.writeProjectOverrideOrInheritance();
433
442
  else await writeHiddenSkills(scope, this.cwd, hiddenSnapshot);
434
- await refreshHiddenSkillCache(this.cwd);
443
+ await refreshHiddenSkillCache(this.cwd, this.projectTrusted);
435
444
  })
436
445
  .catch((error) => {
437
446
  this.notify(`Failed to save ${scope} skill visibility: ${error instanceof Error ? error.message : String(error)}`, "error");
@@ -465,13 +474,18 @@ class SkillfulVisibilityMenu implements Component {
465
474
  if (this.projectMatchesGlobal()) {
466
475
  this.hiddenSkillsDefinedByScope.project = false;
467
476
  this.toggleSlotsDefinedByScope.project = false;
468
- await writeProjectSkillfulOverride(this.cwd, undefined, undefined);
477
+ await writeProjectSkillfulOverride(this.cwd, undefined, undefined, this.projectTrusted);
469
478
  return;
470
479
  }
471
480
 
472
481
  this.hiddenSkillsDefinedByScope.project = true;
473
482
  this.toggleSlotsDefinedByScope.project = true;
474
- await writeProjectSkillfulOverride(this.cwd, this.hiddenByScope.project, this.toggleSlotsByScope.project);
483
+ await writeProjectSkillfulOverride(
484
+ this.cwd,
485
+ this.hiddenByScope.project,
486
+ this.toggleSlotsByScope.project,
487
+ this.projectTrusted,
488
+ );
475
489
  }
476
490
 
477
491
  private projectMatchesGlobal(): boolean {