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.
@@ -0,0 +1,524 @@
1
+ // Two-pane overlay for /stacks: stacks on the left (on/off, create, delete,
2
+ // `a` to add unstacked skills), the selected stack's members on the right
3
+ // (space removes one, enter opens a skill viewer as a third pane inside the
4
+ // overlay). Every mutation is persisted immediately
5
+ // through the caller's callback; the ctx.reload() that makes pi pick the
6
+ // changes up happens once, after the overlay closes, and only if settings.json
7
+ // actually changed (re-stacking alone doesn't need one).
8
+ //
9
+ // StacksOverlay is a plain component (render/handleInput/invalidate) so it can
10
+ // be smoke-tested without a live pi session; showStacksOverlay wires it into
11
+ // the TUI.
12
+
13
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
14
+ import {
15
+ Key,
16
+ matchesKey,
17
+ type OverlayOptions,
18
+ truncateToWidth,
19
+ visibleWidth,
20
+ } from "@earendil-works/pi-tui";
21
+ import { ConfirmDialog, PickDialog, PromptDialog } from "./dialogs.ts";
22
+ import { frameEdge, type OverlayTheme, padToWidth } from "./frame.ts";
23
+ import type { StackMap, StacksSummary } from "../src/core.ts";
24
+ import {
25
+ StacksOverlayModel,
26
+ type MemberRow,
27
+ type StackRow,
28
+ type StacksOverlayInit,
29
+ } from "../src/overlay-model.ts";
30
+
31
+ export interface ApplyOutcome {
32
+ summary: StacksSummary;
33
+ /** True when settings.json was rewritten, i.e. pi needs a reload to notice. */
34
+ settingsChanged: boolean;
35
+ }
36
+
37
+ export interface OverlayResult {
38
+ /** Any mutation happened while the overlay was open. */
39
+ changed: boolean;
40
+ /** Some mutation rewrote settings.json; the caller should ctx.reload(). */
41
+ settingsDirty: boolean;
42
+ outcome: ApplyOutcome | null;
43
+ }
44
+
45
+ export type StacksPersist = (stacks: StackMap, disabledStacks: string[]) => ApplyOutcome;
46
+
47
+ export type Notify = (message: string, type?: "info" | "warning" | "error") => void;
48
+
49
+ export interface StacksOverlayCallbacks {
50
+ persist: StacksPersist;
51
+ notify: Notify;
52
+ input: (title: string, placeholder?: string) => Promise<string | undefined>;
53
+ confirm: (title: string, message: string) => Promise<boolean>;
54
+ /** Multi-select from `items`; undefined when cancelled. */
55
+ pick: (title: string, items: readonly string[]) => Promise<string[] | undefined>;
56
+ done: (result: OverlayResult) => void;
57
+ }
58
+
59
+ export type { OverlayTheme } from "./frame.ts";
60
+
61
+ /** Minimal slice of pi-tui's TUI the overlay touches (kept small so tests can fake it). */
62
+ export interface OverlayTui {
63
+ terminal: { rows: number };
64
+ requestRender(): void;
65
+ }
66
+
67
+ const CURSOR = "› ";
68
+ const NO_CURSOR = " ";
69
+ /** `cursor + "[x]" + " "` before a name in every cell. */
70
+ const CELL_PREFIX_WIDTH = CURSOR.length + 3 + 1;
71
+
72
+ const projectStackNotice = (stack: string) =>
73
+ `"${stack}" is defined in .pi/skill-stacks.json; edit it there`;
74
+
75
+ /**
76
+ * Wheel direction from a terminal mouse report: -1 up, +1 down, 0 not a wheel
77
+ * event. Matches pi-tui's parseWheelEvent — in fullscreen mode pi captures the
78
+ * mouse and, while an overlay is focused, wheel reports fall through to
79
+ * handleInput instead of scrolling the transcript.
80
+ */
81
+ function wheelDirection(data: string): -1 | 0 | 1 {
82
+ const sgr = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data);
83
+ if (!sgr && !(data.length === 6 && data.startsWith("\x1b[M"))) return 0;
84
+ const button = sgr ? Number(sgr[1]) : data.charCodeAt(3) - 32;
85
+ if ((button & 64) === 0) return 0;
86
+ const direction = button & 3;
87
+ return direction === 0 ? -1 : direction === 1 ? 1 : 0;
88
+ }
89
+
90
+ export class StacksOverlay {
91
+ private readonly model: StacksOverlayModel;
92
+ private readonly tui: OverlayTui;
93
+ private readonly theme: OverlayTheme;
94
+ private readonly callbacks: StacksOverlayCallbacks;
95
+ private changed = false;
96
+ private settingsDirty = false;
97
+ private lastOutcome: ApplyOutcome | null = null;
98
+ private dialogOpen = false;
99
+ /** Width of the last render; handleInput uses it for the viewer's wrap width. */
100
+ private lastWidth = 80;
101
+
102
+ constructor(
103
+ tui: OverlayTui,
104
+ theme: OverlayTheme,
105
+ init: StacksOverlayInit,
106
+ callbacks: StacksOverlayCallbacks,
107
+ ) {
108
+ this.tui = tui;
109
+ this.theme = theme;
110
+ this.callbacks = callbacks;
111
+ this.model = new StacksOverlayModel({ ...init, styler: theme });
112
+ }
113
+
114
+ invalidate() {}
115
+
116
+ handleInput(data: string) {
117
+ if (this.dialogOpen) return; // a dialog owns the keyboard until its promise settles
118
+ if (this.model.focus === "stacks") this.handleStacksInput(data);
119
+ else if (this.model.focus === "viewer") this.handleViewerInput(data);
120
+ else this.handleMembersInput(data);
121
+ }
122
+
123
+ private handleStacksInput(data: string) {
124
+ if (matchesKey(data, Key.escape) || data === "q") {
125
+ this.callbacks.done({
126
+ changed: this.changed,
127
+ settingsDirty: this.settingsDirty,
128
+ outcome: this.lastOutcome,
129
+ });
130
+ } else if (matchesKey(data, Key.down) || data === "j") {
131
+ this.model.moveStack(1, this.bodyRows());
132
+ this.tui.requestRender();
133
+ } else if (matchesKey(data, Key.up) || data === "k") {
134
+ this.model.moveStack(-1, this.bodyRows());
135
+ this.tui.requestRender();
136
+ } else if (data === " ") {
137
+ if (this.model.toggleStack()) this.apply();
138
+ } else if (
139
+ matchesKey(data, Key.enter) ||
140
+ matchesKey(data, Key.right) ||
141
+ matchesKey(data, Key.tab) ||
142
+ data === "l"
143
+ ) {
144
+ this.model.setFocus("members");
145
+ this.model.moveMember(0, this.memberRows());
146
+ this.tui.requestRender();
147
+ } else if (data === "n") {
148
+ this.openNewStackDialog();
149
+ } else if (data === "d") {
150
+ this.openDeleteDialog();
151
+ } else if (data === "a") {
152
+ this.openAddDialog();
153
+ }
154
+ }
155
+
156
+ private handleMembersInput(data: string) {
157
+ // consistent pane navigation: ←/esc always back, →/tab always forward
158
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.left) || data === "h") {
159
+ this.model.setFocus("stacks");
160
+ this.tui.requestRender();
161
+ } else if (
162
+ matchesKey(data, Key.enter) ||
163
+ matchesKey(data, Key.right) ||
164
+ matchesKey(data, Key.tab)
165
+ ) {
166
+ // enter opens the skill viewer pane to the right (focus moves with it)
167
+ if (this.model.openViewer()) {
168
+ this.tui.requestRender();
169
+ } else if (this.model.selectedMember) {
170
+ this.callbacks.notify(`"${this.model.selectedMember}" is not on disk`, "warning");
171
+ }
172
+ } else if (matchesKey(data, Key.down) || data === "j") {
173
+ this.model.moveMember(1, this.memberRows());
174
+ this.tui.requestRender();
175
+ } else if (matchesKey(data, Key.up) || data === "k") {
176
+ this.model.moveMember(-1, this.memberRows());
177
+ this.tui.requestRender();
178
+ } else if (matchesKey(data, Key.enter)) {
179
+ // enter opens the skill viewer pane to the right (focus moves with it)
180
+ if (this.model.openViewer()) {
181
+ this.tui.requestRender();
182
+ } else if (this.model.selectedMember) {
183
+ this.callbacks.notify(`"${this.model.selectedMember}" is not on disk`, "warning");
184
+ }
185
+ } else if (data === "a") {
186
+ this.openAddDialog();
187
+ } else if (data === " ") {
188
+ const change = this.model.removeMember();
189
+ if (change !== "blocked") {
190
+ this.apply();
191
+ } else if (this.model.selectedStack && this.model.isProjectStack(this.model.selectedStack)) {
192
+ this.callbacks.notify(projectStackNotice(this.model.selectedStack), "warning");
193
+ }
194
+ }
195
+ }
196
+
197
+ private handleViewerInput(data: string) {
198
+ const wheel = wheelDirection(data);
199
+ if (wheel !== 0) {
200
+ this.model.moveViewer(wheel, this.viewerTextWidth(), this.viewerRows());
201
+ this.tui.requestRender();
202
+ return;
203
+ }
204
+ // ←/esc (and enter) return to members; →/tab are no-ops, the viewer is the last pane
205
+ if (
206
+ matchesKey(data, Key.escape) ||
207
+ matchesKey(data, Key.enter) ||
208
+ matchesKey(data, Key.left) ||
209
+ data === "h"
210
+ ) {
211
+ this.model.closeViewer();
212
+ this.tui.requestRender();
213
+ } else if (matchesKey(data, Key.down) || data === "j") {
214
+ this.model.moveViewer(1, this.viewerTextWidth(), this.viewerRows());
215
+ this.tui.requestRender();
216
+ } else if (matchesKey(data, Key.up) || data === "k") {
217
+ this.model.moveViewer(-1, this.viewerTextWidth(), this.viewerRows());
218
+ this.tui.requestRender();
219
+ }
220
+ }
221
+
222
+ render(width: number) {
223
+ this.lastWidth = width;
224
+ const rows = this.bodyRows();
225
+ const { leftW, membersW, viewerW } = this.paneWidths(width);
226
+ const stack = this.model.selectedStack;
227
+
228
+ const dirty = this.settingsDirty ? this.theme.fg("warning", " · reload pending") : "";
229
+ const title = `skill stacks (${this.model.stackCount}) · ${
230
+ this.model.activeSkillCount
231
+ }/${this.model.discoveredCount} active${dirty}`;
232
+ const lines = [this.border(width, title, true)];
233
+
234
+ const stackWin = this.model.stackWindow(rows);
235
+ const memberWin = this.model.memberWindow(this.memberRows());
236
+ const viewerWin = this.model.viewerOpen
237
+ ? this.model.viewerWindow(Math.max(1, viewerW - 2), this.viewerRows())
238
+ : undefined;
239
+ const blank = (w: number) => " ".repeat(w);
240
+ const hint = (text: string, w: number) => padToWidth(this.theme.fg("dim", text), w);
241
+
242
+ for (let row = 0; row < rows; row += 1) {
243
+ const stackEntry = stackWin.items[row];
244
+ const left = stackEntry
245
+ ? this.renderStackCell(stackEntry, stackWin.start + row, leftW)
246
+ : blank(leftW);
247
+
248
+ let members: string;
249
+ if (!stack) {
250
+ members = row === 0 ? hint(" no stacks · n creates one", membersW) : blank(membersW);
251
+ } else if (row === 0) {
252
+ members = this.renderStackHeader(stack, membersW);
253
+ } else if (row === 1) {
254
+ members = hint(" members", membersW);
255
+ } else {
256
+ const index = row - 2;
257
+ const entry = memberWin.items[index];
258
+ members = entry
259
+ ? this.renderMemberCell(entry, memberWin.start + index, membersW)
260
+ : index === 0
261
+ ? hint(" (none · a adds skills)", membersW)
262
+ : blank(membersW);
263
+ }
264
+
265
+ let viewer = blank(viewerW);
266
+ if (viewerWin) {
267
+ if (row === 0) {
268
+ viewer = this.renderViewerHeader(viewerW);
269
+ } else if (viewerWin.items.length === 0 && row === 1) {
270
+ viewer = hint(" (no content)", viewerW);
271
+ } else {
272
+ const entry = viewerWin.items[row - 1];
273
+ if (entry !== undefined) viewer = padToWidth(this.theme.fg("text", ` ${entry}`), viewerW);
274
+ }
275
+ }
276
+
277
+ const mid = this.theme.fg(this.model.focus === "members" ? "borderAccent" : "borderMuted", "│");
278
+ const viewerSep = viewerWin
279
+ ? this.theme.fg(this.model.focus === "viewer" ? "borderAccent" : "borderMuted", "│")
280
+ : "";
281
+ const edge = this.theme.fg("borderAccent", "│");
282
+ lines.push(`${edge}${left}${mid}${members}${viewerSep}${viewer}${edge}`);
283
+ }
284
+
285
+ const help = this.model.viewerOpen
286
+ ? this.hintBar([
287
+ ["↑↓", "scroll"],
288
+ ["←", "back"],
289
+ ])
290
+ : this.model.focus === "stacks"
291
+ ? this.hintBar([
292
+ ["↑↓", "select"],
293
+ ["space", "on/off"],
294
+ ["→", "members"],
295
+ ["a", "add skills"],
296
+ ["n", "new stack"],
297
+ ["d", "delete"],
298
+ ["esc", "close"],
299
+ ])
300
+ : this.hintBar([
301
+ ["↑↓", "move"],
302
+ ["space", "remove"],
303
+ ["←/→", "back/view"],
304
+ ["a", "add skills"],
305
+ ]);
306
+ lines.push(this.border(width, help, false));
307
+ return lines;
308
+ }
309
+
310
+ // ---- internals ----
311
+
312
+ private apply() {
313
+ const snapshot = this.model.snapshot();
314
+ this.lastOutcome = this.callbacks.persist(snapshot.stacks, snapshot.disabledStacks);
315
+ this.changed = true;
316
+ this.settingsDirty ||= this.lastOutcome.settingsChanged;
317
+ this.tui.requestRender();
318
+ }
319
+
320
+ /** Run a dialog; while it is open no overlay keys are handled, and a rejected dialog can't wedge the flag. */
321
+ private async withDialog(run: () => Promise<void>) {
322
+ if (this.dialogOpen) return;
323
+ this.dialogOpen = true;
324
+ try {
325
+ await run();
326
+ } catch (error) {
327
+ this.callbacks.notify(`skill-stacks: ${error instanceof Error ? error.message : error}`, "error");
328
+ } finally {
329
+ this.dialogOpen = false;
330
+ this.tui.requestRender();
331
+ }
332
+ }
333
+
334
+ private openNewStackDialog() {
335
+ void this.withDialog(async () => {
336
+ const name = (await this.callbacks.input("New stack name", "e.g. writing"))?.trim();
337
+ if (!name) return;
338
+ if (!this.model.createStack(name)) {
339
+ this.callbacks.notify(`Stack "${name}" already exists`, "warning");
340
+ return;
341
+ }
342
+ this.apply();
343
+ });
344
+ }
345
+
346
+ private openDeleteDialog() {
347
+ const name = this.model.selectedStack;
348
+ if (!name) return;
349
+ if (this.model.isProjectStack(name)) {
350
+ this.callbacks.notify(projectStackNotice(name), "warning");
351
+ return;
352
+ }
353
+ const count = this.model.membersOf(name).length;
354
+ void this.withDialog(async () => {
355
+ const confirmed = await this.callbacks.confirm(
356
+ "Delete stack",
357
+ `Remove "${name}" and its ${count} skill assignments?`,
358
+ );
359
+ if (confirmed && this.model.deleteSelectedStack() === "deleted") this.apply();
360
+ });
361
+ }
362
+
363
+ /** `a`: pick from the skills no stack holds yet and add them to the selected stack. */
364
+ private openAddDialog() {
365
+ const name = this.model.selectedStack;
366
+ if (!name) return;
367
+ if (this.model.isProjectStack(name)) {
368
+ this.callbacks.notify(projectStackNotice(name), "warning");
369
+ return;
370
+ }
371
+ const unstacked = this.model.unstackedSkills();
372
+ if (unstacked.length === 0) {
373
+ this.callbacks.notify("Every discovered skill is already in a stack", "info");
374
+ return;
375
+ }
376
+ void this.withDialog(async () => {
377
+ const picked = await this.callbacks.pick(`Add to ${name} · ${unstacked.length} unstacked`, unstacked);
378
+ if (picked && this.model.addSkills(picked) === "added") this.apply();
379
+ });
380
+ }
381
+
382
+ private bodyRows() {
383
+ return Math.max(8, Math.floor(this.tui.terminal.rows * 0.8) - 2);
384
+ }
385
+
386
+ /** Right pane: 1 stack header row + 1 "members" label row, then the list. */
387
+ private memberRows() {
388
+ return Math.max(1, this.bodyRows() - 2);
389
+ }
390
+
391
+ /** Viewer pane: 1 skill-name header row, then the wrapped markdown. */
392
+ private viewerRows() {
393
+ return Math.max(1, this.bodyRows() - 1);
394
+ }
395
+
396
+ private viewerTextWidth() {
397
+ return Math.max(1, this.paneWidths(this.lastWidth).viewerW - 2);
398
+ }
399
+
400
+ private paneWidths(width: number) {
401
+ const leftW = Math.min(30, Math.max(18, Math.floor(width * 0.3)));
402
+ if (!this.model.viewerOpen) {
403
+ return { leftW, membersW: Math.max(1, width - leftW - 3), viewerW: 0 };
404
+ }
405
+ // edge + left + │ + members + │ + viewer + edge = width (four vertical bars)
406
+ const remaining = Math.max(0, width - leftW - 4);
407
+ const viewerW = Math.max(10, Math.floor(remaining * 0.6));
408
+ const membersW = Math.max(1, remaining - viewerW);
409
+ return { leftW, membersW, viewerW };
410
+ }
411
+
412
+ private border(width: number, label: string, top: boolean) {
413
+ return frameEdge(this.theme, width, label, top);
414
+ }
415
+
416
+ /** ` [↑↓] select · [space] on/off`: bracketed keys in plain text, labels dim. */
417
+ private hintBar(parts: Array<readonly [string, string]>) {
418
+ const sep = this.theme.fg("dim", " · ");
419
+ return parts
420
+ .map(([key, label]) => `${this.theme.fg("text", `[${key}]`)} ${this.theme.fg("dim", label)}`)
421
+ .join(sep);
422
+ }
423
+
424
+ private renderStackHeader(stack: string, width: number) {
425
+ const row = this.model.stackRow(stack);
426
+ const header = ` ${stack} · ${row.found}/${row.total} skills${row.enabled ? "" : " · off"}${
427
+ row.project ? " · project-defined" : ""
428
+ }`;
429
+ return padToWidth(this.theme.fg(row.enabled ? "accent" : "muted", this.theme.bold(header)), width);
430
+ }
431
+
432
+ private selectedBg(row: string, pane: "stacks" | "members") {
433
+ return this.theme.bg(this.model.focus === pane ? "selectedBg" : "customMessageBg", row);
434
+ }
435
+
436
+ private renderStackCell(entry: StackRow, index: number, leftW: number) {
437
+ const selected = index === this.model.stackIndex;
438
+ const focused = selected && this.model.focus === "stacks";
439
+ const count = entry.found === entry.total ? `${entry.total}` : `${entry.found}/${entry.total}`;
440
+ const projectTag = entry.project ? " ·proj" : "";
441
+ const tone = entry.enabled ? "text" : "muted";
442
+
443
+ const nameWidth = Math.max(1, leftW - CELL_PREFIX_WIDTH - projectTag.length - count.length - 2);
444
+ const shown = truncateToWidth(entry.name, nameWidth, "…");
445
+ const label =
446
+ (focused ? CURSOR : NO_CURSOR) +
447
+ this.theme.fg(tone, entry.enabled ? "[x]" : "[ ]") +
448
+ " " +
449
+ this.theme.fg(focused ? "accent" : tone, shown) +
450
+ this.theme.fg("dim", projectTag);
451
+ const gap = Math.max(1, leftW - visibleWidth(label) - count.length);
452
+ const row = padToWidth(`${label}${" ".repeat(gap)}${this.theme.fg("dim", count)}`, leftW);
453
+ return selected ? this.selectedBg(row, "stacks") : row;
454
+ }
455
+
456
+ private renderMemberCell(entry: MemberRow, index: number, width: number) {
457
+ const selected = index === this.model.memberIndex;
458
+ const focused = selected && this.model.focus === "members";
459
+ const suffixText = entry.missing ? " missing" : entry.active ? "" : " · excluded";
460
+ const suffix = entry.missing
461
+ ? this.theme.fg("warning", suffixText)
462
+ : this.theme.fg("dim", suffixText);
463
+ const nameWidth = Math.max(1, width - CELL_PREFIX_WIDTH - suffixText.length);
464
+ const shown = truncateToWidth(entry.name, nameWidth, "…");
465
+ const tone = entry.missing ? "warning" : entry.active ? "text" : "muted";
466
+ const row = padToWidth(`${focused ? CURSOR : NO_CURSOR}[x] ${this.theme.fg(tone, shown)}${suffix}`, width);
467
+ return selected ? this.selectedBg(row, "members") : row;
468
+ }
469
+
470
+ private renderViewerHeader(viewerW: number) {
471
+ const name = this.model.selectedMember ?? "";
472
+ return padToWidth(this.theme.fg("accent", this.theme.bold(` ${name}`)), viewerW);
473
+ }
474
+
475
+ }
476
+
477
+ export async function showStacksOverlay(
478
+ ctx: ExtensionCommandContext,
479
+ init: StacksOverlayInit,
480
+ persist: StacksPersist,
481
+ ): Promise<OverlayResult> {
482
+ if (ctx.mode !== "tui") return { changed: false, settingsDirty: false, outcome: null };
483
+
484
+ // pi's ctx.ui.input/confirm render in the main layout, underneath a visible
485
+ // overlay. Our dialogs are overlays themselves so they stack on top of /stacks.
486
+ const dialogOptions = {
487
+ overlay: true,
488
+ overlayOptions: { anchor: "center", width: 60, minWidth: 44 } satisfies OverlayOptions,
489
+ };
490
+
491
+ return await ctx.ui.custom<OverlayResult>(
492
+ (tui, theme, _kb, done) =>
493
+ new StacksOverlay(tui, theme, init, {
494
+ persist,
495
+ notify: (message, type) => ctx.ui.notify(message, type),
496
+ input: (title, placeholder) =>
497
+ ctx.ui.custom<string | undefined>(
498
+ (_tui, theme, _kb, done) => new PromptDialog(theme, title, placeholder, done),
499
+ dialogOptions,
500
+ ),
501
+ confirm: (title, message) =>
502
+ ctx.ui.custom<boolean>(
503
+ (_tui, theme, _kb, done) => new ConfirmDialog(theme, title, message, done),
504
+ dialogOptions,
505
+ ),
506
+ pick: (title, items) =>
507
+ ctx.ui.custom<string[] | undefined>(
508
+ (_tui, theme, _kb, done) => new PickDialog(theme, title, items, done),
509
+ dialogOptions,
510
+ ),
511
+ done,
512
+ }),
513
+ {
514
+ overlay: true,
515
+ overlayOptions: {
516
+ anchor: "center",
517
+ width: "90%",
518
+ minWidth: 76,
519
+ maxHeight: "90%",
520
+ margin: 1,
521
+ },
522
+ },
523
+ );
524
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "pi-skill-stacks",
3
+ "version": "0.4.1",
4
+ "description": "Pi package that groups skills into named stacks you can manage from a /stacks overlay — toggle stacks on/off and re-stack skills without editing JSON. Exclusions are written through pi's own settings override mechanism, and a compact [Skills] header line replaces the per-scope listing.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "extension",
9
+ "skills",
10
+ "context",
11
+ "skill-stacks"
12
+ ],
13
+ "license": "MIT",
14
+ "type": "module",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/oscabriel/pi-skill-stacks.git"
18
+ },
19
+ "files": [
20
+ "extensions",
21
+ "src",
22
+ "README.md"
23
+ ],
24
+ "peerDependencies": {
25
+ "@earendil-works/pi-coding-agent": "*",
26
+ "@earendil-works/pi-tui": "*"
27
+ },
28
+ "pi": {
29
+ "extensions": [
30
+ "./extensions/index.ts",
31
+ "./extensions/header.ts"
32
+ ]
33
+ },
34
+ "devDependencies": {
35
+ "@earendil-works/pi-coding-agent": "^0.84.4",
36
+ "@earendil-works/pi-tui": "^0.84.4",
37
+ "@types/node": "^26.4.1",
38
+ "typescript": "^7.0.2"
39
+ },
40
+ "scripts": {
41
+ "test": "node --test test/*.test.ts",
42
+ "check": "tsc -p tsconfig.json"
43
+ }
44
+ }