@dealdeploy/skl 1.0.0 → 1.1.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/add-tui.test.ts +338 -0
- package/add-tui.ts +269 -0
- package/add.ts +68 -306
- package/index.ts +18 -20
- package/lib.test.ts +446 -85
- package/lib.ts +116 -0
- package/package.json +1 -1
- package/skills-lock.json +10 -0
- package/update.ts +28 -43
package/add-tui.test.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { createTestRenderer } from "@opentui/core/testing";
|
|
3
|
+
import { createAddTui, type AddSkillEntry } from "./add-tui.ts";
|
|
4
|
+
|
|
5
|
+
const WIDTH = 80;
|
|
6
|
+
const HEIGHT = 15;
|
|
7
|
+
|
|
8
|
+
function makeSkills(...names: string[]): AddSkillEntry[] {
|
|
9
|
+
return names.map((n) => ({
|
|
10
|
+
name: n,
|
|
11
|
+
prefix: `skills/${n}`,
|
|
12
|
+
treeSHA: `sha-${n}`,
|
|
13
|
+
exists: false,
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function withExists(skills: AddSkillEntry[], ...existingNames: string[]): AddSkillEntry[] {
|
|
18
|
+
return skills.map((s) => ({
|
|
19
|
+
...s,
|
|
20
|
+
exists: existingNames.includes(s.name),
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function setup(skills: AddSkillEntry[], repo = "owner/repo") {
|
|
25
|
+
const confirmCalls: AddSkillEntry[][] = [];
|
|
26
|
+
let cancelCalled = false;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
confirmCalls,
|
|
30
|
+
get cancelCalled() { return cancelCalled; },
|
|
31
|
+
makeDeps() {
|
|
32
|
+
return {
|
|
33
|
+
repo,
|
|
34
|
+
skills,
|
|
35
|
+
async onConfirm(selected: AddSkillEntry[]) {
|
|
36
|
+
confirmCalls.push(selected);
|
|
37
|
+
},
|
|
38
|
+
onCancel() {
|
|
39
|
+
cancelCalled = true;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("renders repo name and skill names", async () => {
|
|
47
|
+
const skills = makeSkills("alpha", "beta");
|
|
48
|
+
const ctx = setup(skills, "vercel-labs/agent-skills");
|
|
49
|
+
const { renderer, renderOnce, captureCharFrame } =
|
|
50
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
51
|
+
|
|
52
|
+
createAddTui(renderer, ctx.makeDeps());
|
|
53
|
+
await renderOnce();
|
|
54
|
+
|
|
55
|
+
const frame = captureCharFrame();
|
|
56
|
+
expect(frame).toContain("vercel-labs/agent-skills");
|
|
57
|
+
expect(frame).toContain("alpha");
|
|
58
|
+
expect(frame).toContain("beta");
|
|
59
|
+
|
|
60
|
+
renderer.destroy();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("existing skills show [*] and (exists) label", async () => {
|
|
64
|
+
const skills = withExists(makeSkills("alpha", "beta"), "alpha");
|
|
65
|
+
const ctx = setup(skills);
|
|
66
|
+
const { renderer, renderOnce, captureCharFrame } =
|
|
67
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
68
|
+
|
|
69
|
+
createAddTui(renderer, ctx.makeDeps());
|
|
70
|
+
await renderOnce();
|
|
71
|
+
|
|
72
|
+
const frame = captureCharFrame();
|
|
73
|
+
const lines = frame.split("\n");
|
|
74
|
+
const alphaLine = lines.find((l) => l.includes("alpha"))!;
|
|
75
|
+
const betaLine = lines.find((l) => l.includes("beta"))!;
|
|
76
|
+
|
|
77
|
+
expect(alphaLine).toContain("[*]");
|
|
78
|
+
expect(alphaLine).toContain("(exists)");
|
|
79
|
+
expect(betaLine).toContain("[ ]");
|
|
80
|
+
expect(betaLine).not.toContain("(exists)");
|
|
81
|
+
|
|
82
|
+
renderer.destroy();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("cursor starts on first addable skill, skipping existing", async () => {
|
|
86
|
+
const skills = withExists(makeSkills("alpha", "beta", "gamma"), "alpha");
|
|
87
|
+
const ctx = setup(skills);
|
|
88
|
+
const { renderer, renderOnce } =
|
|
89
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
90
|
+
|
|
91
|
+
const tui = createAddTui(renderer, ctx.makeDeps());
|
|
92
|
+
await renderOnce();
|
|
93
|
+
|
|
94
|
+
// alpha exists, so cursor should start on beta (index 1)
|
|
95
|
+
expect(tui.state.cursor).toBe(1);
|
|
96
|
+
|
|
97
|
+
renderer.destroy();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("j/down moves cursor down, k/up moves cursor up", async () => {
|
|
101
|
+
const skills = makeSkills("alpha", "beta", "gamma");
|
|
102
|
+
const ctx = setup(skills);
|
|
103
|
+
const { renderer, mockInput, renderOnce } =
|
|
104
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
105
|
+
|
|
106
|
+
const tui = createAddTui(renderer, ctx.makeDeps());
|
|
107
|
+
await renderOnce();
|
|
108
|
+
expect(tui.state.cursor).toBe(0);
|
|
109
|
+
|
|
110
|
+
mockInput.pressKey("j");
|
|
111
|
+
await renderOnce();
|
|
112
|
+
expect(tui.state.cursor).toBe(1);
|
|
113
|
+
|
|
114
|
+
mockInput.pressArrow("down");
|
|
115
|
+
await renderOnce();
|
|
116
|
+
expect(tui.state.cursor).toBe(2);
|
|
117
|
+
|
|
118
|
+
// Can't go past end
|
|
119
|
+
mockInput.pressKey("j");
|
|
120
|
+
await renderOnce();
|
|
121
|
+
expect(tui.state.cursor).toBe(2);
|
|
122
|
+
|
|
123
|
+
mockInput.pressKey("k");
|
|
124
|
+
await renderOnce();
|
|
125
|
+
expect(tui.state.cursor).toBe(1);
|
|
126
|
+
|
|
127
|
+
mockInput.pressArrow("up");
|
|
128
|
+
await renderOnce();
|
|
129
|
+
expect(tui.state.cursor).toBe(0);
|
|
130
|
+
|
|
131
|
+
// Can't go before start
|
|
132
|
+
mockInput.pressKey("k");
|
|
133
|
+
await renderOnce();
|
|
134
|
+
expect(tui.state.cursor).toBe(0);
|
|
135
|
+
|
|
136
|
+
renderer.destroy();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("space toggles selection on addable skill", async () => {
|
|
140
|
+
const skills = makeSkills("alpha", "beta");
|
|
141
|
+
const ctx = setup(skills);
|
|
142
|
+
const { renderer, mockInput, renderOnce, captureCharFrame } =
|
|
143
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
144
|
+
|
|
145
|
+
const tui = createAddTui(renderer, ctx.makeDeps());
|
|
146
|
+
await renderOnce();
|
|
147
|
+
|
|
148
|
+
// Toggle alpha on
|
|
149
|
+
mockInput.pressKey(" ");
|
|
150
|
+
await renderOnce();
|
|
151
|
+
expect(tui.state.checked.has(0)).toBe(true);
|
|
152
|
+
|
|
153
|
+
const frame = captureCharFrame();
|
|
154
|
+
const lines = frame.split("\n");
|
|
155
|
+
const alphaLine = lines.find((l) => l.includes("alpha"))!;
|
|
156
|
+
expect(alphaLine).toContain("[x]");
|
|
157
|
+
|
|
158
|
+
// Toggle alpha off
|
|
159
|
+
mockInput.pressKey(" ");
|
|
160
|
+
await renderOnce();
|
|
161
|
+
expect(tui.state.checked.has(0)).toBe(false);
|
|
162
|
+
|
|
163
|
+
renderer.destroy();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("space on existing skill does not toggle", async () => {
|
|
167
|
+
const skills = withExists(makeSkills("alpha", "beta"), "alpha", "beta");
|
|
168
|
+
const ctx = setup(skills);
|
|
169
|
+
const { renderer, mockInput, renderOnce, captureCharFrame } =
|
|
170
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
171
|
+
|
|
172
|
+
const tui = createAddTui(renderer, ctx.makeDeps());
|
|
173
|
+
await renderOnce();
|
|
174
|
+
|
|
175
|
+
mockInput.pressKey(" ");
|
|
176
|
+
await renderOnce();
|
|
177
|
+
|
|
178
|
+
expect(tui.state.checked.size).toBe(0);
|
|
179
|
+
const frame = captureCharFrame();
|
|
180
|
+
expect(frame).toContain("already exists");
|
|
181
|
+
|
|
182
|
+
renderer.destroy();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("'a' selects all addable skills, toggles off if all selected", async () => {
|
|
186
|
+
const skills = withExists(makeSkills("alpha", "beta", "gamma"), "alpha");
|
|
187
|
+
const ctx = setup(skills);
|
|
188
|
+
const { renderer, mockInput, renderOnce, captureCharFrame } =
|
|
189
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
190
|
+
|
|
191
|
+
const tui = createAddTui(renderer, ctx.makeDeps());
|
|
192
|
+
await renderOnce();
|
|
193
|
+
|
|
194
|
+
// Select all addable
|
|
195
|
+
mockInput.pressKey("a");
|
|
196
|
+
await renderOnce();
|
|
197
|
+
|
|
198
|
+
// beta (1) and gamma (2) should be checked, alpha (0) should not
|
|
199
|
+
expect(tui.state.checked.has(0)).toBe(false);
|
|
200
|
+
expect(tui.state.checked.has(1)).toBe(true);
|
|
201
|
+
expect(tui.state.checked.has(2)).toBe(true);
|
|
202
|
+
|
|
203
|
+
const frame = captureCharFrame();
|
|
204
|
+
expect(frame).toContain("2 selected");
|
|
205
|
+
|
|
206
|
+
// Toggle all off
|
|
207
|
+
mockInput.pressKey("a");
|
|
208
|
+
await renderOnce();
|
|
209
|
+
expect(tui.state.checked.size).toBe(0);
|
|
210
|
+
|
|
211
|
+
renderer.destroy();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("enter with selection calls onConfirm with selected skills", async () => {
|
|
215
|
+
const skills = makeSkills("alpha", "beta", "gamma");
|
|
216
|
+
const ctx = setup(skills);
|
|
217
|
+
const { renderer, mockInput, renderOnce } =
|
|
218
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
219
|
+
|
|
220
|
+
createAddTui(renderer, ctx.makeDeps());
|
|
221
|
+
await renderOnce();
|
|
222
|
+
|
|
223
|
+
// Select alpha and gamma
|
|
224
|
+
mockInput.pressKey(" ");
|
|
225
|
+
await renderOnce();
|
|
226
|
+
mockInput.pressKey("j");
|
|
227
|
+
mockInput.pressKey("j");
|
|
228
|
+
await renderOnce();
|
|
229
|
+
mockInput.pressKey(" ");
|
|
230
|
+
await renderOnce();
|
|
231
|
+
|
|
232
|
+
mockInput.pressEnter();
|
|
233
|
+
await renderOnce();
|
|
234
|
+
|
|
235
|
+
expect(ctx.confirmCalls.length).toBe(1);
|
|
236
|
+
const selected = ctx.confirmCalls[0]!;
|
|
237
|
+
expect(selected.length).toBe(2);
|
|
238
|
+
expect(selected.map((s) => s.name).sort()).toEqual(["alpha", "gamma"]);
|
|
239
|
+
|
|
240
|
+
renderer.destroy();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("enter with nothing selected shows warning", async () => {
|
|
244
|
+
const skills = makeSkills("alpha");
|
|
245
|
+
const ctx = setup(skills);
|
|
246
|
+
const { renderer, mockInput, renderOnce, captureCharFrame } =
|
|
247
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
248
|
+
|
|
249
|
+
createAddTui(renderer, ctx.makeDeps());
|
|
250
|
+
await renderOnce();
|
|
251
|
+
|
|
252
|
+
mockInput.pressEnter();
|
|
253
|
+
await renderOnce();
|
|
254
|
+
|
|
255
|
+
expect(ctx.confirmCalls.length).toBe(0);
|
|
256
|
+
const frame = captureCharFrame();
|
|
257
|
+
expect(frame).toContain("Nothing selected");
|
|
258
|
+
|
|
259
|
+
renderer.destroy();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("q calls onCancel", async () => {
|
|
263
|
+
const skills = makeSkills("alpha");
|
|
264
|
+
const ctx = setup(skills);
|
|
265
|
+
const { renderer, mockInput, renderOnce } =
|
|
266
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
267
|
+
|
|
268
|
+
createAddTui(renderer, ctx.makeDeps());
|
|
269
|
+
await renderOnce();
|
|
270
|
+
|
|
271
|
+
mockInput.pressKey("q");
|
|
272
|
+
await renderOnce();
|
|
273
|
+
|
|
274
|
+
expect(ctx.cancelCalled).toBe(true);
|
|
275
|
+
|
|
276
|
+
renderer.destroy();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("escape calls onCancel", async () => {
|
|
280
|
+
const skills = makeSkills("alpha");
|
|
281
|
+
const ctx = setup(skills);
|
|
282
|
+
const { renderer, mockInput, renderOnce } =
|
|
283
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
284
|
+
|
|
285
|
+
createAddTui(renderer, ctx.makeDeps());
|
|
286
|
+
await renderOnce();
|
|
287
|
+
|
|
288
|
+
mockInput.pressEscape();
|
|
289
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
290
|
+
await renderOnce();
|
|
291
|
+
|
|
292
|
+
expect(ctx.cancelCalled).toBe(true);
|
|
293
|
+
|
|
294
|
+
renderer.destroy();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("header updates with selection count", async () => {
|
|
298
|
+
const skills = makeSkills("alpha", "beta");
|
|
299
|
+
const ctx = setup(skills, "owner/repo");
|
|
300
|
+
const { renderer, mockInput, renderOnce, captureCharFrame } =
|
|
301
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
302
|
+
|
|
303
|
+
createAddTui(renderer, ctx.makeDeps());
|
|
304
|
+
await renderOnce();
|
|
305
|
+
|
|
306
|
+
const frame0 = captureCharFrame();
|
|
307
|
+
expect(frame0).toContain("0 selected");
|
|
308
|
+
|
|
309
|
+
mockInput.pressKey(" ");
|
|
310
|
+
await renderOnce();
|
|
311
|
+
|
|
312
|
+
const frame1 = captureCharFrame();
|
|
313
|
+
expect(frame1).toContain("1 selected");
|
|
314
|
+
|
|
315
|
+
mockInput.pressKey("j");
|
|
316
|
+
await renderOnce();
|
|
317
|
+
mockInput.pressKey(" ");
|
|
318
|
+
await renderOnce();
|
|
319
|
+
|
|
320
|
+
const frame2 = captureCharFrame();
|
|
321
|
+
expect(frame2).toContain("2 selected");
|
|
322
|
+
|
|
323
|
+
renderer.destroy();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("renders without crashing with empty skills list", async () => {
|
|
327
|
+
const ctx = setup([]);
|
|
328
|
+
const { renderer, renderOnce, captureCharFrame } =
|
|
329
|
+
await createTestRenderer({ width: WIDTH, height: HEIGHT });
|
|
330
|
+
|
|
331
|
+
createAddTui(renderer, ctx.makeDeps());
|
|
332
|
+
await renderOnce();
|
|
333
|
+
|
|
334
|
+
const frame = captureCharFrame();
|
|
335
|
+
expect(frame).toContain("owner/repo");
|
|
336
|
+
|
|
337
|
+
renderer.destroy();
|
|
338
|
+
});
|
package/add-tui.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
TextRenderable,
|
|
4
|
+
ScrollBoxRenderable,
|
|
5
|
+
TextAttributes,
|
|
6
|
+
type KeyEvent,
|
|
7
|
+
type CliRenderer,
|
|
8
|
+
} from "@opentui/core";
|
|
9
|
+
|
|
10
|
+
export type AddSkillEntry = {
|
|
11
|
+
name: string;
|
|
12
|
+
prefix: string;
|
|
13
|
+
treeSHA: string;
|
|
14
|
+
exists: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type AddTuiDeps = {
|
|
18
|
+
repo: string;
|
|
19
|
+
skills: AddSkillEntry[];
|
|
20
|
+
onConfirm: (selected: AddSkillEntry[]) => Promise<void>;
|
|
21
|
+
onCancel: () => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function createAddTui(renderer: CliRenderer, deps: AddTuiDeps) {
|
|
25
|
+
const { repo, skills } = deps;
|
|
26
|
+
|
|
27
|
+
const C = {
|
|
28
|
+
bg: "#1a1a2e",
|
|
29
|
+
rowBg: "#1a1a2e",
|
|
30
|
+
rowAltBg: "#1f1f38",
|
|
31
|
+
cursorBg: "#2a2a5a",
|
|
32
|
+
border: "#444477",
|
|
33
|
+
fg: "#ccccdd",
|
|
34
|
+
fgDim: "#666688",
|
|
35
|
+
checked: "#66ff88",
|
|
36
|
+
unchecked: "#555566",
|
|
37
|
+
warning: "#ffaa44",
|
|
38
|
+
accent: "#8888ff",
|
|
39
|
+
title: "#aaaaff",
|
|
40
|
+
footer: "#888899",
|
|
41
|
+
statusOk: "#66ff88",
|
|
42
|
+
statusErr: "#ff6666",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
let cursor = 0;
|
|
46
|
+
const checked = new Set<number>();
|
|
47
|
+
let statusTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
48
|
+
|
|
49
|
+
// Start cursor on first addable skill
|
|
50
|
+
for (let i = 0; i < skills.length; i++) {
|
|
51
|
+
if (!skills[i]!.exists) { cursor = i; break; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const outer = new BoxRenderable(renderer, {
|
|
55
|
+
id: "outer",
|
|
56
|
+
width: "100%",
|
|
57
|
+
height: "100%",
|
|
58
|
+
flexDirection: "column",
|
|
59
|
+
backgroundColor: C.bg,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const header = new TextRenderable(renderer, {
|
|
63
|
+
id: "header",
|
|
64
|
+
content: ` Add skills from ${repo}`,
|
|
65
|
+
fg: C.title,
|
|
66
|
+
attributes: TextAttributes.BOLD,
|
|
67
|
+
height: 1,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const sep = new TextRenderable(renderer, {
|
|
71
|
+
id: "sep",
|
|
72
|
+
content: "\u2500".repeat(60),
|
|
73
|
+
fg: C.border,
|
|
74
|
+
height: 1,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const scrollBox = new ScrollBoxRenderable(renderer, {
|
|
78
|
+
id: "skill-list",
|
|
79
|
+
flexGrow: 1,
|
|
80
|
+
width: "100%",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
type RowRefs = {
|
|
84
|
+
row: BoxRenderable;
|
|
85
|
+
checkText: TextRenderable;
|
|
86
|
+
nameText: TextRenderable;
|
|
87
|
+
};
|
|
88
|
+
const rows: RowRefs[] = [];
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < skills.length; i++) {
|
|
91
|
+
const skill = skills[i]!;
|
|
92
|
+
|
|
93
|
+
const row = new BoxRenderable(renderer, {
|
|
94
|
+
id: `row-${i}`,
|
|
95
|
+
flexDirection: "row",
|
|
96
|
+
height: 1,
|
|
97
|
+
width: "100%",
|
|
98
|
+
paddingLeft: 1,
|
|
99
|
+
backgroundColor: i % 2 === 0 ? C.rowBg : C.rowAltBg,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const checkText = new TextRenderable(renderer, {
|
|
103
|
+
id: `check-${i}`,
|
|
104
|
+
content: skill.exists ? "[*]" : "[ ]",
|
|
105
|
+
fg: skill.exists ? C.fgDim : C.unchecked,
|
|
106
|
+
width: 4,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const label = skill.exists ? `${skill.name} (exists)` : skill.name;
|
|
110
|
+
const nameText = new TextRenderable(renderer, {
|
|
111
|
+
id: `name-${i}`,
|
|
112
|
+
content: ` ${label}`,
|
|
113
|
+
fg: skill.exists ? C.fgDim : C.fg,
|
|
114
|
+
width: 40,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
row.add(checkText);
|
|
118
|
+
row.add(nameText);
|
|
119
|
+
scrollBox.add(row);
|
|
120
|
+
rows.push({ row, checkText, nameText });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const footerSep = new TextRenderable(renderer, {
|
|
124
|
+
id: "footer-sep",
|
|
125
|
+
content: "\u2500".repeat(60),
|
|
126
|
+
fg: C.border,
|
|
127
|
+
height: 1,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const footer = new TextRenderable(renderer, {
|
|
131
|
+
id: "footer",
|
|
132
|
+
content: " j/k move space toggle a all enter confirm q cancel",
|
|
133
|
+
fg: C.footer,
|
|
134
|
+
height: 1,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const statusLine = new TextRenderable(renderer, {
|
|
138
|
+
id: "status",
|
|
139
|
+
content: "",
|
|
140
|
+
fg: C.statusOk,
|
|
141
|
+
height: 1,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
outer.add(header);
|
|
145
|
+
outer.add(sep);
|
|
146
|
+
outer.add(scrollBox);
|
|
147
|
+
outer.add(footerSep);
|
|
148
|
+
outer.add(footer);
|
|
149
|
+
outer.add(statusLine);
|
|
150
|
+
renderer.root.add(outer);
|
|
151
|
+
|
|
152
|
+
function updateRow(i: number) {
|
|
153
|
+
const skill = skills[i]!;
|
|
154
|
+
const r = rows[i]!;
|
|
155
|
+
const isCursor = cursor === i;
|
|
156
|
+
|
|
157
|
+
const baseBg = i % 2 === 0 ? C.rowBg : C.rowAltBg;
|
|
158
|
+
r.row.backgroundColor = isCursor ? C.cursorBg : baseBg;
|
|
159
|
+
|
|
160
|
+
if (skill.exists) {
|
|
161
|
+
r.checkText.content = "[*]";
|
|
162
|
+
r.checkText.fg = C.fgDim;
|
|
163
|
+
const pointer = isCursor ? "\u25b8" : " ";
|
|
164
|
+
r.nameText.content = `${pointer} ${skill.name} (exists)`;
|
|
165
|
+
r.nameText.fg = C.fgDim;
|
|
166
|
+
r.nameText.attributes = isCursor ? TextAttributes.BOLD : TextAttributes.NONE;
|
|
167
|
+
} else {
|
|
168
|
+
const isChecked = checked.has(i);
|
|
169
|
+
r.checkText.content = isChecked ? "[x]" : "[ ]";
|
|
170
|
+
r.checkText.fg = isCursor ? C.accent : (isChecked ? C.checked : C.unchecked);
|
|
171
|
+
const pointer = isCursor ? "\u25b8" : " ";
|
|
172
|
+
r.nameText.content = `${pointer} ${skill.name}`;
|
|
173
|
+
r.nameText.fg = isCursor ? "#ffffff" : C.fg;
|
|
174
|
+
r.nameText.attributes = isCursor ? TextAttributes.BOLD : TextAttributes.NONE;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function setStatus(msg: string, color: string) {
|
|
179
|
+
statusLine.content = ` ${msg}`;
|
|
180
|
+
statusLine.fg = color;
|
|
181
|
+
if (statusTimeout) clearTimeout(statusTimeout);
|
|
182
|
+
statusTimeout = setTimeout(() => {
|
|
183
|
+
statusLine.content = "";
|
|
184
|
+
}, 3000);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function updateHeader() {
|
|
188
|
+
header.content = ` Add skills from ${repo} (${checked.size} selected)`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function refreshAll() {
|
|
192
|
+
for (let i = 0; i < skills.length; i++) updateRow(i);
|
|
193
|
+
updateHeader();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function ensureVisible() {
|
|
197
|
+
scrollBox.scrollTo(Math.max(0, cursor - 2));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
refreshAll();
|
|
201
|
+
|
|
202
|
+
renderer.keyInput.on("keypress", (key: KeyEvent) => {
|
|
203
|
+
const prevCursor = cursor;
|
|
204
|
+
|
|
205
|
+
switch (key.name) {
|
|
206
|
+
case "j":
|
|
207
|
+
case "down":
|
|
208
|
+
if (cursor < skills.length - 1) cursor++;
|
|
209
|
+
break;
|
|
210
|
+
case "k":
|
|
211
|
+
case "up":
|
|
212
|
+
if (cursor > 0) cursor--;
|
|
213
|
+
break;
|
|
214
|
+
case "space": {
|
|
215
|
+
if (skills[cursor]!.exists) {
|
|
216
|
+
setStatus(`${skills[cursor]!.name} already exists`, C.warning);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
if (checked.has(cursor)) {
|
|
220
|
+
checked.delete(cursor);
|
|
221
|
+
} else {
|
|
222
|
+
checked.add(cursor);
|
|
223
|
+
}
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case "a": {
|
|
227
|
+
const addable = skills
|
|
228
|
+
.map((s, i) => (!s.exists ? i : -1))
|
|
229
|
+
.filter((i) => i >= 0);
|
|
230
|
+
const allChecked = addable.every((i) => checked.has(i));
|
|
231
|
+
if (allChecked) {
|
|
232
|
+
for (const i of addable) checked.delete(i);
|
|
233
|
+
} else {
|
|
234
|
+
for (const i of addable) checked.add(i);
|
|
235
|
+
}
|
|
236
|
+
for (const i of addable) updateRow(i);
|
|
237
|
+
updateHeader();
|
|
238
|
+
ensureVisible();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
case "return": {
|
|
242
|
+
const selected = [...checked].map((i) => skills[i]!);
|
|
243
|
+
if (selected.length === 0) {
|
|
244
|
+
setStatus("Nothing selected \u2014 use space to toggle", C.warning);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
deps.onConfirm(selected);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
case "q":
|
|
251
|
+
case "escape":
|
|
252
|
+
deps.onCancel();
|
|
253
|
+
return;
|
|
254
|
+
default:
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (prevCursor !== cursor) updateRow(prevCursor);
|
|
259
|
+
updateRow(cursor);
|
|
260
|
+
updateHeader();
|
|
261
|
+
ensureVisible();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
get state() {
|
|
266
|
+
return { cursor, checked };
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|