pi-skillful 0.2.3 → 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 +13 -0
- package/CONTRIBUTING.md +4 -4
- package/README.md +30 -5
- package/extensions/index.ts +2 -0
- package/package.json +3 -2
- package/src/config.ts +93 -19
- package/src/extensions/session-skill-toggles.ts +294 -0
- package/src/extensions/skill-visibility.ts +8 -25
- package/src/skill-prompt.ts +8 -0
- package/src/skills.ts +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,19 @@ 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
|
+
|
|
15
|
+
## [0.2.4] - 2026-05-09
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Switched local development, CI, and publishing workflows from Bun to npm for consistency with Pi package conventions.
|
|
20
|
+
- Made the Pi extension entry path explicit as `./extensions/index.ts`.
|
|
21
|
+
|
|
9
22
|
## [0.2.3] - 2026-05-07
|
|
10
23
|
|
|
11
24
|
### Fixed
|
package/CONTRIBUTING.md
CHANGED
|
@@ -5,8 +5,8 @@ Thanks for your interest in contributing to `pi-skillful`.
|
|
|
5
5
|
## Development setup
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
npm install
|
|
9
|
+
npm run check
|
|
10
10
|
```
|
|
11
11
|
|
|
12
12
|
The package is source-distributed: Pi loads the TypeScript extension files directly. There is no build step for runtime use.
|
|
@@ -34,8 +34,8 @@ pi -e /path/to/pi-skillful/extensions/pi-skillful
|
|
|
34
34
|
|
|
35
35
|
Before opening a pull request:
|
|
36
36
|
|
|
37
|
-
- Run `
|
|
38
|
-
- Run `
|
|
37
|
+
- Run `npm run check`.
|
|
38
|
+
- Run `npm run pack:dry-run` and confirm the package contents are intentional.
|
|
39
39
|
- Update `README.md` if user-facing behavior changes.
|
|
40
40
|
- Update `CHANGELOG.md` for notable changes.
|
|
41
41
|
- Keep examples and paths generic; do not commit machine-specific paths or credentials.
|
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
|
|
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:
|
|
@@ -112,14 +137,14 @@ This package is source-distributed. Pi loads the TypeScript extensions directly
|
|
|
112
137
|
Requirements:
|
|
113
138
|
|
|
114
139
|
- Node.js >= 20.6.0
|
|
115
|
-
-
|
|
140
|
+
- npm for local development commands
|
|
116
141
|
|
|
117
142
|
Common commands:
|
|
118
143
|
|
|
119
144
|
```bash
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
145
|
+
npm install
|
|
146
|
+
npm run check
|
|
147
|
+
npm run pack:dry-run
|
|
123
148
|
```
|
|
124
149
|
|
|
125
150
|
## Contributing
|
package/extensions/index.ts
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Pi package with skill invocation and visibility improvements.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"pi": {
|
|
23
23
|
"extensions": [
|
|
24
|
-
"./extensions"
|
|
24
|
+
"./extensions/index.ts"
|
|
25
25
|
]
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
49
|
"check": "tsc --noEmit",
|
|
50
|
+
"typecheck": "tsc --noEmit",
|
|
50
51
|
"pack:dry-run": "npm pack --dry-run"
|
|
51
52
|
},
|
|
52
53
|
"publishConfig": {
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return {
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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?/);
|