pi-skill-stacks 0.4.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 oscabriel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # pi-skill-stacks
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.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pi install npm:pi-skill-stacks
16
+ ```
17
+
18
+ or from git:
19
+
20
+ ```bash
21
+ pi install git:github.com/oscabriel/pi-skill-stacks
22
+ ```
23
+
24
+ Or from a local checkout:
25
+
26
+ ```bash
27
+ pi install /path/to/pi-skill-stacks
28
+ ```
29
+
30
+ Use `-l` to install project-local instead of global. Try it without installing:
31
+
32
+ ```bash
33
+ pi -e git:github.com/oscabriel/pi-skill-stacks
34
+ ```
35
+
36
+ ## Configure stacks
37
+
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:
39
+
40
+ ```json
41
+ {
42
+ "stacks": {
43
+ "frontend": ["react", "tailwind", "vite"],
44
+ "writing": ["writing-beats", "writing-fragments"]
45
+ }
46
+ }
47
+ ```
48
+
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
+ ```
96
+
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.
98
+
99
+ ## Notes
100
+
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.
104
+
105
+ ## Develop
106
+
107
+ ```bash
108
+ npm install # dev-only: pi's type declarations for tsc
109
+ npm run check # typecheck
110
+ npm test # node --test
111
+ ```
@@ -0,0 +1,182 @@
1
+ // Small dialogs the /stacks overlay opens on top of itself. pi's own
2
+ // ctx.ui.input/confirm render in the main layout, underneath any visible
3
+ // overlay, so these are overlays in their own right: opened with
4
+ // ctx.ui.custom({ overlay: true }) they stack above /stacks, take focus while
5
+ // open, and hand it back when done.
6
+
7
+ import { type Component, type Focusable, Input, Key, matchesKey } from "@earendil-works/pi-tui";
8
+ import { frameEdge, frameInnerWidth, frameRow, type OverlayTheme, padToWidth } from "./frame.ts";
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();
13
+ private _focused = false;
14
+ private readonly theme: OverlayTheme;
15
+ private readonly title: string;
16
+ private readonly placeholder: string | undefined;
17
+ private readonly done: (value: string | undefined) => void;
18
+
19
+ constructor(
20
+ theme: OverlayTheme,
21
+ title: string,
22
+ placeholder: string | undefined,
23
+ done: (value: string | undefined) => void,
24
+ ) {
25
+ this.theme = theme;
26
+ this.title = title;
27
+ this.placeholder = placeholder;
28
+ this.done = done;
29
+ }
30
+
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
+ handleInput(data: string) {
42
+ if (matchesKey(data, Key.enter) || data === "\n") {
43
+ this.done(this.input.getValue().trim());
44
+ } else if (matchesKey(data, Key.escape)) {
45
+ this.done(undefined);
46
+ } else {
47
+ this.input.handleInput(data);
48
+ }
49
+ }
50
+
51
+ invalidate() {}
52
+
53
+ render(width: number) {
54
+ const inner = frameInnerWidth(width);
55
+ const [inputLine = ""] = this.input.render(inner);
56
+ const showPlaceholder = this.placeholder && this.input.getValue() === "";
57
+ return [
58
+ frameEdge(this.theme, width, this.title, true),
59
+ frameRow(this.theme, width, ""),
60
+ frameRow(this.theme, width, inputLine),
61
+ frameRow(this.theme, width, showPlaceholder ? this.theme.fg("dim", ` ${this.placeholder}`) : ""),
62
+ frameEdge(this.theme, width, "enter confirm · esc cancel", false),
63
+ ];
64
+ }
65
+ }
66
+
67
+ /** Yes/no confirmation. Resolves true on enter, false on escape. */
68
+ export class ConfirmDialog implements Component {
69
+ private readonly theme: OverlayTheme;
70
+ private readonly title: string;
71
+ private readonly message: string;
72
+ private readonly done: (confirmed: boolean) => void;
73
+
74
+ constructor(theme: OverlayTheme, title: string, message: string, done: (confirmed: boolean) => void) {
75
+ this.theme = theme;
76
+ this.title = title;
77
+ this.message = message;
78
+ this.done = done;
79
+ }
80
+
81
+ handleInput(data: string) {
82
+ if (matchesKey(data, Key.enter) || data === "\n") {
83
+ this.done(true);
84
+ } else if (matchesKey(data, Key.escape)) {
85
+ this.done(false);
86
+ }
87
+ }
88
+
89
+ invalidate() {}
90
+
91
+ render(width: number) {
92
+ return [
93
+ frameEdge(this.theme, width, this.title, true),
94
+ frameRow(this.theme, width, ""),
95
+ frameRow(this.theme, width, this.theme.fg("text", this.message)),
96
+ frameRow(this.theme, width, ""),
97
+ frameEdge(this.theme, width, "enter confirm · esc cancel", false),
98
+ ];
99
+ }
100
+ }
101
+
102
+ /**
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.
106
+ */
107
+ export class PickDialog implements Component {
108
+ static readonly MAX_ROWS = 16;
109
+
110
+ private readonly theme: OverlayTheme;
111
+ private readonly title: string;
112
+ private readonly items: readonly string[];
113
+ private readonly done: (picked: string[] | undefined) => void;
114
+ private readonly marked = new Set<string>();
115
+ private cursor = 0;
116
+ private offset = 0;
117
+
118
+ constructor(
119
+ theme: OverlayTheme,
120
+ title: string,
121
+ items: readonly string[],
122
+ done: (picked: string[] | undefined) => void,
123
+ ) {
124
+ this.theme = theme;
125
+ this.title = title;
126
+ this.items = items;
127
+ this.done = done;
128
+ }
129
+
130
+ handleInput(data: string) {
131
+ if (matchesKey(data, Key.enter) || data === "\n") {
132
+ const highlighted = this.items[this.cursor];
133
+ const picked = this.marked.size > 0 ? this.items.filter((s) => this.marked.has(s)) : highlighted ? [highlighted] : [];
134
+ this.done(picked);
135
+ } else if (matchesKey(data, Key.escape)) {
136
+ this.done(undefined);
137
+ } else if (matchesKey(data, Key.down) || data === "j") {
138
+ this.move(1);
139
+ } else if (matchesKey(data, Key.up) || data === "k") {
140
+ this.move(-1);
141
+ } else if (data === " ") {
142
+ const skill = this.items[this.cursor];
143
+ if (!skill) return;
144
+ if (this.marked.has(skill)) this.marked.delete(skill);
145
+ else this.marked.add(skill);
146
+ }
147
+ }
148
+
149
+ invalidate() {}
150
+
151
+ render(width: number) {
152
+ const rows = this.visibleRows();
153
+ const lines = [frameEdge(this.theme, width, this.title, true)];
154
+ for (let row = 0; row < rows; row += 1) {
155
+ 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)) : ""));
158
+ }
159
+ 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));
161
+ return lines;
162
+ }
163
+
164
+ private visibleRows() {
165
+ return Math.max(1, Math.min(PickDialog.MAX_ROWS, this.items.length));
166
+ }
167
+
168
+ 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));
171
+ const rows = this.visibleRows();
172
+ if (this.cursor < this.offset) this.offset = this.cursor;
173
+ else if (this.cursor >= this.offset + rows) this.offset = this.cursor - rows + 1;
174
+ }
175
+
176
+ private cell(skill: string, index: number, width: number) {
177
+ const selected = index === this.cursor;
178
+ const box = this.marked.has(skill) ? "[x]" : "[ ]";
179
+ const text = padToWidth(`${selected ? "› " : " "}${box} ${skill}`, width);
180
+ return selected ? this.theme.bg("selectedBg", this.theme.fg("accent", text)) : this.theme.fg("text", text);
181
+ }
182
+ }
@@ -0,0 +1,39 @@
1
+ // Shared drawing helpers for the /stacks overlay and its dialogs. Every helper
2
+ // returns lines of exactly the requested width so overlays keep a straight edge.
3
+
4
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
5
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
6
+
7
+ /** Minimal slice of pi's Theme the overlay touches (kept small so tests can fake it). */
8
+ export interface OverlayTheme {
9
+ fg(color: ThemeColor, text: string): string;
10
+ bg(color: "selectedBg" | "customMessageBg", text: string): string;
11
+ bold(text: string): string;
12
+ }
13
+
14
+ /** Truncate or right-pad `text` to exactly `width` columns. */
15
+ export function padToWidth(text: string, width: number) {
16
+ const truncated = truncateToWidth(text, width, "");
17
+ return `${truncated}${" ".repeat(Math.max(0, width - visibleWidth(truncated)))}`;
18
+ }
19
+
20
+ /** `┌─ label ────┐` or `└─ label ────┘`, exactly `width` wide. */
21
+ export function frameEdge(theme: OverlayTheme, width: number, label: string, top: boolean) {
22
+ const left = top ? "┌" : "└";
23
+ const right = top ? "┐" : "┘";
24
+ const text = `─ ${label} `;
25
+ const remaining = Math.max(0, width - visibleWidth(text) - 2);
26
+ return theme.fg(
27
+ "borderAccent",
28
+ truncateToWidth(`${left}${text}${"─".repeat(remaining)}${right}`, width, ""),
29
+ );
30
+ }
31
+
32
+ /** `│ content │` with one column of padding inside each border, exactly `width` wide. */
33
+ export function frameRow(theme: OverlayTheme, width: number, content: string) {
34
+ const edge = theme.fg("borderAccent", "│");
35
+ return padToWidth(`${edge} ${padToWidth(content, frameInnerWidth(width))} ${edge}`, width);
36
+ }
37
+
38
+ /** Width available to content inside `frameRow`. */
39
+ export const frameInnerWidth = (width: number) => Math.max(0, width - 4);
@@ -0,0 +1,262 @@
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
+ }