@dealdeploy/skl 0.4.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 -972
  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.ts ADDED
@@ -0,0 +1,612 @@
1
+ import {
2
+ BoxRenderable,
3
+ TextRenderable,
4
+ ScrollBoxRenderable,
5
+ TextAttributes,
6
+ type KeyEvent,
7
+ type CliRenderer,
8
+ } from "@opentui/core";
9
+ import { rmSync } from "fs";
10
+ import { join } from "path";
11
+ import { getLockEntry, removeFromLock, catalogDir } from "./lib.ts";
12
+
13
+ export type ColId = "global" | "local";
14
+
15
+ export type TuiDeps = {
16
+ allSkills: string[];
17
+ globalInstalled: Set<string>;
18
+ localInstalled: Set<string>;
19
+ catalogPath: string;
20
+ /** Called when a skill toggle is requested. Return true on success. */
21
+ onToggle: (col: ColId, name: string, enable: boolean) => Promise<boolean>;
22
+ /** Called when a skill delete is requested. */
23
+ onDelete: (name: string) => Promise<void>;
24
+ /** Called when edit is requested. Return false to stay in TUI. */
25
+ onEdit: (name: string) => Promise<void>;
26
+ /** Called on quit. */
27
+ onQuit: () => void;
28
+ };
29
+
30
+ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
31
+ const {
32
+ allSkills,
33
+ globalInstalled,
34
+ localInstalled,
35
+ catalogPath: CATALOG,
36
+ } = deps;
37
+
38
+ // ── Helpers ──────────────────────────────────────────────────────────
39
+
40
+ function ellipsize(text: string, max: number): string {
41
+ if (max <= 0) return "";
42
+ if (text.length <= max) return text;
43
+ if (max === 1) return "\u2026";
44
+ return `${text.slice(0, max - 1)}\u2026`;
45
+ }
46
+
47
+ // ── State ───────────────────────────────────────────────────────────
48
+
49
+ let cursor = 0;
50
+ let cursorCol: ColId = "global";
51
+ let focusArea: "search" | "grid" = "search";
52
+ let statusTimeout: ReturnType<typeof setTimeout> | null = null;
53
+ let pendingDelete: number | null = null;
54
+ const deletedSkills = new Set<number>();
55
+ let searchQuery = "";
56
+ let filteredIndices: number[] = allSkills.map((_, i) => i);
57
+ const COL_ORDER: ColId[] = ["global", "local"];
58
+
59
+ const pendingToggles = new Map<string, "adding" | "removing">();
60
+
61
+ // ── Colors ──────────────────────────────────────────────────────────
62
+
63
+ const C = {
64
+ bg: "#1a1a2e",
65
+ rowBg: "#1a1a2e",
66
+ rowAltBg: "#1f1f38",
67
+ cursorBg: "#2a2a5a",
68
+ accentBg: "#3a3a7a",
69
+ border: "#444477",
70
+ fg: "#ccccdd",
71
+ fgDim: "#666688",
72
+ checked: "#66ff88",
73
+ unchecked: "#555566",
74
+ warning: "#ffaa44",
75
+ accent: "#8888ff",
76
+ title: "#aaaaff",
77
+ footer: "#888899",
78
+ statusOk: "#66ff88",
79
+ statusErr: "#ff6666",
80
+ search: "#ffdd55",
81
+ };
82
+
83
+ // ── Checkbox helpers ────────────────────────────────────────────────
84
+
85
+ function checkboxStr(col: ColId, name: string): string {
86
+ const key = `${col}:${name}`;
87
+ if (pendingToggles.has(key)) return "[~]";
88
+ const set = col === "global" ? globalInstalled : localInstalled;
89
+ return set.has(name) ? "[x]" : "[ ]";
90
+ }
91
+
92
+ function checkboxColor(col: ColId, name: string, active: boolean): string {
93
+ const key = `${col}:${name}`;
94
+ if (pendingToggles.has(key)) return C.warning;
95
+ if (active) return C.accent;
96
+ const set = col === "global" ? globalInstalled : localInstalled;
97
+ return set.has(name) ? C.checked : C.unchecked;
98
+ }
99
+
100
+ function isEnabled(col: ColId, name: string): boolean {
101
+ return (col === "global" ? globalInstalled : localInstalled).has(name);
102
+ }
103
+
104
+ // ── Build TUI ───────────────────────────────────────────────────────
105
+
106
+ const outer = new BoxRenderable(renderer, {
107
+ id: "outer",
108
+ width: "100%",
109
+ height: "100%",
110
+ flexDirection: "column",
111
+ backgroundColor: C.bg,
112
+ });
113
+
114
+ const searchRow = new BoxRenderable(renderer, {
115
+ id: "search-row",
116
+ flexDirection: "row",
117
+ height: 1,
118
+ width: "100%",
119
+ paddingLeft: 1,
120
+ backgroundColor: C.rowBg,
121
+ });
122
+ const searchBar = new TextRenderable(renderer, {
123
+ id: "search-bar",
124
+ content: "",
125
+ fg: C.search,
126
+ height: 1,
127
+ });
128
+ searchRow.add(searchBar);
129
+
130
+ const colHeaderRow = new BoxRenderable(renderer, {
131
+ id: "col-header-row",
132
+ flexDirection: "row",
133
+ height: 1,
134
+ paddingLeft: 1,
135
+ });
136
+
137
+ let COL_W = 14;
138
+ let NAME_W = 34;
139
+
140
+ function calcWidths() {
141
+ const w = (renderer as any).width ?? process.stdout.columns ?? 80;
142
+ COL_W = Math.max(5, Math.min(14, Math.floor((w - 20) / 2)));
143
+ NAME_W = Math.min(40, Math.max(15, w - COL_W * 2 - 1));
144
+ }
145
+ calcWidths();
146
+
147
+ const colName = new TextRenderable(renderer, {
148
+ id: "col-name",
149
+ content: "Skill",
150
+ fg: C.fgDim,
151
+ attributes: TextAttributes.BOLD,
152
+ width: NAME_W,
153
+ });
154
+ const colGlobal = new TextRenderable(renderer, {
155
+ id: "col-global",
156
+ content: "Global",
157
+ fg: C.fgDim,
158
+ attributes: TextAttributes.BOLD,
159
+ width: COL_W,
160
+ });
161
+ const colLocal = new TextRenderable(renderer, {
162
+ id: "col-local",
163
+ content: "Local",
164
+ fg: C.fgDim,
165
+ attributes: TextAttributes.BOLD,
166
+ width: COL_W,
167
+ });
168
+
169
+ colHeaderRow.add(colName);
170
+ colHeaderRow.add(colGlobal);
171
+ colHeaderRow.add(colLocal);
172
+
173
+ const sep = new TextRenderable(renderer, {
174
+ id: "sep",
175
+ content: "\u2500".repeat(200),
176
+ fg: C.border,
177
+ width: "100%",
178
+ height: 1,
179
+ });
180
+
181
+ const scrollBox = new ScrollBoxRenderable(renderer, {
182
+ id: "skill-list",
183
+ flexGrow: 1,
184
+ width: "100%",
185
+ });
186
+
187
+ type RowRefs = {
188
+ row: BoxRenderable;
189
+ nameText: TextRenderable;
190
+ globalText: TextRenderable;
191
+ localText: TextRenderable;
192
+ };
193
+ const rows: RowRefs[] = [];
194
+
195
+ scrollBox.add(searchRow);
196
+
197
+ for (let i = 0; i < allSkills.length; i++) {
198
+ const skill = allSkills[i]!;
199
+
200
+ const row = new BoxRenderable(renderer, {
201
+ id: `row-${i}`,
202
+ flexDirection: "row",
203
+ height: 1,
204
+ width: "100%",
205
+ paddingLeft: 1,
206
+ backgroundColor: i % 2 === 0 ? C.rowBg : C.rowAltBg,
207
+ });
208
+
209
+ const nameText = new TextRenderable(renderer, {
210
+ id: `name-${i}`,
211
+ content: ` ${ellipsize(skill, NAME_W - 3)}`,
212
+ fg: C.fg,
213
+ width: NAME_W,
214
+ });
215
+
216
+ const globalText = new TextRenderable(renderer, {
217
+ id: `global-${i}`,
218
+ content: checkboxStr("global", skill),
219
+ fg: checkboxColor("global", skill, false),
220
+ width: COL_W,
221
+ });
222
+
223
+ const localText = new TextRenderable(renderer, {
224
+ id: `local-${i}`,
225
+ content: checkboxStr("local", skill),
226
+ fg: checkboxColor("local", skill, false),
227
+ width: COL_W,
228
+ });
229
+
230
+ row.add(nameText);
231
+ row.add(globalText);
232
+ row.add(localText);
233
+ scrollBox.add(row);
234
+ rows.push({ row, nameText, globalText, localText });
235
+ }
236
+
237
+ const footerSep = new TextRenderable(renderer, {
238
+ id: "footer-sep",
239
+ content: "\u2500".repeat(200),
240
+ fg: C.border,
241
+ width: "100%",
242
+ height: 1,
243
+ });
244
+
245
+ const footer = new TextRenderable(renderer, {
246
+ id: "footer",
247
+ content: " \u2191\u2193 move \u2190\u2192/tab col enter toggle e edit d del / search q/esc quit",
248
+ fg: C.footer,
249
+ height: 1,
250
+ });
251
+
252
+ const statusLine = new TextRenderable(renderer, {
253
+ id: "status",
254
+ content: "",
255
+ fg: C.statusOk,
256
+ height: 1,
257
+ });
258
+
259
+ outer.add(colHeaderRow);
260
+ outer.add(sep);
261
+ outer.add(scrollBox);
262
+ outer.add(footerSep);
263
+ outer.add(footer);
264
+ outer.add(statusLine);
265
+ renderer.root.add(outer);
266
+
267
+ // ── Search / filter ─────────────────────────────────────────────────
268
+
269
+ function applyFilter() {
270
+ const term = searchQuery.toLowerCase();
271
+ filteredIndices = [];
272
+ for (let i = 0; i < allSkills.length; i++) {
273
+ if (deletedSkills.has(i)) {
274
+ rows[i]!.row.visible = false;
275
+ continue;
276
+ }
277
+ const match = !term || allSkills[i]!.toLowerCase().includes(term);
278
+ rows[i]!.row.visible = match;
279
+ if (match) filteredIndices.push(i);
280
+ }
281
+ if (filteredIndices.length === 0) {
282
+ cursor = 0;
283
+ } else if (cursor >= filteredIndices.length) {
284
+ cursor = filteredIndices.length - 1;
285
+ }
286
+ }
287
+
288
+ function updateSearchBar() {
289
+ if (focusArea === "search") {
290
+ searchBar.content = `\u25b8 ${searchQuery}\u2588`;
291
+ searchBar.fg = C.search;
292
+ searchRow.backgroundColor = C.cursorBg;
293
+ } else {
294
+ searchBar.content = searchQuery ? ` ${searchQuery}` : ` search...`;
295
+ searchBar.fg = C.fgDim;
296
+ searchRow.backgroundColor = C.rowBg;
297
+ }
298
+ }
299
+
300
+ // ── Update display ──────────────────────────────────────────────────
301
+
302
+ function currentSkillIndex(): number | null {
303
+ return filteredIndices.length > 0 ? (filteredIndices[cursor] ?? null) : null;
304
+ }
305
+
306
+ function updateRow(i: number) {
307
+ const skill = allSkills[i]!;
308
+ const r = rows[i]!;
309
+ const ci = currentSkillIndex();
310
+ const isCursor = ci === i && focusArea === "grid";
311
+
312
+ const visPos = filteredIndices.indexOf(i);
313
+ const baseBg = visPos % 2 === 0 ? C.rowBg : C.rowAltBg;
314
+ r.row.backgroundColor = isCursor ? C.cursorBg : baseBg;
315
+
316
+ const pointer = isCursor ? "\u25b8" : " ";
317
+ r.nameText.content = `${pointer} ${ellipsize(skill, NAME_W - 3)}`;
318
+ r.nameText.fg = isCursor ? "#ffffff" : C.fg;
319
+ r.nameText.attributes = isCursor ? TextAttributes.BOLD : TextAttributes.NONE;
320
+
321
+ const gActive = isCursor && cursorCol === "global";
322
+ r.globalText.content = checkboxStr("global", skill);
323
+ r.globalText.fg = checkboxColor("global", skill, gActive);
324
+ r.globalText.bg = gActive ? C.accentBg : undefined;
325
+ r.globalText.attributes = gActive ? TextAttributes.BOLD : TextAttributes.NONE;
326
+
327
+ const lActive = isCursor && cursorCol === "local";
328
+ r.localText.content = checkboxStr("local", skill);
329
+ r.localText.fg = checkboxColor("local", skill, lActive);
330
+ r.localText.bg = lActive ? C.accentBg : undefined;
331
+ r.localText.attributes = lActive ? TextAttributes.BOLD : TextAttributes.NONE;
332
+ }
333
+
334
+ function setStatus(msg: string, color: string) {
335
+ statusLine.content = ` ${msg}`;
336
+ statusLine.fg = color;
337
+ if (statusTimeout) clearTimeout(statusTimeout);
338
+ statusTimeout = setTimeout(() => {
339
+ statusLine.content = "";
340
+ }, 3000);
341
+ }
342
+
343
+ function relayout() {
344
+ calcWidths();
345
+ colName.width = NAME_W;
346
+ colGlobal.width = COL_W;
347
+ colLocal.width = COL_W;
348
+ for (let i = 0; i < allSkills.length; i++) {
349
+ const r = rows[i]!;
350
+ r.nameText.width = NAME_W;
351
+ r.globalText.width = COL_W;
352
+ r.localText.width = COL_W;
353
+ }
354
+ }
355
+
356
+ function refreshAll() {
357
+ for (const i of filteredIndices) updateRow(i);
358
+ updateSearchBar();
359
+ }
360
+
361
+ refreshAll();
362
+
363
+ // ── Scrolling helper ────────────────────────────────────────────────
364
+
365
+ function ensureVisible() {
366
+ if (focusArea === "search") {
367
+ scrollBox.scrollTo(0);
368
+ } else {
369
+ scrollBox.scrollTo(Math.max(0, cursor + 1 - 2));
370
+ }
371
+ }
372
+
373
+ // ── Toggle skill (background) ───────────────────────────────────────
374
+
375
+ async function toggleSkill(col: ColId, name: string) {
376
+ const key = `${col}:${name}`;
377
+ if (pendingToggles.has(key)) return;
378
+
379
+ const enabled = isEnabled(col, name);
380
+ pendingToggles.set(key, enabled ? "removing" : "adding");
381
+ refreshAll();
382
+
383
+ try {
384
+ const ok = await deps.onToggle(col, name, !enabled);
385
+ if (ok) {
386
+ const set = col === "global" ? globalInstalled : localInstalled;
387
+ if (enabled) {
388
+ set.delete(name);
389
+ setStatus(`Removed ${name}`, C.statusErr);
390
+ } else {
391
+ set.add(name);
392
+ setStatus(`Added ${name}`, C.statusOk);
393
+ }
394
+ } else {
395
+ setStatus(`Failed to ${enabled ? "remove" : "add"} ${name}`, C.statusErr);
396
+ }
397
+ } finally {
398
+ pendingToggles.delete(key);
399
+ refreshAll();
400
+ }
401
+ }
402
+
403
+ // ── Edit skill ──────────────────────────────────────────────────────
404
+
405
+ async function editSkill(idx: number) {
406
+ const skill = allSkills[idx]!;
407
+ await deps.onEdit(skill);
408
+ }
409
+
410
+ // ── Delete skill ────────────────────────────────────────────────────
411
+
412
+ function cancelPendingDelete() {
413
+ if (pendingDelete !== null) {
414
+ pendingDelete = null;
415
+ statusLine.content = "";
416
+ }
417
+ }
418
+
419
+ async function deleteSkill(idx: number) {
420
+ const skill = allSkills[idx]!;
421
+
422
+ await deps.onDelete(skill);
423
+
424
+ globalInstalled.delete(skill);
425
+ localInstalled.delete(skill);
426
+
427
+ deletedSkills.add(idx);
428
+ rows[idx]!.row.visible = false;
429
+ pendingDelete = null;
430
+
431
+ applyFilter();
432
+ setStatus(`${skill} deleted`, C.statusErr);
433
+ refreshAll();
434
+ ensureVisible();
435
+ }
436
+
437
+ // ── Key handler ─────────────────────────────────────────────────────
438
+
439
+ function colNext(col: ColId): ColId {
440
+ const i = COL_ORDER.indexOf(col);
441
+ return COL_ORDER[(i + 1) % COL_ORDER.length]!;
442
+ }
443
+
444
+ function colPrev(col: ColId): ColId {
445
+ const i = COL_ORDER.indexOf(col);
446
+ return COL_ORDER[(i - 1 + COL_ORDER.length) % COL_ORDER.length]!;
447
+ }
448
+
449
+ renderer.keyInput.on("keypress", (key: KeyEvent) => {
450
+ // ── Delete confirmation mode ──
451
+ if (pendingDelete !== null) {
452
+ if (key.name === "y") {
453
+ deleteSkill(pendingDelete);
454
+ } else {
455
+ cancelPendingDelete();
456
+ setStatus("delete cancelled", C.fgDim);
457
+ }
458
+ return;
459
+ }
460
+
461
+ const prevIdx = currentSkillIndex();
462
+
463
+ // ── Escape ──
464
+ if (key.name === "escape") {
465
+ if (focusArea === "search" && searchQuery) {
466
+ searchQuery = "";
467
+ applyFilter();
468
+ refreshAll();
469
+ ensureVisible();
470
+ } else if (focusArea === "search") {
471
+ focusArea = "grid";
472
+ refreshAll();
473
+ ensureVisible();
474
+ } else {
475
+ deps.onQuit();
476
+ }
477
+ return;
478
+ }
479
+
480
+ // ── Search-focused input ──
481
+ if (focusArea === "search") {
482
+ if (key.name === "backspace") {
483
+ if (searchQuery) {
484
+ searchQuery = searchQuery.slice(0, -1);
485
+ applyFilter();
486
+ cursor = 0;
487
+ refreshAll();
488
+ ensureVisible();
489
+ }
490
+ return;
491
+ }
492
+ if (key.name === "down" || key.name === "return") {
493
+ focusArea = "grid";
494
+ refreshAll();
495
+ ensureVisible();
496
+ return;
497
+ }
498
+ if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
499
+ searchQuery += key.sequence;
500
+ applyFilter();
501
+ cursor = 0;
502
+ refreshAll();
503
+ ensureVisible();
504
+ return;
505
+ }
506
+ return;
507
+ }
508
+
509
+ // ── Grid-focused input ──
510
+
511
+ if (key.name === "backspace") return;
512
+ if (key.ctrl) return;
513
+
514
+ if (key.sequence === "q") {
515
+ deps.onQuit();
516
+ return;
517
+ }
518
+
519
+ if (key.sequence === "/") {
520
+ focusArea = "search";
521
+ refreshAll();
522
+ ensureVisible();
523
+ return;
524
+ }
525
+
526
+ if (key.sequence === "e") {
527
+ const idx = currentSkillIndex();
528
+ if (idx !== null) editSkill(idx);
529
+ return;
530
+ }
531
+
532
+ if (key.sequence === "d") {
533
+ const idx = currentSkillIndex();
534
+ if (idx !== null) {
535
+ pendingDelete = idx;
536
+ setStatus(`delete ${allSkills[idx]!}? (y to confirm)`, C.warning);
537
+ }
538
+ return;
539
+ }
540
+
541
+ // ── Navigation & actions ──
542
+ switch (key.name) {
543
+ case "down":
544
+ if (cursor < filteredIndices.length - 1) cursor++;
545
+ break;
546
+ case "up":
547
+ if (cursor > 0) {
548
+ cursor--;
549
+ } else {
550
+ focusArea = "search";
551
+ refreshAll();
552
+ return;
553
+ }
554
+ break;
555
+ case "left":
556
+ cursorCol = colPrev(cursorCol);
557
+ break;
558
+ case "right":
559
+ cursorCol = colNext(cursorCol);
560
+ break;
561
+ case "tab":
562
+ cursorCol = colNext(cursorCol);
563
+ break;
564
+ case "pagedown":
565
+ cursor = Math.min(filteredIndices.length - 1, cursor + 10);
566
+ break;
567
+ case "pageup":
568
+ cursor = Math.max(0, cursor - 10);
569
+ break;
570
+ case "home":
571
+ cursor = 0;
572
+ break;
573
+ case "end":
574
+ cursor = Math.max(0, filteredIndices.length - 1);
575
+ break;
576
+ case "space":
577
+ case "return": {
578
+ const idx = currentSkillIndex();
579
+ if (idx === null) break;
580
+ const skill = allSkills[idx]!;
581
+ toggleSkill(cursorCol, skill);
582
+ break;
583
+ }
584
+ default:
585
+ return;
586
+ }
587
+
588
+ if (prevIdx !== null && prevIdx !== currentSkillIndex()) {
589
+ updateRow(prevIdx);
590
+ }
591
+ const ci = currentSkillIndex();
592
+ if (ci !== null) updateRow(ci);
593
+ ensureVisible();
594
+ });
595
+
596
+ return {
597
+ refreshAll,
598
+ relayout,
599
+ get state() {
600
+ return {
601
+ cursor,
602
+ cursorCol,
603
+ focusArea,
604
+ searchQuery,
605
+ filteredIndices,
606
+ pendingDelete,
607
+ pendingToggles,
608
+ currentSkillIndex,
609
+ };
610
+ },
611
+ };
612
+ }