@mrclrchtr/supi-extras 7.2.1 → 7.3.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 +19 -24
- package/node_modules/@mrclrchtr/supi-core/README.md +10 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +3 -2
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +2 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +2 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-capabilities.ts +188 -0
- package/node_modules/@mrclrchtr/supi-core/src/session.ts +17 -0
- package/node_modules/@mrclrchtr/supi-core/src/tui.ts +141 -0
- package/package.json +3 -3
- package/src/index.ts +2 -0
- package/src/session-capabilities-state.ts +60 -0
- package/src/session-capabilities-tools.ts +103 -0
- package/src/session-capabilities-ui-rows.ts +192 -0
- package/src/session-capabilities-ui.ts +356 -0
- package/src/session-capabilities.ts +350 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { SessionCapabilityState } from "@mrclrchtr/supi-core/session";
|
|
3
|
+
import { isExtensionTool } from "./session-capabilities-state.ts";
|
|
4
|
+
import type { CapabilityToolItem } from "./session-capabilities-ui.ts";
|
|
5
|
+
|
|
6
|
+
/** Enforce the current session tool choices without changing other active tools. */
|
|
7
|
+
export function applyToolDenylist(
|
|
8
|
+
pi: ExtensionAPI,
|
|
9
|
+
state: SessionCapabilityState,
|
|
10
|
+
restoreNames: readonly string[],
|
|
11
|
+
restorableNames: ReadonlySet<string>,
|
|
12
|
+
): void {
|
|
13
|
+
const available = new Set(
|
|
14
|
+
pi
|
|
15
|
+
.getAllTools()
|
|
16
|
+
.filter(isExtensionTool)
|
|
17
|
+
.map((tool) => tool.name),
|
|
18
|
+
);
|
|
19
|
+
const eligible = new Set(state.eligibleToolNames);
|
|
20
|
+
const initiallyInactive = new Set(state.initiallyInactiveToolNames);
|
|
21
|
+
const denied = new Set(
|
|
22
|
+
state.toolDenylist.filter(
|
|
23
|
+
(name) => available.has(name) && eligible.has(name) && !initiallyInactive.has(name),
|
|
24
|
+
),
|
|
25
|
+
);
|
|
26
|
+
const active = pi.getActiveTools();
|
|
27
|
+
const activeSet = new Set(active);
|
|
28
|
+
const next = active.filter((name) => !denied.has(name));
|
|
29
|
+
const nextSet = new Set(next);
|
|
30
|
+
|
|
31
|
+
for (const name of restoreNames) {
|
|
32
|
+
if (
|
|
33
|
+
available.has(name) &&
|
|
34
|
+
eligible.has(name) &&
|
|
35
|
+
restorableNames.has(name) &&
|
|
36
|
+
!initiallyInactive.has(name) &&
|
|
37
|
+
!denied.has(name) &&
|
|
38
|
+
!activeSet.has(name) &&
|
|
39
|
+
!nextSet.has(name)
|
|
40
|
+
) {
|
|
41
|
+
next.push(name);
|
|
42
|
+
nextSet.add(name);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (next.length !== active.length || next.some((name, index) => name !== active[index])) {
|
|
47
|
+
pi.setActiveTools(next);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Add a late tool to the restore boundary only when PI has activated it. */
|
|
52
|
+
export function observeNewTools(
|
|
53
|
+
pi: ExtensionAPI,
|
|
54
|
+
state: SessionCapabilityState,
|
|
55
|
+
knownToolNames: Set<string>,
|
|
56
|
+
restorableToolNames: Set<string>,
|
|
57
|
+
): void {
|
|
58
|
+
const activeNames = new Set(pi.getActiveTools());
|
|
59
|
+
const initiallyInactive = new Set(state.initiallyInactiveToolNames);
|
|
60
|
+
for (const tool of pi.getAllTools().filter(isExtensionTool)) {
|
|
61
|
+
if (knownToolNames.has(tool.name)) continue;
|
|
62
|
+
knownToolNames.add(tool.name);
|
|
63
|
+
if (activeNames.has(tool.name) && !initiallyInactive.has(tool.name)) {
|
|
64
|
+
restorableToolNames.add(tool.name);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Reconcile known tools while retaining restrictions for tools not registered yet. */
|
|
70
|
+
export function reconcileToolInventory(
|
|
71
|
+
pi: ExtensionAPI,
|
|
72
|
+
state: SessionCapabilityState,
|
|
73
|
+
): { tools: CapabilityToolItem[]; changed: boolean } {
|
|
74
|
+
const tools = pi.getAllTools().filter(isExtensionTool);
|
|
75
|
+
const activeNames = new Set(pi.getActiveTools());
|
|
76
|
+
const previous = JSON.stringify(state);
|
|
77
|
+
const initiallyInactive = new Set(state.initiallyInactiveToolNames);
|
|
78
|
+
const eligible = new Set(state.eligibleToolNames.filter((name) => !initiallyInactive.has(name)));
|
|
79
|
+
|
|
80
|
+
for (const tool of tools) {
|
|
81
|
+
if (activeNames.has(tool.name) && !initiallyInactive.has(tool.name)) eligible.add(tool.name);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
state.initiallyInactiveToolNames = Array.from(initiallyInactive);
|
|
85
|
+
state.eligibleToolNames = Array.from(eligible);
|
|
86
|
+
state.toolDenylist = state.toolDenylist.filter((name) => eligible.has(name));
|
|
87
|
+
state.toolEnabledNames = (state.toolEnabledNames ?? []).filter(
|
|
88
|
+
(name) => eligible.has(name) && !state.toolDenylist.includes(name),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const denied = new Set(state.toolDenylist);
|
|
92
|
+
const visibleTools = tools.filter(
|
|
93
|
+
(tool) => eligible.has(tool.name) && (activeNames.has(tool.name) || denied.has(tool.name)),
|
|
94
|
+
);
|
|
95
|
+
return {
|
|
96
|
+
tools: visibleTools.map((tool) => ({
|
|
97
|
+
name: tool.name,
|
|
98
|
+
description: tool.description,
|
|
99
|
+
source: tool.sourceInfo.source,
|
|
100
|
+
})),
|
|
101
|
+
changed: previous !== JSON.stringify(state),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { SelectItem } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { SessionCapabilitySkill, SessionCapabilityState } from "@mrclrchtr/supi-core/session";
|
|
3
|
+
|
|
4
|
+
/** A section shown by the session capability selector. */
|
|
5
|
+
export type CapabilitySection = "tools" | "skills";
|
|
6
|
+
|
|
7
|
+
/** A tool row that the session selector can display. */
|
|
8
|
+
export interface CapabilityToolItem {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
source: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** One action, group, toggle, or information row in the selector. */
|
|
15
|
+
export interface CapabilityRow {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
currentValue: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
indent: number;
|
|
21
|
+
searchText: string;
|
|
22
|
+
parentId?: string;
|
|
23
|
+
toggleValues?: readonly [string, string];
|
|
24
|
+
actions?: SelectItem[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function actionRow(options: {
|
|
28
|
+
id: string;
|
|
29
|
+
label: string;
|
|
30
|
+
value: string;
|
|
31
|
+
description: string;
|
|
32
|
+
action: SelectItem;
|
|
33
|
+
}): CapabilityRow {
|
|
34
|
+
return {
|
|
35
|
+
id: options.id,
|
|
36
|
+
label: options.label,
|
|
37
|
+
currentValue: options.value,
|
|
38
|
+
description: options.description,
|
|
39
|
+
indent: 2,
|
|
40
|
+
searchText: options.label,
|
|
41
|
+
actions: [options.action],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toolRows(tools: CapabilityToolItem[], state: SessionCapabilityState): CapabilityRow[] {
|
|
46
|
+
const denied = new Set(state.toolDenylist);
|
|
47
|
+
const grouped = new Map<string, CapabilityToolItem[]>();
|
|
48
|
+
for (const tool of tools) {
|
|
49
|
+
const group = grouped.get(tool.source) ?? [];
|
|
50
|
+
group.push(tool);
|
|
51
|
+
grouped.set(tool.source, group);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const rows: CapabilityRow[] = [
|
|
55
|
+
actionRow({
|
|
56
|
+
id: "disable-all-tools",
|
|
57
|
+
label: "Disable All Tools",
|
|
58
|
+
value: "Apply",
|
|
59
|
+
description: "Disable all current eligible extension tools for this session.",
|
|
60
|
+
action: { value: "Apply", label: "Disable all tools" },
|
|
61
|
+
}),
|
|
62
|
+
actionRow({
|
|
63
|
+
id: "reset-tools",
|
|
64
|
+
label: "Reset Tools",
|
|
65
|
+
value: "Reset",
|
|
66
|
+
description: "Restore tools that this selector disabled.",
|
|
67
|
+
action: { value: "Reset", label: "Restore tools" },
|
|
68
|
+
}),
|
|
69
|
+
actionRow({
|
|
70
|
+
id: "reset-all",
|
|
71
|
+
label: "Reset All",
|
|
72
|
+
value: "Reset",
|
|
73
|
+
description: "Restore all session capability overrides.",
|
|
74
|
+
action: { value: "Reset", label: "Restore all session overrides" },
|
|
75
|
+
}),
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
for (const [source, sourceTools] of Array.from(grouped.entries()).sort(([a], [b]) =>
|
|
79
|
+
a.localeCompare(b),
|
|
80
|
+
)) {
|
|
81
|
+
const sortedTools = [...sourceTools].sort((a, b) => a.name.localeCompare(b.name));
|
|
82
|
+
const disabledCount = sortedTools.filter((tool) => denied.has(tool.name)).length;
|
|
83
|
+
const groupValue =
|
|
84
|
+
disabledCount === 0 ? "Enabled" : disabledCount === sortedTools.length ? "Disabled" : "Mixed";
|
|
85
|
+
const groupId = `package:${source}`;
|
|
86
|
+
rows.push({
|
|
87
|
+
id: groupId,
|
|
88
|
+
label: source,
|
|
89
|
+
currentValue: groupValue,
|
|
90
|
+
description: "Toggle current eligible tools from this PI source.",
|
|
91
|
+
indent: 2,
|
|
92
|
+
searchText: source,
|
|
93
|
+
actions: [
|
|
94
|
+
{ value: "disable", label: "Disable package tools" },
|
|
95
|
+
{ value: "enable", label: "Enable package tools" },
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
for (const tool of sortedTools) {
|
|
99
|
+
rows.push({
|
|
100
|
+
id: `tool:${tool.name}`,
|
|
101
|
+
label: tool.name,
|
|
102
|
+
currentValue: denied.has(tool.name) ? "Disabled" : "Enabled",
|
|
103
|
+
description: tool.description,
|
|
104
|
+
indent: 4,
|
|
105
|
+
searchText: `${tool.name} ${source}`,
|
|
106
|
+
parentId: groupId,
|
|
107
|
+
toggleValues: ["Enabled", "Disabled"],
|
|
108
|
+
actions: [
|
|
109
|
+
{ value: "Enabled", label: "Enable tool" },
|
|
110
|
+
{ value: "Disabled", label: "Disable tool" },
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return rows;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function skillRows(
|
|
119
|
+
skills: SessionCapabilitySkill[] | undefined,
|
|
120
|
+
state: SessionCapabilityState,
|
|
121
|
+
): CapabilityRow[] {
|
|
122
|
+
const hidden = new Set(state.hiddenSkillNames);
|
|
123
|
+
const rows: CapabilityRow[] = [
|
|
124
|
+
actionRow({
|
|
125
|
+
id: "hide-all-skills",
|
|
126
|
+
label: "Hide All Skills",
|
|
127
|
+
value: "Apply",
|
|
128
|
+
description: "Hide all current model-visible skills for this session.",
|
|
129
|
+
action: { value: "Apply", label: "Hide all skills" },
|
|
130
|
+
}),
|
|
131
|
+
actionRow({
|
|
132
|
+
id: "reset-skills",
|
|
133
|
+
label: "Reset Skills",
|
|
134
|
+
value: "Reset",
|
|
135
|
+
description: "Show all eligible skills in the model catalog.",
|
|
136
|
+
action: { value: "Reset", label: "Show all skills" },
|
|
137
|
+
}),
|
|
138
|
+
actionRow({
|
|
139
|
+
id: "reset-all",
|
|
140
|
+
label: "Reset All",
|
|
141
|
+
value: "Reset",
|
|
142
|
+
description: "Restore all session capability overrides.",
|
|
143
|
+
action: { value: "Reset", label: "Restore all session overrides" },
|
|
144
|
+
}),
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
if (!skills) {
|
|
148
|
+
rows.push({
|
|
149
|
+
id: "skills-unavailable",
|
|
150
|
+
label: "Skills are not available",
|
|
151
|
+
currentValue: "",
|
|
152
|
+
description: "Load supi-skills to manage skills here.",
|
|
153
|
+
indent: 2,
|
|
154
|
+
searchText: "Skills are not available",
|
|
155
|
+
});
|
|
156
|
+
} else if (skills.length === 0) {
|
|
157
|
+
rows.push({
|
|
158
|
+
id: "no-skills",
|
|
159
|
+
label: "No eligible skills",
|
|
160
|
+
currentValue: "",
|
|
161
|
+
indent: 2,
|
|
162
|
+
searchText: "No eligible skills",
|
|
163
|
+
});
|
|
164
|
+
} else {
|
|
165
|
+
for (const skill of skills) {
|
|
166
|
+
rows.push({
|
|
167
|
+
id: `skill:${skill.name}`,
|
|
168
|
+
label: skill.name,
|
|
169
|
+
currentValue: hidden.has(skill.name) ? "Hidden" : "Visible",
|
|
170
|
+
description: skill.description,
|
|
171
|
+
indent: 2,
|
|
172
|
+
searchText: skill.name,
|
|
173
|
+
toggleValues: ["Visible", "Hidden"],
|
|
174
|
+
actions: [
|
|
175
|
+
{ value: "Visible", label: "Show skill" },
|
|
176
|
+
{ value: "Hidden", label: "Hide skill" },
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return rows;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Build the rows for one session capability section. */
|
|
185
|
+
export function buildCapabilityRows(
|
|
186
|
+
section: CapabilitySection,
|
|
187
|
+
tools: CapabilityToolItem[],
|
|
188
|
+
skills: SessionCapabilitySkill[] | undefined,
|
|
189
|
+
state: SessionCapabilityState,
|
|
190
|
+
): CapabilityRow[] {
|
|
191
|
+
return section === "tools" ? toolRows(tools, state) : skillRows(skills, state);
|
|
192
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { DynamicBorder, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
fuzzyFilter,
|
|
4
|
+
Input,
|
|
5
|
+
Key,
|
|
6
|
+
matchesKey,
|
|
7
|
+
type TuiMouseEvent,
|
|
8
|
+
type TuiMouseEventResult,
|
|
9
|
+
truncateToWidth,
|
|
10
|
+
visibleWidth,
|
|
11
|
+
} from "@earendil-works/pi-tui";
|
|
12
|
+
import type { SessionCapabilitySkill, SessionCapabilityState } from "@mrclrchtr/supi-core/session";
|
|
13
|
+
import {
|
|
14
|
+
createSelectListMenu,
|
|
15
|
+
formatTuiTitleLine,
|
|
16
|
+
renderDescriptionViewport,
|
|
17
|
+
renderSelectableRow,
|
|
18
|
+
renderTuiHintLine,
|
|
19
|
+
} from "@mrclrchtr/supi-core/tui";
|
|
20
|
+
import {
|
|
21
|
+
buildCapabilityRows,
|
|
22
|
+
type CapabilityRow,
|
|
23
|
+
type CapabilitySection,
|
|
24
|
+
type CapabilityToolItem,
|
|
25
|
+
} from "./session-capabilities-ui-rows.ts";
|
|
26
|
+
|
|
27
|
+
export type { CapabilityToolItem } from "./session-capabilities-ui-rows.ts";
|
|
28
|
+
|
|
29
|
+
const MAX_VISIBLE_ROWS = 10;
|
|
30
|
+
const HEADER_HEIGHT = 2;
|
|
31
|
+
|
|
32
|
+
interface SelectorComponent {
|
|
33
|
+
render(width: number): string[];
|
|
34
|
+
invalidate(): void;
|
|
35
|
+
handleInput(data: string): void;
|
|
36
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface CapabilitySelectorOptions {
|
|
40
|
+
done: (result: undefined) => void;
|
|
41
|
+
onChange: (id: string, value: string) => void;
|
|
42
|
+
skills: SessionCapabilitySkill[] | undefined;
|
|
43
|
+
state: SessionCapabilityState;
|
|
44
|
+
theme: ExtensionUIContext["theme"];
|
|
45
|
+
tools: CapabilityToolItem[];
|
|
46
|
+
tui: { requestRender(): void };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type RowLayout = { maxLabelWidth: number; maxIndent: number; width: number };
|
|
50
|
+
|
|
51
|
+
class CapabilityList implements SelectorComponent {
|
|
52
|
+
private rows: CapabilityRow[];
|
|
53
|
+
private selectedIndex = 0;
|
|
54
|
+
private menu: SelectorComponent | null = null;
|
|
55
|
+
private mousePressedIndex: number | undefined;
|
|
56
|
+
private readonly searchInput = new Input();
|
|
57
|
+
private searchQuery = "";
|
|
58
|
+
private cachedWidth?: number;
|
|
59
|
+
private cachedLines?: string[];
|
|
60
|
+
|
|
61
|
+
constructor(
|
|
62
|
+
private readonly section: CapabilitySection,
|
|
63
|
+
private readonly options: CapabilitySelectorOptions,
|
|
64
|
+
) {
|
|
65
|
+
this.rows = this.buildRows();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
hasOpenMenu(): boolean {
|
|
69
|
+
return this.menu !== null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
invalidate(): void {
|
|
73
|
+
this.cachedWidth = undefined;
|
|
74
|
+
this.cachedLines = undefined;
|
|
75
|
+
this.searchInput.invalidate();
|
|
76
|
+
this.menu?.invalidate();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
render(width: number): string[] {
|
|
80
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
81
|
+
this.cachedWidth = width;
|
|
82
|
+
this.cachedLines = this.menu ? this.menu.render(width) : this.renderRows(width);
|
|
83
|
+
return this.cachedLines;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
handleInput(data: string): void {
|
|
87
|
+
if (this.menu) {
|
|
88
|
+
this.menu.handleInput(data);
|
|
89
|
+
this.invalidate();
|
|
90
|
+
this.options.tui.requestRender();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (matchesKey(data, Key.escape)) {
|
|
94
|
+
this.options.done(undefined);
|
|
95
|
+
} else if (matchesKey(data, Key.up)) {
|
|
96
|
+
this.moveSelection(-1);
|
|
97
|
+
} else if (matchesKey(data, Key.down)) {
|
|
98
|
+
this.moveSelection(1);
|
|
99
|
+
} else if (matchesKey(data, Key.enter)) {
|
|
100
|
+
this.activateSelected();
|
|
101
|
+
} else if (data === " ") {
|
|
102
|
+
this.toggleSelected();
|
|
103
|
+
} else {
|
|
104
|
+
this.searchInput.handleInput(data);
|
|
105
|
+
this.searchQuery = this.searchInput.getValue();
|
|
106
|
+
this.selectedIndex = 0;
|
|
107
|
+
this.invalidate();
|
|
108
|
+
this.options.tui.requestRender();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
113
|
+
if (this.menu) return this.menu.handleMouse(event);
|
|
114
|
+
if (event.type === "wheel" && event.wheelDelta) return this.handleWheel(event.wheelDelta);
|
|
115
|
+
if (event.y === 0) return this.handleSearchMouse(event);
|
|
116
|
+
if (
|
|
117
|
+
event.y === 1 ||
|
|
118
|
+
event.button !== "left" ||
|
|
119
|
+
(event.type !== "press" && event.type !== "click")
|
|
120
|
+
) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
return this.handleRowMouse(event);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private buildRows(): CapabilityRow[] {
|
|
127
|
+
return buildCapabilityRows(
|
|
128
|
+
this.section,
|
|
129
|
+
this.options.tools,
|
|
130
|
+
this.options.skills,
|
|
131
|
+
this.options.state,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private filteredRows(): CapabilityRow[] {
|
|
136
|
+
if (!this.searchQuery) return this.rows;
|
|
137
|
+
const matches = fuzzyFilter(this.rows, this.searchQuery, (row) => row.searchText);
|
|
138
|
+
const matchingIds = new Set(matches.map((row) => row.id));
|
|
139
|
+
const parentIds = new Set(matches.flatMap((row) => (row.parentId ? [row.parentId] : [])));
|
|
140
|
+
return this.rows.filter((row) => matchingIds.has(row.id) || parentIds.has(row.id));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private visibleRange(length: number): { start: number; end: number } {
|
|
144
|
+
const maxVisible = Math.min(length, MAX_VISIBLE_ROWS);
|
|
145
|
+
const start = Math.max(
|
|
146
|
+
0,
|
|
147
|
+
Math.min(this.selectedIndex - Math.floor(maxVisible / 2), length - maxVisible),
|
|
148
|
+
);
|
|
149
|
+
return { start, end: Math.min(start + maxVisible, length) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private renderRows(width: number): string[] {
|
|
153
|
+
const lines = [...this.searchInput.render(width), ""];
|
|
154
|
+
const rows = this.filteredRows();
|
|
155
|
+
if (rows.length === 0) return this.renderEmptyRows(lines, width);
|
|
156
|
+
|
|
157
|
+
const { start, end } = this.visibleRange(rows.length);
|
|
158
|
+
const maxLabelWidth = Math.min(30, Math.max(...rows.map((row) => visibleWidth(row.label))));
|
|
159
|
+
const maxIndent = Math.max(...rows.map((row) => row.indent));
|
|
160
|
+
lines.push(
|
|
161
|
+
...this.renderVisibleRows(rows, { start, end }, { maxLabelWidth, maxIndent, width }),
|
|
162
|
+
);
|
|
163
|
+
if (start > 0 || end < rows.length) {
|
|
164
|
+
lines.push(this.options.theme.fg("dim", ` (${this.selectedIndex + 1}/${rows.length})`));
|
|
165
|
+
}
|
|
166
|
+
if (rows.some((row) => row.description)) {
|
|
167
|
+
lines.push("");
|
|
168
|
+
const description = renderDescriptionViewport(rows[this.selectedIndex]?.description, width);
|
|
169
|
+
lines.push(...description.map((line) => (line ? this.options.theme.fg("dim", line) : "")));
|
|
170
|
+
}
|
|
171
|
+
lines.push("", this.renderHint(width));
|
|
172
|
+
return lines;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private renderEmptyRows(lines: string[], width: number): string[] {
|
|
176
|
+
const text = this.searchQuery ? " No matching capabilities" : " No capabilities available";
|
|
177
|
+
lines.push(this.options.theme.fg("dim", text), "", this.renderHint(width));
|
|
178
|
+
return lines;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private renderVisibleRows(
|
|
182
|
+
rows: CapabilityRow[],
|
|
183
|
+
range: { start: number; end: number },
|
|
184
|
+
layout: RowLayout,
|
|
185
|
+
): string[] {
|
|
186
|
+
const lines: string[] = [];
|
|
187
|
+
for (let index = range.start; index < range.end; index++) {
|
|
188
|
+
const row = rows[index];
|
|
189
|
+
if (row) lines.push(this.renderRow(row, index, layout));
|
|
190
|
+
}
|
|
191
|
+
return lines;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private renderRow(row: CapabilityRow, index: number, layout: RowLayout): string {
|
|
195
|
+
return renderSelectableRow({
|
|
196
|
+
label: row.label,
|
|
197
|
+
value: row.currentValue,
|
|
198
|
+
indent: row.indent,
|
|
199
|
+
maxIndent: layout.maxIndent,
|
|
200
|
+
maxLabelWidth: layout.maxLabelWidth,
|
|
201
|
+
width: layout.width,
|
|
202
|
+
selected: index === this.selectedIndex,
|
|
203
|
+
theme: this.options.theme,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private renderHint(width: number): string {
|
|
208
|
+
return renderTuiHintLine(
|
|
209
|
+
["Type to search", "Enter for actions", "Space to toggle", "Tab to switch", "Esc to close"],
|
|
210
|
+
this.options.theme,
|
|
211
|
+
width,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private handleWheel(wheelDelta: number): TuiMouseEventResult {
|
|
216
|
+
const rows = this.filteredRows();
|
|
217
|
+
const previousIndex = this.selectedIndex;
|
|
218
|
+
this.selectedIndex = Math.max(
|
|
219
|
+
0,
|
|
220
|
+
Math.min(rows.length - 1, this.selectedIndex + (wheelDelta < 0 ? -1 : 1)),
|
|
221
|
+
);
|
|
222
|
+
const changed = previousIndex !== this.selectedIndex;
|
|
223
|
+
if (changed) this.invalidate();
|
|
224
|
+
return { handled: true, render: changed };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private handleSearchMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
228
|
+
const result = this.searchInput.handleMouse(event);
|
|
229
|
+
return result ? { ...result, focus: true } : undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private handleRowMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
233
|
+
const rows = this.filteredRows();
|
|
234
|
+
if (rows.length === 0) return undefined;
|
|
235
|
+
const { start, end } = this.visibleRange(rows.length);
|
|
236
|
+
const rowIndex = start + event.y - 2;
|
|
237
|
+
if (rowIndex < start || rowIndex >= end) return undefined;
|
|
238
|
+
if (event.type === "press") {
|
|
239
|
+
this.mousePressedIndex = rowIndex;
|
|
240
|
+
this.selectedIndex = rowIndex;
|
|
241
|
+
this.invalidate();
|
|
242
|
+
return { handled: true, focus: true };
|
|
243
|
+
}
|
|
244
|
+
this.selectedIndex = this.mousePressedIndex ?? rowIndex;
|
|
245
|
+
this.mousePressedIndex = undefined;
|
|
246
|
+
const row = rows[this.selectedIndex];
|
|
247
|
+
if (row?.toggleValues) this.toggleSelected();
|
|
248
|
+
else this.activateSelected();
|
|
249
|
+
return { handled: true };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private moveSelection(delta: number): void {
|
|
253
|
+
const rows = this.filteredRows();
|
|
254
|
+
if (rows.length === 0) return;
|
|
255
|
+
this.selectedIndex =
|
|
256
|
+
delta < 0
|
|
257
|
+
? this.selectedIndex <= 0
|
|
258
|
+
? rows.length - 1
|
|
259
|
+
: this.selectedIndex - 1
|
|
260
|
+
: this.selectedIndex >= rows.length - 1
|
|
261
|
+
? 0
|
|
262
|
+
: this.selectedIndex + 1;
|
|
263
|
+
this.invalidate();
|
|
264
|
+
this.options.tui.requestRender();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private toggleSelected(): void {
|
|
268
|
+
const row = this.filteredRows()[this.selectedIndex];
|
|
269
|
+
if (!row?.toggleValues) return;
|
|
270
|
+
const [enabledValue, disabledValue] = row.toggleValues;
|
|
271
|
+
this.options.onChange(row.id, row.currentValue === enabledValue ? disabledValue : enabledValue);
|
|
272
|
+
this.refreshRows(row.id);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private activateSelected(): void {
|
|
276
|
+
const row = this.filteredRows()[this.selectedIndex];
|
|
277
|
+
if (!row?.actions?.length) return;
|
|
278
|
+
this.menu = createSelectListMenu({
|
|
279
|
+
title: "Actions",
|
|
280
|
+
items: row.actions,
|
|
281
|
+
theme: this.options.theme,
|
|
282
|
+
onSelect: (item) => {
|
|
283
|
+
this.menu = null;
|
|
284
|
+
this.options.onChange(row.id, item.value);
|
|
285
|
+
this.refreshRows(row.id);
|
|
286
|
+
},
|
|
287
|
+
onCancel: () => {
|
|
288
|
+
this.menu = null;
|
|
289
|
+
this.invalidate();
|
|
290
|
+
this.options.tui.requestRender();
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
this.invalidate();
|
|
294
|
+
this.options.tui.requestRender();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private refreshRows(selectedId: string): void {
|
|
298
|
+
this.rows = this.buildRows();
|
|
299
|
+
const rows = this.filteredRows();
|
|
300
|
+
const selectedIndex = rows.findIndex((row) => row.id === selectedId);
|
|
301
|
+
this.selectedIndex =
|
|
302
|
+
selectedIndex >= 0 ? selectedIndex : Math.min(this.selectedIndex, rows.length - 1);
|
|
303
|
+
this.invalidate();
|
|
304
|
+
this.options.tui.requestRender();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Build the searchable selector for the current session. */
|
|
309
|
+
export function createCapabilitySelector(options: CapabilitySelectorOptions): SelectorComponent {
|
|
310
|
+
const { theme, tui } = options;
|
|
311
|
+
let section: CapabilitySection = "tools";
|
|
312
|
+
let list = new CapabilityList(section, options);
|
|
313
|
+
const topBorder = new DynamicBorder((text: string) => theme.fg("borderMuted", text));
|
|
314
|
+
const bottomBorder = new DynamicBorder((text: string) => theme.fg("borderMuted", text));
|
|
315
|
+
|
|
316
|
+
const renderTitle = (width: number): string => {
|
|
317
|
+
const tabs =
|
|
318
|
+
section === "tools"
|
|
319
|
+
? `${theme.fg("accent", theme.bold("[Tools]"))} ${theme.fg("dim", "Skills")}`
|
|
320
|
+
: `${theme.fg("dim", "Tools")} ${theme.fg("accent", theme.bold("[Skills]"))}`;
|
|
321
|
+
return truncateToWidth(formatTuiTitleLine("Session Capabilities", theme, tabs), width);
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
render(width) {
|
|
326
|
+
return [
|
|
327
|
+
...topBorder.render(width),
|
|
328
|
+
renderTitle(width),
|
|
329
|
+
...list.render(width),
|
|
330
|
+
...bottomBorder.render(width),
|
|
331
|
+
];
|
|
332
|
+
},
|
|
333
|
+
invalidate() {
|
|
334
|
+
topBorder.invalidate();
|
|
335
|
+
bottomBorder.invalidate();
|
|
336
|
+
list.invalidate();
|
|
337
|
+
},
|
|
338
|
+
handleInput(data) {
|
|
339
|
+
if (matchesKey(data, Key.tab) && !list.hasOpenMenu()) {
|
|
340
|
+
section = section === "tools" ? "skills" : "tools";
|
|
341
|
+
list = new CapabilityList(section, options);
|
|
342
|
+
tui.requestRender();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
list.handleInput(data);
|
|
346
|
+
},
|
|
347
|
+
handleMouse(event) {
|
|
348
|
+
if (event.y < HEADER_HEIGHT) return undefined;
|
|
349
|
+
return list.handleMouse({
|
|
350
|
+
...event,
|
|
351
|
+
y: event.y - HEADER_HEIGHT,
|
|
352
|
+
height: Math.max(0, event.height - HEADER_HEIGHT - 1),
|
|
353
|
+
});
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
}
|