index.ts
  1  /**
  2   * Command Center Extension
  3   *
  4   * A scrollable commands cheat sheet shown as a widget above the editor.
  5   *
  6   * Keybindings are configured in ./config.json (relative to this file).
  7   */
  8  
  9  import type { ExtensionAPI, ExtensionContext, SlashCommandInfo } from "@mariozechner/pi-coding-agent";
 10  import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
 11  
 12  import * as fs from "node:fs";
 13  import * as path from "node:path";
 14  import { fileURLToPath } from "node:url";
 15  
 16  // Note: pi.getCommands() does NOT include built-in interactive commands (e.g. /model, /settings)
 17  // because those do not execute when sent via prompt. Until the extension API exposes built-ins,
 18  // we keep a small local list in case includeBuiltins is configured true
 19  const BUILTIN_COMMANDS: string[] = [
 20      "/settings",
 21      "/model",
 22      "/scoped-models",
 23      "/name",
 24      "/session",
 25      "/reload",
 26      "/compact",
 27      "/tree",
 28      "/fork",
 29      "/new",
 30      "/resume",
 31      "/export",
 32      "/copy",
 33      "/share",
 34      "/hotkeys",
 35      "/changelog",
 36      "/login",
 37      "/logout",
 38  ];
 39  
 40  type ExtensionKeybindingsConfig = {
 41      toggle?: string | null;
 42      scrollUp?: string | null;
 43      scrollDown?: string | null;
 44      scrollPageUp?: string | null;
 45      scrollPageDown?: string | null;
 46  };
 47  
 48  type ExtensionLayoutConfig = {
 49      /**
 50       * Fixed widget height in rows.
 51       *
 52       * If omitted, height is computed from terminal height.
 53       */
 54      height?: number | null;
 55  };
 56  
 57  type ExtensionDisplayConfig = {
 58      /**
 59       * Whether to include built-in interactive commands in the widget output
 60       *
 61       * Recommended default: false
 62       * - Built-ins are already discoverable via the editor's native `/` autocomplete
 63       * - Keeping built-ins here requires manually maintaining a list as pi evolves
 64       */
 65      includeBuiltins?: boolean;
 66  };
 67  
 68  type ExtensionConfig = {
 69      keybindings?: ExtensionKeybindingsConfig;
 70      layout?: ExtensionLayoutConfig;
 71      display?: ExtensionDisplayConfig;
 72  };
 73  
 74  const DEFAULT_CONFIG: Required<ExtensionConfig> = {
 75      keybindings: {
 76          toggle: "ctrl+/",
 77          scrollUp: "shift+up",
 78          scrollDown: "shift+down",
 79          scrollPageUp: null,
 80          scrollPageDown: null,
 81      },
 82      layout: {
 83          height: null,
 84      },
 85      display: {
 86          includeBuiltins: false,
 87      },
 88  };
 89  
 90  function loadConfig(): Required<ExtensionConfig> {
 91      const dir = path.dirname(fileURLToPath(import.meta.url));
 92      const configPath = path.join(dir, "config.json");
 93  
 94      if (!fs.existsSync(configPath)) {
 95          return DEFAULT_CONFIG;
 96      }
 97  
 98      try {
 99          const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8")) as ExtensionConfig;
100          const keybindings = {
101              ...DEFAULT_CONFIG.keybindings,
102              ...(parsed.keybindings ?? {}),
103          };
104          const layout = {
105              ...DEFAULT_CONFIG.layout,
106              ...(parsed.layout ?? {}),
107          };
108          const display = {
109              ...DEFAULT_CONFIG.display,
110              ...(parsed.display ?? {}),
111          };
112          return { keybindings, layout, display };
113      } catch {
114          // If config is invalid, fall back to defaults rather than breaking the session
115          return DEFAULT_CONFIG;
116      }
117  }
118  
119  function visLen(s: string): number {
120      return visibleWidth(s);
121  }
122  
123  function padRight(s: string, width: number): string {
124      const visible = visLen(s);
125      const padding = Math.max(0, width - visible);
126      return s + " ".repeat(padding);
127  }
128  
129  function makeColumns(items: string[], colWidth: number, maxCols: number): string[] {
130      const lines: string[] = [];
131  
132      // Fill columns vertically (column-major) so alphabetical lists read top-to-bottom
133      // within each column:
134      // col1 col2 col3
135      // a    e    i
136      // b    f    j
137      // ...
138      const rows = Math.ceil(items.length / maxCols);
139  
140      for (let rowIndex = 0; rowIndex < rows; rowIndex++) {
141          const row: string[] = [];
142          for (let colIndex = 0; colIndex < maxCols; colIndex++) {
143              const itemIndex = colIndex * rows + rowIndex;
144              row.push(itemIndex < items.length ? items[itemIndex] : "");
145          }
146          lines.push(row.map((s) => padRight(s, colWidth)).join(""));
147      }
148  
149      return lines;
150  }
151  
152  function clamp(n: number, min: number, max: number): number {
153      return Math.max(min, Math.min(max, n));
154  }
155  
156  function truncatePlain(s: string, maxVisibleChars: number): string {
157      if (s.length <= maxVisibleChars) return s;
158      if (maxVisibleChars <= 1) return "…";
159      return s.slice(0, maxVisibleChars - 1) + "…";
160  }
161  
162  function sortCommandStrings(values: string[]): string[] {
163      return [...values].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
164  }
165  
166  function buildAllLines(width: number, commands: SlashCommandInfo[], options: { includeBuiltins: boolean }): string[] {
167      const lines: string[] = [];
168      const g = (s: string) => `\x1b[32m${s}\x1b[0m`; // green
169      const c = (s: string) => `\x1b[36m${s}\x1b[0m`; // cyan
170      const y = (s: string) => `\x1b[33m${s}\x1b[0m`; // yellow
171      const b = (s: string) => `\x1b[1m${s}\x1b[0m`; // bold
172  
173      const usableWidth = Math.max(60, width - 6);
174  
175      const builtins = BUILTIN_COMMANDS;
176      const extensions = sortCommandStrings(
177          commands.filter((command) => command.source === "extension").map((command) => `/${command.name}`),
178      );
179      const prompts = sortCommandStrings(
180          commands.filter((command) => command.source === "prompt").map((command) => `/${command.name}`),
181      );
182      const skills = sortCommandStrings(
183          commands.filter((command) => command.source === "skill").map((command) => `/${command.name}`),
184      );
185  
186      // Order: extensions -> prompts -> skills -> builtins (optional)
187  
188      lines.push(y(b(`EXTENSIONS (${extensions.length})`)));
189      {
190          const maxItemLen = extensions.length > 0 ? Math.max(...extensions.map((s) => s.length)) : 0;
191          const colWidth = clamp(maxItemLen + 2, 15, 34);
192          const cols = Math.min(6, Math.max(1, Math.floor(usableWidth / colWidth)));
193          const items = extensions.map((s) => g(truncatePlain(s, colWidth - 1)));
194          for (const line of makeColumns(items, colWidth, cols)) {
195              lines.push("  " + line);
196          }
197      }
198      lines.push("");
199  
200      lines.push(y(b(`PROMPTS (${prompts.length})`)));
201      if (prompts.length > 0) {
202          const maxItemLen = Math.max(...prompts.map((s) => s.length));
203          const colWidth = clamp(maxItemLen + 2, 18, 40);
204          const cols = Math.max(1, Math.floor(usableWidth / colWidth));
205          const items = prompts.map((s) => c(truncatePlain(s, colWidth - 1)));
206          for (const line of makeColumns(items, colWidth, cols)) {
207              lines.push("  " + line);
208          }
209      }
210      lines.push("");
211  
212      lines.push(y(b(`SKILLS (${skills.length})`)));
213      if (skills.length > 0) {
214          const maxItemLen = Math.max(...skills.map((s) => s.length));
215          const colWidth = clamp(maxItemLen + 2, 18, 40);
216          const cols = Math.max(1, Math.floor(usableWidth / colWidth));
217          const items = skills.map((s) => c(truncatePlain(s, colWidth - 1)));
218          for (const line of makeColumns(items, colWidth, cols)) {
219              lines.push("  " + line);
220          }
221      }
222      if (options.includeBuiltins) {
223          lines.push("");
224  
225          lines.push(y(b(`BUILT-IN (${builtins.length})`)));
226          {
227              const maxItemLen = builtins.length > 0 ? Math.max(...builtins.map((s) => s.length)) : 0;
228              const colWidth = clamp(maxItemLen + 2, 14, 24);
229              const cols = Math.min(7, Math.max(1, Math.floor(usableWidth / colWidth)));
230              const items = builtins.map((s) => g(truncatePlain(s, colWidth - 1)));
231              for (const line of makeColumns(items, colWidth, cols)) {
232                  lines.push("  " + line);
233              }
234          }
235      }
236  
237      return lines;
238  }
239  
240  type WidgetTheme = {
241      fg: (style: string, text: string) => string;
242      bold: (text: string) => string;
243  };
244  
245  type WidgetTui = {
246      height?: number;
247      requestRender: () => void;
248  };
249  
250  function prettyKeybinding(key: string | null | undefined): string {
251      if (!key) return "(unbound)";
252  
253      // make a few things more readable
254      return key
255          .replaceAll("pageUp", "PgUp")
256          .replaceAll("pageDown", "PgDn")
257          .replaceAll("shift+", "Shift+")
258          .replaceAll("alt+", "Alt+")
259          .replaceAll("ctrl+", "Ctrl+")
260          .replaceAll("up", "↑")
261          .replaceAll("down", "↓")
262          .replaceAll("left", "←")
263          .replaceAll("right", "→");
264  }
265  
266  class CommandCenterWidget {
267      private tui: WidgetTui;
268      private theme: WidgetTheme;
269      private pi: ExtensionAPI;
270      private config: Required<ExtensionConfig>;
271  
272      private scroll: number = 0;
273      private cachedWidth: number = 0;
274      private cachedLines: string[] = [];
275  
276      constructor(tui: WidgetTui, theme: WidgetTheme, pi: ExtensionAPI, config: Required<ExtensionConfig>) {
277          this.tui = tui;
278          this.theme = theme;
279          this.pi = pi;
280          this.config = config;
281      }
282  
283      updateTheme(theme: WidgetTheme): void {
284          this.theme = theme;
285          this.invalidate();
286      }
287  
288      updateConfig(config: Required<ExtensionConfig>): void {
289          this.config = config;
290          this.invalidate();
291      }
292  
293      invalidate(): void {
294          this.cachedWidth = 0;
295          this.cachedLines = [];
296      }
297  
298      scrollBy(delta: number): void {
299          this.scroll += delta;
300          this.tui.requestRender();
301      }
302  
303      render(width: number): string[] {
304          const terminalHeight = this.tui.height ?? 54;
305  
306          // Keep at least a few rows for the editor
307          const maxAllowedHeight = Math.max(10, terminalHeight - 6);
308  
309          const configuredHeight = this.config.layout.height;
310          const height = configuredHeight
311              ? clamp(Math.floor(configuredHeight), 6, maxAllowedHeight)
312              : clamp(Math.floor(terminalHeight * 0.35) + 2, 10, Math.min(18, maxAllowedHeight));
313          const innerHeight = Math.max(3, height - 4);
314  
315          if (width !== this.cachedWidth) {
316              this.cachedLines = buildAllLines(width, this.pi.getCommands(), {
317                  includeBuiltins: this.config.display.includeBuiltins,
318              });
319              this.cachedWidth = width;
320          }
321  
322          const maxScroll = Math.max(0, this.cachedLines.length - innerHeight);
323          this.scroll = clamp(this.scroll, 0, maxScroll);
324  
325          const output: string[] = [];
326  
327          const toggleKey = prettyKeybinding(this.config.keybindings.toggle);
328          const scrollUpKey = prettyKeybinding(this.config.keybindings.scrollUp);
329          const scrollDownKey = prettyKeybinding(this.config.keybindings.scrollDown);
330  
331          const builtinHint = this.config.display.includeBuiltins
332              ? "built-ins included"
333              : "built-ins: type / in editor";
334  
335          const header =
336              this.theme.fg("accent", this.theme.bold("COMMAND CENTER")) +
337              this.theme.fg("dim", `  (toggle ${toggleKey}, scroll ${scrollUpKey}/${scrollDownKey}; ${builtinHint})`);
338  
339          output.push(this.theme.fg("dim", "┌" + "─".repeat(width - 2) + "┐"));
340          output.push(
341              this.theme.fg("dim", "│ ") +
342                  truncateToWidth(header, width - 4, "…", true) +
343                  this.theme.fg("dim", " │"),
344          );
345          output.push(this.theme.fg("dim", "├" + "─".repeat(width - 2) + "┤"));
346  
347          const visible = this.cachedLines.slice(this.scroll, this.scroll + innerHeight);
348          for (const line of visible) {
349              const content = truncateToWidth(line, width - 4, "…", true);
350              output.push(this.theme.fg("dim", "│ ") + content + this.theme.fg("dim", " │"));
351          }
352  
353          for (let i = visible.length; i < innerHeight; i++) {
354              output.push(this.theme.fg("dim", "│") + " ".repeat(width - 2) + this.theme.fg("dim", "│"));
355          }
356  
357          const scrollInfo =
358              maxScroll > 0 ? ` ${this.scroll + 1}-${this.scroll + visible.length}/${this.cachedLines.length} ` : "";
359          const footerPad = Math.max(0, width - 2 - scrollInfo.length);
360          output.push(
361              this.theme.fg(
362                  "dim",
363                  "└" +
364                      "─".repeat(Math.floor(footerPad / 2)) +
365                      scrollInfo +
366                      "─".repeat(Math.ceil(footerPad / 2)) +
367                      "┘",
368              ),
369          );
370  
371          return output.slice(0, height);
372      }
373  }
374  
375  export default function commandCenterExtension(pi: ExtensionAPI): void {
376      const WIDGET_ID = "command-center";
377  
378      let widget: CommandCenterWidget | undefined;
379      let visible = false;
380  
381      const readConfigAndUpdateWidget = () => {
382          const config = loadConfig();
383          if (widget) {
384              widget.updateConfig(config);
385          }
386          return config;
387      };
388  
389      const show = (ctx: ExtensionContext) => {
390          const config = readConfigAndUpdateWidget();
391  
392          ctx.ui.setWidget(
393              WIDGET_ID,
394              (tui, theme) => {
395                  if (!widget) {
396                      widget = new CommandCenterWidget(
397                          tui as unknown as WidgetTui,
398                          theme as unknown as WidgetTheme,
399                          pi,
400                          config,
401                          () => hide(ctx),
402                      );
403                  } else {
404                      widget.updateTheme(theme as unknown as WidgetTheme);
405                      widget.updateConfig(config);
406                  }
407                  return widget as any;
408              },
409              { placement: "aboveEditor" },
410          );
411          visible = true;
412      };
413  
414      const hide = (ctx: ExtensionContext) => {
415          ctx.ui.setWidget(WIDGET_ID, undefined);
416          visible = false;
417          widget = undefined;
418      };
419  
420      const toggle = (ctx: ExtensionContext) => {
421          if (visible) {
422              hide(ctx);
423          } else {
424              show(ctx);
425          }
426      };
427  
428      pi.registerCommand("command-center", {
429          description: "Toggle command center widget",
430          handler: async (_args, ctx) => {
431              toggle(ctx);
432          },
433      });
434  
435      // Shortcut bindings from config.json
436      const config = loadConfig();
437  
438      const registerIfSet = (
439          key: string | null | undefined,
440          description: string,
441          handler: (ctx: ExtensionContext) => void,
442      ) => {
443          if (!key) return;
444          pi.registerShortcut(key as any, { description, handler });
445      };
446  
447      registerIfSet(config.keybindings.toggle, "Toggle command center widget", toggle);
448  
449      registerIfSet(config.keybindings.scrollUp, "Scroll command center up", () => {
450          if (!visible || !widget) return;
451          widget.scrollBy(-1);
452      });
453  
454      registerIfSet(config.keybindings.scrollDown, "Scroll command center down", () => {
455          if (!visible || !widget) return;
456          widget.scrollBy(1);
457      });
458  
459      registerIfSet(config.keybindings.scrollPageUp, "Scroll command center up (page)", () => {
460          if (!visible || !widget) return;
461          widget.scrollBy(-10);
462      });
463  
464      registerIfSet(config.keybindings.scrollPageDown, "Scroll command center down (page)", () => {
465          if (!visible || !widget) return;
466          widget.scrollBy(10);
467      });
468  }