Parent directory

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}