import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { CONFIG_DIR_NAME, getAgentDir, VERSION, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type Theme, type ThemeColor, } from "@earendil-works/pi-coding-agent"; import { Key, truncateToWidth, visibleWidth, type Component, type TUI, } from "@earendil-works/pi-tui"; /** * Main Menu * * Replaces pi's startup header with a small, configurable welcome screen. * The screen is intentionally driven by JSON so the art and copy can be * changed without touching this extension. * * Project config: /.pi/main-menu.json * Global config: ~/.pi/agent/main-menu.json * * Project config wins over global config field-by-field. Run `/welcome` (or * press Ctrl+Shift+M) to edit it from inside pi. * * The header is live: `{time}` ticks once per minute, the model line follows * `/model` switches, and the session name updates after `/name`. */ const CONFIG_FILE_NAME = "main-menu.json"; const SETTINGS_FILE_NAME = "settings.json"; // Rasterized from the supplied logo.svg at 28 × 14 cells. // Keep every row the same width so the stepped logo stays aligned when centered. const DEFAULT_ART = [ "█████████████████████ ", "█████████████████████ ", "█████████████████████ ", "█████████████████████ ", "████████ ███████ ", "████████ ███████ ", "████████ ███████ ", "██████████████ ███████", "██████████████ ███████", "██████████████ ███████", "██████████████ ███████", "████████ ███████", "████████ ███████", "████████ ███████", ]; const DEFAULT_GREETINGS = [ "Welcome back.", "Good to see you.", "Let's make something useful.", "What are we building today?", "Ready when you are.", "Back to the keyboard.", "Your project awaits.", "Let's make something great.", "Fresh session, fresh ideas.", "Hello again, builder.", "Another day, another build.", ]; const DEFAULT_HINTS = ["/welcome customize · Ctrl+Shift+M menu"]; // Theme colors that look reasonable for block art. `artColor` in the config // is validated against this list; unknown values warn and fall back to "text". const ART_COLORS = [ "accent", "text", "muted", "dim", "success", "error", "warning", "border", "borderMuted", "borderAccent", "toolTitle", "mdHeading", ] as const satisfies readonly ThemeColor[]; type ArtColorName = (typeof ART_COLORS)[number]; interface RawConfig { [key: string]: unknown; } interface MenuConfig { greetings: string[]; art: string[]; showArt: boolean; artColor: ThemeColor; subtitle: string; prompt: string; hints: string[]; showHints: boolean; showContext: boolean; showModel: boolean; showClock: boolean; } interface ConfigDocument { path: string; data: RawConfig; exists: boolean; } interface LoadedMenu { config: MenuConfig; global: ConfigDocument; project: ConfigDocument | undefined; settingsPath: string; quietStartup: boolean; warnings: string[]; } interface MenuState { loaded: LoadedMenu | undefined; greeting: string; headerEnabled: boolean; } type ConfigPatch = Record; const isRecord = (value: unknown): value is RawConfig => typeof value === "object" && value !== null && !Array.isArray(value); function oneLine(value: string): string { return value.replace(/\r?\n|\r/g, " "); } function nonEmptyStrings(value: unknown): string[] { if (!Array.isArray(value)) return []; return value .filter((item): item is string => typeof item === "string" && item.trim().length > 0) .map(oneLine); } function linesFrom(value: unknown): string[] | undefined { if (typeof value === "string") { return value.length === 0 ? [] : value.replace(/\r\n/g, "\n").split("\n"); } if (Array.isArray(value)) { return value.flatMap((item) => typeof item === "string" ? item.replace(/\r\n/g, "\n").split("\n") : [], ); } return undefined; } function booleanOr(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } function textOr(value: unknown, fallback: string): string { return typeof value === "string" ? oneLine(value) : fallback; } function readDocument(path: string, warnings: string[]): ConfigDocument { if (!existsSync(path)) { return { path, data: {}, exists: false }; } try { const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); if (!isRecord(parsed)) { warnings.push(`${path} must contain a JSON object`); return { path, data: {}, exists: true }; } return { path, data: parsed, exists: true }; } catch (error) { const reason = error instanceof Error ? error.message : String(error); warnings.push(`Could not read ${path}: ${reason}`); return { path, data: {}, exists: true }; } } function resolveArtFile( raw: RawConfig, baseDir: string, fallback: string[] | undefined, warnings: string[], ): string[] | undefined { if (typeof raw.artFile !== "string" || raw.artFile.trim().length === 0) { return fallback; } const artPath = isAbsolute(raw.artFile) ? raw.artFile : resolve(baseDir, raw.artFile); try { const contents = readFileSync(artPath, "utf8"); return contents.length === 0 ? [] : contents.replace(/\r\n/g, "\n").split("\n"); } catch (error) { const reason = error instanceof Error ? error.message : String(error); warnings.push(`Could not read artFile ${artPath}: ${reason}`); return fallback; } } function resolveArtColor(raw: RawConfig, warnings: string[]): ThemeColor { const value = raw.artColor; if (typeof value !== "string" || value.trim().length === 0) { return "text"; } if ((ART_COLORS as readonly string[]).includes(value)) { return value as ArtColorName; } warnings.push(`Unknown artColor "${value}" (ignored; use one of: ${ART_COLORS.join(", ")})`); return "text"; } function normalizeConfig( global: ConfigDocument, project: ConfigDocument | undefined, warnings: string[], ): MenuConfig { const raw: RawConfig = { ...global.data, ...(project?.data ?? {}), }; const projectGreetings = nonEmptyStrings(project?.data.greetings); const projectGreetingValue = project?.data.greeting; const projectGreeting = typeof projectGreetingValue === "string" && projectGreetingValue.trim().length > 0 ? [oneLine(projectGreetingValue)] : []; const globalGreetings = nonEmptyStrings(global.data.greetings); const globalGreeting = typeof global.data.greeting === "string" && global.data.greeting.trim().length > 0 ? [oneLine(global.data.greeting)] : []; const greetingList = projectGreetings.length > 0 ? projectGreetings : projectGreeting.length > 0 ? projectGreeting : globalGreetings.length > 0 ? globalGreetings : globalGreeting.length > 0 ? globalGreeting : [...DEFAULT_GREETINGS]; const globalInlineArt = linesFrom(global.data.art); const globalArt = resolveArtFile(global.data, dirname(global.path), globalInlineArt, warnings); const projectHasArt = Boolean( project && (Object.prototype.hasOwnProperty.call(project.data, "art") || Object.prototype.hasOwnProperty.call(project.data, "artFile")), ); const projectInlineArt = linesFrom(project?.data.art); const art = projectHasArt ? resolveArtFile( project!.data, dirname(project!.path), projectInlineArt ?? globalArt, warnings, ) ?? [] : globalArt ?? [...DEFAULT_ART]; const hints = linesFrom(raw.hints); return { greetings: greetingList, art, showArt: booleanOr(raw.showArt, art.length > 0), artColor: resolveArtColor(raw, warnings), subtitle: textOr(raw.subtitle, "a small coding cockpit"), prompt: textOr(raw.prompt, "Type a prompt or /welcome to customize"), hints: hints ?? [...DEFAULT_HINTS], showHints: booleanOr(raw.showHints, true), showContext: booleanOr(raw.showContext, true), showModel: booleanOr(raw.showModel, true), showClock: booleanOr(raw.showClock, false), }; } function loadMenu(ctx: ExtensionContext): LoadedMenu { const warnings: string[] = []; const global = readDocument(join(getAgentDir(), CONFIG_FILE_NAME), warnings); const projectDir = join(ctx.cwd, CONFIG_DIR_NAME); const project = ctx.isProjectTrusted() ? readDocument(join(projectDir, CONFIG_FILE_NAME), warnings) : undefined; const globalSettings = readDocument(join(getAgentDir(), SETTINGS_FILE_NAME), warnings); const projectSettings = ctx.isProjectTrusted() ? readDocument(join(projectDir, SETTINGS_FILE_NAME), warnings) : undefined; const config = normalizeConfig(global, project, warnings); const quietStartup = booleanOr( projectSettings?.data.quietStartup, booleanOr(globalSettings.data.quietStartup, false), ); const settingsPath = projectSettings?.path ?? globalSettings.path; return { config, global, project, settingsPath, quietStartup, warnings }; } function pickGreeting(greetings: string[]): string { return greetings[Math.floor(Math.random() * greetings.length)] ?? DEFAULT_GREETINGS[0]!; } function templateValue(value: string, ctx: ExtensionContext): string { const project = basename(ctx.cwd); const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "no model"; const modelName = ctx.model ? ctx.model.name.length > 0 ? ctx.model.name : ctx.model.id : "no model"; const session = ctx.sessionManager.getSessionName() ?? "untitled"; const now = new Date(); const time = now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); const date = now.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" }); return value .replaceAll("{project}", project) .replaceAll("{cwd}", ctx.cwd) .replaceAll("{model}", model) .replaceAll("{modelName}", modelName) .replaceAll("{session}", session) .replaceAll("{pi}", VERSION) .replaceAll("{time}", time) .replaceAll("{date}", date); } function center(text: string, width: number): string { const fitted = truncateToWidth(text, width, ""); const left = Math.max(0, Math.floor((width - visibleWidth(fitted)) / 2)); return truncateToWidth(`${" ".repeat(left)}${fitted}`, width, ""); } function divider(theme: Theme, width: number): string { const length = Math.min(32, Math.max(8, width - 4)); return center(theme.fg("borderMuted", "─".repeat(length)), width); } /** Whether any rendered line depends on `{time}`, meaning the clock should tick. */ function configUsesTime(config: MenuConfig): boolean { if (config.showClock) return true; const texts = [config.subtitle, config.prompt, ...config.hints, ...config.greetings]; return texts.some((text) => text.includes("{time}")); } /** * Header component with lifecycle. Renders the configured welcome screen and * re-renders itself once per minute when any line shows the time, so the * clock stays current. `dispose()` stops the timer when pi replaces or * removes the header. */ class MainMenuHeader implements Component { private clockTimer: ReturnType | undefined; constructor( private readonly config: MenuConfig, private readonly greeting: string, private readonly ctx: ExtensionContext, private readonly tui: TUI, ) { if (configUsesTime(config)) { this.scheduleClockTick(); } } render(width: number): string[] { const safeWidth = Math.max(1, width); const theme = this.ctx.ui.theme; const lines: string[] = []; if (this.config.showArt && this.config.art.length > 0) { lines.push(""); for (const artLine of this.config.art) { lines.push(center(theme.fg(this.config.artColor, artLine), safeWidth)); } } lines.push(""); lines.push(center(theme.bold(theme.fg("text", templateValue(this.greeting, this.ctx))), safeWidth)); if (this.config.subtitle.length > 0) { lines.push(center(theme.fg("muted", templateValue(this.config.subtitle, this.ctx)), safeWidth)); } const contextParts: string[] = []; if (this.config.showContext) contextParts.push(templateValue("{project}", this.ctx)); if (this.config.showModel && this.ctx.model) contextParts.push(`${this.ctx.model.provider}/${this.ctx.model.id}`); if (this.config.showClock) contextParts.push(templateValue("{time}", this.ctx)); if (contextParts.length > 0) { lines.push(""); lines.push(center(theme.fg("dim", contextParts.join(" · ")), safeWidth)); } if (this.config.prompt.length > 0) { lines.push(""); lines.push(center(theme.fg("muted", templateValue(this.config.prompt, this.ctx)), safeWidth)); } if (this.config.showHints && this.config.hints.length > 0) { lines.push(""); lines.push(divider(theme, safeWidth)); for (const hint of this.config.hints) { lines.push(center(theme.fg("dim", templateValue(hint, this.ctx)), safeWidth)); } } lines.push(""); return lines.map((line) => truncateToWidth(line, safeWidth, "")); } invalidate(): void {} dispose(): void { if (this.clockTimer !== undefined) { clearTimeout(this.clockTimer); this.clockTimer = undefined; } } private scheduleClockTick(): void { const now = new Date(); const untilNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 250; this.clockTimer = setTimeout(() => { this.tui.requestRender(); this.scheduleClockTick(); }, untilNextMinute); } } function createHeader( config: MenuConfig, greeting: string, ctx: ExtensionContext, ): (tui: TUI, theme: Theme) => Component & { dispose?(): void } { return (tui, _theme) => new MainMenuHeader(config, greeting, ctx, tui); } function configForEditing(loaded: LoadedMenu, path: string): RawConfig { if (path === loaded.global.path) return { ...loaded.global.data }; if (path === loaded.project?.path) return { ...(loaded.project?.data ?? {}) }; return {}; } function writeConfig(path: string, data: RawConfig): void { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf8"); } /** Apply the custom header with the current state, unless it was disabled. */ function applyHeader(ctx: ExtensionContext, state: MenuState): void { if (state.headerEnabled && ctx.mode === "tui" && state.loaded) { ctx.ui.setHeader(createHeader(state.loaded.config, state.greeting, ctx)); } } function refreshMenu(ctx: ExtensionContext, state: MenuState, rotateGreeting = true): void { state.loaded = loadMenu(ctx); if (rotateGreeting || !state.loaded.config.greetings.includes(state.greeting)) { state.greeting = pickGreeting(state.loaded.config.greetings); } applyHeader(ctx, state); } /** Pick a (preferably different) random greeting from the configured list. */ function rotateGreeting(state: MenuState): void { const greetings = state.loaded?.config.greetings ?? DEFAULT_GREETINGS; const pool = greetings.filter((value) => value !== state.greeting); const candidates = pool.length > 0 ? pool : greetings; state.greeting = candidates[Math.floor(Math.random() * candidates.length)] ?? state.greeting; } function defaultConfigPath(ctx: ExtensionContext, loaded: LoadedMenu): string { if (ctx.isProjectTrusted() && existsSync(join(ctx.cwd, CONFIG_DIR_NAME))) { return loaded.project?.path ?? join(ctx.cwd, CONFIG_DIR_NAME, CONFIG_FILE_NAME); } return loaded.global.path; } async function chooseConfigPath(ctx: ExtensionContext, loaded: LoadedMenu): Promise { const options: string[] = []; const optionPaths = new Map(); if (ctx.isProjectTrusted()) { const projectPath = loaded.project?.path ?? join(ctx.cwd, CONFIG_DIR_NAME, CONFIG_FILE_NAME); const projectLabel = `Project ${projectPath}`; options.push(projectLabel); optionPaths.set(projectLabel, projectPath); } const globalLabel = `Global ${loaded.global.path}`; options.push(globalLabel); optionPaths.set(globalLabel, loaded.global.path); if (options.length === 1) return optionPaths.get(options[0]); const selected = await ctx.ui.select("Save main menu changes to", options); return selected ? optionPaths.get(selected) : undefined; } async function savePatch( ctx: ExtensionContext, state: MenuState, patch: ConfigPatch, removeKeys: string[] = [], ): Promise { // Quick edits always target the active project config when one is available. // This prevents a global edit from being immediately masked by a project // override. const path = defaultConfigPath(ctx, state.loaded!); const next = configForEditing(state.loaded!, path); Object.assign(next, patch); for (const key of removeKeys) delete next[key]; try { writeConfig(path, next); refreshMenu(ctx, state, false); ctx.ui.notify(`Saved ${path}`, "info"); return true; } catch (error) { const reason = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Could not save ${path}: ${reason}`, "error"); return false; } } async function toggleQuietStartup( ctx: ExtensionContext, state: MenuState, reload?: () => Promise, ): Promise { const next = !state.loaded!.quietStartup; const settings = readDocument(state.loaded!.settingsPath, []).data; try { writeConfig(state.loaded!.settingsPath, { ...settings, quietStartup: next }); if (reload) { ctx.ui.notify(`${next ? "Hiding" : "Showing"} loaded startup resources; reloading…`, "info"); await reload(); return; } state.loaded!.quietStartup = next; ctx.ui.notify( `${next ? "Hiding" : "Showing"} loaded startup resources. Use /reload to apply it.`, "info", ); } catch (error) { const reason = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Could not update ${state.loaded!.settingsPath}: ${reason}`, "error"); } } async function editRawConfig( ctx: ExtensionContext, state: MenuState, ): Promise { const path = await chooseConfigPath(ctx, state.loaded!); if (!path) return; const existing = configForEditing(state.loaded!, path); const edited = await ctx.ui.editor("Edit main-menu.json", JSON.stringify(existing, null, 2)); if (edited === undefined) return; let parsed: unknown; try { parsed = JSON.parse(edited); } catch (error) { const reason = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Invalid JSON: ${reason}`, "error"); return; } if (!isRecord(parsed)) { ctx.ui.notify("The config must be a JSON object", "error"); return; } try { writeConfig(path, parsed); refreshMenu(ctx, state); ctx.ui.notify(`Saved ${path}`, "info"); } catch (error) { const reason = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Could not save ${path}: ${reason}`, "error"); } } async function resetConfig( ctx: ExtensionContext, state: MenuState, ): Promise { const path = await chooseConfigPath(ctx, state.loaded!); if (!path) return; const confirmed = await ctx.ui.confirm( "Reset main menu config?", `Remove ${path}? Values from the other config scope may still apply.`, ); if (!confirmed) return; try { if (existsSync(path)) unlinkSync(path); refreshMenu(ctx, state); ctx.ui.notify(`Reset ${path}`, "info"); } catch (error) { const reason = error instanceof Error ? error.message : String(error); ctx.ui.notify(`Could not reset ${path}: ${reason}`, "error"); } } /** * Show the header as a floating overlay for a few seconds — handy after * restoring the built-in header, or just to admire the art. Dismisses on any * key or after 10 seconds. */ async function previewMenu(ctx: ExtensionContext, state: MenuState): Promise { if (!state.loaded || ctx.mode !== "tui") return; await ctx.ui.custom( (tui, theme, _keybindings, done) => { const header = createHeader(state.loaded!.config, state.greeting, ctx)(tui, theme); let finished = false; const finish = () => { if (finished) return; finished = true; clearTimeout(timer); done(); }; const timer = setTimeout(finish, 10_000); return { render: (width) => [ ...header.render(width), "", center(theme.fg("dim", "main menu preview · press any key to dismiss"), width), ], invalidate: () => header.invalidate(), handleInput: () => { finish(); return true; }, dispose: () => { clearTimeout(timer); header.dispose?.(); }, }; }, { overlay: true, overlayOptions: { width: "80%", maxHeight: "90%", margin: 1 } }, ); } async function editArtColor(ctx: ExtensionContext, state: MenuState): Promise { const current = state.loaded!.config.artColor; const options = ART_COLORS.map((color) => ({ color, label: color === current ? `${color} (current)` : color, })); const selected = await ctx.ui.select( "Art color", options.map((option) => option.label), ); const color = options.find((option) => option.label === selected)?.color; if (!color || color === current) return; await savePatch(ctx, state, { artColor: color }); } async function showMenu( ctx: ExtensionContext, state: MenuState, reload?: () => Promise, ): Promise { if (!ctx.hasUI) return; const choices = [ "Edit greeting", "Edit ASCII art", "Change art color", "Edit subtitle", "Edit prompt hint", "Rotate greeting now", "Preview main menu", "Edit raw config", state.loaded!.config.showHints ? "Hide startup hints" : "Show startup hints", state.loaded!.config.showContext ? "Hide project name in header" : "Show project name in header", state.loaded!.config.showModel ? "Hide model in header" : "Show model in header", state.loaded!.config.showClock ? "Hide clock in header" : "Show clock in header", state.loaded!.quietStartup ? "Show loaded startup resources" : "Hide loaded startup resources", "Reload config from disk", state.headerEnabled ? "Restore built-in pi header" : "Show custom main menu", "Reset config file", "Close", ]; const choice = await ctx.ui.select("Main menu", choices); if (!choice || choice === "Close") return; switch (choice) { case "Edit greeting": { const value = await ctx.ui.input("Greeting", state.greeting); if (value !== undefined) { await savePatch(ctx, state, { greetings: value.length > 0 ? [value] : [] }, ["greeting"]); } return; } case "Edit ASCII art": { const value = await ctx.ui.editor("ASCII art", state.loaded!.config.art.join("\n")); if (value !== undefined) { await savePatch( ctx, state, { art: value, showArt: value.trim().length > 0 }, ["artFile"], ); } return; } case "Change art color": await editArtColor(ctx, state); return; case "Edit subtitle": { const value = await ctx.ui.input("Subtitle", state.loaded!.config.subtitle); if (value !== undefined) await savePatch(ctx, state, { subtitle: value }); return; } case "Edit prompt hint": { const value = await ctx.ui.input("Prompt hint", state.loaded!.config.prompt); if (value !== undefined) await savePatch(ctx, state, { prompt: value }); return; } case "Rotate greeting now": rotateGreeting(state); applyHeader(ctx, state); ctx.ui.notify(`Greeting: ${state.greeting}`, "info"); return; case "Preview main menu": await previewMenu(ctx, state); return; case "Edit raw config": await editRawConfig(ctx, state); return; case "Hide startup hints": case "Show startup hints": await savePatch(ctx, state, { showHints: !state.loaded!.config.showHints }); return; case "Hide project name in header": case "Show project name in header": await savePatch(ctx, state, { showContext: !state.loaded!.config.showContext }); return; case "Hide model in header": case "Show model in header": await savePatch(ctx, state, { showModel: !state.loaded!.config.showModel }); return; case "Hide clock in header": case "Show clock in header": await savePatch(ctx, state, { showClock: !state.loaded!.config.showClock }); return; case "Hide loaded startup resources": case "Show loaded startup resources": await toggleQuietStartup(ctx, state, reload); return; case "Reload config from disk": refreshMenu(ctx, state); ctx.ui.notify("Main menu reloaded", "info"); return; case "Restore built-in pi header": state.headerEnabled = false; ctx.ui.setHeader(undefined); ctx.ui.notify("Built-in pi header restored", "info"); return; case "Show custom main menu": state.headerEnabled = true; refreshMenu(ctx, state, false); ctx.ui.notify("Custom main menu enabled", "info"); return; case "Reset config file": await resetConfig(ctx, state); return; } } export default function mainMenuExtension(pi: ExtensionAPI) { const state: MenuState = { loaded: undefined, greeting: DEFAULT_GREETINGS[0]!, headerEnabled: true, }; const command = async (args: string, ctx: ExtensionCommandContext): Promise => { if (!state.loaded) { state.loaded = loadMenu(ctx); state.greeting = pickGreeting(state.loaded.config.greetings); } // Match on the first word so subcommands can take arguments // (e.g. "artcolor accent"). const [subcommand, ...rest] = args.trim().split(/\s+/); switch (subcommand?.toLowerCase() ?? "") { case "reload": refreshMenu(ctx, state); ctx.ui.notify("Main menu reloaded", "info"); return; case "builtin": case "default": state.headerEnabled = false; ctx.ui.setHeader(undefined); ctx.ui.notify("Built-in pi header restored", "info"); return; case "rotate": case "cycle": rotateGreeting(state); applyHeader(ctx, state); ctx.ui.notify(`Greeting: ${state.greeting}`, "info"); return; case "artcolor": { const color = rest[0]?.toLowerCase(); if (!color) { await editArtColor(ctx, state); return; } if (!(ART_COLORS as readonly string[]).includes(color)) { ctx.ui.notify( `Unknown art color \"${color}\" (use one of: ${ART_COLORS.join(", ")})`, "error", ); return; } if (color === state.loaded!.config.artColor) return; await savePatch(ctx, state, { artColor: color }); return; } case "preview": await previewMenu(ctx, state); return; case "reset": await resetConfig(ctx, state); return; default: await showMenu(ctx, state, () => ctx.reload()); } }; const getArgumentCompletions = (prefix: string) => { const trimmed = prefix.trim(); const parts = trimmed.split(/\s+/); if (parts[0] === "artcolor") { const colorPrefix = parts[1] ?? ""; const colors = ART_COLORS.filter((color) => color.startsWith(colorPrefix)); if (colors.length === 0) return null; // The completion value replaces the whole argument text. return colors.map((color) => ({ value: `artcolor ${color}`, label: color })); } const commands = ["reload", "builtin", "rotate", "preview", "reset", "artcolor"]; const matches = commands.filter((value) => value.startsWith(trimmed)); return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null; }; for (const name of ["welcome", "main-menu"]) { pi.registerCommand(name, { description: "Customize the pi startup greeting and ASCII art", getArgumentCompletions, handler: command, }); } pi.registerShortcut(Key.ctrlShift("m"), { description: "Open the custom main menu", handler: async (ctx) => { if (!state.loaded) { state.loaded = loadMenu(ctx); state.greeting = pickGreeting(state.loaded.config.greetings); } await showMenu(ctx, state); }, }); // Keep the header in sync with live session state: model switches and // session renames re-render the header with fresh values. pi.on("model_select", (_event, ctx) => { applyHeader(ctx, state); }); pi.on("session_info_changed", (_event, ctx) => { applyHeader(ctx, state); }); pi.on("session_start", async (_event, ctx) => { state.loaded = loadMenu(ctx); state.greeting = pickGreeting(state.loaded.config.greetings); state.headerEnabled = true; if (state.loaded.warnings.length > 0 && ctx.hasUI) { ctx.ui.notify(state.loaded.warnings.join("\n"), "warning"); } if (ctx.mode === "tui") { ctx.ui.setHeader(createHeader(state.loaded.config, state.greeting, ctx)); } }); }