pi-skillful 0.3.5 → 0.3.7

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
@@ -6,6 +6,20 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.7] - 2026-05-20
10
+
11
+ ### Changed
12
+
13
+ - Moved package source to the `jvm/pi-mono` monorepo.
14
+ - Updated npm metadata to point at the monorepo package directory.
15
+
16
+ ## [0.3.6] - 2026-05-12
17
+
18
+ ### Changed
19
+
20
+ - Made project skill visibility and toggle slots inherit global settings until the project scope is explicitly changed.
21
+ - Added `/skillful` menu support for assigning session toggle slots in global or project scope.
22
+
9
23
  ## [0.3.5] - 2026-05-12
10
24
 
11
25
  ### Fixed
package/CONTRIBUTING.md CHANGED
@@ -18,7 +18,7 @@ Install this checkout into a temporary Pi project:
18
18
  ```bash
19
19
  mkdir -p <test-project>
20
20
  cd <test-project>
21
- pi install -l /path/to/pi-skillful
21
+ pi install -l /path/to/pi-mono/packages/pi-skillful
22
22
  pi
23
23
  ```
24
24
 
@@ -27,7 +27,7 @@ Then run `/skillful` inside Pi.
27
27
  For a one-off run without changing settings:
28
28
 
29
29
  ```bash
30
- pi -e /path/to/pi-skillful/extensions/pi-skillful
30
+ pi -e /path/to/pi-mono/packages/pi-skillful
31
31
  ```
32
32
 
33
33
  ## Pull request checklist
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  <p>
2
- <img src="https://raw.githubusercontent.com/jvm/pi-skillful/main/banner.png" alt="pi-skillful" width="1100">
2
+ <img src="./banner.png" alt="pi-skillful" width="1100">
3
3
  </p>
4
4
 
5
5
  # pi-skillful
6
6
 
7
- `pi-skillful` is a [Pi](https://github.com/badlogic/pi-mono) package that improves skill workflows.
7
+ `pi-skillful` is a [Pi](https://pi.dev) package that improves skill workflows.
8
8
 
9
9
  It currently provides three extensions:
10
10
 
@@ -52,7 +52,7 @@ Supported scopes:
52
52
  - Global: `~/.pi/agent/settings.json`
53
53
  - Project: `.pi/settings.json`
54
54
 
55
- Effective hidden skills are the union of global and project `hiddenSkills`.
55
+ Project visibility and toggle slots inherit global settings until changed in the Project tab. When either is changed, Pi Skillful writes a full project override containing both `hiddenSkills` and `toggleSlots`. If the project state is changed back to match global, those project override keys are removed so the project inherits global again.
56
56
 
57
57
  Open the menu with:
58
58
 
@@ -60,7 +60,7 @@ Open the menu with:
60
60
  /skillful
61
61
  ```
62
62
 
63
- The menu lists all loaded skills alphabetically. Toggle a skill off to save it in the active scope's `hiddenSkills` list. Use the Global/Project tabs to choose which settings file to edit.
63
+ The menu lists all loaded 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.
64
64
 
65
65
  Pi's startup `[Skills]` list also highlights hidden skills in the error color (red in the default dark theme).
66
66
 
@@ -84,7 +84,7 @@ Assign skills to up to nine prompt-editor slots with JSON settings:
84
84
  }
85
85
  ```
86
86
 
87
- Configured slots appear on the prompt editor's top border as `N skill-name`. 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.
87
+ 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.
88
88
 
89
89
  `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.
90
90
 
@@ -92,28 +92,28 @@ On app startup, non-hidden skills are active and hidden skills are inactive. Wit
92
92
 
93
93
  ## Installation
94
94
 
95
- Install from GitHub:
95
+ Install from npm:
96
96
 
97
97
  ```bash
98
- pi install git:github.com/jvm/pi-skillful
98
+ pi install npm:pi-skillful
99
99
  ```
100
100
 
101
101
  Install project-locally with Pi's `-l` flag:
102
102
 
103
103
  ```bash
104
- pi install -l git:github.com/jvm/pi-skillful
104
+ pi install -l npm:pi-skillful
105
105
  ```
106
106
 
107
- During local development from this repository:
107
+ During local development from this monorepo:
108
108
 
109
109
  ```bash
110
- pi install /path/to/pi-skillful
110
+ pi install /path/to/pi-mono/packages/pi-skillful
111
111
  ```
112
112
 
113
113
  For a one-off test run without installing:
114
114
 
115
115
  ```bash
116
- pi -e /path/to/pi-skillful/extensions/pi-skillful
116
+ pi -e /path/to/pi-mono/packages/pi-skillful
117
117
  ```
118
118
 
119
119
  ## Usage
package/package.json CHANGED
@@ -1,18 +1,19 @@
1
1
  {
2
2
  "name": "pi-skillful",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Pi package with skill invocation and visibility improvements.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "jvm",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/jvm/pi-skillful.git"
10
+ "url": "git+https://github.com/jvm/pi-mono.git",
11
+ "directory": "packages/pi-skillful"
11
12
  },
12
13
  "bugs": {
13
- "url": "https://github.com/jvm/pi-skillful/issues"
14
+ "url": "https://github.com/jvm/pi-mono/issues"
14
15
  },
15
- "homepage": "https://github.com/jvm/pi-skillful#readme",
16
+ "homepage": "https://github.com/jvm/pi-mono/tree/main/packages/pi-skillful#readme",
16
17
  "keywords": [
17
18
  "pi-package",
18
19
  "pi-extension",
package/src/config.ts CHANGED
@@ -21,6 +21,9 @@ export interface SkillToggleConfig {
21
21
 
22
22
  export interface SkillfulSettings extends SkillToggleConfig {
23
23
  hiddenSkills: string[];
24
+ hiddenSkillsDefined: boolean;
25
+ visibleSkills: string[];
26
+ toggleSlotsDefined: boolean;
24
27
  }
25
28
 
26
29
  export interface EffectiveSkillfulSettings extends SkillfulSettings {
@@ -107,17 +110,7 @@ async function readSettingsDocument(path: string): Promise<PiSettingsDocument> {
107
110
 
108
111
  export async function readSkillfulSettings(path: string): Promise<SkillfulSettings> {
109
112
  const settings = await readSettingsDocument(path);
110
- const skillful = settings[SKILLFUL_SETTINGS_KEY];
111
- const skillfulObject = skillful && typeof skillful === "object" && !Array.isArray(skillful) ? skillful : {};
112
- const hiddenSkills = Array.isArray(skillfulObject.hiddenSkills)
113
- ? skillfulObject.hiddenSkills.filter((name): name is string => typeof name === "string")
114
- : [];
115
-
116
- return {
117
- hiddenSkills: normalizeSkillNames(hiddenSkills),
118
- toggleSlots: normalizeToggleSlots(skillfulObject.toggleSlots),
119
- toggleModifier: normalizeToggleModifier(skillfulObject.toggleModifier),
120
- };
113
+ return settingsFromRecord(settings[SKILLFUL_SETTINGS_KEY]);
121
114
  }
122
115
 
123
116
  export async function readScopedSkillfulSettings(cwd: string): Promise<Record<SkillfulScope, SkillfulSettings>> {
@@ -130,17 +123,20 @@ export async function readScopedSkillfulSettings(cwd: string): Promise<Record<Sk
130
123
 
131
124
  export async function readEffectiveHiddenSkills(cwd: string): Promise<Set<string>> {
132
125
  const scoped = await readScopedSkillfulSettings(cwd);
133
- return new Set([...scoped.global.hiddenSkills, ...scoped.project.hiddenSkills]);
126
+ return effectiveHiddenSkillSet(scoped);
134
127
  }
135
128
 
136
129
  export async function readEffectiveSkillfulSettings(cwd: string): Promise<EffectiveSkillfulSettings> {
137
130
  const scoped = await readScopedSkillfulSettings(cwd);
138
- const hiddenSkills = normalizeSkillNames([...scoped.global.hiddenSkills, ...scoped.project.hiddenSkills]);
139
- const toggleSlots = normalizeToggleSlots({ ...scoped.global.toggleSlots, ...scoped.project.toggleSlots });
131
+ const hiddenSkills = normalizeSkillNames(effectiveHiddenSkillSet(scoped));
132
+ const toggleSlots = scoped.project.toggleSlotsDefined ? scoped.project.toggleSlots : scoped.global.toggleSlots;
140
133
  return {
141
134
  hiddenSkills,
135
+ hiddenSkillsDefined: scoped.global.hiddenSkillsDefined || scoped.project.hiddenSkillsDefined,
136
+ visibleSkills: [],
142
137
  hiddenSkillSet: new Set(hiddenSkills),
143
138
  toggleSlots,
139
+ toggleSlotsDefined: scoped.global.toggleSlotsDefined || scoped.project.toggleSlotsDefined,
144
140
  toggleModifier:
145
141
  scoped.project.toggleModifier !== DEFAULT_TOGGLE_MODIFIER ? scoped.project.toggleModifier : scoped.global.toggleModifier,
146
142
  };
@@ -150,28 +146,69 @@ export async function writeHiddenSkills(
150
146
  scope: SkillfulScope,
151
147
  cwd: string,
152
148
  hiddenSkills: Iterable<string>,
149
+ ): Promise<SkillfulSettings> {
150
+ return updateSkillfulSettings(scope, cwd, (current) => {
151
+ current.hiddenSkills = normalizeSkillNames(hiddenSkills);
152
+ });
153
+ }
154
+
155
+ export async function writeSkillVisibility(
156
+ scope: SkillfulScope,
157
+ cwd: string,
158
+ hiddenSkills: Iterable<string>,
159
+ visibleSkills: Iterable<string> = [],
160
+ ): Promise<SkillfulSettings> {
161
+ return updateSkillfulSettings(scope, cwd, (current) => {
162
+ const visible = normalizeSkillNames(visibleSkills);
163
+ current.hiddenSkills = normalizeSkillNames(hiddenSkills);
164
+ if (visible.length === 0) delete current.visibleSkills;
165
+ else current.visibleSkills = visible;
166
+ });
167
+ }
168
+
169
+ export async function writeToggleSlots(
170
+ scope: SkillfulScope,
171
+ cwd: string,
172
+ toggleSlots: Partial<Record<SkillToggleSlot, string>>,
173
+ ): Promise<SkillfulSettings> {
174
+ return updateSkillfulSettings(scope, cwd, (current) => {
175
+ current.toggleSlots = normalizeToggleSlots(toggleSlots);
176
+ });
177
+ }
178
+
179
+ export async function writeProjectSkillfulOverride(
180
+ cwd: string,
181
+ hiddenSkills: Iterable<string> | undefined,
182
+ toggleSlots: Partial<Record<SkillToggleSlot, string>> | undefined,
183
+ ): Promise<SkillfulSettings> {
184
+ return updateSkillfulSettings("project", cwd, (current) => {
185
+ delete current.visibleSkills;
186
+ if (hiddenSkills === undefined) delete current.hiddenSkills;
187
+ else current.hiddenSkills = normalizeSkillNames(hiddenSkills);
188
+ if (toggleSlots === undefined) delete current.toggleSlots;
189
+ else current.toggleSlots = normalizeToggleSlots(toggleSlots);
190
+ });
191
+ }
192
+
193
+ async function updateSkillfulSettings(
194
+ scope: SkillfulScope,
195
+ cwd: string,
196
+ updater: (current: Record<string, unknown>) => void,
153
197
  ): Promise<SkillfulSettings> {
154
198
  const path = settingsPath(scope, cwd);
155
199
  const document = await readSettingsDocument(path);
156
- const updated = normalizeSkillNames(hiddenSkills);
157
200
 
158
201
  const existingSkillful =
159
202
  document[SKILLFUL_SETTINGS_KEY] && typeof document[SKILLFUL_SETTINGS_KEY] === "object" && !Array.isArray(document[SKILLFUL_SETTINGS_KEY])
160
203
  ? (document[SKILLFUL_SETTINGS_KEY] as Record<string, unknown>)
161
204
  : {};
162
205
  const nextSkillful = { ...existingSkillful };
163
-
164
- if (updated.length === 0) delete nextSkillful.hiddenSkills;
165
- else nextSkillful.hiddenSkills = updated;
206
+ updater(nextSkillful);
166
207
 
167
208
  if (Object.keys(nextSkillful).length === 0) delete document[SKILLFUL_SETTINGS_KEY];
168
209
  else document[SKILLFUL_SETTINGS_KEY] = nextSkillful as Partial<SkillfulSettings>;
169
210
 
170
- const result: SkillfulSettings = {
171
- hiddenSkills: updated,
172
- toggleSlots: normalizeToggleSlots(nextSkillful.toggleSlots),
173
- toggleModifier: normalizeToggleModifier(nextSkillful.toggleModifier),
174
- };
211
+ const result = settingsFromRecord(nextSkillful);
175
212
 
176
213
  if (scope === "project" && Object.keys(document).length === 0) {
177
214
  await unlinkIfExists(path);
@@ -193,6 +230,27 @@ export async function updateHiddenSkills(
193
230
  return writeHiddenSkills(scope, cwd, updater(current.hiddenSkills));
194
231
  }
195
232
 
233
+ function settingsFromRecord(value: unknown): SkillfulSettings {
234
+ const skillful = value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
235
+
236
+ return {
237
+ hiddenSkills: normalizeSkillNames(stringArray(skillful.hiddenSkills)),
238
+ hiddenSkillsDefined: Object.hasOwn(skillful, "hiddenSkills"),
239
+ visibleSkills: normalizeSkillNames(stringArray(skillful.visibleSkills)),
240
+ toggleSlots: normalizeToggleSlots(skillful.toggleSlots),
241
+ toggleSlotsDefined: Object.hasOwn(skillful, "toggleSlots"),
242
+ toggleModifier: normalizeToggleModifier(skillful.toggleModifier),
243
+ };
244
+ }
245
+
246
+ function stringArray(value: unknown): string[] {
247
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
248
+ }
249
+
250
+ function effectiveHiddenSkillSet(scoped: Record<SkillfulScope, SkillfulSettings>): Set<string> {
251
+ return new Set(scoped.project.hiddenSkillsDefined ? scoped.project.hiddenSkills : scoped.global.hiddenSkills);
252
+ }
253
+
196
254
  async function unlinkIfExists(path: string): Promise<void> {
197
255
  try {
198
256
  await unlink(path);
@@ -64,12 +64,7 @@ export default function sessionSkillToggles(pi: ExtensionAPI) {
64
64
 
65
65
  pi.on("session_start", async (event, ctx) => {
66
66
  const settings = await readEffectiveSkillfulSettings(ctx.cwd);
67
- const loadedSkillNames = new Set(listLoadedSkills(pi.getCommands()).map((s) => s.name));
68
- const slots = SKILL_TOGGLE_SLOTS.flatMap((slot): ToggleSlotState[] => {
69
- const skillName = settings.toggleSlots[slot];
70
- if (!skillName || !loadedSkillNames.has(skillName)) return [];
71
- return [{ slot, skillName }];
72
- });
67
+ const slots = configuredToggleSlots(pi, settings.toggleSlots);
73
68
 
74
69
  const preservedActiveBySkill =
75
70
  event.reason === "new" && store.preservedNewSessionActiveBySkill?.cwd === ctx.cwd
@@ -140,6 +135,43 @@ export function hasActiveSessionSkillToggles(): boolean {
140
135
  return state.slots.length > 0;
141
136
  }
142
137
 
138
+ export async function refreshSessionSkillToggles(pi: ExtensionAPI, cwd: string, ui: SkillfulUi): Promise<void> {
139
+ const settings = await readEffectiveSkillfulSettings(cwd);
140
+ const slots = configuredToggleSlots(pi, settings.toggleSlots);
141
+
142
+ const previousActiveBySkill = state.activeBySkill;
143
+ state.modifier = settings.toggleModifier;
144
+ state.hiddenSkills = settings.hiddenSkillSet;
145
+ state.slots = slots;
146
+ state.activeBySkill = new Map(
147
+ slots.map(({ skillName }) => [skillName, previousActiveBySkill.get(skillName) ?? !settings.hiddenSkillSet.has(skillName)]),
148
+ );
149
+
150
+ if (slots.length > 0 && !state.installedEditor && !state.installedWidget) {
151
+ installEditor(ui);
152
+ } else if (slots.length === 0) {
153
+ if (state.installedEditor) ui.setEditorComponent(state.previousEditorFactory);
154
+ if (state.installedWidget) ui.setWidget(WIDGET_KEY, undefined);
155
+ state.installedEditor = false;
156
+ state.installedWidget = false;
157
+ state.previousEditorFactory = undefined;
158
+ }
159
+
160
+ refreshUi();
161
+ }
162
+
163
+ function configuredToggleSlots(
164
+ pi: ExtensionAPI,
165
+ toggleSlots: Partial<Record<SkillToggleSlot, string>>,
166
+ ): ToggleSlotState[] {
167
+ const loadedSkillNames = new Set(listLoadedSkills(pi.getCommands()).map((skill) => skill.name));
168
+ return SKILL_TOGGLE_SLOTS.flatMap((slot): ToggleSlotState[] => {
169
+ const skillName = toggleSlots[slot];
170
+ if (!skillName || !loadedSkillNames.has(skillName)) return [];
171
+ return [{ slot, skillName }];
172
+ });
173
+ }
174
+
143
175
  function isSkillActive(skillName: string): boolean {
144
176
  return state.activeBySkill.get(skillName) ?? !state.hiddenSkills.has(skillName);
145
177
  }
@@ -12,12 +12,16 @@ import {
12
12
  normalizeSkillNames,
13
13
  readEffectiveHiddenSkills,
14
14
  readScopedSkillfulSettings,
15
+ SKILL_TOGGLE_SLOTS,
15
16
  type SkillfulScope,
17
+ type SkillToggleSlot,
16
18
  writeHiddenSkills,
19
+ writeProjectSkillfulOverride,
20
+ writeToggleSlots,
17
21
  } from "../config.js";
18
22
  import { replaceSkillsSection } from "../skill-prompt.js";
19
23
  import { listLoadedSkills, type LoadedSkillInfo } from "../skills.js";
20
- import { hasActiveSessionSkillToggles } from "./session-skill-toggles.js";
24
+ import { hasActiveSessionSkillToggles, refreshSessionSkillToggles } from "./session-skill-toggles.js";
21
25
  const SCOPES: SkillfulScope[] = ["global", "project"];
22
26
  const STORE_KEY = Symbol.for("pi-skillful.skillVisibilityStore");
23
27
  const STARTUP_PATCH_KEY = Symbol.for("pi-skillful.startupPatchV2");
@@ -51,6 +55,30 @@ const store = (((globalThis as Record<PropertyKey, unknown>)[STORE_KEY] as Skill
51
55
  }) as SkillVisibilityStore;
52
56
 
53
57
  type SkillListItem = LoadedSkillInfo;
58
+ type HiddenSkillsByScope = Record<SkillfulScope, Set<string>>;
59
+ type ToggleSlotsByScope = Record<SkillfulScope, Partial<Record<SkillToggleSlot, string>>>;
60
+ type DefinedByScope = Record<SkillfulScope, boolean>;
61
+
62
+ interface SkillfulVisibilityMenuOptions {
63
+ cwd: string;
64
+ skills: SkillListItem[];
65
+ hiddenByScope: HiddenSkillsByScope;
66
+ hiddenSkillsDefinedByScope: DefinedByScope;
67
+ toggleSlotsByScope: ToggleSlotsByScope;
68
+ toggleSlotsDefinedByScope: DefinedByScope;
69
+ theme: Theme;
70
+ tui: TUI;
71
+ notify: (message: string, type?: "info" | "warning" | "error") => void;
72
+ onToggleSlotsChanged: () => Promise<void>;
73
+ done: () => void;
74
+ }
75
+
76
+ interface SettingsListSelectionView {
77
+ filteredItems?: SettingItem[];
78
+ items?: SettingItem[];
79
+ selectedIndex?: number;
80
+ submenuComponent?: unknown;
81
+ }
54
82
 
55
83
  export default function skillVisibility(pi: ExtensionAPI) {
56
84
  installStartupSkillListPatch();
@@ -97,9 +125,22 @@ export default function skillVisibility(pi: ExtensionAPI) {
97
125
  global: new Set(scoped.global.hiddenSkills),
98
126
  project: new Set(scoped.project.hiddenSkills),
99
127
  },
128
+ hiddenSkillsDefinedByScope: {
129
+ global: scoped.global.hiddenSkillsDefined,
130
+ project: scoped.project.hiddenSkillsDefined,
131
+ },
132
+ toggleSlotsByScope: {
133
+ global: { ...scoped.global.toggleSlots },
134
+ project: { ...scoped.project.toggleSlots },
135
+ },
136
+ toggleSlotsDefinedByScope: {
137
+ global: scoped.global.toggleSlotsDefined,
138
+ project: scoped.project.toggleSlotsDefined,
139
+ },
100
140
  theme,
101
141
  tui,
102
142
  notify: (message, type) => ctx.ui.notify(message, type),
143
+ onToggleSlotsChanged: () => refreshSessionSkillToggles(pi, ctx.cwd, ctx.ui),
103
144
  done,
104
145
  }),
105
146
  );
@@ -113,15 +154,15 @@ async function pruneStaleHiddenSkills(pi: ExtensionAPI, cwd: string): Promise<vo
113
154
 
114
155
  await Promise.all(
115
156
  SCOPES.map(async (scope) => {
116
- const current = scoped[scope].hiddenSkills;
117
- const pruned = current.filter((name) => installedNames.has(name));
118
- if (pruned.length < current.length) {
119
- scoped[scope] = await writeHiddenSkills(scope, cwd, pruned);
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);
120
161
  }
121
162
  }),
122
163
  );
123
164
 
124
- const hidden = new Set([...scoped.global.hiddenSkills, ...scoped.project.hiddenSkills]);
165
+ const hidden = new Set(scoped.project.hiddenSkillsDefined ? scoped.project.hiddenSkills : scoped.global.hiddenSkills);
125
166
  store.hiddenSkillsByCwd.set(cwd, hidden);
126
167
  store.lastHiddenSkills = hidden;
127
168
  }
@@ -204,10 +245,14 @@ function getSkillItems(pi: ExtensionAPI): SkillListItem[] {
204
245
  class SkillfulVisibilityMenu implements Component {
205
246
  private readonly cwd: string;
206
247
  private readonly skills: SkillListItem[];
207
- private readonly hiddenByScope: Record<SkillfulScope, Set<string>>;
248
+ private readonly hiddenByScope: HiddenSkillsByScope;
249
+ private readonly hiddenSkillsDefinedByScope: DefinedByScope;
250
+ private readonly toggleSlotsByScope: ToggleSlotsByScope;
251
+ private readonly toggleSlotsDefinedByScope: DefinedByScope;
208
252
  private readonly theme: Theme;
209
253
  private readonly tui: TUI;
210
254
  private readonly notify: (message: string, type?: "info" | "warning" | "error") => void;
255
+ private readonly onToggleSlotsChanged: () => Promise<void>;
211
256
  private readonly done: () => void;
212
257
  private readonly topBorder: DynamicBorder;
213
258
  private readonly bottomBorder: DynamicBorder;
@@ -215,21 +260,17 @@ class SkillfulVisibilityMenu implements Component {
215
260
  private settingsList: SettingsList;
216
261
  private saveQueue: Promise<void> = Promise.resolve();
217
262
 
218
- constructor(options: {
219
- cwd: string;
220
- skills: SkillListItem[];
221
- hiddenByScope: Record<SkillfulScope, Set<string>>;
222
- theme: Theme;
223
- tui: TUI;
224
- notify: (message: string, type?: "info" | "warning" | "error") => void;
225
- done: () => void;
226
- }) {
263
+ constructor(options: SkillfulVisibilityMenuOptions) {
227
264
  this.cwd = options.cwd;
228
265
  this.skills = options.skills;
229
266
  this.hiddenByScope = options.hiddenByScope;
267
+ this.hiddenSkillsDefinedByScope = options.hiddenSkillsDefinedByScope;
268
+ this.toggleSlotsByScope = options.toggleSlotsByScope;
269
+ this.toggleSlotsDefinedByScope = options.toggleSlotsDefinedByScope;
230
270
  this.theme = options.theme;
231
271
  this.tui = options.tui;
232
272
  this.notify = options.notify;
273
+ this.onToggleSlotsChanged = options.onToggleSlotsChanged;
233
274
  this.done = options.done;
234
275
  this.topBorder = new DynamicBorder((text: string) => this.theme.fg("accent", text));
235
276
  this.bottomBorder = new DynamicBorder((text: string) => this.theme.fg("accent", text));
@@ -244,7 +285,7 @@ class SkillfulVisibilityMenu implements Component {
244
285
  "",
245
286
  ...this.settingsList.render(width),
246
287
  "",
247
- truncateToWidth(this.theme.fg("dim", " Tab/←/→ switch global/project · Enter/Space toggle · Esc close"), width),
288
+ truncateToWidth(this.theme.fg("dim", " Tab/←/→ switch scope · 1-9 assign/clear toggle · Enter/Space on/off · Esc close"), width),
248
289
  ...this.bottomBorder.render(width),
249
290
  ];
250
291
  }
@@ -258,6 +299,10 @@ class SkillfulVisibilityMenu implements Component {
258
299
  this.switchScope(-1);
259
300
  return;
260
301
  }
302
+ if (/^[1-9]$/.test(data)) {
303
+ this.toggleSelectedSkillSlot(data as SkillToggleSlot);
304
+ return;
305
+ }
261
306
 
262
307
  this.settingsList.handleInput(data);
263
308
  this.tui.requestRender();
@@ -289,13 +334,13 @@ class SkillfulVisibilityMenu implements Component {
289
334
 
290
335
  private createSettingsList(): SettingsList {
291
336
  const items: SettingItem[] = this.skills.map((skill) => {
292
- const hidden = this.hiddenByScope[this.scope].has(skill.name);
337
+ const hidden = this.isHidden(this.scope, skill.name);
293
338
  return {
294
339
  id: skill.name,
295
340
  label: skill.name,
296
- description: skill.description || "No skill description provided.",
297
- currentValue: hidden ? "off" : "on",
298
- values: ["on", "off"],
341
+ description: `${skill.description || "No skill description provided."}\nPress 1-9 to assign or clear this skill's toggle slot in the ${this.scope} scope.`,
342
+ currentValue: this.skillValue(skill.name, hidden),
343
+ values: [this.skillValue(skill.name, false), this.skillValue(skill.name, true)],
299
344
  };
300
345
  });
301
346
 
@@ -303,27 +348,169 @@ class SkillfulVisibilityMenu implements Component {
303
348
  items,
304
349
  12,
305
350
  getSettingsListTheme(),
306
- (skillName, newValue) => {
307
- const hidden = this.hiddenByScope[this.scope];
308
- if (newValue === "off") hidden.add(skillName);
309
- else hidden.delete(skillName);
351
+ (id, newValue) => {
352
+ this.setHidden(this.scope, id, newValue.includes("off"));
353
+ this.settingsList.updateValue(id, this.skillValue(id, this.isHidden(this.scope, id)));
310
354
  this.persistScope(this.scope);
311
355
  },
312
- this.done,
356
+ () => this.close(),
313
357
  { enableSearch: true },
314
358
  );
315
359
  }
316
360
 
361
+ private isHidden(scope: SkillfulScope, skillName: string): boolean {
362
+ if (scope === "project" && !this.hiddenSkillsDefinedByScope.project) return this.hiddenByScope.global.has(skillName);
363
+ return this.hiddenByScope[scope].has(skillName);
364
+ }
365
+
366
+ private setHidden(scope: SkillfulScope, skillName: string, hidden: boolean): void {
367
+ const hiddenSkills = scope === "project" ? this.ensureFullProjectOverride().hiddenSkills : this.ensureWritableHiddenSkills(scope);
368
+ if (hidden) hiddenSkills.add(skillName);
369
+ else hiddenSkills.delete(skillName);
370
+ }
371
+
372
+ private ensureWritableHiddenSkills(scope: SkillfulScope): Set<string> {
373
+ if (scope === "global") this.hiddenSkillsDefinedByScope.global = true;
374
+ return this.hiddenByScope[scope];
375
+ }
376
+
377
+ private ensureFullProjectOverride(): { hiddenSkills: Set<string>; toggleSlots: Partial<Record<SkillToggleSlot, string>> } {
378
+ if (!this.hiddenSkillsDefinedByScope.project) {
379
+ this.hiddenByScope.project = new Set(this.hiddenByScope.global);
380
+ this.hiddenSkillsDefinedByScope.project = true;
381
+ }
382
+ if (!this.toggleSlotsDefinedByScope.project) {
383
+ this.toggleSlotsByScope.project = { ...this.toggleSlotsByScope.global };
384
+ this.toggleSlotsDefinedByScope.project = true;
385
+ }
386
+ return { hiddenSkills: this.hiddenByScope.project, toggleSlots: this.toggleSlotsByScope.project };
387
+ }
388
+
389
+ private skillValue(skillName: string, hidden: boolean): string {
390
+ const slot = SKILL_TOGGLE_SLOTS.find((candidate) => this.currentToggleSlots()[candidate] === skillName) ?? "";
391
+ const status = (hidden ? "off" : "on").padEnd(3, " ");
392
+ const statusText = this.isProjectOverride(skillName) ? this.theme.fg("accent", status) : status;
393
+ return `${statusText} ${slot}`;
394
+ }
395
+
396
+ private currentToggleSlots(): Partial<Record<SkillToggleSlot, string>> {
397
+ if (this.scope === "project" && !this.toggleSlotsDefinedByScope.project) return this.toggleSlotsByScope.global;
398
+ return this.toggleSlotsByScope[this.scope];
399
+ }
400
+
401
+ private ensureWritableToggleSlots(scope: SkillfulScope): Partial<Record<SkillToggleSlot, string>> {
402
+ if (scope === "project") return this.ensureFullProjectOverride().toggleSlots;
403
+ this.toggleSlotsDefinedByScope.global = true;
404
+ return this.toggleSlotsByScope.global;
405
+ }
406
+
407
+ private isProjectOverride(skillName: string): boolean {
408
+ if (this.scope !== "project") return false;
409
+ return this.isHidden("project", skillName) !== this.isHidden("global", skillName);
410
+ }
411
+
412
+ private toggleSelectedSkillSlot(slot: SkillToggleSlot): void {
413
+ const skillName = this.selectedSkillName();
414
+ if (!skillName) return;
415
+
416
+ const toggleSlots = this.ensureWritableToggleSlots(this.scope);
417
+ const affected = new Set<string>([skillName]);
418
+ const previousSkill = toggleSlots[slot];
419
+ if (previousSkill) affected.add(previousSkill);
420
+
421
+ if (previousSkill === skillName) {
422
+ delete toggleSlots[slot];
423
+ } else {
424
+ for (const candidate of SKILL_TOGGLE_SLOTS) {
425
+ if (toggleSlots[candidate] === skillName) delete toggleSlots[candidate];
426
+ }
427
+ toggleSlots[slot] = skillName;
428
+ }
429
+
430
+ for (const affectedSkill of affected) {
431
+ this.settingsList.updateValue(affectedSkill, this.skillValue(affectedSkill, this.isHidden(this.scope, affectedSkill)));
432
+ }
433
+ this.persistToggleSlots(this.scope);
434
+ this.tui.requestRender();
435
+ }
436
+
437
+ private selectedSkillName(): string | undefined {
438
+ const list = this.settingsList as unknown as SettingsListSelectionView;
439
+ if (list.submenuComponent) return undefined;
440
+ const items = list.filteredItems ?? list.items ?? [];
441
+ const selectedIndex = list.selectedIndex ?? 0;
442
+ return items[selectedIndex]?.id;
443
+ }
444
+
317
445
  private persistScope(scope: SkillfulScope): void {
318
- const snapshot = normalizeSkillNames(this.hiddenByScope[scope]);
446
+ const hiddenSnapshot = normalizeSkillNames(this.hiddenByScope[scope]);
319
447
  this.saveQueue = this.saveQueue
320
448
  .catch(() => undefined)
321
449
  .then(async () => {
322
- await writeHiddenSkills(scope, this.cwd, snapshot);
450
+ if (scope === "project") await this.writeProjectOverrideOrInheritance();
451
+ else await writeHiddenSkills(scope, this.cwd, hiddenSnapshot);
323
452
  await refreshHiddenSkillCache(this.cwd);
324
453
  })
325
454
  .catch((error) => {
326
455
  this.notify(`Failed to save ${scope} skill visibility: ${error instanceof Error ? error.message : String(error)}`, "error");
327
456
  });
328
457
  }
458
+
459
+ private persistToggleSlots(scope: SkillfulScope): void {
460
+ const snapshot = { ...this.toggleSlotsByScope[scope] };
461
+ this.saveQueue = this.saveQueue
462
+ .catch(() => undefined)
463
+ .then(async () => {
464
+ if (scope === "project") await this.writeProjectOverrideOrInheritance();
465
+ else await writeToggleSlots(scope, this.cwd, snapshot);
466
+ })
467
+ .catch((error) => {
468
+ this.notify(`Failed to save ${scope} skill toggles: ${error instanceof Error ? error.message : String(error)}`, "error");
469
+ });
470
+ }
471
+
472
+ private close(): void {
473
+ this.saveQueue = this.saveQueue
474
+ .catch(() => undefined)
475
+ .then(() => this.onToggleSlotsChanged())
476
+ .catch((error) => {
477
+ this.notify(`Failed to refresh session skill toggles: ${error instanceof Error ? error.message : String(error)}`, "error");
478
+ })
479
+ .finally(() => this.done());
480
+ }
481
+
482
+ private async writeProjectOverrideOrInheritance(): Promise<void> {
483
+ if (this.projectMatchesGlobal()) {
484
+ this.hiddenSkillsDefinedByScope.project = false;
485
+ this.toggleSlotsDefinedByScope.project = false;
486
+ await writeProjectSkillfulOverride(this.cwd, undefined, undefined);
487
+ return;
488
+ }
489
+
490
+ this.hiddenSkillsDefinedByScope.project = true;
491
+ this.toggleSlotsDefinedByScope.project = true;
492
+ await writeProjectSkillfulOverride(this.cwd, this.hiddenByScope.project, this.toggleSlotsByScope.project);
493
+ }
494
+
495
+ private projectMatchesGlobal(): boolean {
496
+ return setsEqual(this.hiddenByScope.project, this.hiddenByScope.global) && toggleSlotsEqual(this.toggleSlotsByScope.project, this.toggleSlotsByScope.global);
497
+ }
498
+ }
499
+
500
+ function setsEqual(a: Set<string>, b: Set<string>): boolean {
501
+ if (a.size !== b.size) return false;
502
+ for (const value of a) {
503
+ if (!b.has(value)) return false;
504
+ }
505
+ return true;
506
+ }
507
+
508
+ function toggleSlotsEqual(
509
+ a: Partial<Record<SkillToggleSlot, string>>,
510
+ b: Partial<Record<SkillToggleSlot, string>>,
511
+ ): boolean {
512
+ for (const slot of SKILL_TOGGLE_SLOTS) {
513
+ if (a[slot] !== b[slot]) return false;
514
+ }
515
+ return true;
329
516
  }