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 +5 -1
- package/extensions/dialogs.ts +73 -31
- package/extensions/frame.ts +30 -0
- package/extensions/overlay.ts +80 -33
- package/package.json +1 -1
- package/src/core.ts +10 -1
- package/src/overlay-model.ts +8 -2
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`
|
|
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
|
package/extensions/dialogs.ts
CHANGED
|
@@ -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
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
|
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
|
|
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)
|
|
151
|
+
} else if (matchesKey(data, Key.down)) {
|
|
138
152
|
this.move(1);
|
|
139
|
-
} else if (matchesKey(data, Key.up)
|
|
153
|
+
} else if (matchesKey(data, Key.up)) {
|
|
140
154
|
this.move(-1);
|
|
141
155
|
} else if (data === " ") {
|
|
142
|
-
const skill = this.
|
|
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 =
|
|
157
|
-
lines.push(frameRow(this.theme, width, skill ? this.cell(skill, index,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
170
|
-
|
|
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;
|
package/extensions/frame.ts
CHANGED
|
@@ -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
|
+
}
|
package/extensions/overlay.ts
CHANGED
|
@@ -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.
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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.
|
|
323
|
+
: this.model.stackCount === 0
|
|
291
324
|
? this.hintBar([
|
|
292
|
-
["
|
|
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.
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
366
|
-
|
|
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.
|
|
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
|
|
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,
|
package/src/overlay-model.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
139
|
+
const stacked = stackedSkills(this.stackMap);
|
|
134
140
|
return sortNames([...this.discovered.keys()].filter((skill) => !stacked.has(skill)));
|
|
135
141
|
}
|
|
136
142
|
|