pi-skill-stacks 0.5.1 → 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
@@ -18,7 +18,9 @@ pi install npm:pi-skill-stacks
18
18
  | members | `↑`/`↓` move · `space` remove · `→` view · `a` add skills · `←` back |
19
19
  | viewer | `↑`/`↓` scroll (mouse wheel in fullscreen) · `←` back |
20
20
 
21
- `a` lists skills not yet in any stack. To move a skill between stacks, `space` it out of one and `a` it into the other.
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.
22
+
23
+ With no stacks defined, `enter` (or `n`) creates the first one and opens the picker right after.
22
24
 
23
25
  From the command line: `/stacks on <stack>`, `/stacks off <stack>`, `/stacks list`.
24
26
 
@@ -41,6 +43,8 @@ Turning a stack off writes `!skills/<dir>/SKILL.md` patterns into the `skills` a
41
43
 
42
44
  Stack names that don't match a discovered skill are flagged `missing` and not counted.
43
45
 
46
+ For other extensions, `loadStacksSummary()` in `src/store.ts` returns stack names, on/off state, and counts (`totalCount`, `activeCount`, `unstackedCount`).
47
+
44
48
  ## Develop
45
49
 
46
50
  ```bash
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-skill-stacks",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
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",
package/src/core.ts CHANGED
@@ -36,6 +36,8 @@ export interface StacksSummary {
36
36
  offStacks: string[];
37
37
  totalCount: number;
38
38
  activeCount: number;
39
+ /** Discovered skills that no stack contains. */
40
+ unstackedCount: number;
39
41
  /** Per-stack status in definition order. */
40
42
  stacks: StackStatus[];
41
43
  }
@@ -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
- /** Stack and active-skill counts. 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