pi-skillful 0.3.11 → 0.3.13

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.13 - 2026-07-17
4
+
5
+ ## 0.3.12 - 2026-07-12
6
+
3
7
  ## 0.3.11 - 2026-07-01
4
8
 
5
9
  ## 0.3.9 - 2026-06-14
@@ -10,6 +14,16 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
10
14
 
11
15
  ## [Unreleased]
12
16
 
17
+ ### Fixed
18
+
19
+ - Ignore project `skillful` settings and block project-scope writes when Pi does not trust the current project.
20
+ - Let an explicit project `toggleModifier: "alt"` override a non-default global modifier; unsupported explicit values now follow the documented `"alt"` fallback.
21
+ - Consume only configured modifier-slot combinations in the prompt editor instead of registering all 54 possible shortcuts.
22
+ - Propagate focus and custom-editor hooks through the session-toggle editor wrapper for IME and extension compatibility.
23
+ - Remove the private `InteractiveMode.prototype` startup skill-list patch; visibility now uses only documented Pi APIs.
24
+ - Remove the stale package-local `package-lock.json` in favor of the monorepo root lockfile.
25
+ - 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.
26
+
13
27
  ## [0.3.10] - 2026-07-01
14
28
 
15
29
  ### Fixed
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
@@ -9,7 +9,7 @@
9
9
  It currently provides three extensions:
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]
@@ -64,9 +64,9 @@ Open the menu with:
64
64
 
65
65
  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
66
 
67
- Pi's startup `[Skills]` list also highlights hidden skills in the error color (red in the default dark theme).
67
+ 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
68
 
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.
69
+ 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
70
 
71
71
  ### Session skill toggles
72
72
 
@@ -86,9 +86,9 @@ Assign skills to up to nine prompt-editor slots with JSON settings:
86
86
  }
87
87
  ```
88
88
 
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.
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. 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
90
 
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.
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. 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
92
 
93
93
  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
94
 
@@ -146,6 +146,7 @@ Common commands:
146
146
  ```bash
147
147
  npm install
148
148
  npm run check
149
+ npm test
149
150
  npm run pack:dry-run
150
151
  ```
151
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-skillful",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "Pi package with skill invocation and visibility improvements.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -47,12 +47,13 @@
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": "^25.9.3",
50
+ "@types/node": "^26.1.0",
51
51
  "typescript": "^6.0.3"
52
52
  },
53
53
  "scripts": {
54
54
  "check": "tsc --noEmit",
55
55
  "typecheck": "tsc --noEmit",
56
+ "test": "rm -rf .test-dist && tsc --noEmit false --outDir .test-dist && node --test tests/*.test.mjs",
56
57
  "pack:dry-run": "npm pack --dry-run"
57
58
  },
58
59
  "publishConfig": {
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));
@@ -1,14 +1,6 @@
1
- import {
2
- DynamicBorder,
3
- getSettingsListTheme,
4
- InteractiveMode,
5
- type ExtensionAPI,
6
- type Skill,
7
- type Theme,
8
- } from "@earendil-works/pi-coding-agent";
1
+ import { DynamicBorder, getSettingsListTheme, type ExtensionAPI, type Skill, type Theme } from "@earendil-works/pi-coding-agent";
9
2
  import { type Component, Key, matchesKey, type SettingItem, SettingsList, truncateToWidth, type TUI } from "@earendil-works/pi-tui";
10
3
  import {
11
- normalizeSkillName,
12
4
  normalizeSkillNames,
13
5
  readEffectiveHiddenSkills,
14
6
  readScopedSkillfulSettings,
@@ -23,36 +15,6 @@ import { replaceSkillsSection } from "../skill-prompt.js";
23
15
  import { isTopLevelSkill, listLoadedSkills, type LoadedSkillInfo } from "../skills.js";
24
16
  import { hasActiveSessionSkillToggles, refreshSessionSkillToggles } from "./session-skill-toggles.js";
25
17
  const SCOPES: SkillfulScope[] = ["global", "project"];
26
- const STORE_KEY = Symbol.for("pi-skillful.skillVisibilityStore");
27
- const STARTUP_PATCH_KEY = Symbol.for("pi-skillful.startupPatchV2");
28
-
29
- interface SkillVisibilityStore {
30
- hiddenSkillsByCwd: Map<string, Set<string>>;
31
- lastHiddenSkills: Set<string>;
32
- theme: Theme | null;
33
- }
34
-
35
- interface ExpandableTextLike {
36
- getCollapsedText: () => string;
37
- setText: (text: string) => void;
38
- }
39
-
40
- interface BoxLike {
41
- children: unknown[];
42
- }
43
-
44
- interface InteractiveModeLike {
45
- loadedResourcesContainer?: BoxLike;
46
- showLoadedResources?: (options?: unknown) => void;
47
- session?: { resourceLoader?: { getSkills: () => { skills: Skill[]; diagnostics: unknown[] } } };
48
- sessionManager?: { getCwd?: () => string };
49
- }
50
-
51
- const store = (((globalThis as Record<PropertyKey, unknown>)[STORE_KEY] as SkillVisibilityStore | undefined) ??= {
52
- hiddenSkillsByCwd: new Map<string, Set<string>>(),
53
- lastHiddenSkills: new Set<string>(),
54
- theme: null,
55
- }) as SkillVisibilityStore;
56
18
 
57
19
  type SkillListItem = LoadedSkillInfo;
58
20
  type HiddenSkillsByScope = Record<SkillfulScope, Set<string>>;
@@ -61,6 +23,7 @@ type DefinedByScope = Record<SkillfulScope, boolean>;
61
23
 
62
24
  interface SkillfulVisibilityMenuOptions {
63
25
  cwd: string;
26
+ projectTrusted: boolean;
64
27
  skills: SkillListItem[];
65
28
  hiddenByScope: HiddenSkillsByScope;
66
29
  hiddenSkillsDefinedByScope: DefinedByScope;
@@ -81,17 +44,14 @@ interface SettingsListSelectionView {
81
44
  }
82
45
 
83
46
  export default function skillVisibility(pi: ExtensionAPI) {
84
- installStartupSkillListPatch();
85
-
86
47
  pi.on("session_start", async (_event, ctx) => {
87
- store.theme = ctx.ui.theme;
88
- await pruneStaleHiddenSkills(pi, ctx.cwd);
48
+ await readEffectiveHiddenSkills(ctx.cwd, ctx.isProjectTrusted());
89
49
  });
90
50
 
91
51
  pi.on("before_agent_start", async (event, ctx) => {
92
52
  if (hasActiveSessionSkillToggles()) return;
93
53
 
94
- const hidden = await refreshHiddenSkillCache(ctx.cwd);
54
+ const hidden = await readEffectiveHiddenSkills(ctx.cwd, ctx.isProjectTrusted());
95
55
  if (hidden.size === 0 || !event.systemPromptOptions.skills?.length) return;
96
56
 
97
57
  const filteredSkills: Skill[] = event.systemPromptOptions.skills.map((skill) =>
@@ -105,7 +65,7 @@ export default function skillVisibility(pi: ExtensionAPI) {
105
65
  pi.registerCommand("skillful", {
106
66
  description: "Open the pi-skillful skill visibility menu.",
107
67
  handler: async (_args, ctx) => {
108
- if (!ctx.hasUI) {
68
+ if (ctx.mode !== "tui") {
109
69
  ctx.ui.notify("/skillful requires interactive UI", "warning");
110
70
  return;
111
71
  }
@@ -116,10 +76,12 @@ export default function skillVisibility(pi: ExtensionAPI) {
116
76
  return;
117
77
  }
118
78
 
119
- const scoped = await readScopedSkillfulSettings(ctx.cwd);
79
+ const projectTrusted = ctx.isProjectTrusted();
80
+ const scoped = await readScopedSkillfulSettings(ctx.cwd, projectTrusted);
120
81
  await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
121
82
  new SkillfulVisibilityMenu({
122
83
  cwd: ctx.cwd,
84
+ projectTrusted,
123
85
  skills,
124
86
  hiddenByScope: {
125
87
  global: new Set(scoped.global.hiddenSkills),
@@ -140,7 +102,7 @@ export default function skillVisibility(pi: ExtensionAPI) {
140
102
  theme,
141
103
  tui,
142
104
  notify: (message, type) => ctx.ui.notify(message, type),
143
- onToggleSlotsChanged: () => refreshSessionSkillToggles(pi, ctx.cwd, ctx.ui),
105
+ onToggleSlotsChanged: () => refreshSessionSkillToggles(pi, ctx.cwd, projectTrusted, ctx.ui),
144
106
  done,
145
107
  }),
146
108
  );
@@ -148,103 +110,14 @@ export default function skillVisibility(pi: ExtensionAPI) {
148
110
  });
149
111
  }
150
112
 
151
- async function pruneStaleHiddenSkills(pi: ExtensionAPI, cwd: string): Promise<void> {
152
- const installedNames = new Set(getSkillItems(pi).map((s) => s.name));
153
- const scoped = await readScopedSkillfulSettings(cwd);
154
-
155
- await Promise.all(
156
- SCOPES.map(async (scope) => {
157
- const currentHidden = scoped[scope].hiddenSkills;
158
- const prunedHidden = currentHidden.filter((name) => installedNames.has(name));
159
- if (prunedHidden.length < currentHidden.length) {
160
- scoped[scope] = await writeHiddenSkills(scope, cwd, prunedHidden);
161
- }
162
- }),
163
- );
164
-
165
- const hidden = new Set(scoped.project.hiddenSkillsDefined ? scoped.project.hiddenSkills : scoped.global.hiddenSkills);
166
- store.hiddenSkillsByCwd.set(cwd, hidden);
167
- store.lastHiddenSkills = hidden;
168
- }
169
-
170
- async function refreshHiddenSkillCache(cwd: string): Promise<Set<string>> {
171
- const hidden = await readEffectiveHiddenSkills(cwd);
172
- store.hiddenSkillsByCwd.set(cwd, hidden);
173
- store.lastHiddenSkills = hidden;
174
- return hidden;
175
- }
176
-
177
- // Must patch the real prototype from pi's module — separate module resolutions have distinct class identities.
178
- function installStartupSkillListPatch(): void {
179
- const realPrototype = (InteractiveMode as unknown as { prototype: InteractiveModeLike }).prototype;
180
- if (!realPrototype) return;
181
-
182
- const patchState = realPrototype as Record<PropertyKey, unknown>;
183
- if (patchState[STARTUP_PATCH_KEY]) return;
184
-
185
- const original = realPrototype.showLoadedResources;
186
- if (typeof original !== "function") return;
187
-
188
- realPrototype.showLoadedResources = function (this: InteractiveModeLike, options?: unknown): void {
189
- const loader = this.session?.resourceLoader;
190
- const originalGetSkills = loader?.getSkills;
191
- if (!loader || typeof originalGetSkills !== "function") {
192
- original.call(this, options);
193
- return;
194
- }
195
-
196
- const cwd = this.sessionManager?.getCwd?.();
197
-
198
- let rawSkillNames: string[] = [];
199
- loader.getSkills = () => {
200
- const result = originalGetSkills.call(loader);
201
- rawSkillNames = result.skills.map((s) => normalizeSkillName(s.name));
202
- return result;
203
- };
204
-
205
- const childrenBefore = this.loadedResourcesContainer?.children.length ?? 0;
206
-
207
- try {
208
- original.call(this, options);
209
- } finally {
210
- loader.getSkills = originalGetSkills;
211
- }
212
-
213
- if (rawSkillNames.length === 0 || !cwd || !this.loadedResourcesContainer) return;
214
-
215
- const children = this.loadedResourcesContainer.children;
216
- for (let i = childrenBefore; i < children.length; i++) {
217
- const child = children[i] as ExpandableTextLike | undefined;
218
- if (!child || typeof child.getCollapsedText !== "function") continue;
219
- const collapsed = child.getCollapsedText();
220
- if (!collapsed.includes("[Skills]")) continue;
221
-
222
- const colorized = buildColorizedSkillList(rawSkillNames, store.lastHiddenSkills, store.theme);
223
- child.getCollapsedText = () => colorized;
224
- child.setText(colorized);
225
- break;
226
- }
227
- };
228
-
229
- patchState[STARTUP_PATCH_KEY] = true;
230
- }
231
-
232
- function buildColorizedSkillList(names: string[], hidden: Set<string>, theme: Theme | null): string {
233
- const sorted = [...names].sort((a, b) => a.localeCompare(b));
234
- if (!theme) {
235
- return `[Skills]\n ${sorted.join(", ")}`;
236
- }
237
- const header = theme.fg("mdHeading", "[Skills]");
238
- const parts = sorted.map((n) => (hidden.has(n) ? theme.fg("error", n) : theme.fg("dim", n)));
239
- return `${header}\n ${parts.join(", ")}`;
240
- }
241
-
242
113
  function getSkillItems(pi: ExtensionAPI): SkillListItem[] {
243
114
  return listLoadedSkills(pi.getCommands()).filter(isTopLevelSkill);
244
115
  }
245
116
 
246
117
  class SkillfulVisibilityMenu implements Component {
247
118
  private readonly cwd: string;
119
+ private readonly projectTrusted: boolean;
120
+ private readonly scopes: SkillfulScope[];
248
121
  private readonly skills: SkillListItem[];
249
122
  private readonly hiddenByScope: HiddenSkillsByScope;
250
123
  private readonly hiddenSkillsDefinedByScope: DefinedByScope;
@@ -257,12 +130,15 @@ class SkillfulVisibilityMenu implements Component {
257
130
  private readonly done: () => void;
258
131
  private readonly topBorder: DynamicBorder;
259
132
  private readonly bottomBorder: DynamicBorder;
260
- private scope: SkillfulScope = "project";
133
+ private scope: SkillfulScope;
261
134
  private settingsList: SettingsList;
262
135
  private saveQueue: Promise<void> = Promise.resolve();
263
136
 
264
137
  constructor(options: SkillfulVisibilityMenuOptions) {
265
138
  this.cwd = options.cwd;
139
+ this.projectTrusted = options.projectTrusted;
140
+ this.scopes = options.projectTrusted ? SCOPES : ["global"];
141
+ this.scope = options.projectTrusted ? "project" : "global";
266
142
  this.skills = options.skills;
267
143
  this.hiddenByScope = options.hiddenByScope;
268
144
  this.hiddenSkillsDefinedByScope = options.hiddenSkillsDefinedByScope;
@@ -286,7 +162,7 @@ class SkillfulVisibilityMenu implements Component {
286
162
  "",
287
163
  ...this.settingsList.render(width),
288
164
  "",
289
- truncateToWidth(this.theme.fg("dim", " Tab/←/→ switch scope · 1-9 assign/clear toggle · Enter/Space on/off · Esc close"), width),
165
+ truncateToWidth(this.theme.fg("dim", this.renderHelp()), width),
290
166
  ...this.bottomBorder.render(width),
291
167
  ];
292
168
  }
@@ -316,7 +192,7 @@ class SkillfulVisibilityMenu implements Component {
316
192
  }
317
193
 
318
194
  private renderTabs(): string {
319
- return SCOPES
195
+ return this.scopes
320
196
  .map((scope) => {
321
197
  const label = scope === "global" ? "Global" : "Project";
322
198
  return scope === this.scope
@@ -326,9 +202,14 @@ class SkillfulVisibilityMenu implements Component {
326
202
  .join(" ");
327
203
  }
328
204
 
205
+ private renderHelp(): string {
206
+ const scopeHelp = this.projectTrusted ? "Tab/←/→ switch scope · " : "";
207
+ return ` ${scopeHelp}1-9 assign/clear toggle · Enter/Space on/off · Esc close`;
208
+ }
209
+
329
210
  private switchScope(direction: 1 | -1): void {
330
- const current = SCOPES.indexOf(this.scope);
331
- this.scope = SCOPES[(current + direction + SCOPES.length) % SCOPES.length];
211
+ const current = this.scopes.indexOf(this.scope);
212
+ this.scope = this.scopes[(current + direction + this.scopes.length) % this.scopes.length];
332
213
  this.settingsList = this.createSettingsList();
333
214
  this.tui.requestRender();
334
215
  }
@@ -450,7 +331,7 @@ class SkillfulVisibilityMenu implements Component {
450
331
  .then(async () => {
451
332
  if (scope === "project") await this.writeProjectOverrideOrInheritance();
452
333
  else await writeHiddenSkills(scope, this.cwd, hiddenSnapshot);
453
- await refreshHiddenSkillCache(this.cwd);
334
+ await readEffectiveHiddenSkills(this.cwd, this.projectTrusted);
454
335
  })
455
336
  .catch((error) => {
456
337
  this.notify(`Failed to save ${scope} skill visibility: ${error instanceof Error ? error.message : String(error)}`, "error");
@@ -484,13 +365,18 @@ class SkillfulVisibilityMenu implements Component {
484
365
  if (this.projectMatchesGlobal()) {
485
366
  this.hiddenSkillsDefinedByScope.project = false;
486
367
  this.toggleSlotsDefinedByScope.project = false;
487
- await writeProjectSkillfulOverride(this.cwd, undefined, undefined);
368
+ await writeProjectSkillfulOverride(this.cwd, undefined, undefined, this.projectTrusted);
488
369
  return;
489
370
  }
490
371
 
491
372
  this.hiddenSkillsDefinedByScope.project = true;
492
373
  this.toggleSlotsDefinedByScope.project = true;
493
- await writeProjectSkillfulOverride(this.cwd, this.hiddenByScope.project, this.toggleSlotsByScope.project);
374
+ await writeProjectSkillfulOverride(
375
+ this.cwd,
376
+ this.hiddenByScope.project,
377
+ this.toggleSlotsByScope.project,
378
+ this.projectTrusted,
379
+ );
494
380
  }
495
381
 
496
382
  private projectMatchesGlobal(): boolean {