@dealdeploy/skl 0.3.0 → 1.0.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.
Files changed (8) hide show
  1. package/add.ts +55 -64
  2. package/index.ts +110 -974
  3. package/lib.test.ts +110 -41
  4. package/lib.ts +125 -38
  5. package/package.json +1 -1
  6. package/tui.test.ts +565 -0
  7. package/tui.ts +612 -0
  8. package/update.ts +102 -87
package/tui.test.ts ADDED
@@ -0,0 +1,565 @@
1
+ import { test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { createTestRenderer } from "@opentui/core/testing";
3
+ import { createTui, type ColId } from "./tui.ts";
4
+
5
+ const WIDTH = 80;
6
+ const HEIGHT = 20;
7
+
8
+ type ToggleCall = { col: ColId; name: string; enable: boolean };
9
+ type DeleteCall = { name: string };
10
+
11
+ function setup(opts?: {
12
+ skills?: string[];
13
+ globalInstalled?: string[];
14
+ localInstalled?: string[];
15
+ }) {
16
+ const skills = opts?.skills ?? ["alpha", "beta", "gamma"];
17
+ const globalInstalled = new Set(opts?.globalInstalled ?? []);
18
+ const localInstalled = new Set(opts?.localInstalled ?? []);
19
+
20
+ const toggleCalls: ToggleCall[] = [];
21
+ const deleteCalls: DeleteCall[] = [];
22
+ const editCalls: string[] = [];
23
+ let quitCalled = false;
24
+
25
+ // Toggle resolvers for async control
26
+ let pendingToggleResolve: ((ok: boolean) => void) | null = null;
27
+
28
+ return {
29
+ skills,
30
+ globalInstalled,
31
+ localInstalled,
32
+ toggleCalls,
33
+ deleteCalls,
34
+ editCalls,
35
+ get quitCalled() { return quitCalled; },
36
+ resolveToggle(ok: boolean) {
37
+ pendingToggleResolve?.(ok);
38
+ pendingToggleResolve = null;
39
+ },
40
+ makeDeps() {
41
+ return {
42
+ allSkills: skills,
43
+ globalInstalled,
44
+ localInstalled,
45
+ catalogPath: "/tmp/test-catalog",
46
+ async onToggle(col: ColId, name: string, enable: boolean) {
47
+ toggleCalls.push({ col, name, enable });
48
+ return new Promise<boolean>((resolve) => {
49
+ pendingToggleResolve = resolve;
50
+ });
51
+ },
52
+ async onDelete(name: string) {
53
+ deleteCalls.push({ name });
54
+ },
55
+ async onEdit(name: string) {
56
+ editCalls.push(name);
57
+ },
58
+ onQuit() {
59
+ quitCalled = true;
60
+ },
61
+ };
62
+ },
63
+ // Immediate toggle (resolves instantly)
64
+ makeDepsImmediate(toggleResult = true) {
65
+ return {
66
+ allSkills: skills,
67
+ globalInstalled,
68
+ localInstalled,
69
+ catalogPath: "/tmp/test-catalog",
70
+ async onToggle(col: ColId, name: string, enable: boolean) {
71
+ toggleCalls.push({ col, name, enable });
72
+ return toggleResult;
73
+ },
74
+ async onDelete(name: string) {
75
+ deleteCalls.push({ name });
76
+ },
77
+ async onEdit(name: string) {
78
+ editCalls.push(name);
79
+ },
80
+ onQuit() {
81
+ quitCalled = true;
82
+ },
83
+ };
84
+ },
85
+ };
86
+ }
87
+
88
+ test("renders skill names and column headers", async () => {
89
+ const ctx = setup();
90
+ const { renderer, mockInput, renderOnce, captureCharFrame } =
91
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
92
+
93
+ createTui(renderer, ctx.makeDepsImmediate());
94
+ await renderOnce();
95
+
96
+ const frame = captureCharFrame();
97
+ expect(frame).toContain("Skill");
98
+ expect(frame).toContain("Global");
99
+ expect(frame).toContain("Local");
100
+ expect(frame).toContain("alpha");
101
+ expect(frame).toContain("beta");
102
+ expect(frame).toContain("gamma");
103
+
104
+ renderer.destroy();
105
+ });
106
+
107
+ test("renders checkboxes for installed skills", async () => {
108
+ const ctx = setup({ globalInstalled: ["alpha"], localInstalled: ["beta"] });
109
+ const { renderer, renderOnce, captureCharFrame } =
110
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
111
+
112
+ createTui(renderer, ctx.makeDepsImmediate());
113
+ await renderOnce();
114
+
115
+ const frame = captureCharFrame();
116
+ // alpha should have [x] in global column and [ ] in local
117
+ // beta should have [ ] in global and [x] in local
118
+ // gamma should have [ ] in both
119
+ const lines = frame.split("\n");
120
+ const alphaLine = lines.find((l) => l.includes("alpha"));
121
+ const betaLine = lines.find((l) => l.includes("beta"));
122
+ const gammaLine = lines.find((l) => l.includes("gamma"));
123
+
124
+ expect(alphaLine).toBeDefined();
125
+ expect(betaLine).toBeDefined();
126
+ expect(gammaLine).toBeDefined();
127
+
128
+ // Count [x] occurrences per line
129
+ const countChecked = (line: string) => (line.match(/\[x\]/g) ?? []).length;
130
+ const countUnchecked = (line: string) => (line.match(/\[ \]/g) ?? []).length;
131
+
132
+ expect(countChecked(alphaLine!)).toBe(1); // global checked
133
+ expect(countUnchecked(alphaLine!)).toBe(1); // local unchecked
134
+ expect(countChecked(betaLine!)).toBe(1); // local checked
135
+ expect(countUnchecked(betaLine!)).toBe(1); // global unchecked
136
+ expect(countUnchecked(gammaLine!)).toBe(2); // both unchecked
137
+
138
+ renderer.destroy();
139
+ });
140
+
141
+ test("starts in search focus area", async () => {
142
+ const ctx = setup();
143
+ const { renderer, renderOnce, captureCharFrame } =
144
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
145
+
146
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
147
+ await renderOnce();
148
+
149
+ expect(tui.state.focusArea).toBe("search");
150
+
151
+ const frame = captureCharFrame();
152
+ // Search bar should show cursor indicator
153
+ expect(frame).toContain("\u25b8");
154
+
155
+ renderer.destroy();
156
+ });
157
+
158
+ test("arrow down moves to grid", async () => {
159
+ const ctx = setup();
160
+ const { renderer, mockInput, renderOnce, captureCharFrame } =
161
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
162
+
163
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
164
+ await renderOnce();
165
+
166
+ expect(tui.state.focusArea).toBe("search");
167
+
168
+ mockInput.pressArrow("down");
169
+ await renderOnce();
170
+
171
+ expect(tui.state.focusArea).toBe("grid");
172
+ expect(tui.state.cursor).toBe(0);
173
+
174
+ renderer.destroy();
175
+ });
176
+
177
+ test("arrow down navigates through skills in grid", async () => {
178
+ const ctx = setup();
179
+ const { renderer, mockInput, renderOnce } =
180
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
181
+
182
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
183
+
184
+ // Enter grid
185
+ mockInput.pressArrow("down");
186
+ await renderOnce();
187
+ expect(tui.state.cursor).toBe(0);
188
+ expect(tui.state.currentSkillIndex()).toBe(0);
189
+
190
+ // Move down
191
+ mockInput.pressArrow("down");
192
+ await renderOnce();
193
+ expect(tui.state.cursor).toBe(1);
194
+
195
+ // Move down again
196
+ mockInput.pressArrow("down");
197
+ await renderOnce();
198
+ expect(tui.state.cursor).toBe(2);
199
+
200
+ // Can't move past last skill
201
+ mockInput.pressArrow("down");
202
+ await renderOnce();
203
+ expect(tui.state.cursor).toBe(2);
204
+
205
+ renderer.destroy();
206
+ });
207
+
208
+ test("left/right switches columns", async () => {
209
+ const ctx = setup();
210
+ const { renderer, mockInput, renderOnce } =
211
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
212
+
213
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
214
+
215
+ // Enter grid
216
+ mockInput.pressArrow("down");
217
+ await renderOnce();
218
+ expect(tui.state.cursorCol).toBe("global");
219
+
220
+ mockInput.pressArrow("right");
221
+ await renderOnce();
222
+ expect(tui.state.cursorCol).toBe("local");
223
+
224
+ // Wraps around
225
+ mockInput.pressArrow("right");
226
+ await renderOnce();
227
+ expect(tui.state.cursorCol).toBe("global");
228
+
229
+ mockInput.pressArrow("left");
230
+ await renderOnce();
231
+ expect(tui.state.cursorCol).toBe("local");
232
+
233
+ renderer.destroy();
234
+ });
235
+
236
+ test("search filters skills", async () => {
237
+ const ctx = setup({ skills: ["alpha", "beta", "gamma", "alphabet"] });
238
+ const { renderer, mockInput, renderOnce, captureCharFrame } =
239
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
240
+
241
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
242
+ await renderOnce();
243
+
244
+ // Type "alph" in search
245
+ mockInput.typeText("alph");
246
+ await renderOnce();
247
+
248
+ expect(tui.state.searchQuery).toBe("alph");
249
+ expect(tui.state.filteredIndices.length).toBe(2); // alpha and alphabet
250
+
251
+ const frame = captureCharFrame();
252
+ expect(frame).toContain("alpha");
253
+ expect(frame).toContain("alphabet");
254
+ expect(frame).not.toContain("beta");
255
+ expect(frame).not.toContain("gamma");
256
+
257
+ renderer.destroy();
258
+ });
259
+
260
+ test("escape clears search", async () => {
261
+ const ctx = setup();
262
+ const { renderer, mockInput, renderOnce } =
263
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
264
+
265
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
266
+
267
+ await mockInput.typeText("test");
268
+ await renderOnce();
269
+ expect(tui.state.searchQuery).toBe("test");
270
+
271
+ mockInput.pressEscape();
272
+ // ESC byte needs time to disambiguate from CSI sequence start
273
+ await new Promise((r) => setTimeout(r, 100));
274
+ await renderOnce();
275
+ expect(tui.state.searchQuery).toBe("");
276
+ expect(tui.state.filteredIndices.length).toBe(3); // all skills visible
277
+
278
+ renderer.destroy();
279
+ });
280
+
281
+ test("enter in grid triggers toggle", async () => {
282
+ const ctx = setup();
283
+ const { renderer, mockInput, renderOnce } =
284
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
285
+
286
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
287
+
288
+ // Enter grid, cursor on alpha, global column
289
+ mockInput.pressArrow("down");
290
+ await renderOnce();
291
+
292
+ mockInput.pressEnter();
293
+ await renderOnce();
294
+
295
+ // Should have called onToggle with global, alpha, enable=true
296
+ expect(ctx.toggleCalls.length).toBe(1);
297
+ expect(ctx.toggleCalls[0]).toEqual({ col: "global", name: "alpha", enable: true });
298
+
299
+ renderer.destroy();
300
+ });
301
+
302
+ test("toggle on local column", async () => {
303
+ const ctx = setup();
304
+ const { renderer, mockInput, renderOnce } =
305
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
306
+
307
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
308
+
309
+ // Enter grid
310
+ mockInput.pressArrow("down");
311
+ await renderOnce();
312
+
313
+ // Move to local column
314
+ mockInput.pressArrow("right");
315
+ await renderOnce();
316
+
317
+ // Move to beta
318
+ mockInput.pressArrow("down");
319
+ await renderOnce();
320
+
321
+ mockInput.pressEnter();
322
+ await renderOnce();
323
+
324
+ expect(ctx.toggleCalls.length).toBe(1);
325
+ expect(ctx.toggleCalls[0]).toEqual({ col: "local", name: "beta", enable: true });
326
+
327
+ renderer.destroy();
328
+ });
329
+
330
+ test("toggle off an installed skill", async () => {
331
+ const ctx = setup({ globalInstalled: ["alpha"] });
332
+ const { renderer, mockInput, renderOnce } =
333
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
334
+
335
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
336
+
337
+ // Enter grid on alpha (which is globally installed)
338
+ mockInput.pressArrow("down");
339
+ await renderOnce();
340
+
341
+ mockInput.pressEnter();
342
+ await renderOnce();
343
+
344
+ expect(ctx.toggleCalls[0]).toEqual({ col: "global", name: "alpha", enable: false });
345
+
346
+ renderer.destroy();
347
+ });
348
+
349
+ test("d key prompts for delete, y confirms", async () => {
350
+ const ctx = setup();
351
+ const { renderer, mockInput, renderOnce } =
352
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
353
+
354
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
355
+
356
+ // Enter grid
357
+ mockInput.pressArrow("down");
358
+ await renderOnce();
359
+
360
+ // Press d
361
+ mockInput.pressKey("d");
362
+ await renderOnce();
363
+
364
+ expect(tui.state.pendingDelete).toBe(0);
365
+
366
+ // Confirm with y
367
+ mockInput.pressKey("y");
368
+ await renderOnce();
369
+
370
+ expect(ctx.deleteCalls.length).toBe(1);
371
+ expect(ctx.deleteCalls[0]).toEqual({ name: "alpha" });
372
+ expect(tui.state.pendingDelete).toBeNull();
373
+
374
+ renderer.destroy();
375
+ });
376
+
377
+ test("d key prompts for delete, other key cancels", async () => {
378
+ const ctx = setup();
379
+ const { renderer, mockInput, renderOnce, captureCharFrame } =
380
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
381
+
382
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
383
+
384
+ // Enter grid
385
+ mockInput.pressArrow("down");
386
+ await renderOnce();
387
+
388
+ mockInput.pressKey("d");
389
+ await renderOnce();
390
+ expect(tui.state.pendingDelete).toBe(0);
391
+
392
+ // Cancel with n
393
+ mockInput.pressKey("n");
394
+ await renderOnce();
395
+
396
+ expect(ctx.deleteCalls.length).toBe(0);
397
+ expect(tui.state.pendingDelete).toBeNull();
398
+
399
+ const frame = captureCharFrame();
400
+ expect(frame).toContain("cancelled");
401
+
402
+ renderer.destroy();
403
+ });
404
+
405
+ test("q in grid calls onQuit", async () => {
406
+ const ctx = setup();
407
+ const { renderer, mockInput, renderOnce } =
408
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
409
+
410
+ createTui(renderer, ctx.makeDepsImmediate());
411
+
412
+ // Enter grid
413
+ mockInput.pressArrow("down");
414
+ await renderOnce();
415
+
416
+ mockInput.pressKey("q");
417
+ await renderOnce();
418
+
419
+ expect(ctx.quitCalled).toBe(true);
420
+
421
+ renderer.destroy();
422
+ });
423
+
424
+ test("/ key enters search from grid", async () => {
425
+ const ctx = setup();
426
+ const { renderer, mockInput, renderOnce } =
427
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
428
+
429
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
430
+
431
+ // Enter grid
432
+ mockInput.pressArrow("down");
433
+ await renderOnce();
434
+ expect(tui.state.focusArea).toBe("grid");
435
+
436
+ // Press /
437
+ mockInput.pressKey("/");
438
+ await renderOnce();
439
+ expect(tui.state.focusArea).toBe("search");
440
+
441
+ renderer.destroy();
442
+ });
443
+
444
+ test("up arrow at top of grid returns to search", async () => {
445
+ const ctx = setup();
446
+ const { renderer, mockInput, renderOnce } =
447
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
448
+
449
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
450
+
451
+ // Enter grid
452
+ mockInput.pressArrow("down");
453
+ await renderOnce();
454
+ expect(tui.state.focusArea).toBe("grid");
455
+ expect(tui.state.cursor).toBe(0);
456
+
457
+ // Up at top → search
458
+ mockInput.pressArrow("up");
459
+ await renderOnce();
460
+ expect(tui.state.focusArea).toBe("search");
461
+
462
+ renderer.destroy();
463
+ });
464
+
465
+ test("pending toggle shows [~] indicator", async () => {
466
+ const ctx = setup();
467
+ const { renderer, mockInput, renderOnce, captureCharFrame } =
468
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
469
+
470
+ const tui = createTui(renderer, ctx.makeDeps()); // uses async toggle
471
+
472
+ // Enter grid
473
+ mockInput.pressArrow("down");
474
+ await renderOnce();
475
+
476
+ // Toggle alpha — will pend since we haven't resolved
477
+ mockInput.pressEnter();
478
+ await renderOnce();
479
+
480
+ const frame = captureCharFrame();
481
+ expect(frame).toContain("[~]");
482
+ expect(tui.state.pendingToggles.size).toBe(1);
483
+
484
+ // Resolve the toggle
485
+ ctx.resolveToggle(true);
486
+ // Let the promise settle
487
+ await new Promise((r) => setTimeout(r, 10));
488
+ await renderOnce();
489
+
490
+ const frame2 = captureCharFrame();
491
+ expect(frame2).not.toContain("[~]");
492
+ expect(tui.state.pendingToggles.size).toBe(0);
493
+
494
+ renderer.destroy();
495
+ });
496
+
497
+ test("renders search... placeholder when search empty and not focused", async () => {
498
+ const ctx = setup();
499
+ const { renderer, mockInput, renderOnce, captureCharFrame } =
500
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
501
+
502
+ createTui(renderer, ctx.makeDepsImmediate());
503
+
504
+ // Enter grid (search loses focus)
505
+ mockInput.pressArrow("down");
506
+ await renderOnce();
507
+
508
+ const frame = captureCharFrame();
509
+ expect(frame).toContain("search...");
510
+
511
+ renderer.destroy();
512
+ });
513
+
514
+ test("backspace removes search characters", async () => {
515
+ const ctx = setup();
516
+ const { renderer, mockInput, renderOnce } =
517
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
518
+
519
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
520
+
521
+ mockInput.typeText("abc");
522
+ await renderOnce();
523
+ expect(tui.state.searchQuery).toBe("abc");
524
+
525
+ mockInput.pressBackspace();
526
+ await renderOnce();
527
+ expect(tui.state.searchQuery).toBe("ab");
528
+
529
+ renderer.destroy();
530
+ });
531
+
532
+ test("tab switches columns like right arrow", async () => {
533
+ const ctx = setup();
534
+ const { renderer, mockInput, renderOnce } =
535
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
536
+
537
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
538
+
539
+ // Enter grid
540
+ mockInput.pressArrow("down");
541
+ await renderOnce();
542
+ expect(tui.state.cursorCol).toBe("global");
543
+
544
+ mockInput.pressTab();
545
+ await renderOnce();
546
+ expect(tui.state.cursorCol).toBe("local");
547
+
548
+ renderer.destroy();
549
+ });
550
+
551
+ test("empty catalog renders without crashing", async () => {
552
+ const ctx = setup({ skills: [] });
553
+ const { renderer, renderOnce, captureCharFrame } =
554
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
555
+
556
+ createTui(renderer, ctx.makeDepsImmediate());
557
+ await renderOnce();
558
+
559
+ const frame = captureCharFrame();
560
+ expect(frame).toContain("Skill");
561
+ expect(frame).toContain("Global");
562
+ expect(frame).toContain("Local");
563
+
564
+ renderer.destroy();
565
+ });