overlay.ts
9572 bytes
1import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2import { Key, matchesKey, type TUI } from "@earendil-works/pi-tui";
3import type { SkillInvocationMode, SkillRecord, SkillToggleUiResult } from "../types.ts";
4import { formatSourceKind } from "../inventory/classifier.ts";
5import { bottomBorder, combineColumns, divider, fit, frameLine, topBorder } from "./render.ts";
6import { filterSkills, modeLabel, toggleMode } from "./view-model.ts";
7
8export async function showSkillToggleUi(ctx: ExtensionContext, skills: SkillRecord[]): Promise<SkillToggleUiResult> {
9 return ctx.ui.custom<SkillToggleUiResult>(
10 (tui, theme, _keybindings, done) => new SkillToggleOverlay(tui, theme, skills, done),
11 {
12 overlay: true,
13 overlayOptions: {
14 anchor: "center",
15 width: "92%",
16 maxHeight: "88%",
17 minWidth: 86,
18 },
19 },
20 );
21}
22
23class SkillToggleOverlay {
24 private readonly desired = new Map<string, SkillInvocationMode>();
25 private search = "";
26 private selectedIndex = 0;
27
28 constructor(
29 private readonly tui: TUI,
30 private readonly theme: Theme,
31 private readonly skills: SkillRecord[],
32 private readonly done: (result: SkillToggleUiResult) => void,
33 ) {
34 for (const skill of skills) this.desired.set(skill.id, skill.mode);
35 }
36
37 handleInput(data: string): void {
38 if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
39 this.done({ action: "cancel", drafts: this.getDrafts() });
40 return;
41 }
42
43 if (matchesKey(data, Key.ctrl("s"))) {
44 this.done({ action: "apply", drafts: this.getDrafts() });
45 return;
46 }
47
48 if (matchesKey(data, Key.up)) {
49 this.moveSelection(-1);
50 return;
51 }
52
53 if (matchesKey(data, Key.down)) {
54 this.moveSelection(1);
55 return;
56 }
57
58 if (matchesKey(data, Key.space)) {
59 const selected = this.getSelectedSkill();
60 if (selected?.editable) {
61 this.desired.set(selected.id, toggleMode(this.desired.get(selected.id) ?? selected.mode));
62 this.tui.requestRender();
63 }
64 return;
65 }
66
67 if (matchesKey(data, Key.backspace)) {
68 if (this.search.length > 0) {
69 this.search = Array.from(this.search).slice(0, -1).join("");
70 this.selectedIndex = 0;
71 this.tui.requestRender();
72 }
73 return;
74 }
75
76 if (isPrintableInput(data)) {
77 this.search += data;
78 this.selectedIndex = 0;
79 this.tui.requestRender();
80 }
81 }
82
83 render(width: number): string[] {
84 const innerWidth = Math.max(20, width - 2);
85 const panelHeight = this.getPanelHeight();
86 const bodyHeight = Math.max(10, panelHeight - 8);
87 const leftWidth = Math.max(32, Math.floor((innerWidth - 1) * 0.48));
88 const rightWidth = Math.max(28, innerWidth - leftWidth - 1);
89
90 const header = this.renderHeader(innerWidth);
91 const search = frameLine(this.theme, this.theme.fg("muted", `Search: ${this.search || "(type to filter)"}`), innerWidth);
92 const body = combineColumns(
93 this.renderList(leftWidth, bodyHeight),
94 this.renderDetails(rightWidth, bodyHeight),
95 leftWidth,
96 rightWidth,
97 this.theme.fg("borderMuted", "│"),
98 ).map((line) => frameLine(this.theme, line, innerWidth));
99
100 const footer = [
101 frameLine(this.theme, this.theme.fg("dim", "type search • ↑↓ move • space toggle • ctrl+s apply + reload"), innerWidth),
102 frameLine(this.theme, this.theme.fg("dim", "esc cancel"), innerWidth),
103 ];
104
105 return [
106 topBorder(this.theme, innerWidth),
107 frameLine(this.theme, header, innerWidth),
108 search,
109 divider(this.theme, innerWidth),
110 ...body,
111 divider(this.theme, innerWidth),
112 ...footer,
113 bottomBorder(this.theme, innerWidth),
114 ];
115 }
116
117 invalidate(): void {}
118
119 private renderHeader(innerWidth: number): string {
120 const title = this.theme.fg("accent", this.theme.bold("Pi Skill Toggle"));
121 const changed = this.getChangedCount();
122 const editable = this.skills.filter((skill) => skill.editable).length;
123 const summary = this.theme.fg("muted", `${this.skills.length} skills • ${editable} editable • ${changed} changed`);
124 const gap = Math.max(1, innerWidth - visibleLength(title) - visibleLength(summary));
125 return `${title}${" ".repeat(gap)}${summary}`;
126 }
127
128 private renderList(width: number, height: number): string[] {
129 const lines: string[] = [];
130 const filtered = this.getFilteredSkills();
131
132 if (filtered.length === 0) {
133 lines.push(this.theme.fg("dim", "No matching skills"));
134 return pad(lines, height);
135 }
136
137 this.selectedIndex = clamp(this.selectedIndex, 0, filtered.length - 1);
138 const visibleCount = Math.max(4, Math.floor(height / 2));
139 const start = Math.max(0, Math.min(this.selectedIndex - Math.floor(visibleCount / 2), Math.max(0, filtered.length - visibleCount)));
140 const end = Math.min(filtered.length, start + visibleCount);
141
142 for (let i = start; i < end; i += 1) {
143 const skill = filtered[i]!;
144 const desired = this.desired.get(skill.id) ?? skill.mode;
145 const selected = i === this.selectedIndex;
146 const changed = desired !== skill.mode;
147 const marker = selected ? "›" : " ";
148 const box = desired === "manual-only" ? "◼" : "□";
149 const readonly = skill.editable ? "" : this.theme.fg("warning", " read-only");
150 const changedMark = changed ? this.theme.fg("accent", " *") : "";
151 const label = `${marker} ${box} ${skill.name}${changedMark}${readonly}`;
152 lines.push(selected ? this.theme.fg("accent", this.theme.bold(fit(label, width))) : fit(label, width));
153 lines.push(this.theme.fg("dim", fit(` ${modeLabel(desired)} — ${shorten(skill.description || "No description", width - 4)}`, width)));
154 }
155
156 return pad(lines, height);
157 }
158
159 private renderDetails(width: number, height: number): string[] {
160 const skill = this.getSelectedSkill();
161 const lines: string[] = [];
162 if (!skill) {
163 lines.push(this.theme.fg("dim", "No skill selected"));
164 return pad(lines, height);
165 }
166
167 const desired = this.desired.get(skill.id) ?? skill.mode;
168 lines.push(this.theme.fg("accent", this.theme.bold(skill.name)));
169 lines.push("");
170 lines.push(`${this.theme.fg("muted", "Current:")} ${modeLabel(skill.mode)}`);
171 lines.push(`${this.theme.fg("muted", "Desired:")} ${modeLabel(desired)}${desired !== skill.mode ? this.theme.fg("accent", " (changed)") : ""}`);
172 lines.push(`${this.theme.fg("muted", "Source:")} ${formatSourceKind(skill.source.kind)}`);
173 lines.push(`${this.theme.fg("muted", "Root:")} ${skill.source.root}`);
174 lines.push(`${this.theme.fg("muted", "Editable:")} ${skill.editable ? "yes" : this.theme.fg("warning", "no")}`);
175 lines.push("");
176 lines.push(this.theme.fg("muted", "Path:"));
177 lines.push(...wrap(skill.filePath, width));
178 lines.push("");
179 lines.push(this.theme.fg("muted", "Description:"));
180 lines.push(...wrap(skill.description || "(missing)", width));
181
182 if (skill.diagnostics.length > 0) {
183 lines.push("");
184 lines.push(this.theme.fg("muted", "Diagnostics:"));
185 for (const diagnostic of skill.diagnostics.slice(0, 4)) {
186 const color = diagnostic.severity === "error" ? "error" : diagnostic.severity === "warning" ? "warning" : "dim";
187 lines.push(...wrap(`- ${diagnostic.message}`, width).map((line) => this.theme.fg(color, line)));
188 }
189 }
190
191 return pad(lines, height);
192 }
193
194 private moveSelection(delta: number): void {
195 const filtered = this.getFilteredSkills();
196 if (filtered.length === 0) return;
197 this.selectedIndex = clamp(this.selectedIndex + delta, 0, filtered.length - 1);
198 this.tui.requestRender();
199 }
200
201 private getFilteredSkills(): SkillRecord[] {
202 return filterSkills(this.skills, this.search);
203 }
204
205 private getSelectedSkill(): SkillRecord | undefined {
206 return this.getFilteredSkills()[this.selectedIndex];
207 }
208
209 private getDrafts() {
210 return this.skills.map((skill) => ({ skill, desiredMode: this.desired.get(skill.id) ?? skill.mode }));
211 }
212
213 private getChangedCount(): number {
214 return this.skills.filter((skill) => (this.desired.get(skill.id) ?? skill.mode) !== skill.mode).length;
215 }
216
217 private getPanelHeight(): number {
218 const rows = this.tui.terminal.rows ?? 30;
219 return clamp(Math.floor(rows * 0.82), 16, 52);
220 }
221}
222
223function isPrintableInput(data: string): boolean {
224 return data.length > 0 && !data.includes("\x1b") && !data.includes("\r") && !data.includes("\n") && data >= " ";
225}
226
227function clamp(value: number, min: number, max: number): number {
228 return Math.max(min, Math.min(max, value));
229}
230
231function pad(lines: string[], height: number): string[] {
232 const padded = [...lines];
233 while (padded.length < height) padded.push("");
234 return padded.slice(0, height);
235}
236
237function shorten(text: string, width: number): string {
238 return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`;
239}
240
241function wrap(text: string, width: number): string[] {
242 const words = text.split(/\s+/).filter(Boolean);
243 if (words.length === 0) return [""];
244 const lines: string[] = [];
245 let current = "";
246 for (const word of words) {
247 if (current.length === 0) {
248 current = word;
249 } else if (`${current} ${word}`.length <= width) {
250 current = `${current} ${word}`;
251 } else {
252 lines.push(current);
253 current = word;
254 }
255 }
256 if (current) lines.push(current);
257 return lines;
258}
259
260function visibleLength(input: string): number {
261 return input.replace(/\x1b\[[0-9;]*m/g, "").length;
262}