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 }