pi-skillful 0.2.4 → 0.3.0

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,12 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-05-09
10
+
11
+ ### Added
12
+
13
+ - Added session-scoped skill toggle slots with configurable modifier-number shortcuts and prompt-editor top-border status.
14
+
9
15
  ## [0.2.4] - 2026-05-09
10
16
 
11
17
  ### Changed
package/README.md CHANGED
@@ -6,10 +6,11 @@
6
6
 
7
7
  `pi-skillful` is a [Pi](https://github.com/badlogic/pi-mono) package that improves skill workflows.
8
8
 
9
- It currently provides two extensions:
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
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.
13
+ - **Session skill toggles**: assign up to nine skills to number slots and toggle model visibility while writing a prompt.
13
14
 
14
15
  > [!WARNING]
15
16
  > Pi packages can execute arbitrary code through extensions. Review package source before installing any third-party Pi package.
@@ -65,6 +66,30 @@ Pi's startup `[Skills]` list also highlights hidden skills in the error color (r
65
66
 
66
67
  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.
67
68
 
69
+ ### Session skill toggles
70
+
71
+ Assign skills to up to nine prompt-editor slots with JSON settings:
72
+
73
+ ```json
74
+ {
75
+ "skillful": {
76
+ "hiddenSkills": ["pdf", "xlsx"],
77
+ "toggleSlots": {
78
+ "1": "typescript",
79
+ "2": "code-review",
80
+ "3": "git"
81
+ },
82
+ "toggleModifier": "alt"
83
+ }
84
+ }
85
+ ```
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.
88
+
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
+
91
+ On session start, non-hidden skills are active and hidden skills are inactive. Starting, resuming, or forking a session resets toggle state from settings. Inline `/skill:name` invocation remains explicit and works even when that skill is inactive.
92
+
68
93
  ## Installation
69
94
 
70
95
  Install from GitHub:
@@ -1,8 +1,10 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import inlineSkillInvocation from "../src/extensions/inline-skill-invocation.js";
3
3
  import skillVisibility from "../src/extensions/skill-visibility.js";
4
+ import sessionSkillToggles from "../src/extensions/session-skill-toggles.js";
4
5
 
5
6
  export default function piSkillful(pi: ExtensionAPI) {
6
7
  inlineSkillInvocation(pi);
7
8
  skillVisibility(pi);
9
+ sessionSkillToggles(pi);
8
10
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-skillful",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "Pi package with skill invocation and visibility improvements.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -3,17 +3,40 @@ import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
 
5
5
  export type SkillfulScope = "global" | "project";
6
+ export type SkillToggleSlot = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
7
+ export const SUPPORTED_TOGGLE_MODIFIERS = [
8
+ "alt",
9
+ "ctrl",
10
+ "ctrl+shift",
11
+ "alt+shift",
12
+ "ctrl+alt",
13
+ "ctrl+alt+shift",
14
+ ] as const;
15
+ export type SkillToggleModifier = (typeof SUPPORTED_TOGGLE_MODIFIERS)[number];
16
+
17
+ export interface SkillToggleConfig {
18
+ toggleSlots: Partial<Record<SkillToggleSlot, string>>;
19
+ toggleModifier: SkillToggleModifier;
20
+ }
6
21
 
7
- export interface SkillfulSettings {
22
+ export interface SkillfulSettings extends SkillToggleConfig {
8
23
  hiddenSkills: string[];
9
24
  }
10
25
 
26
+ export interface EffectiveSkillfulSettings extends SkillfulSettings {
27
+ hiddenSkillSet: Set<string>;
28
+ }
29
+
11
30
  interface PiSettingsDocument {
12
31
  skillful?: Partial<SkillfulSettings>;
13
32
  [key: string]: unknown;
14
33
  }
15
34
 
16
35
  export const SKILLFUL_SETTINGS_KEY = "skillful";
36
+ export const SKILL_TOGGLE_SLOTS: SkillToggleSlot[] = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
37
+ export const DEFAULT_TOGGLE_MODIFIER: SkillToggleModifier = "alt";
38
+
39
+ const SUPPORTED_TOGGLE_MODIFIERS_SET: ReadonlySet<string> = new Set(SUPPORTED_TOGGLE_MODIFIERS);
17
40
 
18
41
  export function globalSettingsPath(): string {
19
42
  return join(homedir(), ".pi", "agent", "settings.json");
@@ -42,6 +65,33 @@ export function normalizeSkillNames(names: Iterable<string>): string[] {
42
65
  );
43
66
  }
44
67
 
68
+ export function normalizeToggleSlots(value: unknown): Partial<Record<SkillToggleSlot, string>> {
69
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
70
+
71
+ const result: Partial<Record<SkillToggleSlot, string>> = {};
72
+ const seenSkillNames = new Set<string>();
73
+ const source = value as Record<string, unknown>;
74
+
75
+ for (const slot of SKILL_TOGGLE_SLOTS) {
76
+ const rawName = source[slot];
77
+ if (typeof rawName !== "string") continue;
78
+
79
+ const name = normalizeSkillName(rawName);
80
+ if (!name || seenSkillNames.has(name)) continue;
81
+
82
+ result[slot] = name;
83
+ seenSkillNames.add(name);
84
+ }
85
+
86
+ return result;
87
+ }
88
+
89
+ export function normalizeToggleModifier(value: unknown): SkillToggleModifier {
90
+ if (typeof value !== "string") return DEFAULT_TOGGLE_MODIFIER;
91
+ const normalized = value.trim().toLowerCase();
92
+ return SUPPORTED_TOGGLE_MODIFIERS_SET.has(normalized) ? (normalized as SkillToggleModifier) : DEFAULT_TOGGLE_MODIFIER;
93
+ }
94
+
45
95
  async function readSettingsDocument(path: string): Promise<PiSettingsDocument> {
46
96
  try {
47
97
  const raw = await readFile(path, "utf-8");
@@ -58,12 +108,16 @@ async function readSettingsDocument(path: string): Promise<PiSettingsDocument> {
58
108
  export async function readSkillfulSettings(path: string): Promise<SkillfulSettings> {
59
109
  const settings = await readSettingsDocument(path);
60
110
  const skillful = settings[SKILLFUL_SETTINGS_KEY];
61
- const hiddenSkills =
62
- skillful && typeof skillful === "object" && Array.isArray(skillful.hiddenSkills)
63
- ? skillful.hiddenSkills.filter((name): name is string => typeof name === "string")
64
- : [];
65
-
66
- return { hiddenSkills: normalizeSkillNames(hiddenSkills) };
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
+ };
67
121
  }
68
122
 
69
123
  export async function readScopedSkillfulSettings(cwd: string): Promise<Record<SkillfulScope, SkillfulSettings>> {
@@ -79,6 +133,19 @@ export async function readEffectiveHiddenSkills(cwd: string): Promise<Set<string
79
133
  return new Set([...scoped.global.hiddenSkills, ...scoped.project.hiddenSkills]);
80
134
  }
81
135
 
136
+ export async function readEffectiveSkillfulSettings(cwd: string): Promise<EffectiveSkillfulSettings> {
137
+ 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 });
140
+ return {
141
+ hiddenSkills,
142
+ hiddenSkillSet: new Set(hiddenSkills),
143
+ toggleSlots,
144
+ toggleModifier:
145
+ scoped.project.toggleModifier !== DEFAULT_TOGGLE_MODIFIER ? scoped.project.toggleModifier : scoped.global.toggleModifier,
146
+ };
147
+ }
148
+
82
149
  export async function writeHiddenSkills(
83
150
  scope: SkillfulScope,
84
151
  cwd: string,
@@ -88,25 +155,32 @@ export async function writeHiddenSkills(
88
155
  const document = await readSettingsDocument(path);
89
156
  const updated = normalizeSkillNames(hiddenSkills);
90
157
 
91
- if (updated.length === 0) {
92
- delete document[SKILLFUL_SETTINGS_KEY];
93
- } else {
94
- document[SKILLFUL_SETTINGS_KEY] = {
95
- ...(document[SKILLFUL_SETTINGS_KEY] && typeof document[SKILLFUL_SETTINGS_KEY] === "object"
96
- ? document[SKILLFUL_SETTINGS_KEY]
97
- : {}),
98
- hiddenSkills: updated,
99
- };
100
- }
158
+ const existingSkillful =
159
+ document[SKILLFUL_SETTINGS_KEY] && typeof document[SKILLFUL_SETTINGS_KEY] === "object" && !Array.isArray(document[SKILLFUL_SETTINGS_KEY])
160
+ ? (document[SKILLFUL_SETTINGS_KEY] as Record<string, unknown>)
161
+ : {};
162
+ const nextSkillful = { ...existingSkillful };
163
+
164
+ if (updated.length === 0) delete nextSkillful.hiddenSkills;
165
+ else nextSkillful.hiddenSkills = updated;
166
+
167
+ if (Object.keys(nextSkillful).length === 0) delete document[SKILLFUL_SETTINGS_KEY];
168
+ else document[SKILLFUL_SETTINGS_KEY] = nextSkillful as Partial<SkillfulSettings>;
169
+
170
+ const result: SkillfulSettings = {
171
+ hiddenSkills: updated,
172
+ toggleSlots: normalizeToggleSlots(nextSkillful.toggleSlots),
173
+ toggleModifier: normalizeToggleModifier(nextSkillful.toggleModifier),
174
+ };
101
175
 
102
176
  if (scope === "project" && Object.keys(document).length === 0) {
103
177
  await unlinkIfExists(path);
104
- return { hiddenSkills: updated };
178
+ return result;
105
179
  }
106
180
 
107
181
  await mkdir(dirname(path), { recursive: true });
108
182
  await writeFile(path, `${JSON.stringify(document, null, 2)}\n`, "utf-8");
109
- return { hiddenSkills: updated };
183
+ return result;
110
184
  }
111
185
 
112
186
  export async function updateHiddenSkills(
@@ -0,0 +1,294 @@
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";
4
+ import {
5
+ DEFAULT_TOGGLE_MODIFIER,
6
+ normalizeSkillName,
7
+ readEffectiveSkillfulSettings,
8
+ SKILL_TOGGLE_SLOTS,
9
+ SUPPORTED_TOGGLE_MODIFIERS,
10
+ type SkillToggleModifier,
11
+ type SkillToggleSlot,
12
+ } from "../config.js";
13
+ import { replaceSkillsSection } from "../skill-prompt.js";
14
+ import { listLoadedSkills } from "../skills.js";
15
+
16
+ const WIDGET_KEY = "pi-skillful-session-toggles";
17
+ const BORDER_PREFIX = "─── ";
18
+ const BORDER_SUFFIX = " ";
19
+ const BORDER_PREFIX_WIDTH = visibleWidth(BORDER_PREFIX);
20
+ const BORDER_SUFFIX_WIDTH = visibleWidth(BORDER_SUFFIX);
21
+ const MAX_SLOT_NAME_WIDTH = 16;
22
+
23
+ interface ToggleSlotState {
24
+ slot: SkillToggleSlot;
25
+ skillName: string;
26
+ }
27
+
28
+ interface SessionToggleState {
29
+ cwd: string;
30
+ modifier: SkillToggleModifier;
31
+ hiddenSkills: Set<string>;
32
+ slots: ToggleSlotState[];
33
+ activeBySkill: Map<string, boolean>;
34
+ installedEditor: boolean;
35
+ installedWidget: boolean;
36
+ previousEditorFactory: SkillfulEditorFactory | undefined;
37
+ activeTui: TUI | undefined;
38
+ theme: Theme | undefined;
39
+ }
40
+
41
+ let state: SessionToggleState = createEmptyState();
42
+
43
+ export default function sessionSkillToggles(pi: ExtensionAPI) {
44
+ for (const modifier of SUPPORTED_TOGGLE_MODIFIERS) {
45
+ for (const slot of SKILL_TOGGLE_SLOTS) {
46
+ pi.registerShortcut(`${modifier}+${slot}`, {
47
+ description: `Toggle pi-skillful slot ${slot}`,
48
+ handler: (ctx) => {
49
+ if (state.modifier !== modifier) return;
50
+ toggleSlot(slot, ctx.ui.notify.bind(ctx.ui));
51
+ },
52
+ });
53
+ }
54
+ }
55
+
56
+ pi.on("session_start", async (_event, ctx) => {
57
+ const settings = await readEffectiveSkillfulSettings(ctx.cwd);
58
+ const loadedSkillNames = new Set(listLoadedSkills(pi.getCommands()).map((s) => s.name));
59
+ const slots = SKILL_TOGGLE_SLOTS.flatMap((slot): ToggleSlotState[] => {
60
+ const skillName = settings.toggleSlots[slot];
61
+ if (!skillName || !loadedSkillNames.has(skillName)) return [];
62
+ return [{ slot, skillName }];
63
+ });
64
+
65
+ state = {
66
+ cwd: ctx.cwd,
67
+ modifier: settings.toggleModifier,
68
+ hiddenSkills: settings.hiddenSkillSet,
69
+ slots,
70
+ activeBySkill: new Map(slots.map(({ skillName }) => [skillName, !settings.hiddenSkillSet.has(skillName)])),
71
+ installedEditor: false,
72
+ installedWidget: false,
73
+ previousEditorFactory: undefined,
74
+ activeTui: undefined,
75
+ theme: ctx.ui.theme,
76
+ };
77
+
78
+ if (ctx.hasUI && slots.length > 0) installEditor(ctx.ui);
79
+ refreshUi();
80
+ });
81
+
82
+ pi.on("before_agent_start", (event) => {
83
+ if (state.slots.length === 0 || !event.systemPromptOptions.skills?.length) return;
84
+
85
+ const updatedSkills: Skill[] = event.systemPromptOptions.skills.map((skill) => ({
86
+ ...skill,
87
+ disableModelInvocation: !isSkillActive(normalizeSkillName(skill.name)),
88
+ }));
89
+
90
+ const systemPrompt = replaceSkillsSection(event.systemPrompt, updatedSkills);
91
+ if (!systemPrompt) return;
92
+ return { systemPrompt };
93
+ });
94
+
95
+ pi.on("session_shutdown", (_event, ctx) => {
96
+ if (state.installedEditor) ctx.ui.setEditorComponent(state.previousEditorFactory);
97
+ if (state.installedWidget) ctx.ui.setWidget(WIDGET_KEY, undefined);
98
+ state = createEmptyState();
99
+ });
100
+ }
101
+
102
+ function createEmptyState(): SessionToggleState {
103
+ return {
104
+ cwd: "",
105
+ modifier: DEFAULT_TOGGLE_MODIFIER,
106
+ hiddenSkills: new Set<string>(),
107
+ slots: [],
108
+ activeBySkill: new Map<string, boolean>(),
109
+ installedEditor: false,
110
+ installedWidget: false,
111
+ previousEditorFactory: undefined,
112
+ activeTui: undefined,
113
+ theme: undefined,
114
+ };
115
+ }
116
+
117
+ function isSkillActive(skillName: string): boolean {
118
+ return state.activeBySkill.get(skillName) ?? !state.hiddenSkills.has(skillName);
119
+ }
120
+
121
+ function toggleSlot(slot: SkillToggleSlot, notify: (message: string, type?: "info" | "warning" | "error") => void): void {
122
+ const entry = state.slots.find((candidate) => candidate.slot === slot);
123
+ if (!entry) {
124
+ notify(`No pi-skillful skill assigned to slot ${slot}.`, "info");
125
+ return;
126
+ }
127
+
128
+ const next = !isSkillActive(entry.skillName);
129
+ state.activeBySkill.set(entry.skillName, next);
130
+ notify(`${entry.skillName} ${next ? "active" : "inactive"} for this session.`, "info");
131
+ refreshUi();
132
+ }
133
+
134
+ type SkillfulEditorFactory = (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => EditorComponent;
135
+
136
+ type SkillfulUi = {
137
+ getEditorComponent: () => SkillfulEditorFactory | undefined;
138
+ setEditorComponent: (factory: SkillfulEditorFactory | undefined) => void;
139
+ setWidget: (
140
+ key: string,
141
+ content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined,
142
+ options?: { placement?: "aboveEditor" | "belowEditor" },
143
+ ) => void;
144
+ };
145
+
146
+ function installEditor(ui: SkillfulUi): void {
147
+ state.previousEditorFactory = ui.getEditorComponent();
148
+
149
+ try {
150
+ const previous = state.previousEditorFactory;
151
+ ui.setEditorComponent((tui, theme, keybindings) => {
152
+ state.activeTui = tui;
153
+ if (previous) return new SkillToggleEditorWrapper(previous(tui, theme, keybindings));
154
+ return new SkillToggleEditor(tui, theme, keybindings);
155
+ });
156
+ state.installedEditor = true;
157
+ } catch {
158
+ ui.setWidget(WIDGET_KEY, (tui) => {
159
+ state.activeTui = tui;
160
+ return new SkillToggleWidget();
161
+ });
162
+ state.installedWidget = true;
163
+ }
164
+ }
165
+
166
+ function refreshUi(): void {
167
+ state.activeTui?.requestRender();
168
+ }
169
+
170
+ class SkillToggleEditor extends CustomEditor {
171
+ render(width: number): string[] {
172
+ const lines = super.render(width);
173
+ if (lines.length === 0) return lines;
174
+ lines[0] = renderToggleBorder(width, (text) => this.borderColor(text));
175
+ return lines;
176
+ }
177
+ }
178
+
179
+ class SkillToggleEditorWrapper implements EditorComponent {
180
+ borderColor?: (str: string) => string;
181
+
182
+ constructor(private readonly inner: EditorComponent) {
183
+ this.borderColor = inner.borderColor?.bind(inner);
184
+ }
185
+
186
+ get onSubmit(): ((text: string) => void) | undefined {
187
+ return this.inner.onSubmit;
188
+ }
189
+
190
+ set onSubmit(handler: ((text: string) => void) | undefined) {
191
+ this.inner.onSubmit = handler;
192
+ }
193
+
194
+ get onChange(): ((text: string) => void) | undefined {
195
+ return this.inner.onChange;
196
+ }
197
+
198
+ set onChange(handler: ((text: string) => void) | undefined) {
199
+ this.inner.onChange = handler;
200
+ }
201
+
202
+ render(width: number): string[] {
203
+ const lines = this.inner.render(width);
204
+ if (lines.length === 0) return lines;
205
+ lines[0] = renderToggleBorder(width, this.borderColor ?? ((text) => text));
206
+ return lines;
207
+ }
208
+
209
+ invalidate(): void {
210
+ this.inner.invalidate();
211
+ }
212
+
213
+ getText(): string {
214
+ return this.inner.getText();
215
+ }
216
+
217
+ setText(text: string): void {
218
+ this.inner.setText(text);
219
+ }
220
+
221
+ handleInput(data: string): void {
222
+ this.inner.handleInput(data);
223
+ }
224
+
225
+ addToHistory(text: string): void {
226
+ this.inner.addToHistory?.(text);
227
+ }
228
+
229
+ insertTextAtCursor(text: string): void {
230
+ this.inner.insertTextAtCursor?.(text);
231
+ }
232
+
233
+ getExpandedText(): string {
234
+ return this.inner.getExpandedText?.() ?? this.inner.getText();
235
+ }
236
+
237
+ setAutocompleteProvider(provider: AutocompleteProvider): void {
238
+ this.inner.setAutocompleteProvider?.(provider);
239
+ }
240
+
241
+ setPaddingX(padding: number): void {
242
+ this.inner.setPaddingX?.(padding);
243
+ }
244
+
245
+ setAutocompleteMaxVisible(maxVisible: number): void {
246
+ this.inner.setAutocompleteMaxVisible?.(maxVisible);
247
+ }
248
+
249
+ dispose(): void {
250
+ (this.inner as EditorComponent & { dispose?(): void }).dispose?.();
251
+ }
252
+ }
253
+
254
+ class SkillToggleWidget implements Component {
255
+ render(width: number): string[] {
256
+ return [renderToggleBorder(width, (text) => text)];
257
+ }
258
+
259
+ invalidate(): void {}
260
+ }
261
+
262
+ function renderToggleBorder(width: number, borderColor: (text: string) => string): string {
263
+ if (width <= 0) return "";
264
+ if (state.slots.length === 0) return borderColor("─".repeat(width));
265
+
266
+ const available = Math.max(0, width - BORDER_PREFIX_WIDTH - BORDER_SUFFIX_WIDTH);
267
+ const fittedContent = truncateToWidth(renderToggleSegments(available), available, "");
268
+ const used = BORDER_PREFIX_WIDTH + visibleWidth(fittedContent) + BORDER_SUFFIX_WIDTH;
269
+ const fill = borderColor("─".repeat(Math.max(0, width - used)));
270
+ return `${borderColor(BORDER_PREFIX)}${fittedContent}${BORDER_SUFFIX}${fill}`;
271
+ }
272
+
273
+ function renderToggleSegments(availableWidth: number): string {
274
+ const separatorWidth = Math.max(0, state.slots.length - 1) * 2;
275
+ const slotLabelWidth = state.slots.length * 2;
276
+ const fullNameWidth = state.slots.reduce((total, slot) => total + visibleWidth(slot.skillName), 0);
277
+ const truncate = separatorWidth + slotLabelWidth + fullNameWidth > availableWidth;
278
+ const maxNameWidth = Math.max(
279
+ 1,
280
+ Math.min(MAX_SLOT_NAME_WIDTH, Math.floor((availableWidth - separatorWidth - slotLabelWidth) / Math.max(1, state.slots.length))),
281
+ );
282
+
283
+ return state.slots
284
+ .map(({ slot, skillName }) => {
285
+ const name = truncate ? truncateToWidth(skillName, maxNameWidth, "…") : skillName;
286
+ const text = `${slot} ${name}`;
287
+ return stateThemeFg(isSkillActive(skillName) ? "accent" : "muted", text);
288
+ })
289
+ .join(" ");
290
+ }
291
+
292
+ function stateThemeFg(color: "accent" | "muted", text: string): string {
293
+ return state.theme?.fg(color, text) ?? text;
294
+ }
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  DynamicBorder,
3
- formatSkillsForPrompt,
4
3
  getSettingsListTheme,
5
4
  InteractiveMode,
6
5
  type ExtensionAPI,
@@ -16,8 +15,8 @@ import {
16
15
  type SkillfulScope,
17
16
  writeHiddenSkills,
18
17
  } from "../config.js";
19
-
20
- const SKILLS_SECTION_PATTERN = /\n\nThe following skills provide specialized instructions for specific tasks\.[\s\S]*?<\/available_skills>/;
18
+ import { replaceSkillsSection } from "../skill-prompt.js";
19
+ import { listLoadedSkills, type LoadedSkillInfo } from "../skills.js";
21
20
  const SCOPES: SkillfulScope[] = ["global", "project"];
22
21
  const STORE_KEY = Symbol.for("pi-skillful.skillVisibilityStore");
23
22
  const STARTUP_PATCH_KEY = Symbol.for("pi-skillful.startupPatchV2");
@@ -50,10 +49,7 @@ const store = (((globalThis as Record<PropertyKey, unknown>)[STORE_KEY] as Skill
50
49
  theme: null,
51
50
  }) as SkillVisibilityStore;
52
51
 
53
- interface SkillListItem {
54
- name: string;
55
- description: string;
56
- }
52
+ type SkillListItem = LoadedSkillInfo;
57
53
 
58
54
  export default function skillVisibility(pi: ExtensionAPI) {
59
55
  installStartupSkillListPatch();
@@ -70,10 +66,9 @@ export default function skillVisibility(pi: ExtensionAPI) {
70
66
  const filteredSkills: Skill[] = event.systemPromptOptions.skills.map((skill) =>
71
67
  hidden.has(skill.name) ? { ...skill, disableModelInvocation: true } : skill,
72
68
  );
73
- const replacement = formatSkillsForPrompt(filteredSkills);
74
-
75
- if (!SKILLS_SECTION_PATTERN.test(event.systemPrompt)) return;
76
- return { systemPrompt: event.systemPrompt.replace(SKILLS_SECTION_PATTERN, replacement) };
69
+ const systemPrompt = replaceSkillsSection(event.systemPrompt, filteredSkills);
70
+ if (!systemPrompt) return;
71
+ return { systemPrompt };
77
72
  });
78
73
 
79
74
  pi.registerCommand("skillful", {
@@ -118,8 +113,7 @@ async function pruneStaleHiddenSkills(pi: ExtensionAPI, cwd: string): Promise<vo
118
113
  const current = scoped[scope].hiddenSkills;
119
114
  const pruned = current.filter((name) => installedNames.has(name));
120
115
  if (pruned.length < current.length) {
121
- await writeHiddenSkills(scope, cwd, pruned);
122
- scoped[scope] = { hiddenSkills: pruned };
116
+ scoped[scope] = await writeHiddenSkills(scope, cwd, pruned);
123
117
  }
124
118
  }),
125
119
  );
@@ -201,18 +195,7 @@ function buildColorizedSkillList(names: string[], hidden: Set<string>, theme: Th
201
195
  }
202
196
 
203
197
  function getSkillItems(pi: ExtensionAPI): SkillListItem[] {
204
- const byName = new Map<string, SkillListItem>();
205
- for (const command of pi.getCommands()) {
206
- if (command.source !== "skill") continue;
207
- const name = normalizeSkillName(command.name);
208
- if (!name) continue;
209
- byName.set(name, {
210
- name,
211
- description: command.description ?? "",
212
- });
213
- }
214
-
215
- return Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
198
+ return listLoadedSkills(pi.getCommands());
216
199
  }
217
200
 
218
201
  class SkillfulVisibilityMenu implements Component {
@@ -0,0 +1,8 @@
1
+ import { formatSkillsForPrompt, type Skill } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const SKILLS_SECTION_PATTERN = /\n\nThe following skills provide specialized instructions for specific tasks\.[\s\S]*?<\/available_skills>/;
4
+
5
+ export function replaceSkillsSection(systemPrompt: string, skills: Skill[]): string | undefined {
6
+ const next = systemPrompt.replace(SKILLS_SECTION_PATTERN, formatSkillsForPrompt(skills));
7
+ return next === systemPrompt ? undefined : next;
8
+ }
package/src/skills.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
+ import { normalizeSkillName } from "./config.js";
3
4
 
4
5
  export interface SkillCommandInfo {
5
6
  name: string;
@@ -7,6 +8,28 @@ export interface SkillCommandInfo {
7
8
  baseDir: string;
8
9
  }
9
10
 
11
+ export interface LoadedSkillInfo {
12
+ name: string;
13
+ description: string;
14
+ }
15
+
16
+ interface CommandLike {
17
+ name: string;
18
+ source: string;
19
+ description?: string;
20
+ }
21
+
22
+ export function listLoadedSkills(commands: Iterable<CommandLike>): LoadedSkillInfo[] {
23
+ const byName = new Map<string, LoadedSkillInfo>();
24
+ for (const command of commands) {
25
+ if (command.source !== "skill") continue;
26
+ const name = normalizeSkillName(command.name);
27
+ if (!name) continue;
28
+ byName.set(name, { name, description: command.description ?? "" });
29
+ }
30
+ return Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
31
+ }
32
+
10
33
  export function stripFrontmatter(markdown: string): string {
11
34
  const normalized = markdown.replace(/^\uFEFF/, "");
12
35
  const match = normalized.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);