pi-skill-stacks 0.4.2 → 0.6.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/README.md CHANGED
@@ -1,13 +1,6 @@
1
1
  # pi-skill-stacks
2
2
 
3
- A [pi](https://github.com/earendil-works/pi-mono) package that groups your skills into named stacks you can toggle on and off, so an agent session only pays for the skills it needs. `/stacks` opens an overlay; disabled skills vanish from the system prompt, `/skill:` commands, and discovery.
4
-
5
- ## What it does
6
-
7
- - `/stacks` opens a two-pane overlay. Left: stacks with on/off state, create (`n`), delete (`d`), and `a` to add skills that aren't in any stack yet. Right: the selected stack's members; `space` removes one, `enter` opens the skill's full markdown in a viewer pane. So you can build and edit stacks without touching JSON. Changes persist as you make them; pi reloads once when you close the overlay, and only if a toggle actually changed `settings.json`.
8
- - `/stacks on <stack>` / `/stacks off <stack>` toggle a single stack (with argument completion); `/stacks list` prints state without changing anything. Bare `/stacks` outside the TUI (RPC/print mode) prints the list too.
9
- - Toggling off writes `!skills/<dir>/SKILL.md` exclusion patterns into the global settings `skills` array — pi's own override mechanism — so disabled skills are excluded everywhere, not just from the prompt. The extension only ever removes patterns it wrote itself (tracked in `managedExclusions`); hand-written `pi config` entries are left alone.
10
- - The bundled header extension replaces pi's startup `[Skills]` listing with a compact line like `matt-pocock (28), firecrawl (28) · 76/76 skills active` (with `off: <name> (n)` segments for disabled stacks) and hides the `[Themes]` section.
3
+ A [pi](https://github.com/earendil-works/pi-mono) package that groups skills into named stacks you can toggle on and off. Disabled skills leave the system prompt, `/skill:` commands, and discovery.
11
4
 
12
5
  ## Install
13
6
 
@@ -15,27 +8,25 @@ A [pi](https://github.com/earendil-works/pi-mono) package that groups your skill
15
8
  pi install npm:pi-skill-stacks
16
9
  ```
17
10
 
18
- or from git:
11
+ ## Use
19
12
 
20
- ```bash
21
- pi install git:github.com/oscabriel/pi-skill-stacks
22
- ```
13
+ `/stacks` opens an overlay. Left pane: stacks with on/off state. Right pane: the selected stack's members. `enter` on a member shows its markdown.
23
14
 
24
- Or from a local checkout:
15
+ | Pane | Keys |
16
+ | --- | --- |
17
+ | stacks | `↑`/`↓` select · `space` on/off · `→` members · `a` add skills · `n` new stack · `d` delete · `esc` close |
18
+ | members | `↑`/`↓` move · `space` remove · `→` view · `a` add skills · `←` back |
19
+ | viewer | `↑`/`↓` scroll (mouse wheel in fullscreen) · `←` back |
25
20
 
26
- ```bash
27
- pi install /path/to/pi-skill-stacks
28
- ```
21
+ `a` opens a picker of skills not yet in any stack. Type to filter it, `space` to mark several, `enter` to add. To move a skill between stacks, `space` it out of one and `a` it into the other.
29
22
 
30
- Use `-l` to install project-local instead of global. Try it without installing:
23
+ With no stacks defined, `enter` (or `n`) creates the first one and opens the picker right after.
31
24
 
32
- ```bash
33
- pi -e git:github.com/oscabriel/pi-skill-stacks
34
- ```
25
+ From the command line: `/stacks on <stack>`, `/stacks off <stack>`, `/stacks list`.
35
26
 
36
- ## Configure stacks
27
+ ## How it works
37
28
 
38
- The package ships with no stacks. Open `/stacks` and press `n` to create one, or write `~/.pi/agent/skill-stacks.json` (or `<agentDir>/skill-stacks.json`; `PI_CODING_AGENT_DIR` is honoured like pi does) by hand:
29
+ Stacks live in `~/.pi/agent/skill-stacks.json` (`PI_CODING_AGENT_DIR` is honoured). You can edit it by hand:
39
30
 
40
31
  ```json
41
32
  {
@@ -46,66 +37,18 @@ The package ships with no stacks. Open `/stacks` and press `n` to create one, or
46
37
  }
47
38
  ```
48
39
 
49
- Each stack maps a name to skill names. Skills are discovered the way pi discovers them: every `SKILL.md` under `~/.agents/skills/` and `<agentDir>/skills/`, recursing into subdirectories (skipping dot-directories and `node_modules`), named by the frontmatter `name` or, failing that, the directory name. Nested skills get full-path exclusion patterns such as `!skills/group/nested/SKILL.md`.
50
-
51
- A project can add or override stack definitions in `<project>/.pi/skill-stacks.json`. Project stacks contribute definitions only; on/off state is global. In the overlay they can be toggled but not edited or deleted — edit the project file instead.
52
-
53
- The config file is validated on every load. A malformed entry (a stack that isn't an array, invalid JSON, and so on) aborts the command with an error and nothing is written, rather than defaulting to an empty config and saving that back.
54
-
55
- ## Overlay keys
56
-
57
- Navigation is consistent across panes: `←`/`esc` always goes back a pane, `→`/`tab` (or `enter` on a member) always goes forward. `←` in the leftmost pane and `→` in the viewer do nothing.
58
-
59
- | Pane | Keys |
60
- | --- | --- |
61
- | stacks | `↑`/`↓` (or `k`/`j`) select · `space` on/off · `→`/`l`/`tab`/`enter` into members · `a` add skills · `n` new stack · `d` delete · `esc`/`q` close |
62
- | members | `↑`/`↓` (or `k`/`j`) move · `space` remove the skill · `enter`/`→`/`tab` view skill · `a` add skills · `←`/`h`/`esc` back to stacks |
63
- | skill viewer | `↑`/`↓` (or `k`/`j`) scroll · `enter`/`←`/`h`/`esc` back to members |
64
- | add skills dialog | `↑`/`↓` move · `space` mark · `enter` add the marked skills (or the highlighted one) · `esc` cancel |
65
- | new stack / delete dialogs | `enter` confirm · `esc` cancel |
66
-
67
- The `a`, `n` and `d` dialogs open as small overlays on top of `/stacks`. `a` lists only skills that no stack holds yet. To move a skill between stacks, `space` it out of one and `a` it into the other.
68
-
69
- In fullscreen TUI mode the mouse wheel scrolls the skill viewer when the pointer is over the overlay. In regular mode the terminal owns the mouse and wheel input goes to its own scrollback.
70
-
71
- `enter` in the members pane opens the skill's full markdown in a viewer pane inside the overlay: frontmatter dim, headings bold, bullets and indented code rendered, links shown as their text.
72
-
73
- The title bar shows `reload pending` once a change has touched `settings.json`; the reload runs when you close the overlay.
74
-
75
- ## Rules
76
-
77
- - **Overlap:** a skill is excluded only when it appears in at least one stack and no enabled stack contains it. Skills in no stack are never touched. So disabling a stack whose members all also belong to an enabled stack excludes nothing.
78
- - **Stacks, not skills:** `on`/`off` take stack names only. To exclude a single skill regardless of stacks, use `pi config` (its `!` entries are respected and never claimed).
79
- - **Persist-then-reload:** `on`/`off` commands write immediately and reload if `settings.json` changed. In the overlay every change is written as you make it and a single reload runs on close, again only if `settings.json` changed; re-stacking alone doesn't need one (the header line catches up on the next reload).
80
- - **Other projects' stacks:** a project stack you disabled from inside its project stays disabled when you toggle things elsewhere. Its `disabledStacks` entry and the exclusions written for its skills are preserved, because from another directory those skills are "in no stack" and so left alone.
81
-
82
- ## Header
83
-
84
- The header extension swaps pi's full skills listing for a compact summary and hides `[Themes]`. It also replaces the stock header banner with a minimal one-line directory label. If you run your own header extension (custom art, dashboard, and so on), disable this one and keep yours — add a filter to the package entry in `settings.json`:
85
-
86
- ```json
87
- {
88
- "packages": [
89
- {
90
- "source": "git:github.com/oscabriel/pi-skill-stacks",
91
- "extensions": ["!extensions/header.ts"]
92
- }
93
- ]
94
- }
95
- ```
40
+ Skills are discovered like pi does: every `SKILL.md` under `~/.agents/skills/` and `<agentDir>/skills/`, named by frontmatter `name` or the directory name. A project can add stack definitions in `<project>/.pi/skill-stacks.json`; those can be toggled but not edited from the overlay.
96
41
 
97
- Your header extension can import the section machinery from this package's `extensions/header.ts` to render the same compact line itself: `buildSkillsSection(cwd, theme)` builds the node, `replaceSkillsSection(root, node)` and `hideThemesSection(root)` splice pi's sections, `isOurSection`, `firstLineOf`, and `renderedText` are the matchers, and `SectionTheme`/`RenderableNode` are the structural types they take.
42
+ Turning a stack off writes `!skills/<dir>/SKILL.md` patterns into the `skills` array of `~/.pi/agent/settings.json`, pi's own exclusion mechanism. The package only removes patterns it wrote itself; hand-written entries stay. A skill is excluded only when no enabled stack contains it, and skills in no stack are never touched. pi reloads once when the overlay closes, only if `settings.json` changed.
98
43
 
99
- ## Notes
44
+ Stack names that don't match a discovered skill are flagged `missing` and not counted.
100
45
 
101
- - If a stack references skill names that don't resolve to a discovered skill, every `/stacks` invocation warns with the missing names, and the overlay flags them inline as `missing`.
102
- - Counts in the overlay and header only include discovered skills, so a stale name doesn't inflate the numbers.
103
- - `settings.json` is rewritten without a trailing newline to match pi's own formatting; `skill-stacks.json` ends with one.
46
+ For other extensions, `loadStacksSummary()` in `src/store.ts` returns stack names, on/off state, and counts (`totalCount`, `activeCount`, `unstackedCount`).
104
47
 
105
48
  ## Develop
106
49
 
107
50
  ```bash
108
- npm install # dev-only: pi's type declarations for tsc
109
- npm run check # typecheck
110
- npm test # node --test
51
+ npm install
52
+ npm run check
53
+ npm test
111
54
  ```
@@ -7,10 +7,30 @@
7
7
  import { type Component, type Focusable, Input, Key, matchesKey } from "@earendil-works/pi-tui";
8
8
  import { frameEdge, frameInnerWidth, frameRow, type OverlayTheme, padToWidth } from "./frame.ts";
9
9
 
10
- /** Single-line text prompt. Resolves with the trimmed value, or undefined on escape. */
11
- export class PromptDialog implements Component, Focusable {
12
- private readonly input = new Input();
10
+ /**
11
+ * A dialog built around one pi-tui Input. Focus is forwarded to the Input so it
12
+ * emits the hardware-cursor marker (IME placement) while the dialog is open.
13
+ */
14
+ abstract class InputDialog implements Component, Focusable {
15
+ protected readonly input = new Input();
13
16
  private _focused = false;
17
+
18
+ get focused() {
19
+ return this._focused;
20
+ }
21
+
22
+ set focused(value: boolean) {
23
+ this._focused = value;
24
+ this.input.focused = value;
25
+ }
26
+
27
+ abstract handleInput(data: string): void;
28
+ abstract render(width: number): string[];
29
+ invalidate() {}
30
+ }
31
+
32
+ /** Single-line text prompt. Resolves with the trimmed value, or undefined on escape. */
33
+ export class PromptDialog extends InputDialog {
14
34
  private readonly theme: OverlayTheme;
15
35
  private readonly title: string;
16
36
  private readonly placeholder: string | undefined;
@@ -22,22 +42,13 @@ export class PromptDialog implements Component, Focusable {
22
42
  placeholder: string | undefined,
23
43
  done: (value: string | undefined) => void,
24
44
  ) {
45
+ super();
25
46
  this.theme = theme;
26
47
  this.title = title;
27
48
  this.placeholder = placeholder;
28
49
  this.done = done;
29
50
  }
30
51
 
31
- /** Propagate focus to the Input so it emits the hardware-cursor marker (IME placement). */
32
- get focused() {
33
- return this._focused;
34
- }
35
-
36
- set focused(value: boolean) {
37
- this._focused = value;
38
- this.input.focused = value;
39
- }
40
-
41
52
  handleInput(data: string) {
42
53
  if (matchesKey(data, Key.enter) || data === "\n") {
43
54
  this.done(this.input.getValue().trim());
@@ -48,8 +59,6 @@ export class PromptDialog implements Component, Focusable {
48
59
  }
49
60
  }
50
61
 
51
- invalidate() {}
52
-
53
62
  render(width: number) {
54
63
  const inner = frameInnerWidth(width);
55
64
  const [inputLine = ""] = this.input.render(inner);
@@ -100,11 +109,12 @@ export class ConfirmDialog implements Component {
100
109
  }
101
110
 
102
111
  /**
103
- * Pick one or more skills from a list. `space` marks, `enter` resolves with the
104
- * marked set (or just the highlighted skill when nothing is marked), `esc`
105
- * resolves undefined.
112
+ * Pick one or more skills from a searchable list. Typing filters (case-insensitive
113
+ * substring); `space` marks, `enter` resolves with the marked set (or just the
114
+ * highlighted skill when nothing is marked — marks survive refiltering), `esc`
115
+ * resolves undefined. Arrows move; plain letters go to the filter, so no j/k here.
106
116
  */
107
- export class PickDialog implements Component {
117
+ export class PickDialog extends InputDialog {
108
118
  static readonly MAX_ROWS = 16;
109
119
 
110
120
  private readonly theme: OverlayTheme;
@@ -114,6 +124,8 @@ export class PickDialog implements Component {
114
124
  private readonly marked = new Set<string>();
115
125
  private cursor = 0;
116
126
  private offset = 0;
127
+ /** Filtered view of `items`, recomputed only when the query changes. */
128
+ private matchCache: { query: string; items: string[] } | undefined;
117
129
 
118
130
  constructor(
119
131
  theme: OverlayTheme,
@@ -121,6 +133,7 @@ export class PickDialog implements Component {
121
133
  items: readonly string[],
122
134
  done: (picked: string[] | undefined) => void,
123
135
  ) {
136
+ super();
124
137
  this.theme = theme;
125
138
  this.title = title;
126
139
  this.items = items;
@@ -129,45 +142,74 @@ export class PickDialog implements Component {
129
142
 
130
143
  handleInput(data: string) {
131
144
  if (matchesKey(data, Key.enter) || data === "\n") {
132
- const highlighted = this.items[this.cursor];
145
+ const matches = this.matches();
146
+ const highlighted = matches[this.cursor];
133
147
  const picked = this.marked.size > 0 ? this.items.filter((s) => this.marked.has(s)) : highlighted ? [highlighted] : [];
134
148
  this.done(picked);
135
149
  } else if (matchesKey(data, Key.escape)) {
136
150
  this.done(undefined);
137
- } else if (matchesKey(data, Key.down) || data === "j") {
151
+ } else if (matchesKey(data, Key.down)) {
138
152
  this.move(1);
139
- } else if (matchesKey(data, Key.up) || data === "k") {
153
+ } else if (matchesKey(data, Key.up)) {
140
154
  this.move(-1);
141
155
  } else if (data === " ") {
142
- const skill = this.items[this.cursor];
156
+ const skill = this.matches()[this.cursor];
143
157
  if (!skill) return;
144
158
  if (this.marked.has(skill)) this.marked.delete(skill);
145
159
  else this.marked.add(skill);
160
+ } else {
161
+ this.input.handleInput(data);
162
+ this.clampToMatches();
146
163
  }
147
164
  }
148
165
 
149
- invalidate() {}
150
-
151
166
  render(width: number) {
167
+ const inner = frameInnerWidth(width);
152
168
  const rows = this.visibleRows();
169
+ const matches = this.matches();
153
170
  const lines = [frameEdge(this.theme, width, this.title, true)];
171
+ const query = this.input.getValue();
172
+ const [inputLine = ""] = this.input.render(inner);
173
+ lines.push(frameRow(this.theme, width, query === "" ? this.theme.fg("dim", "type to filter") : inputLine));
154
174
  for (let row = 0; row < rows; row += 1) {
155
175
  const index = this.offset + row;
156
- const skill = this.items[index];
157
- lines.push(frameRow(this.theme, width, skill ? this.cell(skill, index, frameInnerWidth(width)) : ""));
176
+ const skill = matches[index];
177
+ lines.push(frameRow(this.theme, width, skill ? this.cell(skill, index, inner) : ""));
158
178
  }
159
179
  const marked = this.marked.size > 0 ? ` · ${this.marked.size} marked` : "";
160
- lines.push(frameEdge(this.theme, width, `↑↓ move · space mark · enter add${marked} · esc cancel`, false));
180
+ const filtered = query === "" ? "" : ` · ${matches.length}/${this.items.length}`;
181
+ lines.push(frameEdge(this.theme, width, `type filter · ↑↓ move · space mark · enter add${marked}${filtered} · esc cancel`, false));
161
182
  return lines;
162
183
  }
163
184
 
164
185
  private visibleRows() {
165
- return Math.max(1, Math.min(PickDialog.MAX_ROWS, this.items.length));
186
+ return Math.max(1, Math.min(PickDialog.MAX_ROWS, this.matches().length));
187
+ }
188
+
189
+ /** Items that match the current filter, in list order. Cached per query; callers must not mutate it. */
190
+ private matches(): readonly string[] {
191
+ const query = this.input.getValue().trim().toLowerCase();
192
+ if (this.matchCache?.query !== query) {
193
+ this.matchCache = {
194
+ query,
195
+ items: query === "" ? [...this.items] : this.items.filter((item) => item.toLowerCase().includes(query)),
196
+ };
197
+ }
198
+ return this.matchCache.items;
199
+ }
200
+
201
+ /** Keep cursor and scroll window inside the filtered list after a query change. */
202
+ private clampToMatches() {
203
+ const total = this.matches().length;
204
+ this.cursor = Math.max(0, Math.min(total - 1, this.cursor));
205
+ const rows = this.visibleRows();
206
+ this.offset = Math.max(0, Math.min(this.offset, Math.max(0, total - rows)));
166
207
  }
167
208
 
168
209
  private move(delta: number) {
169
- if (this.items.length === 0) return;
170
- this.cursor = Math.max(0, Math.min(this.items.length - 1, this.cursor + delta));
210
+ const total = this.matches().length;
211
+ if (total === 0) return;
212
+ this.cursor = Math.max(0, Math.min(total - 1, this.cursor + delta));
171
213
  const rows = this.visibleRows();
172
214
  if (this.cursor < this.offset) this.offset = this.cursor;
173
215
  else if (this.cursor >= this.offset + rows) this.offset = this.cursor - rows + 1;
@@ -37,3 +37,33 @@ export function frameRow(theme: OverlayTheme, width: number, content: string) {
37
37
 
38
38
  /** Width available to content inside `frameRow`. */
39
39
  export const frameInnerWidth = (width: number) => Math.max(0, width - 4);
40
+
41
+ /** Word-wrap plain text to `width` display columns; over-long words are hard-split. */
42
+ export function wrapText(text: string, width: number): string[] {
43
+ const out: string[] = [];
44
+ for (const paragraph of text.split("\n")) {
45
+ if (paragraph === "") {
46
+ out.push("");
47
+ continue;
48
+ }
49
+ let line = "";
50
+ for (const word of paragraph.split(" ")) {
51
+ const candidate = line ? `${line} ${word}` : word;
52
+ if (visibleWidth(candidate) <= width) {
53
+ line = candidate;
54
+ continue;
55
+ }
56
+ if (line) out.push(line);
57
+ let rest = word;
58
+ while (visibleWidth(rest) > width) {
59
+ let cut = width;
60
+ while (cut > 1 && visibleWidth(rest.slice(0, cut)) > width) cut -= 1;
61
+ out.push(rest.slice(0, cut));
62
+ rest = rest.slice(cut);
63
+ }
64
+ line = rest;
65
+ }
66
+ if (line) out.push(line);
67
+ }
68
+ return out;
69
+ }
@@ -19,8 +19,10 @@ import {
19
19
  visibleWidth,
20
20
  } from "@earendil-works/pi-tui";
21
21
  import { ConfirmDialog, PickDialog, PromptDialog } from "./dialogs.ts";
22
- import { frameEdge, type OverlayTheme, padToWidth } from "./frame.ts";
22
+ import { frameEdge, type OverlayTheme, padToWidth, wrapText } from "./frame.ts";
23
+ import { homedir } from "node:os";
23
24
  import type { StackMap, StacksSummary } from "../src/core.ts";
25
+ import { defaultSkillRoots } from "../src/store.ts";
24
26
  import {
25
27
  StacksOverlayModel,
26
28
  type MemberRow,
@@ -72,6 +74,24 @@ const CELL_PREFIX_WIDTH = CURSOR.length + 3 + 1;
72
74
  const projectStackNotice = (stack: string) =>
73
75
  `"${stack}" is defined in .pi/skill-stacks.json; edit it there`;
74
76
 
77
+ /** The directories skills are discovered from, with $HOME shortened to `~`. */
78
+ function skillRootsNotice() {
79
+ const home = homedir();
80
+ const shown = defaultSkillRoots().map(({ dir }) => (dir.startsWith(home) ? `~${dir.slice(home.length)}` : dir));
81
+ return `No skills discovered (looked in ${shown.join(" and ")})`;
82
+ }
83
+
84
+ // Paragraphs only — wrapText reflows each to the members pane's width.
85
+ const EMPTY_INTRO = [
86
+ "No stacks yet",
87
+ "",
88
+ "A stack is a named group of skills you can switch on and off together. Skills that are off leave the system prompt, /skill: commands, and discovery.",
89
+ "",
90
+ "Press n (or enter) to create one. You'll pick its skills right after.",
91
+ "",
92
+ "Later, a adds skills to the selected stack and space switches it on or off.",
93
+ ].join("\n");
94
+
75
95
  /**
76
96
  * Wheel direction from a terminal mouse report: -1 up, +1 down, 0 not a wheel
77
97
  * event. Matches pi-tui's parseWheelEvent — in fullscreen mode pi captures the
@@ -141,9 +161,14 @@ export class StacksOverlay {
141
161
  matchesKey(data, Key.tab) ||
142
162
  data === "l"
143
163
  ) {
144
- this.model.setFocus("members");
145
- this.model.moveMember(0, this.memberRows());
146
- this.tui.requestRender();
164
+ if (this.model.stackCount === 0) {
165
+ // first-run shortcut: enter on an empty list starts the create flow
166
+ this.openNewStackDialog();
167
+ } else {
168
+ this.model.setFocus("members");
169
+ this.model.moveMember(0, this.memberRows());
170
+ this.tui.requestRender();
171
+ }
147
172
  } else if (data === "n") {
148
173
  this.openNewStackDialog();
149
174
  } else if (data === "d") {
@@ -247,7 +272,15 @@ export class StacksOverlay {
247
272
 
248
273
  let members: string;
249
274
  if (!stack) {
250
- members = row === 0 ? hint(" no stacks · n creates one", membersW) : blank(membersW);
275
+ const intro = wrapText(EMPTY_INTRO, Math.max(10, membersW - 1));
276
+ const text = intro[row];
277
+ if (text === undefined) {
278
+ members = blank(membersW);
279
+ } else if (row === 0) {
280
+ members = padToWidth(this.theme.fg("accent", this.theme.bold(` ${text}`)), membersW);
281
+ } else {
282
+ members = padToWidth(this.theme.fg("text", ` ${text}`), membersW);
283
+ }
251
284
  } else if (row === 0) {
252
285
  members = this.renderStackHeader(stack, membersW);
253
286
  } else if (row === 1) {
@@ -287,22 +320,27 @@ export class StacksOverlay {
287
320
  ["↑↓", "scroll"],
288
321
  ["←", "back"],
289
322
  ])
290
- : this.model.focus === "stacks"
323
+ : this.model.stackCount === 0
291
324
  ? this.hintBar([
292
- ["↑↓", "select"],
293
- ["space", "on/off"],
294
- ["→", "members"],
295
- ["a", "add skills"],
296
- ["n", "new stack"],
297
- ["d", "delete"],
325
+ ["n/enter", "new stack"],
298
326
  ["esc", "close"],
299
327
  ])
300
- : this.hintBar([
301
- ["↑↓", "move"],
302
- ["space", "remove"],
303
- ["←/→", "back/view"],
304
- ["a", "add skills"],
305
- ]);
328
+ : this.model.focus === "stacks"
329
+ ? this.hintBar([
330
+ ["↑↓", "select"],
331
+ ["space", "on/off"],
332
+ ["→", "members"],
333
+ ["a", "add skills"],
334
+ ["n", "new stack"],
335
+ ["d", "delete"],
336
+ ["esc", "close"],
337
+ ])
338
+ : this.hintBar([
339
+ ["↑↓", "move"],
340
+ ["space", "remove"],
341
+ ["←/→", "back/view"],
342
+ ["a", "add skills"],
343
+ ]);
306
344
  lines.push(this.border(width, help, false));
307
345
  return lines;
308
346
  }
@@ -333,6 +371,7 @@ export class StacksOverlay {
333
371
 
334
372
  private openNewStackDialog() {
335
373
  void this.withDialog(async () => {
374
+ const wasEmpty = this.model.stackCount === 0;
336
375
  const name = (await this.callbacks.input("New stack name", "e.g. writing"))?.trim();
337
376
  if (!name) return;
338
377
  if (!this.model.createStack(name)) {
@@ -340,9 +379,30 @@ export class StacksOverlay {
340
379
  return;
341
380
  }
342
381
  this.apply();
382
+ if (wasEmpty) await this.pickSkillsForSelectedStack();
343
383
  });
344
384
  }
345
385
 
386
+ /** The add-skills picker for the selected stack, shared by `a` and the first-run create flow. */
387
+ private async pickSkillsForSelectedStack() {
388
+ const name = this.model.selectedStack;
389
+ if (!name) return;
390
+ if (this.model.isProjectStack(name)) {
391
+ this.callbacks.notify(projectStackNotice(name), "warning");
392
+ return;
393
+ }
394
+ const unstacked = this.model.unstackedSkills();
395
+ if (unstacked.length === 0) {
396
+ this.callbacks.notify(
397
+ this.model.discoveredCount === 0 ? skillRootsNotice() : "Every discovered skill is already in a stack",
398
+ "info",
399
+ );
400
+ return;
401
+ }
402
+ const picked = await this.callbacks.pick(`Add to ${name} · ${unstacked.length} unstacked`, unstacked);
403
+ if (picked && this.model.addSkills(picked) === "added") this.apply();
404
+ }
405
+
346
406
  private openDeleteDialog() {
347
407
  const name = this.model.selectedStack;
348
408
  if (!name) return;
@@ -362,21 +422,8 @@ export class StacksOverlay {
362
422
 
363
423
  /** `a`: pick from the skills no stack holds yet and add them to the selected stack. */
364
424
  private openAddDialog() {
365
- const name = this.model.selectedStack;
366
- if (!name) return;
367
- if (this.model.isProjectStack(name)) {
368
- this.callbacks.notify(projectStackNotice(name), "warning");
369
- return;
370
- }
371
- const unstacked = this.model.unstackedSkills();
372
- if (unstacked.length === 0) {
373
- this.callbacks.notify("Every discovered skill is already in a stack", "info");
374
- return;
375
- }
376
- void this.withDialog(async () => {
377
- const picked = await this.callbacks.pick(`Add to ${name} · ${unstacked.length} unstacked`, unstacked);
378
- if (picked && this.model.addSkills(picked) === "added") this.apply();
379
- });
425
+ if (!this.model.selectedStack) return;
426
+ void this.withDialog(() => this.pickSkillsForSelectedStack());
380
427
  }
381
428
 
382
429
  private bodyRows() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-skill-stacks",
3
- "version": "0.4.2",
4
- "description": "Pi package that groups skills into named stacks you can manage from a /stacks overlay — toggle stacks on/off and re-stack skills without editing JSON. Exclusions are written through pi's own settings override mechanism, and a compact [Skills] header line replaces the per-scope listing.",
3
+ "version": "0.6.0",
4
+ "description": "Pi package that groups skills into named stacks you can manage from a /stacks overlay — toggle stacks on/off and re-stack skills without editing JSON. Exclusions are written through pi's own settings override mechanism.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi",
@@ -27,8 +27,7 @@
27
27
  },
28
28
  "pi": {
29
29
  "extensions": [
30
- "./extensions/index.ts",
31
- "./extensions/header.ts"
30
+ "./extensions/index.ts"
32
31
  ]
33
32
  },
34
33
  "devDependencies": {
package/src/core.ts CHANGED
@@ -36,7 +36,9 @@ export interface StacksSummary {
36
36
  offStacks: string[];
37
37
  totalCount: number;
38
38
  activeCount: number;
39
- /** Per-stack status in definition order, for the header section body. */
39
+ /** Discovered skills that no stack contains. */
40
+ unstackedCount: number;
41
+ /** Per-stack status in definition order. */
40
42
  stacks: StackStatus[];
41
43
  }
42
44
 
@@ -59,6 +61,9 @@ export function missingSkillNames(stacks: StackMap, discovered: DiscoveredSkills
59
61
  return missing;
60
62
  }
61
63
 
64
+ /** Every skill name any stack references, discovered or not. */
65
+ export const stackedSkills = (stacks: StackMap) => new Set(Object.values(stacks).flat());
66
+
62
67
  /**
63
68
  * A skill is excluded iff it appears in at least one stack and no enabled
64
69
  * stack contains it. Skills in no stack are never excluded.
@@ -161,7 +166,7 @@ export function nextDisabledStacks(
161
166
  return sortNames(new Set([...unseen, ...visibleDisabled]));
162
167
  }
163
168
 
164
- /** Counts for the compact `[Skills]` header line. Only discovered skills are counted. */
169
+ /** Stack, active-skill, and unstacked-skill counts. Only discovered skills are counted. */
165
170
  export function summarizeStacks(
166
171
  stacks: StackMap,
167
172
  disabledStacks: string[],
@@ -169,9 +174,12 @@ export function summarizeStacks(
169
174
  ): StacksSummary {
170
175
  const stackNames = Object.keys(stacks);
171
176
  const excluded = computeExcludedSkills(stacks, disabledStacks);
177
+ const stacked = stackedSkills(stacks);
172
178
  let activeCount = 0;
179
+ let unstackedCount = 0;
173
180
  for (const name of discovered.keys()) {
174
181
  if (!excluded.has(name)) activeCount += 1;
182
+ if (!stacked.has(name)) unstackedCount += 1;
175
183
  }
176
184
  const disabled = new Set(disabledStacks);
177
185
  return {
@@ -179,6 +187,7 @@ export function summarizeStacks(
179
187
  offStacks: disabledStacks.filter((name) => stackNames.includes(name)),
180
188
  totalCount: discovered.size,
181
189
  activeCount,
190
+ unstackedCount,
182
191
  stacks: Object.entries(stacks).map(([name, skills]) => ({
183
192
  name,
184
193
  size: skills.filter((skill) => discovered.has(skill)).length,
@@ -8,7 +8,13 @@
8
8
  // to them would be shadowed by the project config on the next merge.
9
9
 
10
10
  import { renderMarkdown, type MarkdownStyler } from "./markdown.ts";
11
- import { computeExcludedSkills, sortNames, type DiscoveredSkills, type StackMap } from "./core.ts";
11
+ import {
12
+ computeExcludedSkills,
13
+ sortNames,
14
+ stackedSkills,
15
+ type DiscoveredSkills,
16
+ type StackMap,
17
+ } from "./core.ts";
12
18
 
13
19
  export type OverlayFocus = "stacks" | "members" | "viewer";
14
20
 
@@ -130,7 +136,7 @@ export class StacksOverlayModel {
130
136
 
131
137
  /** Discovered skills that no stack (enabled or not) contains, sorted. */
132
138
  unstackedSkills() {
133
- const stacked = new Set(Object.values(this.stackMap).flat());
139
+ const stacked = stackedSkills(this.stackMap);
134
140
  return sortNames([...this.discovered.keys()].filter((skill) => !stacked.has(skill)));
135
141
  }
136
142
 
package/src/store.ts CHANGED
@@ -246,9 +246,10 @@ export function updateSettingsSkills(path: string, skills: string[]) {
246
246
  }
247
247
 
248
248
  /**
249
- * Fresh-from-disk summary for the compact `[Skills]` header line.
250
- * Returns undefined when no stacks are configured (leave pi's section alone).
251
- * Throws ConfigError on malformed config; the header catches and falls back.
249
+ * Fresh-from-disk stacks summary for external consumers (e.g. a user's own
250
+ * header extension). Not used by the package itself.
251
+ * Returns undefined when no stacks are configured. Throws ConfigError on
252
+ * malformed config; callers decide whether to fall back.
252
253
  */
253
254
  export function loadStacksSummary(cwd: string) {
254
255
  const global = loadStacksConfig();
@@ -1,262 +0,0 @@
1
- /**
2
- * pi-skill-stacks header extension: replaces pi's startup `[Skills]` section
3
- * with a compact one-line summary (e.g. `matt-pocock (28), firecrawl (28) ·
4
- * 76/76 skills active`), hides the `[Themes]` section, and renders a minimal
5
- * one-line banner in place of pi's stock header.
6
- *
7
- * Disable this file (settings.json package filter `!extensions/header.ts`) to
8
- * keep your own header extension; the /stacks command extension is unaffected.
9
- * The section-building helpers are exported so a custom header can reuse them.
10
- *
11
- * Mechanism notes:
12
- * - pi's startup sections are childless leaves inside one container. The
13
- * container's first rendered line can itself be "[Skills]" (when no
14
- * [Context] section precedes it), so matchers require !hasChildren.
15
- * - showLoadedResources runs after extension session_start handlers, so
16
- * post-paint sweeps alone flash the full listing for a frame. addChild
17
- * wrapping swaps our node in at insertion time instead. Timed sweeps stay
18
- * as fallback for late installs; resources_discover re-runs the walk.
19
- */
20
- import { homedir } from "node:os";
21
- import { isAbsolute, relative } from "node:path";
22
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
- import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
24
- import { loadStacksSummary } from "../src/store.ts";
25
-
26
- export interface RenderableNode {
27
- children?: RenderableNode[];
28
- addChild?(component: RenderableNode): void;
29
- invalidate(): void;
30
- render(width: number): string[];
31
- }
32
-
33
- /** Subset of pi's Theme we use; the real Theme is assignable to it. */
34
- export interface SectionTheme {
35
- fg(color: "mdHeading" | "dim", text: string): string;
36
- }
37
-
38
- export interface DashboardTui extends RenderableNode {
39
- requestRender(force?: boolean): void;
40
- }
41
-
42
- const RESET = "\x1b[0m";
43
- const BOLD = "\x1b[1m";
44
- const DIM = "\x1b[2m";
45
- const ANSI_PATTERN =
46
- /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
47
-
48
- const hasChildren = (
49
- component: RenderableNode,
50
- ): component is RenderableNode & { children: RenderableNode[] } => Array.isArray(component.children);
51
-
52
- export function renderedText(component: RenderableNode) {
53
- try {
54
- return component.render(200).join("\n").replace(ANSI_PATTERN, "");
55
- } catch {
56
- return "";
57
- }
58
- }
59
-
60
- export function firstLineOf(component: RenderableNode) {
61
- return renderedText(component)
62
- .split("\n")
63
- .find((line) => line.trim())
64
- ?.trim();
65
- }
66
-
67
- // Marks the section nodes we build, so the addChild interceptor and the sweep
68
- // never re-match them (their first rendered line is also exactly "[Skills]").
69
- const ourSections = new WeakSet<object>();
70
-
71
- export const isOurSection = (component: object) => ourSections.has(component);
72
-
73
- /** A pi section leaf we should act on: exact header line, childless, not one of ours. */
74
- const isPiSection = (child: RenderableNode, header: string) =>
75
- firstLineOf(child) === header && !hasChildren(child) && !isOurSection(child);
76
-
77
- export function hideThemesSection(component: RenderableNode): boolean {
78
- if (!hasChildren(component)) return false;
79
-
80
- for (let index = 0; index < component.children.length; index += 1) {
81
- const child = component.children[index]!;
82
- // Leaf check: pi's section components are childless ExpandableText nodes.
83
- // The parent resources container can render the same first line when it's
84
- // the first section, and matching it would splice out every section.
85
- if (isPiSection(child, "[Themes]")) {
86
- const next = component.children[index + 1];
87
- const removeCount = next && renderedText(next).trim() === "" ? 2 : 1;
88
- component.children.splice(index, removeCount);
89
- component.invalidate();
90
- return true;
91
- }
92
- if (hideThemesSection(child)) return true;
93
- }
94
- return false;
95
- }
96
-
97
- /**
98
- * Build a replacement [Skills] section styled like pi's own sections:
99
- * mdHeading header line, dim indented body listing each stack with its size,
100
- * disabled stacks separately, plus the active-skill count. Data is read fresh
101
- * from disk so it stays correct across /reload. Returns undefined when no
102
- * stacks are configured or the config is unreadable (pi's section is left alone).
103
- */
104
- export function buildSkillsSection(cwd: string, theme: SectionTheme | undefined) {
105
- let summary;
106
- try {
107
- summary = loadStacksSummary(cwd);
108
- } catch {
109
- return undefined;
110
- }
111
- if (!summary) return undefined;
112
- const heading = (text: string) => (theme ? theme.fg("mdHeading", text) : `${BOLD}${text}${RESET}`);
113
- const dim = (text: string) => (theme ? theme.fg("dim", text) : `${DIM}${text}${RESET}`);
114
- const label = (stack: { name: string; size: number }) => `${stack.name} (${stack.size})`;
115
- const on = summary.stacks.filter((stack) => stack.enabled).map(label).join(", ");
116
- const off = summary.stacks.filter((stack) => !stack.enabled).map(label).join(", ");
117
- const parts: string[] = [];
118
- if (on) parts.push(on);
119
- if (off) parts.push(`off: ${off}`);
120
- parts.push(`${summary.activeCount}/${summary.totalCount} skills active`);
121
- const section = new Text(`${heading("[Skills]")}\n${dim(` ${parts.join(" · ")}`)}`, 0, 0);
122
- ourSections.add(section);
123
- return section;
124
- }
125
-
126
- export function replaceSkillsSection(component: RenderableNode, section: Text): boolean {
127
- if (!hasChildren(component)) return false;
128
-
129
- for (let index = 0; index < component.children.length; index += 1) {
130
- const child = component.children[index]!;
131
- if (isPiSection(child, "[Skills]")) {
132
- component.children.splice(index, 1, section);
133
- component.invalidate();
134
- return true;
135
- }
136
- if (replaceSkillsSection(child, section)) return true;
137
- }
138
- return false;
139
- }
140
-
141
- function formatDirectory(cwd: string) {
142
- const home = homedir();
143
- if (cwd === home) return "~";
144
- const rel = relative(home, cwd);
145
- return rel.startsWith("..") || isAbsolute(rel) ? cwd : `~/${rel}`;
146
- }
147
-
148
- export default function skillStacksHeader(pi: ExtensionAPI) {
149
- let cwd = process.cwd();
150
- let activeTui: DashboardTui | undefined;
151
- let sectionTheme: SectionTheme | undefined;
152
- let banner: string[] = [];
153
- let fixupTimers: Array<ReturnType<typeof setTimeout>> = [];
154
- const interceptedContainers = new WeakSet<object>();
155
-
156
- // One disk read per fixup cycle: the interceptor and every sweep in the
157
- // cycle share the same section instead of re-scanning the skill roots.
158
- let cachedSection: { cwd: string; section: Text | undefined } | undefined;
159
- function skillsSection() {
160
- if (!cachedSection || cachedSection.cwd !== cwd) {
161
- cachedSection = { cwd, section: buildSkillsSection(cwd, sectionTheme) };
162
- }
163
- return cachedSection.section;
164
- }
165
-
166
- // Wrap addChild on every container reachable from the TUI root so pi's
167
- // [Skills] section is swapped (and [Themes] dropped) at insertion time,
168
- // before the first paint.
169
- function interceptContainers(node: RenderableNode) {
170
- if (hasChildren(node) && typeof node.addChild === "function" && !interceptedContainers.has(node)) {
171
- interceptedContainers.add(node);
172
- const original = node.addChild.bind(node);
173
- let dropNextBlank = false;
174
- node.addChild = (child: RenderableNode) => {
175
- if (dropNextBlank) {
176
- dropNextBlank = false;
177
- // The spacer pi adds right after the section it belongs to.
178
- if (!renderedText(child).trim()) return;
179
- }
180
- if (!isOurSection(child)) {
181
- const firstLine = firstLineOf(child);
182
- if (firstLine === "[Themes]") {
183
- dropNextBlank = true;
184
- return;
185
- }
186
- if (firstLine === "[Skills]") {
187
- const section = skillsSection();
188
- if (section) {
189
- original(section);
190
- return;
191
- }
192
- }
193
- }
194
- original(child);
195
- };
196
- }
197
- if (hasChildren(node)) {
198
- for (const child of node.children) interceptContainers(child);
199
- }
200
- }
201
-
202
- // Fallback for sections that were inserted before we wrapped their
203
- // container (e.g. this extension loading into an already-painted session).
204
- function sweepHeader(tui: DashboardTui) {
205
- let changed = hideThemesSection(tui);
206
- const section = skillsSection();
207
- if (section && replaceSkillsSection(tui, section)) changed = true;
208
- if (changed) tui.requestRender(true);
209
- }
210
-
211
- function clearFixupTimers() {
212
- for (const timer of fixupTimers) clearTimeout(timer);
213
- fixupTimers = [];
214
- }
215
-
216
- function scheduleHeaderFixups(tui: DashboardTui) {
217
- clearFixupTimers();
218
- cachedSection = undefined;
219
- interceptContainers(tui);
220
- sweepHeader(tui);
221
- for (const delay of [0, 50, 250, 1_000]) {
222
- fixupTimers.push(setTimeout(() => sweepHeader(tui), delay));
223
- }
224
- }
225
-
226
- pi.on("session_start", (_event, ctx) => {
227
- cwd = ctx.cwd;
228
- if (ctx.mode !== "tui") return;
229
-
230
- ctx.ui.setHeader((tui, theme) => {
231
- activeTui = tui;
232
- sectionTheme = theme;
233
- const label = theme.fg("muted", formatDirectory(cwd));
234
- banner = [];
235
- scheduleHeaderFixups(tui);
236
-
237
- return {
238
- render(width: number) {
239
- // One dim centered line: the working directory.
240
- if (banner.length === 0) {
241
- const padding = Math.max(0, Math.floor((width - visibleWidth(label)) / 2));
242
- banner = [truncateToWidth(`${" ".repeat(padding)}${label}`, width)];
243
- }
244
- return banner;
245
- },
246
- invalidate() {
247
- banner = [];
248
- },
249
- };
250
- });
251
- });
252
-
253
- pi.on("resources_discover", () => {
254
- if (activeTui) scheduleHeaderFixups(activeTui);
255
- });
256
-
257
- pi.on("session_shutdown", (_event, ctx) => {
258
- clearFixupTimers();
259
- activeTui = undefined;
260
- if (ctx.mode === "tui") ctx.ui.setHeader(undefined);
261
- });
262
- }