@aiwayds/dsh-tui-pi 0.4.3 → 0.5.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/README.md +270 -420
- package/README.zh.md +306 -0
- package/lib/agents.js +11 -9
- package/lib/agents.js.map +1 -1
- package/lib/commands.d.ts +12 -0
- package/lib/commands.js +62 -9
- package/lib/commands.js.map +1 -1
- package/lib/footer.d.ts +2 -1
- package/lib/footer.js +16 -3
- package/lib/footer.js.map +1 -1
- package/lib/frame.d.ts +4 -3
- package/lib/frame.js +5 -4
- package/lib/frame.js.map +1 -1
- package/lib/index.js +105 -6
- package/lib/index.js.map +1 -1
- package/lib/live-widgets.js +4 -2
- package/lib/live-widgets.js.map +1 -1
- package/lib/messages.d.ts +10 -2
- package/lib/messages.js +20 -6
- package/lib/messages.js.map +1 -1
- package/lib/selectors.d.ts +13 -0
- package/lib/selectors.js +48 -0
- package/lib/selectors.js.map +1 -1
- package/lib/session.d.ts +19 -0
- package/lib/session.js +81 -0
- package/lib/session.js.map +1 -1
- package/lib/settings.d.ts +18 -1
- package/lib/settings.js +343 -7
- package/lib/settings.js.map +1 -1
- package/lib/skills.d.ts +225 -0
- package/lib/skills.js +426 -0
- package/lib/skills.js.map +1 -0
- package/lib/theme/index.js +5 -1
- package/lib/theme/index.js.map +1 -1
- package/lib/theme/palette.d.ts +19 -4
- package/lib/theme/palette.js +98 -55
- package/lib/theme/palette.js.map +1 -1
- package/package.json +2 -1
package/lib/settings.js
CHANGED
|
@@ -35,11 +35,12 @@
|
|
|
35
35
|
*/
|
|
36
36
|
import { getPath, nodeAtPath, rehydrateSchema, } from '@deepseek-ai/dsh-client-schema-form';
|
|
37
37
|
import { settingsNamespace } from '@deepseek-ai/dsh-settings';
|
|
38
|
-
import { getKeybindings, Input, SettingsList, } from '@earendil-works/pi-tui';
|
|
38
|
+
import { getKeybindings, Input, matchesKey, SettingsList, } from '@earendil-works/pi-tui';
|
|
39
39
|
import { ansiBg, ansiFg, BOLD, RESET } from "./theme/index.js";
|
|
40
40
|
import { clipToWidth, visibleWidth } from "./text.js";
|
|
41
41
|
import { wrapFramedOverlay } from "./frame.js";
|
|
42
42
|
import { catalogEntry, deriveKeyRef, directoryProviderEntries, providerProfileFor, providerRowView, unconfiguredCatalogEntries, } from "./provider-catalog.js";
|
|
43
|
+
import { applySkillFrontmatter, BADGE_WIDTH, clampScrollOffset, clampSkillCursor, filterSkillRows, isPrintableInput, readSkillToggle, skillDisableUpdates, skillEnableUpdates, skillEnabled, skillJumpCursor, skillPanelRowLine, SKILL_INDEX_WIDTH, SKILL_STATE_WIDTH, skillToggleEnabled, } from "./skills.js";
|
|
43
44
|
/**
|
|
44
45
|
* Static namespace→category mapping, mirroring the web settings page's
|
|
45
46
|
* client-side slot structure (see the module header): the data plane carries
|
|
@@ -54,6 +55,7 @@ export const CATEGORY_MAP = [
|
|
|
54
55
|
{ id: 'models', label: 'Models', namespaces: ['llm-deepseek', 'llm-pi-ai', 'agent-default-model'] },
|
|
55
56
|
{ id: 'plugins', label: 'Plugins', namespaces: ['shell', 'agent-loop', 'web-search-deepseek'] },
|
|
56
57
|
{ id: 'agent', label: 'Agent Presets', namespaces: ['agent-presets'] },
|
|
58
|
+
{ id: 'skills', label: 'Skills', namespaces: [] },
|
|
57
59
|
];
|
|
58
60
|
/** Cap for a category row's member-name description line. */
|
|
59
61
|
export const CATEGORY_DESC_MAX = 60;
|
|
@@ -64,7 +66,10 @@ const NS_AGENT_DEFAULT_MODEL = settingsNamespace('agent-default-model');
|
|
|
64
66
|
/**
|
|
65
67
|
* Group a describe() namespace list into ordered categories — general, models,
|
|
66
68
|
* plugins, agent, then `other` for everything unmapped. Categories with no
|
|
67
|
-
* members are dropped, including `other` when nothing falls into it.
|
|
69
|
+
* members are dropped, including `other` when nothing falls into it. The
|
|
70
|
+
* Skills category is the exception: it is not namespace-driven (it lists the
|
|
71
|
+
* `ctx.skills` registry's user skills, see the Skills category view) and
|
|
72
|
+
* always appears.
|
|
68
73
|
*
|
|
69
74
|
* Defensive: duplicate namespaces in the input count once (a namespace
|
|
70
75
|
* registered twice must not be listed twice), and the `mapped` guard resolves
|
|
@@ -76,6 +81,11 @@ export function categorizeNamespaces(nses) {
|
|
|
76
81
|
const mapped = new Set();
|
|
77
82
|
const input = new Set(nses);
|
|
78
83
|
for (const def of CATEGORY_MAP) {
|
|
84
|
+
if (def.id === 'skills') {
|
|
85
|
+
// Namespace-independent: the user-skill browser always shows.
|
|
86
|
+
categories.push({ id: def.id, label: def.label, namespaces: [] });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
79
89
|
const members = [...new Set(def.namespaces)].filter(ns => input.has(ns) && !mapped.has(ns));
|
|
80
90
|
if (members.length === 0)
|
|
81
91
|
continue;
|
|
@@ -441,6 +451,203 @@ class ModelsCategoryView {
|
|
|
441
451
|
this.list?.handleInput(data);
|
|
442
452
|
}
|
|
443
453
|
}
|
|
454
|
+
/**
|
|
455
|
+
* Self-drawn Skills panel for the `/settings` Skills category. Drawn directly
|
|
456
|
+
* (no pi-tui SettingsList) so a row shows its toggle state exactly once, in
|
|
457
|
+
* front — SettingsList forces the currentValue into a right-hand value column
|
|
458
|
+
* too, which duplicated the state (`true [skill] x true`). Navigation is
|
|
459
|
+
* up/down plus PgUp/PgDn paging and Home/End jump; Enter/Space toggles (the
|
|
460
|
+
* same keys SettingsList accepts), Esc exits; the selected row's description
|
|
461
|
+
* is shown under the list, plus a footer hint. Every rendered row is clipped
|
|
462
|
+
* to the panel width so narrow terminals truncate instead of overflowing.
|
|
463
|
+
* The browser swaps rows in asynchronously (loading / empty / no-service states
|
|
464
|
+
* come through setStatus).
|
|
465
|
+
*/
|
|
466
|
+
class SkillsPanel {
|
|
467
|
+
tui;
|
|
468
|
+
theme;
|
|
469
|
+
onToggle;
|
|
470
|
+
onExit;
|
|
471
|
+
rows = [];
|
|
472
|
+
cursor = 0;
|
|
473
|
+
scrollOffset = 0;
|
|
474
|
+
/** One-line notice shown in place of the list (loading / empty / no service). */
|
|
475
|
+
status;
|
|
476
|
+
/** Filter query accumulated from printable keystrokes; Backspace/Delete erases. */
|
|
477
|
+
filterQuery = '';
|
|
478
|
+
/** The overlay renders at `maxHeight` of terminal rows; FramedOverlay adds
|
|
479
|
+
* 4 chrome rows (top border + spacer + bottom spacer + border); the child
|
|
480
|
+
* also appends2 tail rows (description + footer) after the skill list. */
|
|
481
|
+
static FRAME_OVERHEAD = 4;
|
|
482
|
+
static TAIL_ROWS = 2;
|
|
483
|
+
constructor(tui, theme, onToggle, onExit) {
|
|
484
|
+
this.tui = tui;
|
|
485
|
+
this.theme = theme;
|
|
486
|
+
this.onToggle = onToggle;
|
|
487
|
+
this.onExit = onExit;
|
|
488
|
+
}
|
|
489
|
+
invalidate() {
|
|
490
|
+
this.tui.requestRender();
|
|
491
|
+
}
|
|
492
|
+
/** Replace the whole list (keeps the cursor clamped to the filtered length). */
|
|
493
|
+
setRows(rows) {
|
|
494
|
+
this.rows = [...rows];
|
|
495
|
+
this.status = undefined;
|
|
496
|
+
this.cursor = clampSkillCursor(this.cursor, this.getFilteredRows().length);
|
|
497
|
+
this.scrollToCursor();
|
|
498
|
+
this.tui.requestRender();
|
|
499
|
+
}
|
|
500
|
+
/** Show a one-line notice in place of the list. */
|
|
501
|
+
setStatus(text) {
|
|
502
|
+
this.status = text;
|
|
503
|
+
this.rows = [];
|
|
504
|
+
this.cursor = 0;
|
|
505
|
+
this.scrollOffset = 0;
|
|
506
|
+
this.tui.requestRender();
|
|
507
|
+
}
|
|
508
|
+
render(width) {
|
|
509
|
+
const p = this.theme.palette;
|
|
510
|
+
const fg = (hex) => (text) => ansiFg(hex) + text + RESET;
|
|
511
|
+
if (this.rows.length === 0) {
|
|
512
|
+
return [fg(p.fgMuted)(clipToWidth(this.status ?? '', width))];
|
|
513
|
+
}
|
|
514
|
+
const filtered = this.getFilteredRows();
|
|
515
|
+
if (filtered.length === 0) {
|
|
516
|
+
return [
|
|
517
|
+
fg(p.fgMuted)(clipToWidth(`No matches for '${this.filterQuery}'`, width)),
|
|
518
|
+
fg(p.fgSubtle)(clipToWidth(this.footer, width)),
|
|
519
|
+
];
|
|
520
|
+
}
|
|
521
|
+
// Calculate how many skill rows fit. The overlay is capped at 80% of
|
|
522
|
+
// terminal rows (SettingsBrowser's maxHeight); FramedOverlay adds 4 chrome
|
|
523
|
+
// rows; the child appends TAIL_ROWS (description + footer) after the
|
|
524
|
+
// skill list.
|
|
525
|
+
const overlayMax = Math.floor(this.tui.terminal.rows * 0.8);
|
|
526
|
+
const maxVisibleRows = Math.max(1, overlayMax - SkillsPanel.FRAME_OVERHEAD - SkillsPanel.TAIL_ROWS);
|
|
527
|
+
// Ensure the cursor is in the visible window.
|
|
528
|
+
this.scrollToCursor(maxVisibleRows, filtered.length);
|
|
529
|
+
const visibleRows = filtered.slice(this.scrollOffset, this.scrollOffset + maxVisibleRows);
|
|
530
|
+
// Fixed-width prefix segments: marker(2) + index(4) + state(6) + badge(8) = 20.
|
|
531
|
+
const prefixCols = 2 + (SKILL_INDEX_WIDTH + 1) + (SKILL_STATE_WIDTH + 1) + (BADGE_WIDTH + 1);
|
|
532
|
+
const out = [];
|
|
533
|
+
for (let vi = 0; vi < visibleRows.length; vi++) {
|
|
534
|
+
const i = this.scrollOffset + vi;
|
|
535
|
+
const row = filtered[i];
|
|
536
|
+
const selected = i === this.cursor;
|
|
537
|
+
// One plain-text row (marker+space, index+space, state+space,
|
|
538
|
+
// badge+space+name), clipped once to width, then colored per
|
|
539
|
+
// fixed-width segment. The prefix segments are fixed ASCII width so
|
|
540
|
+
// slicing the clipped line by index is column-exact; the name tail
|
|
541
|
+
// takes the rest, and clipToWidth guarantees no row ever exceeds
|
|
542
|
+
// `width`.
|
|
543
|
+
const plain = clipToWidth(skillPanelRowLine(selected, row.enabled, row.name, i + 1), width);
|
|
544
|
+
out.push(fg(selected ? p.accent : p.fgMuted)(plain.slice(0, 2))
|
|
545
|
+
+ fg(p.fgSubtle)(plain.slice(2, 2 + SKILL_INDEX_WIDTH + 1))
|
|
546
|
+
+ fg(row.enabled ? p.success : p.fgMuted)(plain.slice(2 + SKILL_INDEX_WIDTH + 1, prefixCols))
|
|
547
|
+
+ fg(selected ? p.accent : p.fgDefault)(plain.slice(prefixCols)));
|
|
548
|
+
}
|
|
549
|
+
const sel = filtered[this.cursor];
|
|
550
|
+
if (sel !== undefined && sel.description !== '') {
|
|
551
|
+
out.push(fg(p.fgSubtle)(clipToWidth(` ${sel.description}`, width)));
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
out.push('');
|
|
555
|
+
}
|
|
556
|
+
out.push(fg(p.fgSubtle)(clipToWidth(this.footer, width)));
|
|
557
|
+
return out;
|
|
558
|
+
}
|
|
559
|
+
handleInput(data) {
|
|
560
|
+
const kb = getKeybindings();
|
|
561
|
+
if (kb.matches(data, 'tui.select.cancel')) {
|
|
562
|
+
// Esc with an active filter clears it first; a second Esc exits.
|
|
563
|
+
if (this.filterQuery !== '') {
|
|
564
|
+
this.filterQuery = '';
|
|
565
|
+
this.cursor = 0;
|
|
566
|
+
this.scrollOffset = 0;
|
|
567
|
+
this.tui.requestRender();
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
this.onExit();
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (kb.matches(data, 'tui.select.up')) {
|
|
574
|
+
this.move('up');
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (kb.matches(data, 'tui.select.down')) {
|
|
578
|
+
this.move('down');
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (kb.matches(data, 'tui.select.pageUp')) {
|
|
582
|
+
this.move('pageUp');
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (kb.matches(data, 'tui.select.pageDown')) {
|
|
586
|
+
this.move('pageDown');
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
// There is no tui.select.home/end binding, so match the raw keys with
|
|
590
|
+
// matchesKey (the same check the keybindings manager uses internally,
|
|
591
|
+
// including ctrl+home/end variants).
|
|
592
|
+
if (matchesKey(data, 'home')) {
|
|
593
|
+
this.move('home');
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (matchesKey(data, 'end')) {
|
|
597
|
+
this.move('end');
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
// Enter (tui.select.confirm) or Space — the same toggle keys SettingsList
|
|
601
|
+
// accepts (SettingsList treats a raw space as confirm when not searching).
|
|
602
|
+
if (kb.matches(data, 'tui.select.confirm') || data === ' ') {
|
|
603
|
+
const filtered = this.getFilteredRows();
|
|
604
|
+
const row = filtered[this.cursor];
|
|
605
|
+
if (row !== undefined)
|
|
606
|
+
this.onToggle(row.name, !row.enabled);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
// Backspace / Delete — erase the last character of the filter query.
|
|
610
|
+
if (data === '\x7f' || matchesKey(data, 'backspace')) {
|
|
611
|
+
if (this.filterQuery !== '') {
|
|
612
|
+
this.filterQuery = this.filterQuery.slice(0, -1);
|
|
613
|
+
this.cursor = 0;
|
|
614
|
+
this.scrollOffset = 0;
|
|
615
|
+
this.tui.requestRender();
|
|
616
|
+
}
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
// Printable characters accumulate into the filter query.
|
|
620
|
+
if (isPrintableInput(data)) {
|
|
621
|
+
this.filterQuery += data;
|
|
622
|
+
this.cursor = 0;
|
|
623
|
+
this.scrollOffset = 0;
|
|
624
|
+
this.tui.requestRender();
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/** Move the cursor by a jump kind, then scroll into view and repaint. */
|
|
628
|
+
move(jump) {
|
|
629
|
+
const filtered = this.getFilteredRows();
|
|
630
|
+
this.cursor = skillJumpCursor(this.cursor, filtered.length, jump);
|
|
631
|
+
this.tui.requestRender();
|
|
632
|
+
}
|
|
633
|
+
/** Adjust scrollOffset so the cursor is within `[offset, offset+visibleRows)`. */
|
|
634
|
+
scrollToCursor(visibleRows, length) {
|
|
635
|
+
const vr = visibleRows ?? Math.max(1, Math.floor(this.tui.terminal.rows * 0.8) - SkillsPanel.FRAME_OVERHEAD - SkillsPanel.TAIL_ROWS);
|
|
636
|
+
const len = length ?? this.getFilteredRows().length;
|
|
637
|
+
this.scrollOffset = clampScrollOffset(this.cursor, vr, len, this.scrollOffset);
|
|
638
|
+
}
|
|
639
|
+
/** The rows after applying the current filter query. */
|
|
640
|
+
getFilteredRows() {
|
|
641
|
+
return filterSkillRows(this.rows, this.filterQuery);
|
|
642
|
+
}
|
|
643
|
+
/** Footer hint changes when a filter is active. */
|
|
644
|
+
get footer() {
|
|
645
|
+
if (this.filterQuery !== '') {
|
|
646
|
+
return `Filter: ${this.filterQuery} · Backspace clear · Esc clear filter`;
|
|
647
|
+
}
|
|
648
|
+
return '↑↓ nav · PgUp/PgDn page · Home/End jump · Enter/Space toggle · Esc back';
|
|
649
|
+
}
|
|
650
|
+
}
|
|
444
651
|
/**
|
|
445
652
|
* Add-provider flow for the Models category — the terminal counterpart of
|
|
446
653
|
* pi-agent's /login, trimmed to the information a user actually needs: pick
|
|
@@ -546,6 +753,7 @@ class SettingsBrowser {
|
|
|
546
753
|
settings;
|
|
547
754
|
restoreFocus;
|
|
548
755
|
onError;
|
|
756
|
+
agent;
|
|
549
757
|
descriptors = [];
|
|
550
758
|
/** Rehydrated schema roots, cached per namespace (schemas never change). */
|
|
551
759
|
roots = new Map();
|
|
@@ -555,6 +763,8 @@ class SettingsBrowser {
|
|
|
555
763
|
nsList;
|
|
556
764
|
modelsView;
|
|
557
765
|
modelsExit;
|
|
766
|
+
skillsView;
|
|
767
|
+
skillsExit;
|
|
558
768
|
/**
|
|
559
769
|
* Credential refs just stored by the add flow (possibly several in one
|
|
560
770
|
* browser session) — their rows read as key set via the merged env.
|
|
@@ -577,6 +787,7 @@ class SettingsBrowser {
|
|
|
577
787
|
this.settings = options.settings;
|
|
578
788
|
this.restoreFocus = options.restoreFocus;
|
|
579
789
|
this.onError = options.onError;
|
|
790
|
+
this.agent = options.agent;
|
|
580
791
|
// Assigned here, not as a field initializer: a later field declaration
|
|
581
792
|
// would `defineProperty(…, undefined)` over the promise's resolve.
|
|
582
793
|
this.closed = new Promise(resolve => { this.closeResolve = resolve; });
|
|
@@ -617,6 +828,8 @@ class SettingsBrowser {
|
|
|
617
828
|
this.nsList = undefined;
|
|
618
829
|
this.modelsView = undefined;
|
|
619
830
|
this.modelsExit = undefined;
|
|
831
|
+
this.skillsView = undefined;
|
|
832
|
+
this.skillsExit = undefined;
|
|
620
833
|
this.justStoredRefs.clear();
|
|
621
834
|
this.restoreFocus();
|
|
622
835
|
this.closeResolve();
|
|
@@ -657,11 +870,13 @@ class SettingsBrowser {
|
|
|
657
870
|
description: this.categoryDescription(cat),
|
|
658
871
|
submenu: cat.id === 'models'
|
|
659
872
|
? (_current, done) => this.openModelsSubmenu(done)
|
|
660
|
-
:
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
873
|
+
: cat.id === 'skills'
|
|
874
|
+
? (_current, done) => this.openSkillsSubmenu(done)
|
|
875
|
+
: (_current, done) => {
|
|
876
|
+
const list = this.namespaceList(this.descriptors.filter(d => cat.namespaces.includes(d.ns)), done);
|
|
877
|
+
this.nsList = list;
|
|
878
|
+
return list;
|
|
879
|
+
},
|
|
665
880
|
}));
|
|
666
881
|
const list = new SettingsList(items, 10, this.listTheme, () => { }, () => this.close(), { enableSearch: true });
|
|
667
882
|
return list;
|
|
@@ -955,6 +1170,127 @@ class SettingsBrowser {
|
|
|
955
1170
|
// calling it again here would double-refresh (C8).
|
|
956
1171
|
() => { exit(); }, { enableSearch: true });
|
|
957
1172
|
}
|
|
1173
|
+
/** Cap for a skill row's description line (columns; width-safe). */
|
|
1174
|
+
static SKILL_DESC_MAX = 60;
|
|
1175
|
+
// ------------------------------------------------------------- skills category --
|
|
1176
|
+
/**
|
|
1177
|
+
* The Skills category is not namespace-driven: it lists the live
|
|
1178
|
+
* `ctx.skills` registry's user skills, each with an enabled/disabled toggle
|
|
1179
|
+
* that writes the skill's own SKILL.md frontmatter (`disable-model-
|
|
1180
|
+
* invocation` / `user-invocable`). Listing is async (unlike the namespace
|
|
1181
|
+
* walk), so the category opens with a "Loading…" notice and swaps in the
|
|
1182
|
+
* rows when discovery settles. Degrades to a distinct notice when the skills
|
|
1183
|
+
* service is absent.
|
|
1184
|
+
*/
|
|
1185
|
+
openSkillsSubmenu(done) {
|
|
1186
|
+
this.skillsExit = () => {
|
|
1187
|
+
this.refreshCategoryList();
|
|
1188
|
+
done();
|
|
1189
|
+
};
|
|
1190
|
+
const panel = new SkillsPanel(this.tui, this.theme, (name, enable) => { void this.toggleSkill(name, enable); }, () => { this.skillsExit?.(); });
|
|
1191
|
+
this.skillsView = panel;
|
|
1192
|
+
panel.setStatus('Loading skills…');
|
|
1193
|
+
this.refreshSkillsList();
|
|
1194
|
+
return panel;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Re-fetch the skill list onto the current panel. An optional `diskEnabled`
|
|
1198
|
+
* map overrides the enabled flag for the named skills with on-disk truth
|
|
1199
|
+
* (readSkillToggle) — the toggle path passes it so a stale in-memory summary
|
|
1200
|
+
* or a failed write never leaves a lying row.
|
|
1201
|
+
*/
|
|
1202
|
+
refreshSkillsList(diskEnabled) {
|
|
1203
|
+
const view = this.skillsView;
|
|
1204
|
+
if (view === undefined)
|
|
1205
|
+
return;
|
|
1206
|
+
const skills = this.ctx.get('skills');
|
|
1207
|
+
if (skills === undefined) {
|
|
1208
|
+
// Skills service absent — the category degrades to a distinct notice
|
|
1209
|
+
// (vs. an empty list, which means "no user skills").
|
|
1210
|
+
view.setStatus('Skills are not available in this environment.');
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
const cwd = this.agent?.session.header.cwd ?? process.cwd();
|
|
1214
|
+
void skills
|
|
1215
|
+
.list({ scope: this.agent, cwd })
|
|
1216
|
+
.then(listed => {
|
|
1217
|
+
// The category may have closed or re-opened while discovery ran.
|
|
1218
|
+
if (this.skillsView !== view)
|
|
1219
|
+
return;
|
|
1220
|
+
if (listed.length === 0) {
|
|
1221
|
+
view.setStatus('No user-invocable skills available.');
|
|
1222
|
+
}
|
|
1223
|
+
else {
|
|
1224
|
+
view.setRows(this.buildSkillRows(listed, diskEnabled));
|
|
1225
|
+
}
|
|
1226
|
+
})
|
|
1227
|
+
.catch(() => {
|
|
1228
|
+
if (this.skillsView !== view)
|
|
1229
|
+
return;
|
|
1230
|
+
view.setStatus('No user-invocable skills available.');
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
buildSkillRows(listed, diskEnabled) {
|
|
1234
|
+
return listed.map(skill => ({
|
|
1235
|
+
name: skill.name,
|
|
1236
|
+
description: clipToWidth(skill.description, SettingsBrowser.SKILL_DESC_MAX),
|
|
1237
|
+
// On-disk truth (readSkillToggle) wins over the in-memory summary when
|
|
1238
|
+
// present, so the toggle reflects the file even before the watcher.
|
|
1239
|
+
enabled: diskEnabled?.has(skill.name)
|
|
1240
|
+
? diskEnabled.get(skill.name)
|
|
1241
|
+
: skillEnabled(skill),
|
|
1242
|
+
}));
|
|
1243
|
+
}
|
|
1244
|
+
/** The on-disk enabled flag for one skill, as a name→value disk-truth map. */
|
|
1245
|
+
diskToggleOverride(path, name) {
|
|
1246
|
+
const toggle = readSkillToggle(path);
|
|
1247
|
+
if (toggle === undefined)
|
|
1248
|
+
return undefined;
|
|
1249
|
+
return new Map([[name, skillToggleEnabled(toggle)]]);
|
|
1250
|
+
}
|
|
1251
|
+
/** Toggle one user skill by editing its SKILL.md frontmatter. */
|
|
1252
|
+
async toggleSkill(name, enable) {
|
|
1253
|
+
const skills = this.ctx.get('skills');
|
|
1254
|
+
if (skills === undefined)
|
|
1255
|
+
return;
|
|
1256
|
+
const cwd = this.agent?.session.header.cwd ?? process.cwd();
|
|
1257
|
+
let path;
|
|
1258
|
+
try {
|
|
1259
|
+
const skill = await skills.get(name, { scope: this.agent, cwd });
|
|
1260
|
+
if (skill === undefined) {
|
|
1261
|
+
this.onError(`Skill "${name}" is no longer available.`);
|
|
1262
|
+
this.refreshSkillsList();
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
path = skill.path;
|
|
1266
|
+
}
|
|
1267
|
+
catch (error) {
|
|
1268
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1269
|
+
this.onError(message);
|
|
1270
|
+
this.refreshSkillsList();
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
if (path === undefined) {
|
|
1274
|
+
// A non-local (runtime-registered) skill has no file to edit.
|
|
1275
|
+
this.onError(`Skill "${name}" is not a local file and cannot be toggled.`);
|
|
1276
|
+
this.refreshSkillsList();
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
const error = applySkillFrontmatter(path, enable ? skillEnableUpdates() : skillDisableUpdates());
|
|
1280
|
+
// Read the file's actual toggle state as the row's disk truth: a failed
|
|
1281
|
+
// write (or a watcher/summary lag) must not leave a lying on-screen value.
|
|
1282
|
+
const diskOverride = this.diskToggleOverride(path, name);
|
|
1283
|
+
if (error !== undefined) {
|
|
1284
|
+
this.onError(error);
|
|
1285
|
+
// Re-sync the row to the on-disk truth (the cycle already flipped the
|
|
1286
|
+
// displayed value before the write was attempted).
|
|
1287
|
+
this.refreshSkillsList(diskOverride);
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
// The skill-filesystem watcher rescans the file (no restart needed);
|
|
1291
|
+
// refetch the list so the on-screen rows show the new state.
|
|
1292
|
+
this.refreshSkillsList(diskOverride);
|
|
1293
|
+
}
|
|
958
1294
|
/** Value column of the Default model row: provider/model · think level. */
|
|
959
1295
|
defaultModelSummary(desc) {
|
|
960
1296
|
const value = desc.value;
|