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 +20 -77
- package/extensions/dialogs.ts +73 -31
- package/extensions/frame.ts +30 -0
- package/extensions/overlay.ts +80 -33
- package/package.json +3 -4
- package/src/core.ts +11 -2
- package/src/overlay-model.ts +8 -2
- package/src/store.ts +4 -3
- package/extensions/header.ts +0 -262
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
|
|
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
|
-
|
|
11
|
+
## Use
|
|
19
12
|
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
+
With no stacks defined, `enter` (or `n`) creates the first one and opens the picker right after.
|
|
31
24
|
|
|
32
|
-
|
|
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
|
-
##
|
|
27
|
+
## How it works
|
|
37
28
|
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
+
Stack names that don't match a discovered skill are flagged `missing` and not counted.
|
|
100
45
|
|
|
101
|
-
|
|
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
|
|
109
|
-
npm run check
|
|
110
|
-
npm test
|
|
51
|
+
npm install
|
|
52
|
+
npm run check
|
|
53
|
+
npm test
|
|
111
54
|
```
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-skill-stacks",
|
|
3
|
-
"version": "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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
250
|
-
*
|
|
251
|
-
*
|
|
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();
|
package/extensions/header.ts
DELETED
|
@@ -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
|
-
}
|